Initial commit
AtTable iOS app with multipeer connectivity for mesh messaging. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
73
.gitignore
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
# Xcode
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcworkspace/xcuserdata/
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
xcuserdata/
|
||||
|
||||
# Xcode Scheme
|
||||
*.xcscheme
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
|
||||
# CocoaPods
|
||||
Pods/
|
||||
Podfile.lock
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
Carthage/Checkouts/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# Thumbnails
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Archives
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
BIN
At-Table_orig.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
357
AtTable.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,357 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
7F9DE17C2EFC324800008582 /* AtTable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtTable.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
7F9DE17E2EFC324800008582 /* AtTable */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AtTable;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
7F9DE1792EFC324800008582 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
7F9DE1732EFC324800008582 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7F9DE17E2EFC324800008582 /* AtTable */,
|
||||
7F9DE17D2EFC324800008582 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7F9DE17D2EFC324800008582 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7F9DE17C2EFC324800008582 /* AtTable.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
7F9DE17B2EFC324800008582 /* AtTable */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7F9DE1872EFC324800008582 /* Build configuration list for PBXNativeTarget "AtTable" */;
|
||||
buildPhases = (
|
||||
7F9DE1782EFC324800008582 /* Sources */,
|
||||
7F9DE1792EFC324800008582 /* Frameworks */,
|
||||
7F9DE17A2EFC324800008582 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
7F9DE17E2EFC324800008582 /* AtTable */,
|
||||
);
|
||||
name = AtTable;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = AtTable;
|
||||
productReference = 7F9DE17C2EFC324800008582 /* AtTable.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
7F9DE1742EFC324800008582 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
7F9DE17B2EFC324800008582 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 7F9DE1772EFC324800008582 /* Build configuration list for PBXProject "AtTable" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 7F9DE1732EFC324800008582;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 7F9DE17D2EFC324800008582 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
7F9DE17B2EFC324800008582 /* AtTable */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
7F9DE17A2EFC324800008582 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
7F9DE1782EFC324800008582 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
7F9DE1852EFC324800008582 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7F9DE1862EFC324800008582 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
7F9DE1882EFC324800008582 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MeshInfo.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "At-Table";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We use the local network to find and connect with other devices for chat.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "We need access to your microphone to transcribe your speech.";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "We use speech recognition to convert your speech into text for the chat.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7F9DE1892EFC324800008582 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MeshInfo.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "At-Table";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We use the local network to find and connect with other devices for chat.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "We need access to your microphone to transcribe your speech.";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "We use speech recognition to convert your speech into text for the chat.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
7F9DE1772EFC324800008582 /* Build configuration list for PBXProject "AtTable" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7F9DE1852EFC324800008582 /* Debug */,
|
||||
7F9DE1862EFC324800008582 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
7F9DE1872EFC324800008582 /* Build configuration list for PBXNativeTarget "AtTable" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7F9DE1882EFC324800008582 /* Debug */,
|
||||
7F9DE1892EFC324800008582 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 7F9DE1742EFC324800008582 /* Project object */;
|
||||
}
|
||||
7
AtTable.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
11
AtTable/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
AtTable/Assets.xcassets/AppIcon.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
1
AtTable/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]}
|
||||
6
AtTable/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
AtTable/Assets.xcassets/qrcode.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "qrcode.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
AtTable/Assets.xcassets/qrcode.imageset/qrcode.png
vendored
Normal file
|
After Width: | Height: | Size: 412 B |
47
AtTable/AtTableApp.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// AtTableApp.swift
|
||||
// AtTable
|
||||
//
|
||||
// Created by Jared Evans on 12/24/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct AtTableApp: App {
|
||||
@AppStorage("isOnboardingComplete") var isOnboardingComplete: Bool = false
|
||||
@AppStorage("userName") var userName: String = ""
|
||||
@AppStorage("userRole") var userRole: UserRole = .hearing
|
||||
@AppStorage("userColorHex") var userColorHex: String = "#00008B"
|
||||
|
||||
// APP-LEVEL SESSION: Ensures stable identity (Instance ID) across view reloads
|
||||
@StateObject var multipeerSession = MultipeerSession()
|
||||
|
||||
init() {
|
||||
// Reset navigation state on launch so users always see the "Login" screen first.
|
||||
// This prevents getting "stuck" in a connecting loop if the app crashed.
|
||||
UserDefaults.standard.set(false, forKey: "isOnboardingComplete")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if isOnboardingComplete {
|
||||
ChatView(
|
||||
userName: userName,
|
||||
userRole: userRole,
|
||||
userColorHex: userColorHex
|
||||
)
|
||||
} else {
|
||||
OnboardingView(
|
||||
isOnboardingComplete: $isOnboardingComplete,
|
||||
userName: $userName,
|
||||
userRole: $userRole,
|
||||
userColorHex: $userColorHex
|
||||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(multipeerSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
323
AtTable/ChatView.swift
Normal file
@@ -0,0 +1,323 @@
|
||||
import SwiftUI
|
||||
import MultipeerConnectivity
|
||||
import Combine
|
||||
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var multipeerSession: MultipeerSession
|
||||
@StateObject var speechRecognizer = SpeechRecognizer()
|
||||
@ObservedObject var networkMonitor = NetworkMonitor.shared
|
||||
|
||||
let userName: String
|
||||
let userRole: UserRole
|
||||
let userColorHex: String
|
||||
|
||||
@AppStorage("isOnboardingComplete") var isOnboardingComplete: Bool = true
|
||||
@State private var messageText: String = ""
|
||||
@State private var waitingDots = 0
|
||||
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 1. Animated Mesh Background
|
||||
MeshBackground(colors: [
|
||||
Color(hex: userColorHex),
|
||||
userRole == .deaf ? .purple : .cyan,
|
||||
.black
|
||||
])
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 2. Floating Dynamic Header
|
||||
HStack {
|
||||
Button(action: {
|
||||
speechRecognizer.stopRecording()
|
||||
multipeerSession.stop()
|
||||
isOnboardingComplete = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.left")
|
||||
Text("Leave")
|
||||
}
|
||||
.font(.system(.subheadline, design: .rounded, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("MESH")
|
||||
.font(.system(size: 10, weight: .black, design: .monospaced))
|
||||
.tracking(2)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
|
||||
Text("\(multipeerSession.connectedPeers.count) Active")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 50) // Safe Area
|
||||
.padding(.bottom, 10)
|
||||
|
||||
// 3. Peer Status / Live Transcriptions
|
||||
if multipeerSession.connectedPeers.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
// Show "Warming up..." on first connection when not on WiFi
|
||||
if !networkMonitor.isWiFi {
|
||||
Text("Warming up" + String(repeating: ".", count: waitingDots))
|
||||
.font(.title3)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.onReceive(timer) { _ in
|
||||
waitingDots = (waitingDots + 1) % 4
|
||||
}
|
||||
|
||||
Text("No WiFi: connection may take up to 60 seconds")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Searching for others" + String(repeating: ".", count: waitingDots))
|
||||
.font(.title3)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.onReceive(timer) { _ in
|
||||
waitingDots = (waitingDots + 1) % 4
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
// Connected Peers Row (Small Pills)
|
||||
if !multipeerSession.connectedPeerUsers.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(multipeerSession.connectedPeerUsers, id: \.self) { peer in
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(hex: peer.colorHex))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(peer.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(.leading)
|
||||
}
|
||||
|
||||
// Full Mesh Indicator
|
||||
if multipeerSession.isAtCapacity {
|
||||
Text("FULL")
|
||||
.font(.system(size: 10, weight: .black, design: .monospaced))
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange.opacity(0.2))
|
||||
.cornerRadius(8)
|
||||
.padding(.trailing)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// 4. Message List
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(multipeerSession.receivedMessages) { message in
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isMyMessage: message.senderNodeID == multipeerSession.myNodeIDPublic
|
||||
)
|
||||
.id(message.id)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.onChange(of: multipeerSession.receivedMessages.count) {
|
||||
if let lastId = multipeerSession.receivedMessages.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
if userRole == .deaf {
|
||||
if let lastMsg = multipeerSession.receivedMessages.last, lastMsg.senderNodeID != multipeerSession.myNodeIDPublic {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-scroll when transcription card appears to prevent blocking
|
||||
.onChange(of: multipeerSession.liveTranscripts.isEmpty) {
|
||||
if !multipeerSession.liveTranscripts.isEmpty {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if let lastId = multipeerSession.receivedMessages.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live Transcription Cards (Moved to Bottom)
|
||||
// Live Transcription Cards (Moved to Bottom, Stacked Vertically)
|
||||
if userRole == .deaf && !multipeerSession.liveTranscripts.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(multipeerSession.liveTranscripts.sorted(by: { $0.key < $1.key }), id: \.key) { nodeIDKey, text in
|
||||
// Look up friendly name from connectedPeerUsers by nodeID
|
||||
let displayName = multipeerSession.connectedPeerUsers.first(where: { $0.nodeID == nodeIDKey })?.name ?? nodeIDKey
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(displayName)
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.cyan)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: .black, radius: 1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.id("text-\(nodeIDKey)")
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.id("bottom-\(nodeIDKey)")
|
||||
}
|
||||
}
|
||||
.onChange(of: text) {
|
||||
withAnimation(.easeOut(duration: 0.1)) {
|
||||
proxy.scrollTo("bottom-\(nodeIDKey)", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 70) // Fixed height for approx 2 lines
|
||||
.liquidCard()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// 5. Input Bar
|
||||
InputBar(
|
||||
userRole: userRole,
|
||||
text: $messageText,
|
||||
isRecording: $speechRecognizer.isRecording,
|
||||
transcript: speechRecognizer.transcript,
|
||||
onSend: sendMessage,
|
||||
onToggleRecording: toggleRecording
|
||||
)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .all)
|
||||
.onAppear {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
multipeerSession.start()
|
||||
|
||||
// Re-apply identity if needed (though App state should handle it)
|
||||
if multipeerSession.userName != userName {
|
||||
multipeerSession.setIdentity(name: userName, colorHex: userColorHex, role: userRole)
|
||||
}
|
||||
|
||||
// Configure Speech Recognizer callback
|
||||
speechRecognizer.onFinalResult = { resultText in
|
||||
let message = MeshMessage(
|
||||
id: UUID(),
|
||||
senderID: userName,
|
||||
senderNodeID: multipeerSession.myNodeIDPublic,
|
||||
senderInstance: multipeerSession.myInstancePublic,
|
||||
senderRole: userRole,
|
||||
senderColorHex: userColorHex,
|
||||
content: resultText,
|
||||
isTranscription: true,
|
||||
isPartial: false,
|
||||
timestamp: Date()
|
||||
)
|
||||
multipeerSession.send(message: message)
|
||||
}
|
||||
|
||||
// Configure Partial Result callback (No Throttling)
|
||||
speechRecognizer.onPartialResult = { partialText in
|
||||
let message = MeshMessage(
|
||||
id: UUID(),
|
||||
senderID: userName,
|
||||
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID for consistent transcript keying
|
||||
senderInstance: multipeerSession.myInstancePublic,
|
||||
senderRole: userRole,
|
||||
senderColorHex: userColorHex,
|
||||
content: partialText,
|
||||
isTranscription: true,
|
||||
isPartial: true,
|
||||
timestamp: Date()
|
||||
)
|
||||
multipeerSession.send(message: message)
|
||||
}
|
||||
|
||||
// Auto-start recording for Hearing users
|
||||
if userRole == .hearing {
|
||||
speechRecognizer.startRecording()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage() {
|
||||
guard !messageText.isEmpty else { return }
|
||||
|
||||
let message = MeshMessage(
|
||||
id: UUID(),
|
||||
senderID: userName,
|
||||
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID
|
||||
senderInstance: multipeerSession.myInstancePublic,
|
||||
senderRole: userRole,
|
||||
senderColorHex: userColorHex,
|
||||
content: messageText,
|
||||
isTranscription: false,
|
||||
isPartial: false,
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
multipeerSession.send(message: message)
|
||||
messageText = ""
|
||||
}
|
||||
|
||||
func toggleRecording() {
|
||||
if speechRecognizer.isRecording {
|
||||
speechRecognizer.stopRecording()
|
||||
} else {
|
||||
speechRecognizer.startRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
AtTable/Constants.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
struct Constants {
|
||||
static let serviceType = "access-mesh" // Must match Info.plist _access-mesh._tcp
|
||||
}
|
||||
216
AtTable/DesignSystem.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Colors & Gradients
|
||||
struct DesignSystem {
|
||||
struct Colors {
|
||||
static let deepSpace = Color(hex: "0a0a12")
|
||||
static let neonBlue = Color(hex: "00f2ff")
|
||||
static let neonPurple = Color(hex: "bd00ff")
|
||||
static let neonTeal = Color(hex: "00ff9d")
|
||||
|
||||
static func roleGradient(for role: UserRole) -> LinearGradient {
|
||||
switch role {
|
||||
case .deaf:
|
||||
return LinearGradient(
|
||||
gradient: Gradient(colors: [Color(hex: "43cea2"), Color(hex: "185a9d")]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
case .hearing:
|
||||
return LinearGradient(
|
||||
gradient: Gradient(colors: [Color(hex: "FDB99B"), Color(hex: "CF8BF3"), Color(hex: "A770EF")]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Typography {
|
||||
static func title(_ text: String) -> Text {
|
||||
Text(text)
|
||||
.font(.system(.largeTitle, design: .rounded))
|
||||
.fontWeight(.heavy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
struct LiquidGlassCard: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(24)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlowingEdge: ViewModifier {
|
||||
var color: Color
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.shadow(color: color.opacity(0.6), radius: 20, x: 0, y: 0)
|
||||
.shadow(color: color.opacity(0.3), radius: 40, x: 0, y: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct MeshBackground: View {
|
||||
@State private var start = UnitPoint(x: 0, y: -2)
|
||||
@State private var end = UnitPoint(x: 4, y: 0)
|
||||
|
||||
// Dynamic Orb States
|
||||
@State private var orb1Offset = CGSize.zero
|
||||
@State private var orb2Offset = CGSize.zero
|
||||
@State private var orb3Offset = CGSize.zero // Pulsing Blue Orb
|
||||
|
||||
@State private var orb1Scale: CGFloat = 1.0
|
||||
// Orb 2 Shape Shifting (Independent Scales)
|
||||
@State private var orb2ScaleX: CGFloat = 1.0
|
||||
@State private var orb2ScaleY: CGFloat = 1.0
|
||||
|
||||
@State private var orb3Scale: CGFloat = 1.0 // Pulsing Blue Orb
|
||||
@State private var orb3Opacity: Double = 0.3 // Pulsing Opacity
|
||||
|
||||
let colors: [Color]
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// 1. Moving Gradient Background
|
||||
LinearGradient(gradient: Gradient(colors: colors), startPoint: start, endPoint: end)
|
||||
.opacity(0.4)
|
||||
.ignoresSafeArea()
|
||||
.blur(radius: 100)
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 10).repeatForever(autoreverses: true)) {
|
||||
start = UnitPoint(x: 4, y: 0)
|
||||
end = UnitPoint(x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Floating Orbs for Depth
|
||||
|
||||
// Orb 3: Pulsing Blue Orb (Lightens/Darkens)
|
||||
Circle()
|
||||
.fill(Color.blue)
|
||||
.frame(width: proxy.size.width * 1.0, height: proxy.size.width * 1.0)
|
||||
.blur(radius: 150) // Highly blurred
|
||||
.opacity(orb3Opacity) // Pulsing Opacity
|
||||
.scaleEffect(orb3Scale)
|
||||
.offset(orb3Offset)
|
||||
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
|
||||
|
||||
// Orb 1
|
||||
Circle()
|
||||
.fill(colors.first ?? .blue)
|
||||
.frame(width: proxy.size.width * 0.9, height: proxy.size.width * 0.9)
|
||||
.blur(radius: 120)
|
||||
.scaleEffect(orb1Scale)
|
||||
.offset(orb1Offset)
|
||||
.position(x: proxy.size.width / 2, y: proxy.size.height / 2) // Center start
|
||||
|
||||
// Orb 2 (Black Orb) - Shape Shifting
|
||||
Circle()
|
||||
.fill(colors.last ?? .purple)
|
||||
.frame(width: proxy.size.width * 0.8, height: proxy.size.width * 0.8)
|
||||
.blur(radius: 120)
|
||||
.scaleEffect(x: orb2ScaleX, y: orb2ScaleY) // Independent scaling creates ovals
|
||||
.offset(orb2Offset)
|
||||
.position(x: proxy.size.width / 2, y: proxy.size.height / 2) // Center start
|
||||
}
|
||||
.onAppear {
|
||||
animateOrb1(in: proxy.size)
|
||||
animateOrb2(in: proxy.size)
|
||||
animateOrb3(in: proxy.size)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func animateOrb1(in size: CGSize) {
|
||||
// Random wandering loop
|
||||
let duration = Double.random(in: 6...9)
|
||||
withAnimation(.easeInOut(duration: duration)) {
|
||||
orb1Offset = CGSize(
|
||||
width: CGFloat.random(in: -size.width/2...size.width/2),
|
||||
height: CGFloat.random(in: -size.height/2...size.height/2)
|
||||
)
|
||||
orb1Scale = CGFloat.random(in: 0.9...1.2)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||
animateOrb1(in: size)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateOrb2(in size: CGSize) {
|
||||
// Random wandering loop with Shape Shifting
|
||||
let duration = Double.random(in: 7...10)
|
||||
withAnimation(.easeInOut(duration: duration)) {
|
||||
orb2Offset = CGSize(
|
||||
width: CGFloat.random(in: -size.width/2...size.width/2),
|
||||
height: CGFloat.random(in: -size.height/2...size.height/2)
|
||||
)
|
||||
// Independent X and Y scales morph the shape between circle and oval
|
||||
orb2ScaleX = CGFloat.random(in: 0.4...1.1)
|
||||
orb2ScaleY = CGFloat.random(in: 0.4...1.1)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||
animateOrb2(in: size)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateOrb3(in size: CGSize) {
|
||||
// Pulsing Blue Orb Animation
|
||||
let duration = Double.random(in: 10...15)
|
||||
withAnimation(.easeInOut(duration: duration)) {
|
||||
orb3Offset = CGSize(
|
||||
width: CGFloat.random(in: -size.width/3...size.width/3),
|
||||
height: CGFloat.random(in: -size.height/3...size.height/3)
|
||||
)
|
||||
orb3Scale = CGFloat.random(in: 0.9...1.1)
|
||||
orb3Opacity = Double.random(in: 0.1...0.6) // Lightens and darkens
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||
animateOrb3(in: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
var complementary: Color {
|
||||
let uiColor = UIColor(self)
|
||||
var h: CGFloat = 0
|
||||
var s: CGFloat = 0
|
||||
var b: CGFloat = 0
|
||||
var a: CGFloat = 0
|
||||
|
||||
uiColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||
|
||||
let newHue = h + 0.5
|
||||
let finalHue = newHue > 1.0 ? newHue - 1.0 : newHue
|
||||
|
||||
return Color(hue: finalHue, saturation: s, brightness: b, opacity: a)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func liquidCard() -> some View {
|
||||
self.modifier(LiquidGlassCard())
|
||||
}
|
||||
|
||||
func glowingEdge(color: Color) -> some View {
|
||||
self.modifier(GlowingEdge(color: color))
|
||||
}
|
||||
}
|
||||
191
AtTable/InputBar.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InputBar: View {
|
||||
let userRole: UserRole
|
||||
@Binding var text: String
|
||||
@Binding var isRecording: Bool
|
||||
var transcript: String // New property for live transcript
|
||||
var onSend: () -> Void
|
||||
var onToggleRecording: () -> Void
|
||||
|
||||
@State private var orbScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Gradient Divider
|
||||
LinearGradient(
|
||||
colors: [.clear, .white.opacity(0.2), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(height: 1)
|
||||
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if userRole == .deaf {
|
||||
deafInputView
|
||||
} else {
|
||||
hearingInputView
|
||||
}
|
||||
}
|
||||
.frame(height: userRole == .hearing ? 200 : 160) // Increased height for transcript
|
||||
}
|
||||
}
|
||||
|
||||
var deafInputView: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
TextField("Type a message...", text: $text)
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.3))
|
||||
.cornerRadius(24)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
if !text.isEmpty {
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
onSend()
|
||||
}) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(text.isEmpty ? Color.gray : Color.white)
|
||||
)
|
||||
.shadow(radius: 5)
|
||||
}
|
||||
.disabled(text.isEmpty)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(["Yes", "No", "Hold on", "Thanks", "Please repeat"], id: \.self) { phrase in
|
||||
Button(action: {
|
||||
text = phrase
|
||||
onSend()
|
||||
}) {
|
||||
Text(phrase)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.1))
|
||||
.cornerRadius(16)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
|
||||
var hearingInputView: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Live Transcription Box
|
||||
if !transcript.isEmpty {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
Text(transcript)
|
||||
.font(.system(.body, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.leading) // Leading alignment better for scrolling reading
|
||||
.padding()
|
||||
.id("transcriptionText")
|
||||
}
|
||||
.frame(height: 60) // Approx 2 lines + padding
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.black.opacity(0.2))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.onChange(of: transcript) {
|
||||
withAnimation {
|
||||
proxy.scrollTo("transcriptionText", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
Text(isRecording ? "Listening..." : "Press to speak...")
|
||||
.frame(height: 60)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.black.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.font(.system(.body, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
// Glowing Intelligence Orb
|
||||
Button(action: onToggleRecording) {
|
||||
ZStack {
|
||||
// Outer Glow
|
||||
Circle()
|
||||
.fill(
|
||||
AngularGradient(
|
||||
gradient: Gradient(colors: [.blue, .purple, .cyan, .blue]),
|
||||
center: .center
|
||||
)
|
||||
)
|
||||
.frame(width: 80, height: 80)
|
||||
.blur(radius: isRecording ? 10 : 0)
|
||||
.rotationEffect(.degrees(isRecording ? 360 : 0))
|
||||
.animation(isRecording ? Animation.linear(duration: 2).repeatForever(autoreverses: false) : .default, value: isRecording)
|
||||
|
||||
// Core
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 76, height: 76)
|
||||
.overlay(
|
||||
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
|
||||
.font(.title)
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: .white, radius: 5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.scaleEffect(isRecording ? 1.1 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isRecording)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
17
AtTable/MeshMessage.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct MeshMessage: Codable, Identifiable, Hashable {
|
||||
var id: UUID
|
||||
var senderID: String // MCPeerID.displayName (for display purposes)
|
||||
var senderNodeID: String = "" // Stable node ID per install
|
||||
var senderInstance: Int = 0 // Monotonic instance ID to detect ghosts/restarts
|
||||
var senderRole: UserRole
|
||||
var senderColorHex: String
|
||||
var content: String
|
||||
var isTranscription: Bool // True = Audio source, False = Typed source
|
||||
var isPartial: Bool = false // True = Live partial result, False = Final confirmed result
|
||||
var isHandshake: Bool = false // True = Handshake message with user details
|
||||
var isKeepAlive: Bool = false // True = Heartbeat to maintain AWDL links (ignore in UI)
|
||||
var connectedNodeIDs: [String]? = nil // Gossip: List of NodeIDs this peer is connected to (for clique repair)
|
||||
var timestamp: Date
|
||||
}
|
||||
84
AtTable/MessageBubble.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MessageBubble: View {
|
||||
let message: MeshMessage
|
||||
let isMyMessage: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
if isMyMessage { Spacer() }
|
||||
|
||||
if !isMyMessage {
|
||||
// Avatar Placeholder
|
||||
Circle()
|
||||
.fill(Color(hex: message.senderColorHex))
|
||||
.frame(width: 30, height: 30)
|
||||
.shadow(color: Color(hex: message.senderColorHex).opacity(0.5), radius: 5)
|
||||
}
|
||||
|
||||
VStack(alignment: isMyMessage ? .trailing : .leading, spacing: 4) {
|
||||
if !isMyMessage {
|
||||
Text(message.senderID)
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
HStack(alignment: .top) {
|
||||
if message.isTranscription {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(.body, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
ZStack {
|
||||
if isMyMessage {
|
||||
LinearGradient(
|
||||
colors: [Color.blue.opacity(0.8), Color.purple.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
} else {
|
||||
Color(hex: message.senderColorHex).opacity(0.3)
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(18, corners: isMyMessage ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight])
|
||||
.shadow(color: isMyMessage ? .blue.opacity(0.3) : Color(hex: message.senderColorHex).opacity(0.2), radius: 5, x: 0, y: 2)
|
||||
.overlay(
|
||||
RoundedCorner(radius: 18, corners: isMyMessage ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight])
|
||||
.stroke(.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
if !isMyMessage { Spacer() }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded Corner Shape for Bubble
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
}
|
||||
1191
AtTable/MultipeerSession.swift
Normal file
26
AtTable/NetworkMonitor.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
|
||||
class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
@Published var isConnected: Bool = true
|
||||
@Published var isWiFi: Bool = true
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
DispatchQueue.main.async {
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.isWiFi = path.usesInterfaceType(.wifi)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
32
AtTable/NodeIdentity.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// Provides a stable node identity for mesh networking.
|
||||
/// - `nodeID`: Stable per install (persisted in UserDefaults)
|
||||
/// - `instance`: Monotonic counter per session start
|
||||
struct NodeIdentity {
|
||||
private static let nodeIDKey = "com.attable.mesh.nodeID"
|
||||
private static let instanceKey = "com.attable.mesh.instance"
|
||||
|
||||
/// Get or create a stable nodeID for this installation
|
||||
static var nodeID: String {
|
||||
if let existing = UserDefaults.standard.string(forKey: nodeIDKey) {
|
||||
return existing
|
||||
}
|
||||
let newID = UUID().uuidString
|
||||
UserDefaults.standard.set(newID, forKey: nodeIDKey)
|
||||
return newID
|
||||
}
|
||||
|
||||
/// Increment and return the current instance counter
|
||||
static func nextInstance() -> Int {
|
||||
let current = UserDefaults.standard.integer(forKey: instanceKey)
|
||||
let next = current + 1
|
||||
UserDefaults.standard.set(next, forKey: instanceKey)
|
||||
return next
|
||||
}
|
||||
|
||||
/// Get the current instance without incrementing (for reference)
|
||||
static var currentInstance: Int {
|
||||
return UserDefaults.standard.integer(forKey: instanceKey)
|
||||
}
|
||||
}
|
||||
181
AtTable/OnboardingView.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var isOnboardingComplete: Bool
|
||||
@Binding var userName: String
|
||||
@Binding var userRole: UserRole
|
||||
@Binding var userColorHex: String
|
||||
|
||||
let colors: [String] = [
|
||||
"#FF0055", // Neon Red
|
||||
"#00FF99", // Neon Green
|
||||
"#00CCFF", // Neon Blue
|
||||
"#9900FF", // Neon Purple
|
||||
"#FF9900", // Neon Orange
|
||||
"#FF00CC", // Neon Pink
|
||||
"#00FFEA", // Cyan
|
||||
"#FFFFFF" // White
|
||||
]
|
||||
@State private var showQRCode = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
MeshBackground(colors: [
|
||||
Color(hex: userColorHex),
|
||||
userRole == .deaf ? .purple : .blue,
|
||||
.black
|
||||
])
|
||||
|
||||
// Top Right QR Code Link
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showQRCode = true
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "qrcode")
|
||||
Text("QR code for the app")
|
||||
}
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 50) // Safe area
|
||||
.padding(.horizontal)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 40) {
|
||||
DesignSystem.Typography.title("Welcome")
|
||||
.foregroundColor(.white)
|
||||
.shadow(radius: 10)
|
||||
|
||||
VStack(spacing: 25) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Your name", systemImage: "person.fill")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
TextField("Enter Name", text: $userName)
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(12)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("I am...", systemImage: "ear")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
Picker("Role", selection: $userRole) {
|
||||
ForEach(UserRole.allCases, id: \.self) { role in
|
||||
Text(role.displayName).tag(role)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.colorMultiply(.white)
|
||||
.onChange(of: userRole) { _, newRole in
|
||||
withAnimation {
|
||||
if newRole == .hearing {
|
||||
userColorHex = "#00FF99" // Neon Green
|
||||
} else {
|
||||
userColorHex = "#00CCFF" // Neon Blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
Label("Pick your Aura color", systemImage: "paintpalette.fill")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: 15) {
|
||||
ForEach(colors, id: \.self) { color in
|
||||
Circle()
|
||||
.fill(Color(hex: color))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: userColorHex == color ? 4 : 0)
|
||||
.shadow(color: Color(hex: color).opacity(0.8), radius: 10)
|
||||
)
|
||||
.scaleEffect(userColorHex == color ? 1.1 : 1.0)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring()) {
|
||||
userColorHex = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(30)
|
||||
.liquidCard()
|
||||
.padding(.horizontal)
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
if !userName.isEmpty {
|
||||
isOnboardingComplete = true
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text("Start Conversation")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
LinearGradient(colors: [.white, .white.opacity(0.8)], startPoint: .top, endPoint: .bottom)
|
||||
)
|
||||
.cornerRadius(20)
|
||||
.glowingEdge(color: .white)
|
||||
}
|
||||
.disabled(userName.isEmpty)
|
||||
.opacity(userName.isEmpty ? 0.5 : 1)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showQRCode) {
|
||||
QRCodeView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep Hex Color extension or ensure it's in Constants/Shared
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (1, 1, 1, 0)
|
||||
}
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
18
AtTable/PeerUser.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import MultipeerConnectivity
|
||||
|
||||
struct PeerUser: Identifiable, Hashable {
|
||||
let id: MCPeerID
|
||||
let nodeID: String // Stable identifier from handshake (for reliable identity)
|
||||
let name: String
|
||||
let colorHex: String
|
||||
let role: UserRole // Role from handshake (for data integrity)
|
||||
|
||||
init(peerID: MCPeerID, nodeID: String = "", name: String? = nil, colorHex: String = "#808080", role: UserRole = .hearing) {
|
||||
self.id = peerID
|
||||
self.nodeID = nodeID
|
||||
self.name = name ?? peerID.displayName
|
||||
self.colorHex = colorHex
|
||||
self.role = role
|
||||
}
|
||||
}
|
||||
47
AtTable/QRCodeView.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QRCodeView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 30) {
|
||||
// Header
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 30))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
Image("qrcode")
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 280, maxHeight: 280)
|
||||
.padding(20)
|
||||
.background(Color.white) // QR code usually needs white background to be scannable
|
||||
.cornerRadius(20)
|
||||
.shadow(color: .white.opacity(0.2), radius: 20)
|
||||
|
||||
Text("Download At-Table app from Apple Appstore")
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
262
AtTable/SpeechRecognizer.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class SpeechRecognizer: ObservableObject {
|
||||
@Published var transcript: String = ""
|
||||
@Published var isRecording: Bool = false
|
||||
@Published var error: String?
|
||||
|
||||
// Callback for when a final result is ready to be broadcast
|
||||
var onFinalResult: ((String) -> Void)?
|
||||
// Callback for partial results
|
||||
var onPartialResult: ((String) -> Void)?
|
||||
|
||||
private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
|
||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var recognitionTask: SFSpeechRecognitionTask?
|
||||
private let audioEngine = AVAudioEngine()
|
||||
|
||||
private var silenceTimer: Timer?
|
||||
|
||||
init() {
|
||||
requestAuthorization()
|
||||
}
|
||||
|
||||
private func requestAuthorization() {
|
||||
SFSpeechRecognizer.requestAuthorization { authStatus in
|
||||
DispatchQueue.main.async {
|
||||
switch authStatus {
|
||||
case .authorized:
|
||||
break
|
||||
case .denied:
|
||||
self.error = "Speech recognition authorization denied"
|
||||
case .restricted:
|
||||
self.error = "Speech recognition restricted on this device"
|
||||
case .notDetermined:
|
||||
self.error = "Speech recognition not yet authorized"
|
||||
@unknown default:
|
||||
self.error = "Unknown authorization status"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
// If already recording, we might be calling this to restart, so we shouldn't necessarily just return.
|
||||
// But for the public API toggle, we check `isRecording`.
|
||||
// To support internal restarts, we might want to separate the logic.
|
||||
// However, `stopRecording` sets `isRecording = false`.
|
||||
|
||||
if isRecording {
|
||||
stopRecording()
|
||||
// If the user tapped the button, we stop.
|
||||
// If we are auto-restarting, we want to start again immediately.
|
||||
// Let's rely on the caller or proper state management.
|
||||
// If this is called from the UI toggle, we stop.
|
||||
return
|
||||
}
|
||||
|
||||
startRecordingInternal()
|
||||
}
|
||||
|
||||
private func startRecordingInternal() {
|
||||
// Reset state
|
||||
lastFlushedTranscript = ""
|
||||
currentFullTranscription = ""
|
||||
|
||||
// Cancel previous task if any
|
||||
if recognitionTask != nil {
|
||||
recognitionTask?.cancel()
|
||||
recognitionTask = nil
|
||||
}
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
self.error = "Audio session setup failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
guard let recognitionRequest = recognitionRequest else {
|
||||
self.error = "Unable to create recognition request"
|
||||
return
|
||||
}
|
||||
|
||||
// Check network status to determine recognition mode
|
||||
if NetworkMonitor.shared.isConnected {
|
||||
// Force on-device recognition to false as per PRD for higher accuracy when online
|
||||
recognitionRequest.requiresOnDeviceRecognition = false
|
||||
print("SpeechRecognizer: Using server-based recognition (Online)")
|
||||
} else {
|
||||
// Fallback to on-device recognition when offline
|
||||
recognitionRequest.requiresOnDeviceRecognition = true
|
||||
print("SpeechRecognizer: Using on-device recognition (Offline)")
|
||||
}
|
||||
recognitionRequest.shouldReportPartialResults = true
|
||||
|
||||
// Keep a local reference to the request to check against stale callbacks
|
||||
let currentRequest = recognitionRequest
|
||||
|
||||
let inputNode = audioEngine.inputNode
|
||||
|
||||
// Remove any existing tap to prevent "Tap already installed" crash
|
||||
inputNode.removeTap(onBus: 0)
|
||||
|
||||
recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { [weak self] result, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Safety Check: Ignore callbacks from old/stale requests
|
||||
// This prevents a previous session's error/completion from stopping the NEW session
|
||||
guard self.recognitionRequest === currentRequest else {
|
||||
return
|
||||
}
|
||||
|
||||
var isFinal = false
|
||||
|
||||
if let result = result {
|
||||
DispatchQueue.main.async {
|
||||
let fullText = result.bestTranscription.formattedString
|
||||
self.currentFullTranscription = fullText
|
||||
|
||||
// Robust Delta Calculation
|
||||
var newText = fullText
|
||||
|
||||
if !self.lastFlushedTranscript.isEmpty {
|
||||
if fullText.hasPrefix(self.lastFlushedTranscript) {
|
||||
// Perfect match logic
|
||||
newText = String(fullText.dropFirst(self.lastFlushedTranscript.count))
|
||||
} else if fullText.lowercased().hasPrefix(self.lastFlushedTranscript.lowercased()) {
|
||||
// Case-insensitive match logic
|
||||
newText = String(fullText.dropFirst(self.lastFlushedTranscript.count))
|
||||
} else {
|
||||
// Fallback: History rewritten (punctuation/spelling change).
|
||||
// Blindly drop the length of the old text to avoid duplicating history.
|
||||
let dropCount = min(fullText.count, self.lastFlushedTranscript.count)
|
||||
newText = String(fullText.dropFirst(dropCount))
|
||||
}
|
||||
}
|
||||
|
||||
self.transcript = newText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Trigger partial result callback
|
||||
self.onPartialResult?(self.transcript)
|
||||
|
||||
// Reset silence timer on every new result
|
||||
self.resetSilenceTimer()
|
||||
}
|
||||
isFinal = result.isFinal
|
||||
|
||||
// If it's a final result, trigger the callback to broadcast
|
||||
if isFinal {
|
||||
DispatchQueue.main.async {
|
||||
// Send the DELTA (self.transcript) not the full result
|
||||
if !self.transcript.isEmpty {
|
||||
self.onFinalResult?(self.transcript)
|
||||
}
|
||||
self.silenceTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if error != nil || isFinal {
|
||||
// Perform cleanup
|
||||
self.audioEngine.stop()
|
||||
inputNode.removeTap(onBus: 0)
|
||||
self.recognitionRequest = nil
|
||||
self.recognitionTask = nil
|
||||
self.silenceTimer?.invalidate() // Safety invalidation
|
||||
|
||||
if error != nil {
|
||||
DispatchQueue.main.async {
|
||||
self.isRecording = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, when in
|
||||
self.recognitionRequest?.append(buffer)
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
|
||||
do {
|
||||
try audioEngine.start()
|
||||
isRecording = true
|
||||
transcript = "Listening..."
|
||||
} catch {
|
||||
self.error = "Audio engine start failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(shouldUpdateState: Bool = true) {
|
||||
silenceTimer?.invalidate()
|
||||
|
||||
// Manual Flush: If we have pending text, send it now to guarantee it's not lost.
|
||||
if !transcript.isEmpty && transcript != "Listening..." {
|
||||
onFinalResult?(transcript)
|
||||
transcript = ""
|
||||
} else {
|
||||
// Also clear if it was just "Listening..."
|
||||
transcript = ""
|
||||
}
|
||||
|
||||
// Always trigger an empty partial result to clear the UI on the receiver side
|
||||
onPartialResult?("")
|
||||
|
||||
audioEngine.stop()
|
||||
// We use cancel() here instead of endAudio() because we just handled the final result manually.
|
||||
// This prevents double-sending if endAudio() were to trigger a final callback later.
|
||||
recognitionTask?.cancel()
|
||||
|
||||
// Deactivate Audio Session to release Bluetooth/Mic resources
|
||||
// This is critical to prevent interference with MultipeerConnectivity
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
print("Failed to deactivate audio session: \(error)")
|
||||
}
|
||||
|
||||
if shouldUpdateState {
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
private func resetSilenceTimer() {
|
||||
silenceTimer?.invalidate()
|
||||
silenceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
|
||||
self?.handleSilence()
|
||||
}
|
||||
}
|
||||
|
||||
private var lastFlushedTranscript: String = ""
|
||||
private var currentFullTranscription: String = ""
|
||||
|
||||
private func handleSilence() {
|
||||
// Soft flush: Send what we have, clear local view, but keep engine running
|
||||
guard !transcript.isEmpty && transcript != "Listening..." else { return }
|
||||
|
||||
// Send the final result
|
||||
onFinalResult?(transcript)
|
||||
|
||||
// Mark the current *full* text as "flushed" so we ignore it in future updates
|
||||
// We use the full string that the recognizer currently has
|
||||
lastFlushedTranscript = currentFullTranscription
|
||||
|
||||
// Clear local transcript view
|
||||
transcript = ""
|
||||
onPartialResult?("")
|
||||
|
||||
print("Silence detected, flushed text. Engine continuing...")
|
||||
}
|
||||
}
|
||||
|
||||
13
AtTable/UserRole.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
enum UserRole: String, Codable, CaseIterable {
|
||||
case deaf
|
||||
case hearing
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .deaf: return "Deaf / HoH"
|
||||
case .hearing: return "Hearing"
|
||||
}
|
||||
}
|
||||
}
|
||||
255
MPC_how_it_works.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Multipeer Connectivity (MPC) Architecture
|
||||
|
||||
This document explains how AtTable uses Apple's Multipeer Connectivity framework to create a peer-to-peer mesh network for real-time communication between deaf and hearing users.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
AtTable uses **Multipeer Connectivity (MPC)** to establish direct device-to-device connections without requiring a central server. The app supports connections over:
|
||||
|
||||
- **Wi-Fi** (same network)
|
||||
- **Peer-to-peer Wi-Fi** (AWDL - Apple Wireless Direct Link)
|
||||
- **Bluetooth**
|
||||
|
||||
When devices aren't on the same Wi-Fi network (e.g., on 5G/cellular), MPC automatically falls back to **AWDL** for peer-to-peer discovery and data transfer.
|
||||
|
||||
---
|
||||
|
||||
## User Onboarding Flow
|
||||
|
||||
### 1. Initial Setup (`OnboardingView.swift`)
|
||||
|
||||
When a user launches the app:
|
||||
|
||||
1. They enter their **name**
|
||||
2. Select their **role** (Deaf or Hearing)
|
||||
3. Choose an **aura color** (for visual identity in the mesh)
|
||||
4. Tap **"Start Conversation"** to enter the mesh
|
||||
|
||||
```
|
||||
User launches app → OnboardingView → Enter details → ChatView (mesh starts)
|
||||
```
|
||||
|
||||
### 2. Identity Generation (`NodeIdentity.swift`)
|
||||
|
||||
Upon first launch, the app generates a **stable Node Identity**:
|
||||
|
||||
- **`nodeID`**: A UUID persisted in UserDefaults (stable per app installation)
|
||||
- **`instance`**: A monotonic counter that increments each time a session starts
|
||||
|
||||
This identity system allows the mesh to:
|
||||
- Reliably identify users across reconnections
|
||||
- Detect and filter "ghost" peers (stale connections from previous sessions)
|
||||
- Handle device reboots gracefully
|
||||
|
||||
---
|
||||
|
||||
## Network Connection Process
|
||||
|
||||
### Discovery & Connection (`MultipeerSession.swift`)
|
||||
|
||||
When `ChatView` appears, it calls `multipeerSession.start()`, which:
|
||||
|
||||
1. **Sets up the MCSession** with encryption disabled (for faster AWDL connections)
|
||||
2. **Starts browsing** for nearby peers using `MCNearbyServiceBrowser`
|
||||
3. **Starts advertising** (after 0.5s delay) using `MCNearbyServiceAdvertiser`
|
||||
|
||||
### Wi-Fi vs Cellular/5G Connections
|
||||
|
||||
| Network Type | Connection Method | Handshake Delay | Connection Time |
|
||||
|--------------|-------------------|-----------------|-----------------|
|
||||
| **Wi-Fi (same network)** | Infrastructure Wi-Fi | 0.5 seconds | Near-instant |
|
||||
| **Cellular/5G** | AWDL (peer-to-peer Wi-Fi) | 1.5 seconds | Up to 60 seconds |
|
||||
|
||||
The app uses `NetworkMonitor.swift` to detect the current network type and adjusts timing:
|
||||
|
||||
```swift
|
||||
let isWiFi = NetworkMonitor.shared.isWiFi
|
||||
let delay = isWiFi ? 0.5s : 1.5s // Slower for AWDL stability
|
||||
```
|
||||
|
||||
### Deterministic Leader/Follower Protocol
|
||||
|
||||
To prevent connection races (both devices trying to invite each other), the app uses a **deterministic leader election**:
|
||||
|
||||
```swift
|
||||
if myNodeID > theirNodeID {
|
||||
// I am LEADER - I will send the invite
|
||||
} else {
|
||||
// I am FOLLOWER - I wait for their invite
|
||||
}
|
||||
```
|
||||
|
||||
This ensures exactly one device initiates each connection.
|
||||
|
||||
---
|
||||
|
||||
## Handshake Protocol
|
||||
|
||||
Once connected at the socket level, devices exchange **handshake messages** containing:
|
||||
|
||||
```swift
|
||||
struct MeshMessage {
|
||||
var senderNodeID: String // Stable identity
|
||||
var senderInstance: Int // Session counter (for ghost detection)
|
||||
var senderRole: UserRole // Deaf or Hearing
|
||||
var senderColorHex: String // Aura color
|
||||
var isHandshake: Bool // Identifies this as handshake
|
||||
}
|
||||
```
|
||||
|
||||
The handshake:
|
||||
|
||||
1. Registers the peer in `connectedPeerUsers` for UI display
|
||||
2. Starts a **15-second stability timer** before clearing failure counters
|
||||
3. Maps the `MCPeerID` to the stable `nodeID` for reliable identification
|
||||
|
||||
---
|
||||
|
||||
## User Leaving the Conversation
|
||||
|
||||
### Explicit Leave (`ChatView.swift`)
|
||||
|
||||
When a user taps **"Leave"**:
|
||||
|
||||
```swift
|
||||
Button(action: {
|
||||
speechRecognizer.stopRecording() // Stop audio transcription
|
||||
multipeerSession.stop() // Disconnect from mesh
|
||||
isOnboardingComplete = false // Return to onboarding
|
||||
})
|
||||
```
|
||||
|
||||
### Disconnect Cleanup (`MultipeerSession.disconnect()`)
|
||||
|
||||
The `disconnect()` function performs complete cleanup:
|
||||
|
||||
1. **Cancel pending work**: Recovery tasks, connection timers
|
||||
2. **Stop services**: Advertising and browsing
|
||||
3. **Clear delegates**: Prevent zombie callbacks
|
||||
4. **Disconnect session**: `session?.disconnect()`
|
||||
5. **Clear all state**:
|
||||
- `connectedPeers` / `connectedPeerUsers`
|
||||
- `pendingInvites` / `latestByNodeID`
|
||||
- `cooldownUntil` / `consecutiveFailures`
|
||||
6. **Stop keep-alive heartbeats**
|
||||
|
||||
### Partial Transcript Preservation
|
||||
|
||||
If a peer disconnects mid-speech, their **partial transcript is preserved** as a final message:
|
||||
|
||||
```swift
|
||||
if let partialText = liveTranscripts[peerKey], !partialText.isEmpty {
|
||||
let finalMessage = MeshMessage(content: partialText, ...)
|
||||
receivedMessages.append(finalMessage)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rejoining the Conversation
|
||||
|
||||
### Identity Recovery
|
||||
|
||||
When a user returns to the conversation:
|
||||
|
||||
1. App resets `isOnboardingComplete = false` on every launch (intentional - forces Login screen)
|
||||
2. User completes onboarding again (name/role/color preserved in `@AppStorage`)
|
||||
3. `multipeerSession.start()` called again
|
||||
|
||||
### Instance Increment
|
||||
|
||||
The key to reliable rejoining is the **instance counter**:
|
||||
|
||||
```swift
|
||||
myInstance = NodeIdentity.nextInstance() // Monotonically increasing
|
||||
```
|
||||
|
||||
When other devices see the new instance:
|
||||
|
||||
1. **Ghost Detection**: Old connections with lower instances are rejected
|
||||
2. **Cooldown Clear**: Any cooldowns from previous failures are removed
|
||||
3. **Fresh Connect**: The leader initiates a new invitation
|
||||
|
||||
### Handling Stale Peers
|
||||
|
||||
The mesh uses multiple mechanisms to handle rejoins:
|
||||
|
||||
| Mechanism | Purpose |
|
||||
|-----------|---------|
|
||||
| **Ghost Filtering** | Reject messages/invites from older instances |
|
||||
| **Cooldown Clear** | Give returning peers a fresh chance |
|
||||
| **Half-Open Deadlock Fix** | If we think we're connected but they invite us, accept the new invite |
|
||||
| **Stability Timer** | Only reset failure counts after 15s of stable connection |
|
||||
|
||||
---
|
||||
|
||||
## Keep-Alive & Mesh Health
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
When connected, the mesh sends **heartbeats every 10 seconds**:
|
||||
|
||||
```swift
|
||||
let message = MeshMessage(
|
||||
content: "💓",
|
||||
isKeepAlive: true,
|
||||
connectedNodeIDs: connectedPeerUsers.map { $0.nodeID } // Gossip
|
||||
)
|
||||
```
|
||||
|
||||
### Gossip Protocol
|
||||
|
||||
Heartbeats include a list of connected peers, enabling **clique repair**:
|
||||
|
||||
1. Device A receives heartbeat from Device B
|
||||
2. If B knows Device C but A doesn't, A can proactively invite C
|
||||
3. This heals mesh partitions without requiring everyone to be discoverable
|
||||
|
||||
---
|
||||
|
||||
## Connection Recovery
|
||||
|
||||
### Exponential Backoff
|
||||
|
||||
Failed connections trigger increasing cooldown periods:
|
||||
|
||||
```swift
|
||||
// 0.5s → 1.0s → 2.0s → 4.0s → ... → max 30s
|
||||
let delay = min(0.5 * pow(2, failures - 1), 30.0)
|
||||
```
|
||||
|
||||
### Smart Retry
|
||||
|
||||
Instead of restarting everything, failed connections are retried individually:
|
||||
|
||||
1. Only the **leader** initiates retries (prevents race conditions)
|
||||
2. Retries respect cooldown periods
|
||||
3. After 5 consecutive failures → **"Poisoned State"** triggers full reset
|
||||
|
||||
### Poisoned State Recovery
|
||||
|
||||
If a peer has too many consecutive failures:
|
||||
|
||||
```swift
|
||||
if failures >= 5 {
|
||||
restartServices(forcePoisonedRecovery: true)
|
||||
// Creates new MCPeerID, clears all cooldowns
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Event | What Happens |
|
||||
|-------|--------------|
|
||||
| **User joins** | NodeID retrieved, instance incremented, advertise + browse started |
|
||||
| **On Wi-Fi** | Fast handshake (0.5s), near-instant connections |
|
||||
| **On 5G/Cellular** | AWDL used, slower handshake (1.5s), up to 60s to connect |
|
||||
| **User leaves** | Full cleanup, partial transcripts preserved |
|
||||
| **User rejoins** | New instance number, ghosts filtered, cooldowns cleared |
|
||||
| **Connection fails** | Exponential backoff, smart retry by leader only |
|
||||
|
||||
The architecture prioritizes **reliability over speed**, using defensive mechanisms like ghost filtering, stability timers, and gossip-based clique repair to maintain mesh health despite the inherent unreliability of peer-to-peer wireless connections.
|
||||
11
MeshInfo.plist
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_access-mesh._tcp</string>
|
||||
<string>_access-mesh._udp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# At-Table
|
||||
|
||||
**Bridging the gap between Deaf and Hearing conversations through local mesh networking and calm design.**
|
||||
|
||||
At-Table is an iOS accessibility application designed to enable seamless, simultaneous conversations between multiple deaf and hearing individuals. By leveraging a full mesh network, devices connect automatically without the need for Wi-Fi routers or cellular data, creating a local "table" of communication.
|
||||
|
||||
The app prioritizes a stress-free environment for deaf users, utilizing calming visuals to counteract the anxiety often associated with following rapid-fire hearing conversations.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### Core Communication
|
||||
* **Full Mesh Networking:** Devices enter a peer-to-peer full mesh network using Apple's Multipeer Connectivity. No manual pairing or internet connection is required to chat.
|
||||
* **Real-Time Transcription (Hearing Role):** Hearing users speak naturally. The app transcribes speech in real-time and automatically posts the text as a message to the group once the speaker pauses.
|
||||
* **Text & Quick Replies (Deaf Role):** Deaf users can type messages or use one-tap "Quick Reply" chips (Yes, No, Hold on, Thanks) for rapid interaction.
|
||||
* **Role-Based UI:** Distinct interfaces tailored for "Deaf/HoH" and "Hearing" users, optimized for their specific communication needs.
|
||||
|
||||
### Visual Design & Atmosphere
|
||||
* **Calm ASMR Aesthetic:** The background features a slow-moving, blurring mesh of "Liquid Glass" orbs. This visual ASMR is designed to induce a calm mood, helping alleviate the cognitive load and stress deaf users experience during hearing conversations.
|
||||
* **Personalized Auras:** Users select a "Neon Aura" color during onboarding. This color identifies them in the mesh and dynamically influences the background animation of their device.
|
||||
* **Live Transcript Streaming:** Hearing users' speech appears as "partial" streaming text on receiving devices before becoming a finalized message, allowing for faster reading speeds.
|
||||
|
||||
### Technical Resilience
|
||||
* **Ghost Filtering:** Advanced logic prevents "ghost" connections (stale peer signals) from clogging the network.
|
||||
* **Smart Recovery:** Includes "Identity Thrashing" prevention and auto-reconnection logic to handle interruptions or app backgrounding seamlessly.
|
||||
* **Hybrid Speech Recognition:** Utilizes server-based speech recognition for high accuracy when online, with a seamless fallback to on-device recognition when offline.
|
||||
|
||||
## 📱 How It Works
|
||||
|
||||
1. **Onboarding:** Users enter a display name, select their role (Deaf/HoH or Hearing), and choose an identifying color.
|
||||
2. **Discovery:** The app automatically advertises and browses for nearby devices running At-Table.
|
||||
3. **Connection:** Devices negotiate a connection automatically using a leader/follower algorithm to prevent collision loops.
|
||||
4. **The Conversation:**
|
||||
* **Hearing users** simply hold their phone; the microphone listens for speech, visualizes it, and broadcasts it.
|
||||
* **Deaf users** read incoming bubbles and participate via text.
|
||||
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
* **Transient Data:** Messages and transcriptions are ephemeral. They exist only for the duration of the session and are not stored in any persistent database or uploaded to a cloud server (other than the temporary audio buffer sent to Apple for transcription if online).
|
||||
* **Local Connectivity:** Chat data flows directly between devices over Wi-Fi/Bluetooth peer-to-peer protocols.
|
||||
BIN
Screenshots/iPad/1.PNG
Normal file
|
After Width: | Height: | Size: 16 MiB |
BIN
Screenshots/iPad/2.PNG
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Screenshots/iPhone/1.png
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
Screenshots/iPhone/2.PNG
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
31
check_build.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/zsh
|
||||
set -o pipefail # Fail if xcodebuild fails, even with xcbeautify
|
||||
|
||||
# --- Configuration ---
|
||||
TARGET_NAME="AtTable"
|
||||
DEVICE_NAME="iPhone 17 Pro"
|
||||
BUILD_PATH="$(pwd)/build"
|
||||
|
||||
echo "🔍 Checking compilation for $TARGET_NAME..."
|
||||
|
||||
# Build Only (No Install/Launch)
|
||||
# Uses -target instead of -scheme to bypass potential scheme misconfigurations
|
||||
# Explicitly unsets CC/CXX/LIBCLANG_PATH to avoid environment pollution
|
||||
# Also overrides them in xcodebuild arguments to ensure Xcode uses default toolchain
|
||||
# Uses SYMROOT instead of -derivedDataPath because -derivedDataPath requires -scheme
|
||||
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||
-target "$TARGET_NAME" \
|
||||
-sdk iphonesimulator \
|
||||
-destination "platform=iOS Simulator,name=$DEVICE_NAME" \
|
||||
-configuration Debug \
|
||||
SYMROOT="$BUILD_PATH" \
|
||||
CC=clang CXX=clang++ LIBCLANG_PATH= \
|
||||
build | xcbeautify
|
||||
|
||||
# Check exit code of the pipeline
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Build Succeeded. No errors found."
|
||||
else
|
||||
echo "❌ Build Failed."
|
||||
exit 1
|
||||
fi
|
||||