Initial commit: FlipTalk iOS app
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
67
.gitignore
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||||
|
|
||||||
|
# Xcode build state
|
||||||
|
*.moved-aside
|
||||||
|
*.xcuserstate
|
||||||
|
*.xccheckout
|
||||||
|
*.xcscmblueprint
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
|
# Carthage
|
||||||
|
Carthage/Build/
|
||||||
|
Carthage/Checkouts/
|
||||||
|
|
||||||
|
# Node (if any JS tooling)
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.npm/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.mobileprovision
|
||||||
361
FlipTalk.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
7F95F89F2EDF7D3B00ABB7F4 /* FlipTalk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FlipTalk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7F95F8A12EDF7D3B00ABB7F4 /* FlipTalk */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = FlipTalk;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
7F95F89C2EDF7D3B00ABB7F4 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
7F95F8962EDF7D3B00ABB7F4 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F95F8A12EDF7D3B00ABB7F4 /* FlipTalk */,
|
||||||
|
7F95F8A02EDF7D3B00ABB7F4 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7F95F8A02EDF7D3B00ABB7F4 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F95F89F2EDF7D3B00ABB7F4 /* FlipTalk.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
7F95F89E2EDF7D3B00ABB7F4 /* FlipTalk */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7F95F8AA2EDF7D3C00ABB7F4 /* Build configuration list for PBXNativeTarget "FlipTalk" */;
|
||||||
|
buildPhases = (
|
||||||
|
7F95F89B2EDF7D3B00ABB7F4 /* Sources */,
|
||||||
|
7F95F89C2EDF7D3B00ABB7F4 /* Frameworks */,
|
||||||
|
7F95F89D2EDF7D3B00ABB7F4 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7F95F8A12EDF7D3B00ABB7F4 /* FlipTalk */,
|
||||||
|
);
|
||||||
|
name = FlipTalk;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = FlipTalk;
|
||||||
|
productReference = 7F95F89F2EDF7D3B00ABB7F4 /* FlipTalk.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
7F95F8972EDF7D3B00ABB7F4 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2610;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
7F95F89E2EDF7D3B00ABB7F4 = {
|
||||||
|
CreatedOnToolsVersion = 26.1.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 7F95F89A2EDF7D3B00ABB7F4 /* Build configuration list for PBXProject "FlipTalk" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 7F95F8962EDF7D3B00ABB7F4;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 7F95F8A02EDF7D3B00ABB7F4 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
7F95F89E2EDF7D3B00ABB7F4 /* FlipTalk */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
7F95F89D2EDF7D3B00ABB7F4 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
7F95F89B2EDF7D3B00ABB7F4 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
7F95F8A82EDF7D3C00ABB7F4 /* 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.1;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F95F8A92EDF7D3C00ABB7F4 /* 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.1;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7F95F8AB2EDF7D3C00ABB7F4 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Flip-Talk";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Needed for recording the person speaking";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Needed to transcribe.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.jaredlog.Flip-Talk";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
7F95F8AC2EDF7D3C00ABB7F4 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Flip-Talk";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Needed for recording the person speaking";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Needed to transcribe.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.jaredlog.Flip-Talk";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
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 */
|
||||||
|
7F95F89A2EDF7D3B00ABB7F4 /* Build configuration list for PBXProject "FlipTalk" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F95F8A82EDF7D3C00ABB7F4 /* Debug */,
|
||||||
|
7F95F8A92EDF7D3C00ABB7F4 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
7F95F8AA2EDF7D3C00ABB7F4 /* Build configuration list for PBXNativeTarget "FlipTalk" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F95F8AB2EDF7D3C00ABB7F4 /* Debug */,
|
||||||
|
7F95F8AC2EDF7D3C00ABB7F4 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 7F95F8972EDF7D3B00ABB7F4 /* Project object */;
|
||||||
|
}
|
||||||
7
FlipTalk.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>
|
||||||
78
FlipTalk.xcodeproj/xcshareddata/xcschemes/FlipTalk.xcscheme
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F95F89E2EDF7D3B00ABB7F4"
|
||||||
|
BuildableName = "FlipTalk.app"
|
||||||
|
BlueprintName = "FlipTalk"
|
||||||
|
ReferencedContainer = "container:FlipTalk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<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 = "7F95F89E2EDF7D3B00ABB7F4"
|
||||||
|
BuildableName = "FlipTalk.app"
|
||||||
|
BlueprintName = "FlipTalk"
|
||||||
|
ReferencedContainer = "container:FlipTalk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F95F89E2EDF7D3B00ABB7F4"
|
||||||
|
BuildableName = "FlipTalk.app"
|
||||||
|
BlueprintName = "FlipTalk"
|
||||||
|
ReferencedContainer = "container:FlipTalk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
11
FlipTalk/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 977 B |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
FlipTalk/Assets.xcassets/AppIcon.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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
FlipTalk/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
1998
FlipTalk/ContentView.swift
Normal file
20
FlipTalk/FlipTalkApp.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// FlipTalkApp.swift
|
||||||
|
// FlipTalk
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/2/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FlipTalkApp: App {
|
||||||
|
@StateObject private var themeManager = ThemeManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(themeManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
FlipTalk/LanguageManager.swift
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
#if canImport(Translation)
|
||||||
|
import Translation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class LanguageManager: ObservableObject {
|
||||||
|
static let shared = LanguageManager()
|
||||||
|
|
||||||
|
@Published var supportedLanguages: [LanguageStatus] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize with candidates assuming internet required (fail-safe)
|
||||||
|
self.supportedLanguages = candidateLocales.compactMap { id in
|
||||||
|
let locale = Locale(identifier: id)
|
||||||
|
return LanguageStatus(id: id, locale: locale, isOnlineRequired: true)
|
||||||
|
}.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LanguageStatus: Identifiable, Equatable {
|
||||||
|
let id: String // Locale identifier
|
||||||
|
let locale: Locale
|
||||||
|
let isOnlineRequired: Bool
|
||||||
|
|
||||||
|
// English names for all supported languages
|
||||||
|
private static let englishNames: [String: String] = [
|
||||||
|
"ar-SA": "Arabic",
|
||||||
|
"zh-CN": "Chinese (Simplified)",
|
||||||
|
"zh-TW": "Chinese (Traditional)",
|
||||||
|
"nl-NL": "Dutch",
|
||||||
|
"en-US": "English (United States)",
|
||||||
|
"en-UK": "English (United Kingdom)",
|
||||||
|
"fr-FR": "French (France)",
|
||||||
|
"de-DE": "German",
|
||||||
|
"id-ID": "Indonesian",
|
||||||
|
"it-IT": "Italian",
|
||||||
|
"ja-JP": "Japanese",
|
||||||
|
"ko-KR": "Korean",
|
||||||
|
"pl-PL": "Polish",
|
||||||
|
"pt-BR": "Portuguese (Brazil)",
|
||||||
|
"ru-RU": "Russian",
|
||||||
|
"es-ES": "Spanish (Spain)",
|
||||||
|
"es-MX": "Spanish (Mexico)",
|
||||||
|
"th-TH": "Thai",
|
||||||
|
"tr-TR": "Turkish",
|
||||||
|
"uk-UA": "Ukrainian",
|
||||||
|
"vi-VN": "Vietnamese"
|
||||||
|
]
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
Self.englishNames[id] ?? locale.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
locale.flagEmoji ?? "🏳️"
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiresInternet: Bool {
|
||||||
|
isOnlineRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Languages that don't have downloadable packs (use built-in/internet)
|
||||||
|
var isDownloadAvailable: Bool {
|
||||||
|
// Only en-UK and es-MX don't have download available
|
||||||
|
return id != "en-UK" && id != "es-MX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A curated list of languages supported by Apple Translate
|
||||||
|
private let candidateLocales: [String] = [
|
||||||
|
"ar-SA", "zh-CN", "zh-TW", "nl-NL", "en-UK", "fr-FR",
|
||||||
|
"de-DE", "id-ID", "it-IT", "ja-JP", "ko-KR", "pl-PL", "pt-BR",
|
||||||
|
"ru-RU", "es-ES", "es-MX", "th-TH", "tr-TR", "uk-UA", "vi-VN"
|
||||||
|
]
|
||||||
|
|
||||||
|
private let startSpeakingTranslations: [String: String] = [
|
||||||
|
"ar-SA": "تحدث الآن...",
|
||||||
|
"zh-CN": "开始说话...",
|
||||||
|
"zh-TW": "開始說話...",
|
||||||
|
"nl-NL": "Begin met spreken...",
|
||||||
|
"en-US": "Start speaking...",
|
||||||
|
"en-UK": "Start speaking...",
|
||||||
|
"fr-FR": "Commencez à parler...",
|
||||||
|
"de-DE": "Jetzt sprechen...",
|
||||||
|
"id-ID": "Mulai berbicara...",
|
||||||
|
"it-IT": "Inizia a parlare...",
|
||||||
|
"ja-JP": "話し始めてください...",
|
||||||
|
"ko-KR": "말씀해 주세요...",
|
||||||
|
"pl-PL": "Zacznij mówić...",
|
||||||
|
"pt-BR": "Comece a falar...",
|
||||||
|
"ru-RU": "Начните говорить...",
|
||||||
|
"es-ES": "Empieza a hablar...",
|
||||||
|
"es-MX": "Empieza a hablar...",
|
||||||
|
"th-TH": "เริ่มพูด...",
|
||||||
|
"tr-TR": "Konuşmaya başla...",
|
||||||
|
"uk-UA": "Почніть говорити...",
|
||||||
|
"vi-VN": "Bắt đầu nói..."
|
||||||
|
]
|
||||||
|
|
||||||
|
func getStartSpeakingText(for identifier: String) -> String {
|
||||||
|
return startSpeakingTranslations[identifier] ?? "Start speaking..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAvailability() async {
|
||||||
|
guard #available(iOS 18.0, *) else { return }
|
||||||
|
|
||||||
|
#if canImport(Translation)
|
||||||
|
var results: [LanguageStatus] = []
|
||||||
|
let availability = LanguageAvailability()
|
||||||
|
let source = Locale.Language(identifier: "en-US")
|
||||||
|
|
||||||
|
for identifier in candidateLocales {
|
||||||
|
let target = Locale.Language(identifier: identifier)
|
||||||
|
let status = await availability.status(from: source, to: target)
|
||||||
|
|
||||||
|
// Should we limit to only supported/installed?
|
||||||
|
// If we initialized with all candidates, maybe we should keep all candidates
|
||||||
|
// but update their online status?
|
||||||
|
// For now, let's just stick to the discovered ones to be accurate about "Translation" support.
|
||||||
|
// Actually, if we want the menu to work, we need items.
|
||||||
|
// If 'status' is unsupported, we should probably remove it?
|
||||||
|
// But 'candidateLocales' are presumably supported by the backend.
|
||||||
|
|
||||||
|
if status == .supported || status == .installed {
|
||||||
|
let locale = Locale(identifier: identifier)
|
||||||
|
let isOnline = (status == .supported)
|
||||||
|
results.append(LanguageStatus(id: identifier, locale: locale, isOnlineRequired: isOnline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
// Update the list with verified verification status
|
||||||
|
if !results.isEmpty {
|
||||||
|
self.supportedLanguages = results.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a language identifier is internet-only (no download available)
|
||||||
|
func isInternetOnlyLanguage(_ identifier: String) -> Bool {
|
||||||
|
return identifier == "en-UK" || identifier == "es-MX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Locale {
|
||||||
|
var flagEmoji: String? {
|
||||||
|
// Simple heuristic for region code
|
||||||
|
guard let region = self.region?.identifier else {
|
||||||
|
// Try to parse from identifier if region is missing
|
||||||
|
let parts = identifier.split(separator: "-")
|
||||||
|
if parts.count > 1 {
|
||||||
|
return String(parts.last!).flagEmoji
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return region.flagEmoji
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var flagEmoji: String {
|
||||||
|
let base: UInt32 = 127397
|
||||||
|
var s = ""
|
||||||
|
for v in self.unicodeScalars {
|
||||||
|
s.unicodeScalars.append(UnicodeScalar(base + v.value)!)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
136
FlipTalk/RecommendedVoicesView.swift
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RecommendedVoicesView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
Section(header: Text("English (United States)")) {
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Alex",
|
||||||
|
details: "Male, High Quality",
|
||||||
|
description: "The smartest voice on iOS; breathes between sentences, sounds academic and very natural."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Samantha",
|
||||||
|
details: "Female, Standard",
|
||||||
|
description: "The classic \"original Siri\" voice; clear and friendly but slightly computerized."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Ava",
|
||||||
|
details: "Female, Premium",
|
||||||
|
description: "A modern, high-quality voice that sounds professional, warm, and very human-like."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Allison",
|
||||||
|
details: "Female, Premium",
|
||||||
|
description: "A lighter, breathy, and pleasant voice; sounds like a helpful assistant."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Tom",
|
||||||
|
details: "Male, Premium",
|
||||||
|
description: "A friendly, standard American male voice; clear and trustworthy."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Susan",
|
||||||
|
details: "Female, Standard/Premium",
|
||||||
|
description: "A slightly more formal and crisp voice; sounds like a teacher or automated reader."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Zoe",
|
||||||
|
details: "Female, Premium",
|
||||||
|
description: "A bright, cheerful, and younger-sounding voice; energetic vibe."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Evan",
|
||||||
|
details: "Male, Enhanced",
|
||||||
|
description: "A deep, smooth, and modern voice; very natural sounding."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Nathan",
|
||||||
|
details: "Male, Enhanced",
|
||||||
|
description: "A lighter, younger-sounding male voice; casual and friendly."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Noelle",
|
||||||
|
details: "Female, Enhanced",
|
||||||
|
description: "A soft, sweet, and modern female voice; very smooth flow."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Joelle",
|
||||||
|
details: "Female, Enhanced",
|
||||||
|
description: "A clear, articulate, and slightly deeper modern female voice."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Aaron (Siri Voice 2)",
|
||||||
|
details: "Male, Neural",
|
||||||
|
description: "The current standard American Male Siri voice; distinct, helpful, and highly polished."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Nicky (Siri Voice 1)",
|
||||||
|
details: "Female, Neural",
|
||||||
|
description: "The current standard American Female Siri voice; recognizable and high-fidelity."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Spanish (Mexico)")) {
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Paulina",
|
||||||
|
details: "Female, Standard/Premium",
|
||||||
|
description: "The gold standard for Mexican Spanish; sounds like a professional news anchor or navigator."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Juan",
|
||||||
|
details: "Male, Standard/Premium",
|
||||||
|
description: "A clear, neutral male voice; sounds polite but slightly more robotic than Paulina."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Siri Female (Voice 1)",
|
||||||
|
details: "Female, Neural",
|
||||||
|
description: "(If downloaded) Very smooth, natural, and helpful; indistinguishable from a real human assistant."
|
||||||
|
)
|
||||||
|
VoiceRecommendationRow(
|
||||||
|
name: "Siri Male (Voice 2)",
|
||||||
|
details: "Male, Neural",
|
||||||
|
description: "(If downloaded) A professional, modern male assistant voice with a Mexican accent."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Recommended Voices")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoiceRecommendationRow: View {
|
||||||
|
let name: String
|
||||||
|
let details: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(name)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text(details)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
Text(description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
253
FlipTalk/SettingsView.swift
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
#if canImport(Translation)
|
||||||
|
import Translation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
|
@ObservedObject var voiceManager = VoiceManager.shared
|
||||||
|
@State private var showRecommendedVoices = false
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
// Easter Egg State
|
||||||
|
@State private var lightbulbTapCount = 0
|
||||||
|
@State private var showEasterEgg = false
|
||||||
|
|
||||||
|
// Persist selected language
|
||||||
|
@AppStorage("targetLanguageIdentifier") private var targetLanguageIdentifier: String = ""
|
||||||
|
|
||||||
|
// Download confirmation state
|
||||||
|
@State private var showDownloadAlert = false
|
||||||
|
@State private var languageToDownload: LanguageManager.LanguageStatus?
|
||||||
|
@State private var previousLanguageIdentifier: String = ""
|
||||||
|
|
||||||
|
// Translation configuration for triggering download
|
||||||
|
#if canImport(Translation)
|
||||||
|
@State private var downloadConfig: TranslationSession.Configuration?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Theme Colors")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textCase(nil)) {
|
||||||
|
Picker("Theme Colors", selection: $themeManager.currentTheme) {
|
||||||
|
ForEach(AppTheme.allCases) { theme in
|
||||||
|
Text(theme.displayName)
|
||||||
|
.tag(theme)
|
||||||
|
.onTapGesture {
|
||||||
|
themeManager.currentTheme = theme
|
||||||
|
if theme == .lightbulb {
|
||||||
|
lightbulbTapCount += 1
|
||||||
|
if lightbulbTapCount >= 5 {
|
||||||
|
showEasterEgg = true
|
||||||
|
lightbulbTapCount = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lightbulbTapCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Translation Language")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textCase(nil)) {
|
||||||
|
Picker("Target Language", selection: $targetLanguageIdentifier) {
|
||||||
|
Text("Select a language").tag("")
|
||||||
|
ForEach(LanguageManager.shared.supportedLanguages) { lang in
|
||||||
|
if lang.isDownloadAvailable {
|
||||||
|
Text("\(lang.flag) \(lang.name) (Download available)").tag(lang.id)
|
||||||
|
} else {
|
||||||
|
Text("\(lang.flag) \(lang.name) (Internet required)").tag(lang.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Voice Settings")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textCase(nil),
|
||||||
|
footer: Text("Tip: For the most natural sound, download \"Enhanced\" or \"Premium\" voices in your iPhone Settings:\nSettings > Accessibility > Read & Speak > Voices.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 8)) {
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("English Voice")
|
||||||
|
.font(.headline)
|
||||||
|
Picker("English (US)", selection: $voiceManager.selectedEnglishVoiceIdentifier) {
|
||||||
|
ForEach(voiceManager.availableEnglishVoices, id: \.identifier) { voice in
|
||||||
|
Text(voiceManager.description(for: voice))
|
||||||
|
.tag(voice.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
// Dynamic Header for Target Voice
|
||||||
|
if let lang = LanguageManager.shared.supportedLanguages.first(where: { $0.id == targetLanguageIdentifier }) {
|
||||||
|
Text("\(lang.name) Voice")
|
||||||
|
.font(.headline)
|
||||||
|
} else {
|
||||||
|
Text("Target Language Voice")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Voice", selection: $voiceManager.selectedTargetVoiceIdentifier) {
|
||||||
|
ForEach(voiceManager.availableTargetVoices, id: \.identifier) { voice in
|
||||||
|
Text(voiceManager.description(for: voice))
|
||||||
|
.tag(voice.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.disabled(voiceManager.availableTargetVoices.isEmpty)
|
||||||
|
|
||||||
|
if voiceManager.availableTargetVoices.isEmpty {
|
||||||
|
Text("No specific voices found for this language. System default will be used.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showRecommendedVoices = true
|
||||||
|
}) {
|
||||||
|
Text("See recommended voices.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Ensure VoiceManager has the correct target language loaded on appear
|
||||||
|
if !targetLanguageIdentifier.isEmpty {
|
||||||
|
voiceManager.updateTargetLanguage(to: targetLanguageIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: targetLanguageIdentifier) { oldValue, newValue in
|
||||||
|
// Update voice manager when user picks a new language
|
||||||
|
voiceManager.updateTargetLanguage(to: newValue)
|
||||||
|
|
||||||
|
// Check if this language is downloadable and prompt for download
|
||||||
|
if !newValue.isEmpty,
|
||||||
|
let lang = LanguageManager.shared.supportedLanguages.first(where: { $0.id == newValue }),
|
||||||
|
lang.isDownloadAvailable {
|
||||||
|
previousLanguageIdentifier = oldValue
|
||||||
|
languageToDownload = lang
|
||||||
|
showDownloadAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
// Settings closed
|
||||||
|
}
|
||||||
|
.alert("Download Language", isPresented: $showDownloadAlert) {
|
||||||
|
Button("Download") {
|
||||||
|
triggerDownload()
|
||||||
|
}
|
||||||
|
Button("Not Now", role: .cancel) {
|
||||||
|
// Keep the selection but don't download
|
||||||
|
languageToDownload = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let lang = languageToDownload {
|
||||||
|
Text("Would you like to download \(lang.name) for offline translation? This provides faster translations and works without internet.")
|
||||||
|
} else {
|
||||||
|
Text("Would you like to download this language for offline translation?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if canImport(Translation)
|
||||||
|
.translationTask(downloadConfig) { session in
|
||||||
|
// This will trigger Apple's download UI automatically
|
||||||
|
do {
|
||||||
|
try await session.prepareTranslation()
|
||||||
|
} catch {
|
||||||
|
print("Download preparation failed: \(error)")
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
downloadConfig = nil
|
||||||
|
languageToDownload = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.sheet(isPresented: $showRecommendedVoices) {
|
||||||
|
RecommendedVoicesView()
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showEasterEgg) {
|
||||||
|
EasterEggPlayerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerDownload() {
|
||||||
|
guard let lang = languageToDownload else { return }
|
||||||
|
|
||||||
|
#if canImport(Translation)
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
// Create a configuration to trigger the download
|
||||||
|
downloadConfig = TranslationSession.Configuration(
|
||||||
|
source: Locale.Language(identifier: "en-US"),
|
||||||
|
target: Locale.Language(identifier: lang.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EasterEggPlayerView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var player: AVPlayer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
|
if let player = player {
|
||||||
|
VideoPlayer(player: player)
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
.onAppear {
|
||||||
|
player.play()
|
||||||
|
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let url = Bundle.main.url(forResource: "easteregg", withExtension: "mp4") {
|
||||||
|
self.player = AVPlayer(url: url)
|
||||||
|
} else {
|
||||||
|
print("Easter egg video not found")
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
player?.pause()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
FlipTalk/ThemeManager.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum AppTheme: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case dark
|
||||||
|
case light
|
||||||
|
case lightbulb
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .dark: return "Default (Dark)"
|
||||||
|
case .light: return "Light"
|
||||||
|
case .lightbulb: return "Lightbulb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThemeManager: ObservableObject {
|
||||||
|
@Published var currentTheme: AppTheme {
|
||||||
|
didSet {
|
||||||
|
saveTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: "selectedTheme"),
|
||||||
|
let theme = try? JSONDecoder().decode(AppTheme.self, from: data) {
|
||||||
|
self.currentTheme = theme
|
||||||
|
} else {
|
||||||
|
self.currentTheme = .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveTheme() {
|
||||||
|
if let data = try? JSONEncoder().encode(currentTheme) {
|
||||||
|
UserDefaults.standard.set(data, forKey: "selectedTheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Accessors
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch currentTheme {
|
||||||
|
case .dark: return .black
|
||||||
|
case .light: return .white
|
||||||
|
case .lightbulb: return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var textColor: Color {
|
||||||
|
switch currentTheme {
|
||||||
|
case .dark: return .white
|
||||||
|
case .light: return .black
|
||||||
|
case .lightbulb: return .yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondaryTextColor: Color {
|
||||||
|
switch currentTheme {
|
||||||
|
case .dark: return .white.opacity(0.25)
|
||||||
|
case .light: return .black.opacity(0.25)
|
||||||
|
case .lightbulb: return .yellow.opacity(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var menuBackgroundColor: Color {
|
||||||
|
// Keeping menu dark for now as per usual side menu patterns, or should it match?
|
||||||
|
// User asked for "main screen and voice screen" colors.
|
||||||
|
// Let's make the menu adaptive too for consistency, or keep it dark?
|
||||||
|
// "Theme Colors ... for the main screen and the voice screen"
|
||||||
|
// Let's assume Side Menu should probably at least not clash.
|
||||||
|
// For now, let's keep side menu dark to distinguish it, or maybe match?
|
||||||
|
// Let's stick to modifying main and voice screens primarily as requested.
|
||||||
|
// But the text in side menu is white. If we make background white, text needs to be black.
|
||||||
|
return .black // Keeping side menu consistently dark for this iteration unless genericized.
|
||||||
|
}
|
||||||
|
}
|
||||||
185
FlipTalk/VoiceManager.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class VoiceManager: ObservableObject {
|
||||||
|
static let shared = VoiceManager()
|
||||||
|
|
||||||
|
@Published var availableEnglishVoices: [AVSpeechSynthesisVoice] = []
|
||||||
|
@Published var availableTargetVoices: [AVSpeechSynthesisVoice] = []
|
||||||
|
|
||||||
|
// UserDefaults Keys
|
||||||
|
private let kSelectedEnglishVoice = "selectedEnglishVoiceIdentifier"
|
||||||
|
private let kSelectedTargetVoice = "selectedTargetVoiceIdentifier"
|
||||||
|
|
||||||
|
// Internal state to track current target language
|
||||||
|
private var currentTargetLocaleID: String = "es-MX" // Default to Spanish (MX)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Load initial target language from somewhere or default
|
||||||
|
// For now, we load with default, but we'll expose a method to update it
|
||||||
|
loadVoices()
|
||||||
|
setupNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(forName: Notification.Name("AVSpeechSynthesisVoiceIdentifierDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in
|
||||||
|
self?.loadVoices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTargetLanguage(to localeID: String) {
|
||||||
|
guard !localeID.isEmpty else { return }
|
||||||
|
self.currentTargetLocaleID = localeID
|
||||||
|
loadVoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadVoices() {
|
||||||
|
let allVoices = AVSpeechSynthesisVoice.speechVoices()
|
||||||
|
|
||||||
|
// Blacklist of Novelty voices to exclude
|
||||||
|
let noveltyVoices = Set([
|
||||||
|
"Albert", "Bad News", "Bahh", "Bells", "Boing", "Bubbles", "Cellos",
|
||||||
|
"Deranged", "Good News", "Hysterical", "Junior", "Kathy", "Organ",
|
||||||
|
"Princess", "Ralph", "Trinoids", "Whisper", "Zarvox",
|
||||||
|
"Jester", "Superstar", "Wobble", "Fred",
|
||||||
|
"Eddy", "Flo", "Grandma", "Grandpa", "Reed", "Rocko", "Sandy", "Shelley"
|
||||||
|
])
|
||||||
|
|
||||||
|
// Helper to sort by quality (Premium > Enhanced > Default)
|
||||||
|
let sortComparator: (AVSpeechSynthesisVoice, AVSpeechSynthesisVoice) -> Bool = { v1, v2 in
|
||||||
|
// First sort by Quality
|
||||||
|
if v1.quality != v2.quality {
|
||||||
|
// strict order: premium > enhanced > default
|
||||||
|
let q1 = v1.quality == .premium ? 3 : (v1.quality == .enhanced ? 2 : 1)
|
||||||
|
let q2 = v2.quality == .premium ? 3 : (v2.quality == .enhanced ? 2 : 1)
|
||||||
|
return q1 > q2
|
||||||
|
}
|
||||||
|
// Then by Name
|
||||||
|
return v1.name < v2.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for English (US)
|
||||||
|
availableEnglishVoices = allVoices.filter {
|
||||||
|
$0.language == "en-US" && !noveltyVoices.contains($0.name)
|
||||||
|
}.sorted(by: sortComparator)
|
||||||
|
|
||||||
|
// Filter for Target Language
|
||||||
|
// Handle simplified codes (e.g. "fr" -> "fr-FR") if necessary, though usually we have full codes
|
||||||
|
let targetVoices = allVoices.filter {
|
||||||
|
($0.language == self.currentTargetLocaleID || $0.language.starts(with: self.currentTargetLocaleID)) && !noveltyVoices.contains($0.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !targetVoices.isEmpty {
|
||||||
|
availableTargetVoices = targetVoices.sorted(by: sortComparator)
|
||||||
|
} else {
|
||||||
|
// If no specific voices found, empty list (UI should handle fallback to system default)
|
||||||
|
availableTargetVoices = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection Handling
|
||||||
|
|
||||||
|
var selectedEnglishVoiceIdentifier: String {
|
||||||
|
get {
|
||||||
|
if let saved = UserDefaults.standard.string(forKey: kSelectedEnglishVoice) {
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
// Default to Samantha if available
|
||||||
|
if let samantha = availableEnglishVoices.first(where: { $0.name == "Samantha" }) {
|
||||||
|
return samantha.identifier
|
||||||
|
}
|
||||||
|
return availableEnglishVoices.first?.identifier ?? AVSpeechSynthesisVoice(language: "en-US")?.identifier ?? ""
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue, forKey: kSelectedEnglishVoice)
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We persist the selected voice PER language ideally, but for simplicity/user request
|
||||||
|
// we might just store one "Target Voice" preference.
|
||||||
|
// HOWEVER, a French voice ID won't work for Spanish.
|
||||||
|
// So we should probably key the storage by the language code?
|
||||||
|
// User plan didn't specify, but "Smart" behavior is better.
|
||||||
|
// Let's use a dynamic key: "selectedVoice_\(currentTargetLocaleID)"
|
||||||
|
|
||||||
|
var selectedTargetVoiceIdentifier: String {
|
||||||
|
get {
|
||||||
|
let key = "selectedVoice_\(currentTargetLocaleID)"
|
||||||
|
if let saved = UserDefaults.standard.string(forKey: key) {
|
||||||
|
// Verify this voice still exists and matches language (optional, but good)
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
// Default logic: Premium/Enhanced if available
|
||||||
|
return availableTargetVoices.first?.identifier ?? ""
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let key = "selectedVoice_\(currentTargetLocaleID)"
|
||||||
|
UserDefaults.standard.set(newValue, forKey: key)
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSelectedEnglishVoice() -> AVSpeechSynthesisVoice? {
|
||||||
|
if let voice = AVSpeechSynthesisVoice(identifier: selectedEnglishVoiceIdentifier) {
|
||||||
|
return voice
|
||||||
|
}
|
||||||
|
return availableEnglishVoices.first ?? AVSpeechSynthesisVoice(language: "en-US")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSelectedTargetVoice() -> AVSpeechSynthesisVoice? {
|
||||||
|
if let voice = AVSpeechSynthesisVoice(identifier: selectedTargetVoiceIdentifier) {
|
||||||
|
return voice
|
||||||
|
}
|
||||||
|
// Fallback to any voice for this language
|
||||||
|
return availableTargetVoices.first ?? AVSpeechSynthesisVoice(language: currentTargetLocaleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Descriptions
|
||||||
|
|
||||||
|
func description(for voice: AVSpeechSynthesisVoice) -> String {
|
||||||
|
var traits: [String] = []
|
||||||
|
|
||||||
|
if voice.quality == .enhanced { traits.append("Enhanced") }
|
||||||
|
if voice.quality == .premium { traits.append("Premium") }
|
||||||
|
|
||||||
|
let qualitySuffix = traits.isEmpty ? "" : " (\(traits.joined(separator: ", ")))"
|
||||||
|
|
||||||
|
// Detailed User Descriptions
|
||||||
|
let knownDescriptions: [String: String] = [
|
||||||
|
"Alex": "Male - Top Tier. The smartest voice; sounds very natural.",
|
||||||
|
"Samantha": "Female - Standard. The classic 'original Siri' voice.",
|
||||||
|
"Ava": "Female - Premium. Professional, warm, and very human-like.",
|
||||||
|
"Allison": "Female - Premium. Lighter, breathy, and pleasant.",
|
||||||
|
"Tom": "Male - Premium. Friendly, standard American male.",
|
||||||
|
"Susan": "Female - Standard/Premium. Slightly formal and crisp.",
|
||||||
|
"Zoe": "Female - Premium. Bright, cheerful, and younger-sounding.",
|
||||||
|
"Evan": "Male - Enhanced. Deep, smooth, and modern.",
|
||||||
|
"Nathan": "Male - Enhanced. Lighter, younger-sounding male.",
|
||||||
|
"Noelle": "Female - Enhanced. Soft, sweet, and modern flow.",
|
||||||
|
"Joelle": "Female - Enhanced. Clear, articulate, modern.",
|
||||||
|
"Aaron": "Male - Neural. Standard American Male Siri voice.",
|
||||||
|
"Nicky": "Female - Neural. Standard American Female Siri voice.",
|
||||||
|
|
||||||
|
// Spanish
|
||||||
|
"Paulina": "Female (MX) - Standard/Premium. Gold standard; professional news anchor style.",
|
||||||
|
"Juan": "Male (MX) - Standard/Premium. Clear, neutral, polite.",
|
||||||
|
"Siri Female": "Female (MX) - Neural. Very smooth, natural assistant.",
|
||||||
|
"Siri Male": "Male (MX) - Neural. Professional modern assistant.",
|
||||||
|
"Monica": "Female (ES) - Clear Spanish (Spain).",
|
||||||
|
"Jorge": "Male (ES) - Clear Spanish (Spain)."
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check exact name match first
|
||||||
|
if let specificDesc = knownDescriptions[voice.name] {
|
||||||
|
return "\(voice.name) - \(specificDesc)\(qualitySuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(voice.name)\(qualitySuffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
660
FlipTalk/VoiceNoteView.swift
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Speech
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
#if canImport(Translation)
|
||||||
|
import Translation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class SpeechRecognizer: ObservableObject {
|
||||||
|
static let shared = SpeechRecognizer()
|
||||||
|
|
||||||
|
@Published var transcript = ""
|
||||||
|
@Published var isRecording = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
private var audioEngine = AVAudioEngine()
|
||||||
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var task: SFSpeechRecognitionTask?
|
||||||
|
private var recognizer: SFSpeechRecognizer?
|
||||||
|
private var currentLocale = Locale(identifier: "en-US")
|
||||||
|
|
||||||
|
// To handle appending new sessions to existing text
|
||||||
|
private var sessionStartTranscript = ""
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load saved transcript
|
||||||
|
if let saved = UserDefaults.standard.string(forKey: "voiceNoteTranscript") {
|
||||||
|
self.transcript = saved
|
||||||
|
}
|
||||||
|
// Initialize default recognizer
|
||||||
|
self.recognizer = SFSpeechRecognizer(locale: currentLocale)
|
||||||
|
requestPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPermissions() {
|
||||||
|
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 setLanguage(locale: Locale) {
|
||||||
|
currentLocale = locale
|
||||||
|
// If we are already recording, we'd need to stop and restart,
|
||||||
|
// but for now we just update the recognizer for the next session
|
||||||
|
if recognizer?.locale != locale {
|
||||||
|
recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTranscribing(allowOnline: Bool = true, locale: Locale? = nil) {
|
||||||
|
// Determine locale
|
||||||
|
if let locale = locale {
|
||||||
|
self.currentLocale = locale
|
||||||
|
}
|
||||||
|
let localeToUse = self.currentLocale
|
||||||
|
|
||||||
|
// Safety net: Reload from UserDefaults if empty
|
||||||
|
if transcript.isEmpty {
|
||||||
|
if let saved = UserDefaults.standard.string(forKey: "voiceNoteTranscript") {
|
||||||
|
self.transcript = saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isRecording else { return }
|
||||||
|
|
||||||
|
// Update recognizer if needed
|
||||||
|
if recognizer?.locale != localeToUse {
|
||||||
|
recognizer = SFSpeechRecognizer(locale: localeToUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let recognizer = recognizer, recognizer.isAvailable else {
|
||||||
|
self.error = "Speech recognizer is not available for \(localeToUse.identifier)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current transcript as the starting point for this session
|
||||||
|
sessionStartTranscript = transcript
|
||||||
|
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker, .allowBluetoothHFP])
|
||||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
|
||||||
|
request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
guard let request = request else { return }
|
||||||
|
request.shouldReportPartialResults = true
|
||||||
|
request.requiresOnDeviceRecognition = !allowOnline
|
||||||
|
|
||||||
|
// Enable automatic punctuation (iOS 16+)
|
||||||
|
if #available(iOS 16, *) {
|
||||||
|
request.addsPunctuation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputNode = audioEngine.inputNode
|
||||||
|
|
||||||
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
// Check if we should fallback (if we were trying online and it failed)
|
||||||
|
if allowOnline {
|
||||||
|
print("Online transcription failed: \(error.localizedDescription). Retrying offline.")
|
||||||
|
// We must stop the current engine/request before retrying
|
||||||
|
self.stopTranscribing()
|
||||||
|
|
||||||
|
// Retry with on-device only
|
||||||
|
// Add a small delay to ensure cleanup completes
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.startTranscribing(allowOnline: false, locale: localeToUse)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// We were already offline (or forced offline), just report error
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.error = "Transcription error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let result = result {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Only update if we're still recording (prevents race condition with clear)
|
||||||
|
guard self.isRecording else { return }
|
||||||
|
|
||||||
|
// Append new text to the session start text
|
||||||
|
let newText = result.bestTranscription.formattedString
|
||||||
|
|
||||||
|
// Ignore empty results to prevent overwriting persistence
|
||||||
|
guard !newText.isEmpty else { return }
|
||||||
|
|
||||||
|
if self.sessionStartTranscript.isEmpty {
|
||||||
|
self.transcript = newText
|
||||||
|
} else {
|
||||||
|
self.transcript = self.sessionStartTranscript + " " + newText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to UserDefaults
|
||||||
|
UserDefaults.standard.set(self.transcript, forKey: "voiceNoteTranscript")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if error != nil || (result?.isFinal ?? false) {
|
||||||
|
self.stopTranscribing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
|
||||||
|
request.append(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
try audioEngine.start()
|
||||||
|
isRecording = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
self.error = "Error starting recording: \(error.localizedDescription)"
|
||||||
|
stopTranscribing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTranscribing() {
|
||||||
|
// Ensure we only stop if we are actually recording/have resources allocated
|
||||||
|
guard isRecording else { return }
|
||||||
|
|
||||||
|
audioEngine.stop()
|
||||||
|
audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
|
request?.endAudio()
|
||||||
|
task?.cancel()
|
||||||
|
|
||||||
|
task = nil
|
||||||
|
request = nil
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
// Stop recording first to prevent it from saving again
|
||||||
|
stopTranscribing()
|
||||||
|
|
||||||
|
// Then clear everything
|
||||||
|
transcript = ""
|
||||||
|
sessionStartTranscript = ""
|
||||||
|
UserDefaults.standard.removeObject(forKey: "voiceNoteTranscript")
|
||||||
|
|
||||||
|
// Restart transcribing after a brief delay to allow audio engine to fully stop
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
self.startTranscribing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoiceNoteView: View {
|
||||||
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
|
@ObservedObject var speechRecognizer = SpeechRecognizer.shared
|
||||||
|
var onFlipBack: () -> Void
|
||||||
|
|
||||||
|
@State private var fontSize: CGFloat = 40
|
||||||
|
@AppStorage("isSpanishMode") private var isSpanishMode = false
|
||||||
|
@State private var translatedText = ""
|
||||||
|
@State private var triggerTranslation = false
|
||||||
|
@State private var translationError: String?
|
||||||
|
@State private var manualTranslationConfig: TranslationSession.Configuration?
|
||||||
|
@State private var showTranslation = false
|
||||||
|
@State private var translationTaskID = UUID()
|
||||||
|
@State private var longPressTriggered = false
|
||||||
|
@State private var showingSettingsAlert = false
|
||||||
|
|
||||||
|
// New Dynamic Language State
|
||||||
|
@ObservedObject private var languageManager = LanguageManager.shared
|
||||||
|
@AppStorage("targetLanguageIdentifier") private var targetLanguageIdentifier: String = ""
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
|
// Configuration for iOS 18+ Translation
|
||||||
|
#if canImport(Translation)
|
||||||
|
@State private var translationConfig: TranslationSession.Configuration?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// Main Text Area
|
||||||
|
if isSpanishMode {
|
||||||
|
// Target Language Mode (Single View)
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if showTranslation {
|
||||||
|
// Show English Translation
|
||||||
|
if let error = translationError {
|
||||||
|
Text("Error: \(error)")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
Text(translatedText.isEmpty ? "Translation will appear here..." : translatedText)
|
||||||
|
.font(.system(size: fontSize, weight: .bold)) // Use dynamic fontSize
|
||||||
|
.foregroundColor(translatedText.isEmpty ? .gray : themeManager.textColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
} else {
|
||||||
|
// Show Target Transcript
|
||||||
|
Text(speechRecognizer.transcript.isEmpty ? languageManager.getStartSpeakingText(for: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier) : speechRecognizer.transcript)
|
||||||
|
.font(.system(size: fontSize, weight: .bold)) // Use dynamic fontSize
|
||||||
|
.foregroundColor(themeManager.textColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 80) // Clear top bar
|
||||||
|
.padding(.bottom, 120) // Clear bottom controls
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.onChange(of: speechRecognizer.transcript) {
|
||||||
|
adjustFontSize(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
.onChange(of: translatedText) {
|
||||||
|
adjustFontSize(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
.onChange(of: showTranslation) {
|
||||||
|
adjustFontSize(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
.onChange(of: targetLanguageIdentifier) {
|
||||||
|
print("VoiceNoteView: Language changed to \(targetLanguageIdentifier)")
|
||||||
|
// Force state update if needed, though AppStorage should trigger redraw.
|
||||||
|
// Might need to update speechRecognizer language if in Spanish mode?
|
||||||
|
if isSpanishMode {
|
||||||
|
let locale = Locale(identifier: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier)
|
||||||
|
speechRecognizer.setLanguage(locale: locale)
|
||||||
|
speechRecognizer.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabled(true)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(themeManager.backgroundColor)
|
||||||
|
} else {
|
||||||
|
// Normal English Full Screen
|
||||||
|
ScrollView {
|
||||||
|
Text(speechRecognizer.transcript.isEmpty ? "Start speaking..." : speechRecognizer.transcript)
|
||||||
|
.font(.system(size: fontSize, weight: .bold))
|
||||||
|
.foregroundColor(themeManager.textColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 80) // Clear top bar
|
||||||
|
.padding(.bottom, 120) // Clear bottom controls
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.onChange(of: speechRecognizer.transcript) {
|
||||||
|
adjustFontSize(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabled(true)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top Bar with Clear All Button
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
// Language Toggle
|
||||||
|
Button(action: {
|
||||||
|
toggleLanguage()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
// Dynamic Flag and Name
|
||||||
|
if isSpanishMode {
|
||||||
|
if let lang = languageManager.supportedLanguages.first(where: { $0.id == targetLanguageIdentifier }) {
|
||||||
|
Text(lang.flag)
|
||||||
|
.font(.title2)
|
||||||
|
Text(lang.name)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
} else {
|
||||||
|
// Fallback if not found or empty (e.g. legacy state)
|
||||||
|
Text("🏳️")
|
||||||
|
.font(.title2)
|
||||||
|
Text("Select Language")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("🇺🇸")
|
||||||
|
.font(.title2)
|
||||||
|
Text("English")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.black.opacity(0.1))
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
.padding(.leading)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
// Ensure the recognizer stays in the correct mode
|
||||||
|
let localeID = isSpanishMode ? (targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier) : "en-US"
|
||||||
|
speechRecognizer.setLanguage(locale: Locale(identifier: localeID))
|
||||||
|
|
||||||
|
speechRecognizer.clear()
|
||||||
|
translatedText = ""
|
||||||
|
translationError = nil
|
||||||
|
showTranslation = false
|
||||||
|
|
||||||
|
// Reset translation config so it can be re-triggered
|
||||||
|
manualTranslationConfig?.invalidate()
|
||||||
|
manualTranslationConfig = nil
|
||||||
|
}) {
|
||||||
|
Text("Clear all")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom Area (Status & Flip Button)
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Left & Right Controls
|
||||||
|
HStack {
|
||||||
|
// Left: Status
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if speechRecognizer.isRecording {
|
||||||
|
Text("Listening...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
if let error = speechRecognizer.error {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 20)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Right: Flip Button
|
||||||
|
Button(action: {
|
||||||
|
onFlipBack()
|
||||||
|
}) {
|
||||||
|
Text("Flip")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.blue)
|
||||||
|
.cornerRadius(25)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple Translate Button
|
||||||
|
if isSpanishMode {
|
||||||
|
Button(action: {
|
||||||
|
if targetLanguageIdentifier.isEmpty {
|
||||||
|
// No language selected. Prompt user to open settings.
|
||||||
|
showingSettingsAlert = true
|
||||||
|
} else {
|
||||||
|
if showTranslation {
|
||||||
|
showTranslation = false
|
||||||
|
} else {
|
||||||
|
performTranslation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Group {
|
||||||
|
if targetLanguageIdentifier.isEmpty {
|
||||||
|
Text("Translate")
|
||||||
|
} else {
|
||||||
|
if let lang = languageManager.supportedLanguages.first(where: { $0.id == targetLanguageIdentifier }) {
|
||||||
|
if showTranslation {
|
||||||
|
Text("Original \(lang.flag)")
|
||||||
|
} else {
|
||||||
|
Text("Translate to 🇺🇸")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Translate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.blue)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(25)
|
||||||
|
.shadow(radius: 3)
|
||||||
|
}
|
||||||
|
.alert("Select Language", isPresented: $showingSettingsAlert) {
|
||||||
|
Button("Open Settings") {
|
||||||
|
showSettings = true
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("Please select a translation language in Settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [themeManager.backgroundColor.opacity(0), themeManager.backgroundColor]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(themeManager.backgroundColor)
|
||||||
|
.onAppear {
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
Task {
|
||||||
|
await languageManager.checkAvailability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate font size immediately in case there is existing text
|
||||||
|
adjustFontSize(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
.onChange(of: targetLanguageIdentifier) {
|
||||||
|
if isSpanishMode {
|
||||||
|
let locale = Locale(identifier: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier)
|
||||||
|
speechRecognizer.setLanguage(locale: locale)
|
||||||
|
speechRecognizer.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSettings, onDismiss: {
|
||||||
|
if isSpanishMode {
|
||||||
|
let locale = Locale(identifier: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier)
|
||||||
|
speechRecognizer.setLanguage(locale: locale)
|
||||||
|
speechRecognizer.clear()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(themeManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
.onDisappear {
|
||||||
|
// speechRecognizer.stopTranscribing() - Removed to keep recording in background
|
||||||
|
}
|
||||||
|
// Batch Translation Task
|
||||||
|
#if canImport(Translation)
|
||||||
|
.translationTask(manualTranslationConfig) { session in
|
||||||
|
do {
|
||||||
|
// Perform single batch translation
|
||||||
|
let response = try await session.translate(speechRecognizer.transcript)
|
||||||
|
translatedText = response.targetText
|
||||||
|
translationError = nil
|
||||||
|
// showTranslation is already true
|
||||||
|
} catch {
|
||||||
|
// Suppress specific "empty" errors or generic noise
|
||||||
|
let errorMsg = error.localizedDescription
|
||||||
|
if !errorMsg.localizedCaseInsensitiveContains("empty") {
|
||||||
|
print("Translation error: \(error)")
|
||||||
|
translationError = errorMsg
|
||||||
|
} else {
|
||||||
|
translationError = nil
|
||||||
|
// If silently failed, revert view so user can try again or see transcript
|
||||||
|
showTranslation = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Fallback or empty logic for older iOS versions handled by UI checks
|
||||||
|
#endif
|
||||||
|
.id(translationTaskID) // Force recreation of the task on every request
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onEnded { value in
|
||||||
|
if value.translation.width > 50 {
|
||||||
|
// Swipe Right -> Flip Back
|
||||||
|
onFlipBack()
|
||||||
|
} else if value.translation.width < -50 {
|
||||||
|
// Swipe Left -> Clear & Reset
|
||||||
|
if showTranslation {
|
||||||
|
showTranslation = false
|
||||||
|
}
|
||||||
|
speechRecognizer.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Logic
|
||||||
|
private func toggleLanguage() {
|
||||||
|
isSpanishMode.toggle()
|
||||||
|
showTranslation = false
|
||||||
|
|
||||||
|
// Stop current recording
|
||||||
|
speechRecognizer.stopTranscribing()
|
||||||
|
|
||||||
|
// Update the locale in SpeechRecognizer immediately
|
||||||
|
// Update the locale in SpeechRecognizer immediately
|
||||||
|
let localeID = isSpanishMode ? (targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier) : "en-US"
|
||||||
|
speechRecognizer.setLanguage(locale: Locale(identifier: localeID))
|
||||||
|
|
||||||
|
// Clear old text and restart (clear() automatically restarts transcription using the current locale)
|
||||||
|
speechRecognizer.clear()
|
||||||
|
|
||||||
|
// Configure translation session if needed
|
||||||
|
if #available(iOS 18.0, *), isSpanishMode {
|
||||||
|
#if canImport(Translation)
|
||||||
|
// Invalidating and recreating config triggers the task
|
||||||
|
manualTranslationConfig = TranslationSession.Configuration(
|
||||||
|
source: Locale.Language(identifier: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier),
|
||||||
|
target: Locale.Language(identifier: "en-US")
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
#if canImport(Translation)
|
||||||
|
manualTranslationConfig?.invalidate()
|
||||||
|
manualTranslationConfig = nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger translation
|
||||||
|
private func performTranslation() {
|
||||||
|
guard !speechRecognizer.transcript.isEmpty else { return }
|
||||||
|
translatedText = "Translating..."
|
||||||
|
translationError = nil
|
||||||
|
showTranslation = true // Show loading state immediately
|
||||||
|
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
#if canImport(Translation)
|
||||||
|
// Force a reset of the configuration
|
||||||
|
if manualTranslationConfig != nil {
|
||||||
|
manualTranslationConfig?.invalidate()
|
||||||
|
manualTranslationConfig = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let langId = targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier
|
||||||
|
|
||||||
|
// All languages use .translationTask modifier
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
translationTaskID = UUID()
|
||||||
|
manualTranslationConfig = TranslationSession.Configuration(
|
||||||
|
source: Locale.Language(identifier: langId),
|
||||||
|
target: Locale.Language(identifier: "en-US")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
translationError = "Translation requires iOS 18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startWithCurrentLanguage() {
|
||||||
|
let localeID = isSpanishMode ? (targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier) : "en-US"
|
||||||
|
speechRecognizer.startTranscribing(locale: Locale(identifier: localeID))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adjustFontSize(containerSize: CGSize) {
|
||||||
|
|
||||||
|
let maxFontSize: CGFloat = 40
|
||||||
|
let minFontSize: CGFloat = 12
|
||||||
|
// Use the displayed text for sizing
|
||||||
|
let text: String
|
||||||
|
if isSpanishMode {
|
||||||
|
if showTranslation {
|
||||||
|
text = translatedText.isEmpty ? "Translation will appear here..." : translatedText
|
||||||
|
} else {
|
||||||
|
text = speechRecognizer.transcript.isEmpty ? languageManager.getStartSpeakingText(for: targetLanguageIdentifier.isEmpty ? "es-MX" : targetLanguageIdentifier) : speechRecognizer.transcript
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = speechRecognizer.transcript.isEmpty ? "Start speaking..." : speechRecognizer.transcript
|
||||||
|
}
|
||||||
|
// Account for padding
|
||||||
|
let horizontalPadding: CGFloat = 32 // 16 * 2
|
||||||
|
let width = containerSize.width - horizontalPadding
|
||||||
|
let height = containerSize.height - 180 // Increased buffer for bottom controls
|
||||||
|
|
||||||
|
var bestSize = minFontSize
|
||||||
|
|
||||||
|
for size in stride(from: maxFontSize, through: minFontSize, by: -2) {
|
||||||
|
let font = UIFont.systemFont(ofSize: size, weight: .bold)
|
||||||
|
let attributes = [NSAttributedString.Key.font: font]
|
||||||
|
let boundingRect = NSString(string: text).boundingRect(
|
||||||
|
with: CGSize(width: width, height: .greatestFiniteMagnitude),
|
||||||
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||||
|
attributes: attributes,
|
||||||
|
context: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if boundingRect.height <= height {
|
||||||
|
bestSize = size
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fontSize != bestSize {
|
||||||
|
withAnimation {
|
||||||
|
fontSize = bestSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
FlipTalk/appstore.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
FlipTalk/easteregg.mp4
Normal file
BIN
FlipTalk/playstore.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#FlipTalk: The Bridge for Instant In-Person Communication
|
||||||
|
|
||||||
|
Effortless 1:1 conversations for the Deaf and Hard of Hearing. Type your message, flip the screen, and instantly see spoken words transcribed. Private, multilingual, and always ready.
|
||||||
|
|
||||||
|
## Full Description
|
||||||
|
|
||||||
|
**Bridging the Conversation Gap**
|
||||||
|
FlipTalk is designed for immediate, barrier-free communication between Deaf and hearing individuals. Whether you are ordering coffee or having a deep conversation, FlipTalk empowers you to communicate in over 20 languages without missing a beat.
|
||||||
|
|
||||||
|
**Key Features**
|
||||||
|
|
||||||
|
* **⚡️ Instant Transcription:** No "Start" button needed. Transcription is active the moment you open the app or flip the screen, ensuring you never miss the first word.
|
||||||
|
* **🔒 Privacy First:** Your security matters. All processing happens 100% on-device. Your conversations are never stored on a server, guaranteeing complete privacy.
|
||||||
|
* **🔄 The "Flip" Experience:** Seamlessly switch between your typed message and the hearing person's transcribed speech. Use the Split-View for a unified real-time conversation log.
|
||||||
|
* **🗣️ Continuous Speaking Mode:** Keep the conversation natural. Enable "Speak for me" to automatically vocalize your sentences as you finish typing them (triggered by punctuation), eliminating the need for constant tapping.
|
||||||
|
* **🌍 Multilingual Support:** Fluent in 20+ languages including English, Spanish, French, German, and Chinese. Ideal for travel and multilingual households.
|
||||||
|
* **🧠 Smart History & Favorites:** Swipe left to review past chats or swipe right to access your custom Quick Phrases (e.g., "Coffee please," "Emergency").
|
||||||
|
* **👁️ Designed for Readability:** Built with high-contrast optics, Dark Mode, and auto-sizing text that dynamically fills the screen for maximum clarity.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
1. **Type:** You type your message.
|
||||||
|
2. **Flip:** Swipe right or tap to flip the screen towards the hearing person.
|
||||||
|
3. **Read:** They speak, and their words appear instantly on your screen.
|
||||||
BIN
Screenshots/iPad/1.png
Normal file
|
After Width: | Height: | Size: 465 KiB |
BIN
Screenshots/iPad/2.png
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
Screenshots/iPad/3.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
Screenshots/iPad/4.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
Screenshots/iPad/5.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
Screenshots/iPad/6.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
Screenshots/iPhone/1.png
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
Screenshots/iPhone/2.png
Normal file
|
After Width: | Height: | Size: 552 KiB |
BIN
Screenshots/iPhone/3.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
Screenshots/iPhone/4.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
Screenshots/iPhone/5.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
Screenshots/iPhone/6.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
Screenshots/iPhone/7.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
27
check_build.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -o pipefail # Fail if xcodebuild fails, even with xcbeautify
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
SCHEME="FlipTalk"
|
||||||
|
DEVICE_NAME="iPhone 17 Pro"
|
||||||
|
BUILD_PATH="./build"
|
||||||
|
|
||||||
|
echo "🔍 Checking compilation for $SCHEME..."
|
||||||
|
|
||||||
|
# Build Only (No Install/Launch)
|
||||||
|
# We use 'env -u' to hide Homebrew variables
|
||||||
|
# We use '-derivedDataPath' to keep it isolated
|
||||||
|
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE_NAME" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$BUILD_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
|
||||||
BIN
fliptalk_orig.png
Normal file
|
After Width: | Height: | Size: 943 KiB |
47
run_ios_simulator.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -e # Exit immediately if any command fails
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
SCHEME="FlipTalk"
|
||||||
|
BUNDLE_ID="com.jaredlog.Flip-Talk"
|
||||||
|
DEVICE_NAME="iPhone 17 Pro"
|
||||||
|
BUILD_PATH="./build" # This ensures Predictable Paths
|
||||||
|
|
||||||
|
echo "🚀 Starting Build for $DEVICE_NAME..."
|
||||||
|
|
||||||
|
# 1. Boot the simulator if it isn't already running
|
||||||
|
# We use 'grep' to check status so we don't try to boot an active device
|
||||||
|
if ! xcrun simctl list devices | grep "$DEVICE_NAME" | grep -q "(Booted)"; then
|
||||||
|
echo "⚙️ Booting Simulator..."
|
||||||
|
xcrun simctl boot "$DEVICE_NAME"
|
||||||
|
fi
|
||||||
|
open -a Simulator
|
||||||
|
|
||||||
|
# 2. Build the App
|
||||||
|
# We use 'env -u' to hide your Homebrew variables (CC, CXX) from Xcode
|
||||||
|
# We use '-derivedDataPath' to force the build into the local ./build folder
|
||||||
|
echo "🔨 Compiling..."
|
||||||
|
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE_NAME" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$BUILD_PATH" \
|
||||||
|
clean build | xcbeautify
|
||||||
|
|
||||||
|
# 3. Locate the .app bundle
|
||||||
|
# Since we used -derivedDataPath, we know EXACTLY where this is.
|
||||||
|
APP_PATH="$BUILD_PATH/Build/Products/Debug-iphonesimulator/$SCHEME.app"
|
||||||
|
|
||||||
|
if [ ! -d "$APP_PATH" ]; then
|
||||||
|
echo "❌ Error: App bundle not found at $APP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Install and Launch
|
||||||
|
echo "📲 Installing..."
|
||||||
|
xcrun simctl install "$DEVICE_NAME" "$APP_PATH"
|
||||||
|
|
||||||
|
echo "▶️ Launching $BUNDLE_ID..."
|
||||||
|
xcrun simctl launch "$DEVICE_NAME" "$BUNDLE_ID"
|
||||||
|
|
||||||
|
echo "✅ Done!"
|
||||||