diff --git a/CustomKeyboard/Info.plist b/CustomKeyboard/Info.plist
index 6b0b101..baa47a8 100644
--- a/CustomKeyboard/Info.plist
+++ b/CustomKeyboard/Info.plist
@@ -6,6 +6,8 @@
kbkeyboardAppExtension
+ NSMicrophoneUsageDescription
+ 需要使用麦克风进行语音输入
NSExtension
NSExtensionAttributes
diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m
index 564c13c..ad4d5d7 100644
--- a/CustomKeyboard/KeyboardViewController.m
+++ b/CustomKeyboard/KeyboardViewController.m
@@ -18,11 +18,15 @@
#import "KBKeyboardSubscriptionProduct.h"
#import "KBKeyboardSubscriptionView.h"
#import "KBSettingView.h"
+#import "KBChatMessage.h"
+#import "KBChatPanelView.h"
#import "KBSkinInstallBridge.h"
#import "KBSkinManager.h"
#import "KBSuggestionEngine.h"
+#import "KBNetworkManager.h"
#import "Masonry.h"
#import "UIImage+KBColor.h"
+#import
// #import "KBLog.h"
@@ -34,6 +38,8 @@
// 以 375 宽设计稿为基准的键盘总高度
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
+static const CGFloat kKBChatPanelHeight = 180;
+static const NSUInteger kKBChatMessageLimit = 6;
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
@@ -57,7 +63,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
@interface KeyboardViewController ()
+ KBKeyboardSubscriptionViewDelegate,
+ KBChatPanelViewDelegate>
@property(nonatomic, strong)
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
@property(nonatomic, strong) UIView *contentView;
@@ -67,16 +74,23 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
+@property(nonatomic, strong) KBChatPanelView *chatPanelView;
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
@property(nonatomic, copy) NSString *currentWord;
@property(nonatomic, assign) BOOL suppressSuggestions;
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
+@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
+@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
+@property(nonatomic, strong) NSMutableArray *chatMessages;
+@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
+@property(nonatomic, strong) NSCache *chatAvatarCache;
+@property(nonatomic, assign) BOOL chatPanelVisible;
@end
@implementation KeyboardViewController
@@ -167,6 +181,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
+ CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
+ CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
CGFloat outerVerticalInset = KBFit(4.0f);
@@ -210,8 +226,20 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
[self.contentView addSubview:self.keyBoardMainView];
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
- make.edges.equalTo(self.contentView);
+ make.left.right.equalTo(self.contentView);
+ make.bottom.equalTo(self.contentView);
+ self.keyBoardMainHeightConstraint =
+ make.height.mas_equalTo(keyboardBaseHeight);
}];
+
+ [self.contentView addSubview:self.chatPanelView];
+ [self.chatPanelView mas_makeConstraints:^(MASConstraintMaker *make) {
+ make.left.right.equalTo(self.contentView);
+ make.bottom.equalTo(self.keyBoardMainView.mas_top);
+ self.chatPanelHeightConstraint =
+ make.height.mas_equalTo(chatPanelHeight);
+ }];
+ self.chatPanelView.hidden = YES;
}
#pragma mark - Private
@@ -349,6 +377,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
/// 切换显示功能面板/键盘主视图
- (void)showFunctionPanel:(BOOL)show {
// 简单显隐切换,复用相同的布局区域
+ if (show) {
+ [self showChatPanel:NO];
+ }
self.functionView.hidden = !show;
self.keyBoardMainView.hidden = show;
@@ -378,6 +409,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
- (void)showSettingView:(BOOL)show {
if (show) {
+ [self showChatPanel:NO];
[[KBMaiPointReporter sharedReporter]
reportPageExposureWithEventName:@"enter_keyboard_settings"
pageId:@"keyboard_settings"
@@ -436,6 +468,40 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
}
}
+/// 显示/隐藏聊天面板(覆盖整个键盘区域)
+- (void)showChatPanel:(BOOL)show {
+ if (show == self.chatPanelVisible) {
+ return;
+ }
+ self.chatPanelVisible = show;
+ if (show) {
+ self.chatPanelView.hidden = NO;
+ self.chatPanelView.alpha = 0.0;
+ [self.contentView bringSubviewToFront:self.chatPanelView];
+ self.functionView.hidden = YES;
+ [self hideSubscriptionPanel];
+ [self showSettingView:NO];
+ [UIView animateWithDuration:0.2
+ delay:0
+ options:UIViewAnimationOptionCurveEaseOut
+ animations:^{
+ self.chatPanelView.alpha = 1.0;
+ }
+ completion:nil];
+ } else {
+ [UIView animateWithDuration:0.18
+ delay:0
+ options:UIViewAnimationOptionCurveEaseIn
+ animations:^{
+ self.chatPanelView.alpha = 0.0;
+ }
+ completion:^(BOOL finished) {
+ self.chatPanelView.hidden = YES;
+ }];
+ }
+ [self kb_updateKeyboardLayoutIfNeeded];
+}
+
- (void)showSubscriptionPanel {
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
if (![[KBFullAccessManager shared] hasFullAccess]) {
@@ -544,6 +610,10 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
[[KBInputBufferManager shared] appendText:@" "];
break;
case KBKeyTypeReturn:
+ if (self.chatPanelVisible) {
+ [self kb_handleChatSendAction];
+ break;
+ }
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self.textDocumentProxy insertText:@"\n"];
[self kb_clearCurrentWord];
@@ -575,11 +645,18 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
extra:extra
completion:nil];
if (index == 0) {
+ [self showChatPanel:NO];
[self showFunctionPanel:YES];
[self kb_clearCurrentWord];
return;
}
+ if (index == 1) {
+ [self showFunctionPanel:NO];
+ [self showChatPanel:YES];
+ return;
+ }
[self showFunctionPanel:NO];
+ [self showChatPanel:NO];
}
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
@@ -697,6 +774,399 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
[self showSubscriptionPanel];
}
+#pragma mark - KBChatPanelViewDelegate
+
+- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
+ NSString *trim =
+ [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (trim.length == 0) {
+ return;
+ }
+ [self kb_sendChatText:trim];
+}
+
+- (void)chatPanelView:(KBChatPanelView *)view
+ didTapMessage:(KBChatMessage *)message {
+ if (message.audioFilePath.length == 0) {
+ return;
+ }
+ [self kb_playChatAudioAtPath:message.audioFilePath];
+}
+
+#pragma mark - Chat Helpers
+
+- (void)kb_handleChatSendAction {
+ if (!self.chatPanelVisible) {
+ return;
+ }
+ [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy];
+ NSString *rawText = [KBInputBufferManager shared].liveText ?: @"";
+ NSString *trim =
+ [rawText stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (trim.length == 0) {
+ [KBHUD showInfo:KBLocalized(@"请输入内容")];
+ return;
+ }
+ [self kb_sendChatText:trim];
+ [self kb_clearHostInputForText:rawText];
+}
+
+- (void)kb_sendChatText:(NSString *)text {
+ if (text.length == 0) {
+ return;
+ }
+ KBChatMessage *outgoing =
+ [KBChatMessage messageWithText:text outgoing:YES audioFilePath:nil];
+ outgoing.avatarURL = [self kb_sharedUserAvatarURL];
+ [self kb_appendChatMessage:outgoing];
+ [self kb_prefetchAvatarForMessage:outgoing];
+
+ if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
+ [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
+ return;
+ }
+ [self kb_requestChatAudioForText:text];
+}
+
+- (void)kb_clearHostInputForText:(NSString *)text {
+ if (text.length == 0) {
+ return;
+ }
+ NSUInteger count = [self kb_composedCharacterCountForString:text];
+ for (NSUInteger i = 0; i < count; i++) {
+ [self.textDocumentProxy deleteBackward];
+ }
+ [[KBInputBufferManager shared] clearAllLiveText];
+ [self kb_clearCurrentWord];
+}
+
+- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
+ if (text.length == 0) {
+ return 0;
+ }
+ __block NSUInteger count = 0;
+ [text enumerateSubstringsInRange:NSMakeRange(0, text.length)
+ options:NSStringEnumerationByComposedCharacterSequences
+ usingBlock:^(__unused NSString *substring,
+ __unused NSRange substringRange,
+ __unused NSRange enclosingRange,
+ __unused BOOL *stop) {
+ count += 1;
+ }];
+ return count;
+}
+
+- (NSString *)kb_sharedUserAvatarURL {
+ NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
+ NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
+ return url ?: @"";
+}
+
+- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
+ if (!message || message.avatarImage) {
+ return;
+ }
+ NSString *urlString = message.avatarURL ?: @"";
+ if (urlString.length == 0) {
+ return;
+ }
+ UIImage *cached = [self.chatAvatarCache objectForKey:urlString];
+ if (cached) {
+ message.avatarImage = cached;
+ [self.chatPanelView kb_reloadWithMessages:self.chatMessages];
+ return;
+ }
+ if (![[KBFullAccessManager shared] hasFullAccess]) {
+ return;
+ }
+ __weak typeof(self) weakSelf = self;
+ [[KBNetworkManager shared]
+ GETData:urlString
+ parameters:nil
+ headers:nil
+ completion:^(NSData *data, NSURLResponse *response, NSError *error) {
+ if (error || data.length == 0) {
+ return;
+ }
+ UIImage *image = [UIImage imageWithData:data];
+ if (!image) {
+ return;
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) {
+ return;
+ }
+ [self.chatAvatarCache setObject:image forKey:urlString];
+ message.avatarImage = image;
+ [self.chatPanelView kb_reloadWithMessages:self.chatMessages];
+ });
+ }];
+}
+
+- (void)kb_requestChatAudioForText:(NSString *)text {
+ NSString *mockPath = [self kb_mockChatAudioPath];
+ if (mockPath.length > 0) {
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ NSString *displayText = KBLocalized(@"语音回复");
+ KBChatMessage *incoming =
+ [KBChatMessage messageWithText:displayText
+ outgoing:NO
+ audioFilePath:mockPath];
+ incoming.displayName = KBLocalized(@"AI助手");
+ [self kb_appendChatMessage:incoming];
+ [self kb_playChatAudioAtPath:mockPath];
+ });
+ return;
+ }
+ NSDictionary *payload = @{@"message" : text ?: @""};
+ __weak typeof(self) weakSelf = self;
+ [[KBNetworkManager shared] POST:API_AI_TALK
+ jsonBody:payload
+ headers:nil
+ completion:^(NSDictionary *json, NSURLResponse *response,
+ NSError *error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) {
+ return;
+ }
+ if (error) {
+ NSString *tip = error.localizedDescription
+ ?: KBLocalized(@"请求失败");
+ [KBHUD showInfo:tip];
+ return;
+ }
+ NSString *displayText =
+ [self kb_chatTextFromJSON:json];
+ NSString *audioURL =
+ [self kb_chatAudioURLFromJSON:json];
+ NSString *audioBase64 =
+ [self kb_chatAudioBase64FromJSON:json];
+ if (audioURL.length > 0) {
+ [self kb_downloadChatAudioFromURL:audioURL
+ displayText:displayText];
+ return;
+ }
+ if (audioBase64.length > 0) {
+ NSData *data = [[NSData alloc]
+ initWithBase64EncodedString:audioBase64
+ options:0];
+ if (data.length == 0) {
+ [KBHUD showInfo:KBLocalized(@"音频数据解析失败")];
+ return;
+ }
+ [self kb_handleChatAudioData:data
+ fileExtension:@"m4a"
+ displayText:displayText];
+ return;
+ }
+ [KBHUD showInfo:KBLocalized(@"未获取到音频文件")];
+ });
+ }];
+}
+
+- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
+ displayText:(NSString *)displayText {
+ __weak typeof(self) weakSelf = self;
+ [[KBNetworkManager shared]
+ GETData:audioURL
+ parameters:nil
+ headers:nil
+ completion:^(NSData *data, NSURLResponse *response, NSError *error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) {
+ return;
+ }
+ if (error) {
+ NSString *tip = error.localizedDescription ?: KBLocalized(@"下载失败");
+ [KBHUD showInfo:tip];
+ return;
+ }
+ if (data.length == 0) {
+ [KBHUD showInfo:KBLocalized(@"未获取到音频数据")];
+ return;
+ }
+ NSString *ext = @"m4a";
+ NSURL *url = [NSURL URLWithString:audioURL];
+ if (url.pathExtension.length > 0) {
+ ext = url.pathExtension;
+ }
+ [self kb_handleChatAudioData:data
+ fileExtension:ext
+ displayText:displayText];
+ });
+ }];
+}
+
+- (void)kb_handleChatAudioData:(NSData *)data
+ fileExtension:(NSString *)extension
+ displayText:(NSString *)displayText {
+ if (data.length == 0) {
+ [KBHUD showInfo:KBLocalized(@"音频数据为空")];
+ return;
+ }
+ NSString *ext = extension.length > 0 ? extension : @"m4a";
+ NSString *fileName = [NSString
+ stringWithFormat:@"kb_chat_%@.%@",
+ @((long long)([NSDate date].timeIntervalSince1970 *
+ 1000)),
+ ext];
+ NSString *filePath =
+ [NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
+ if (![data writeToFile:filePath atomically:YES]) {
+ [KBHUD showInfo:KBLocalized(@"音频保存失败")];
+ return;
+ }
+ NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息");
+ KBChatMessage *incoming =
+ [KBChatMessage messageWithText:text
+ outgoing:NO
+ audioFilePath:filePath];
+ incoming.displayName = KBLocalized(@"AI助手");
+ [self kb_appendChatMessage:incoming];
+}
+
+- (void)kb_appendChatMessage:(KBChatMessage *)message {
+ if (!message) {
+ return;
+ }
+ [self.chatMessages addObject:message];
+ if (self.chatMessages.count > kKBChatMessageLimit) {
+ NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
+ NSArray *removed =
+ [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
+ [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
+ for (KBChatMessage *msg in removed) {
+ if (msg.audioFilePath.length > 0) {
+ NSString *tmpRoot = NSTemporaryDirectory();
+ if (tmpRoot.length > 0 &&
+ [msg.audioFilePath hasPrefix:tmpRoot]) {
+ [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
+ error:nil];
+ }
+ }
+ }
+ }
+ [self.chatPanelView kb_reloadWithMessages:self.chatMessages];
+}
+
+- (NSString *)kb_mockChatAudioPath {
+ NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
+ ofType:@"m4a"];
+ return path ?: @"";
+}
+
+- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
+ NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
+ NSString *text =
+ [self kb_stringValueInDict:data
+ keys:@[ @"text", @"message", @"content" ]];
+ if (text.length == 0) {
+ text = [self kb_stringValueInDict:json
+ keys:@[ @"text", @"message", @"content" ]];
+ }
+ return text ?: @"";
+}
+
+- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
+ NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
+ NSArray *keys =
+ @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
+ @"file_url", @"audioFileUrl", @"audio_file_url" ];
+ NSString *url = [self kb_stringValueInDict:data keys:keys];
+ if (url.length == 0) {
+ url = [self kb_stringValueInDict:json keys:keys];
+ }
+ return url ?: @"";
+}
+
+- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
+ NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
+ NSArray *keys =
+ @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
+ @"base64" ];
+ NSString *b64 = [self kb_stringValueInDict:data keys:keys];
+ if (b64.length == 0) {
+ b64 = [self kb_stringValueInDict:json keys:keys];
+ }
+ return b64 ?: @"";
+}
+
+- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
+ if (![json isKindOfClass:[NSDictionary class]]) {
+ return @{};
+ }
+ id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
+ if ([dataObj isKindOfClass:[NSDictionary class]]) {
+ return (NSDictionary *)dataObj;
+ }
+ return @{};
+}
+
+- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
+ keys:(NSArray *)keys {
+ if (![dict isKindOfClass:[NSDictionary class]]) {
+ return @"";
+ }
+ for (NSString *key in keys) {
+ id value = dict[key];
+ if ([value isKindOfClass:[NSString class]] &&
+ ((NSString *)value).length > 0) {
+ return (NSString *)value;
+ }
+ }
+ return @"";
+}
+
+- (void)kb_playChatAudioAtPath:(NSString *)path {
+ if (path.length == 0) {
+ return;
+ }
+ NSURL *url = [NSURL fileURLWithPath:path];
+ if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
+ [KBHUD showInfo:KBLocalized(@"音频文件不存在")];
+ return;
+ }
+
+ if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
+ NSURL *currentURL = self.chatAudioPlayer.url;
+ if ([currentURL isEqual:url]) {
+ [self.chatAudioPlayer stop];
+ self.chatAudioPlayer = nil;
+ return;
+ }
+ [self.chatAudioPlayer stop];
+ self.chatAudioPlayer = nil;
+ }
+
+ NSError *sessionError = nil;
+ AVAudioSession *session = [AVAudioSession sharedInstance];
+ if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
+ [session setCategory:AVAudioSessionCategoryPlayback
+ withOptions:AVAudioSessionCategoryOptionDuckOthers
+ error:&sessionError];
+ } else {
+ [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
+ }
+ [session setActive:YES error:nil];
+
+ NSError *playerError = nil;
+ AVAudioPlayer *player =
+ [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
+ if (playerError || !player) {
+ [KBHUD showInfo:KBLocalized(@"音频播放失败")];
+ return;
+ }
+ self.chatAudioPlayer = player;
+ [player prepareToPlay];
+ [player play];
+}
+
#pragma mark - KBKeyboardSubscriptionViewDelegate
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
@@ -750,6 +1220,28 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
return _settingView;
}
+- (KBChatPanelView *)chatPanelView {
+ if (!_chatPanelView) {
+ _chatPanelView = [[KBChatPanelView alloc] init];
+ _chatPanelView.delegate = self;
+ }
+ return _chatPanelView;
+}
+
+- (NSMutableArray *)chatMessages {
+ if (!_chatMessages) {
+ _chatMessages = [NSMutableArray array];
+ }
+ return _chatMessages;
+}
+
+- (NSCache *)chatAvatarCache {
+ if (!_chatAvatarCache) {
+ _chatAvatarCache = [[NSCache alloc] init];
+ }
+ return _chatAvatarCache;
+}
+
- (KBKeyboardSubscriptionView *)subscriptionView {
if (!_subscriptionView) {
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
@@ -1150,12 +1642,36 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
if (width <= 0) {
width = KB_DESIGN_WIDTH;
}
- return kKBKeyboardBaseHeight * (width / KB_DESIGN_WIDTH);
+ CGFloat scale = width / KB_DESIGN_WIDTH;
+ CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
+ CGFloat chatHeight = kKBChatPanelHeight * scale;
+ if (self.chatPanelVisible) {
+ return baseHeight + chatHeight;
+ }
+ return baseHeight;
+}
+
+- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
+ if (width <= 0) {
+ width = KB_DESIGN_WIDTH;
+ }
+ CGFloat scale = width / KB_DESIGN_WIDTH;
+ return kKBKeyboardBaseHeight * scale;
+}
+
+- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
+ if (width <= 0) {
+ width = KB_DESIGN_WIDTH;
+ }
+ CGFloat scale = width / KB_DESIGN_WIDTH;
+ return kKBChatPanelHeight * scale;
}
- (void)kb_updateKeyboardLayoutIfNeeded {
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
+ CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
+ CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
if (containerWidth <= 0) {
containerWidth = CGRectGetWidth(self.view.window.bounds);
@@ -1186,6 +1702,12 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
if (self.contentHeightConstraint) {
[self.contentHeightConstraint setOffset:keyboardHeight];
}
+ if (self.keyBoardMainHeightConstraint) {
+ [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
+ }
+ if (self.chatPanelHeightConstraint) {
+ [self.chatPanelHeightConstraint setOffset:chatPanelHeight];
+ }
[self.view layoutIfNeeded];
}
diff --git a/CustomKeyboard/Model/KBChatMessage.h b/CustomKeyboard/Model/KBChatMessage.h
new file mode 100644
index 0000000..acf69f4
--- /dev/null
+++ b/CustomKeyboard/Model/KBChatMessage.h
@@ -0,0 +1,26 @@
+//
+// KBChatMessage.h
+// CustomKeyboard
+//
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface KBChatMessage : NSObject
+
+@property (nonatomic, copy) NSString *text;
+@property (nonatomic, assign) BOOL outgoing;
+@property (nonatomic, copy, nullable) NSString *audioFilePath;
+@property (nonatomic, copy, nullable) NSString *avatarURL;
+@property (nonatomic, copy, nullable) NSString *displayName;
+@property (nonatomic, strong, nullable) UIImage *avatarImage;
+
++ (instancetype)messageWithText:(NSString *)text
+ outgoing:(BOOL)outgoing
+ audioFilePath:(nullable NSString *)audioFilePath;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/CustomKeyboard/Model/KBChatMessage.m b/CustomKeyboard/Model/KBChatMessage.m
new file mode 100644
index 0000000..ce85acf
--- /dev/null
+++ b/CustomKeyboard/Model/KBChatMessage.m
@@ -0,0 +1,20 @@
+//
+// KBChatMessage.m
+// CustomKeyboard
+//
+
+#import "KBChatMessage.h"
+
+@implementation KBChatMessage
+
++ (instancetype)messageWithText:(NSString *)text
+ outgoing:(BOOL)outgoing
+ audioFilePath:(NSString *)audioFilePath {
+ KBChatMessage *msg = [[KBChatMessage alloc] init];
+ msg.text = text ?: @"";
+ msg.outgoing = outgoing;
+ msg.audioFilePath = audioFilePath;
+ return msg;
+}
+
+@end
diff --git a/CustomKeyboard/Network/KBNetworkManager.h b/CustomKeyboard/Network/KBNetworkManager.h
index b319dac..da0a96f 100644
--- a/CustomKeyboard/Network/KBNetworkManager.h
+++ b/CustomKeyboard/Network/KBNetworkManager.h
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
headers:(nullable NSDictionary *)headers
completion:(KBNetworkCompletion)completion;
+/// POST multipart 上传文件(常用于语音/图片等文件)
+- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
+ fileURL:(NSURL *)fileURL
+ name:(NSString *)name
+ mimeType:(NSString *)mimeType
+ parameters:(nullable NSDictionary *)parameters
+ headers:(nullable NSDictionary *)headers
+ completion:(KBNetworkCompletion)completion;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/CustomKeyboard/Network/KBNetworkManager.m b/CustomKeyboard/Network/KBNetworkManager.m
index 0d9103e..5e1385a 100644
--- a/CustomKeyboard/Network/KBNetworkManager.m
+++ b/CustomKeyboard/Network/KBNetworkManager.m
@@ -124,6 +124,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
return [self startAFJSONTaskWithRequest:req completion:completion];
}
+- (NSURLSessionDataTask *)uploadFile:(NSString *)path
+ fileURL:(NSURL *)fileURL
+ name:(NSString *)name
+ mimeType:(NSString *)mimeType
+ parameters:(NSDictionary *)parameters
+ headers:(NSDictionary *)headers
+ completion:(KBNetworkCompletion)completion {
+ [self getSignWithParare:parameters];
+ if (![self ensureEnabled:completion]) return nil;
+ NSString *urlString = [self buildURLStringWithPath:path];
+ if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
+ if (!fileURL) {
+ if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
+ return nil;
+ }
+ AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
+ serializer.timeoutInterval = self.timeout;
+ NSError *error = nil;
+ NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
+ URLString:urlString
+ parameters:parameters
+ constructingBodyWithBlock:^(id formData) {
+ NSString *safeName = (name.length > 0) ? name : @"file";
+ NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
+ NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
+ [formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
+ } error:&error];
+ if (error || !req) {
+ if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
+ return nil;
+ }
+ [self applyHeaders:headers toMutableRequest:req contentType:nil];
+ self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
+ NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
+ if (error) {
+ if (completion) completion(nil, response, error);
+ return;
+ }
+ NSData *data = (NSData *)responseObject;
+ if (![data isKindOfClass:[NSData class]]) {
+ if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
+ return;
+ }
+ NSString *ct = nil;
+ if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
+ ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
+ }
+ BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
+ if (!looksJSON) {
+ const unsigned char *bytes = data.bytes;
+ NSUInteger len = data.length;
+ for (NSUInteger i = 0; !looksJSON && i < len; i++) {
+ unsigned char c = bytes[i];
+ if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
+ looksJSON = (c == '{' || c == '[');
+ break;
+ }
+ }
+ if (looksJSON) {
+ NSError *jsonErr = nil;
+ id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
+ if (jsonErr) {
+ if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
+ return;
+ }
+ if (![json isKindOfClass:[NSDictionary class]]) {
+ if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
+ return;
+ }
+ if (completion) completion((NSDictionary *)json, response, nil);
+ } else {
+ if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
+ }
+ }];
+ [task resume];
+ return task;
+}
+
- (NSURLSessionDataTask *)GETData:(NSString *)path
parameters:(NSDictionary *)parameters
headers:(NSDictionary *)headers
diff --git a/CustomKeyboard/Resource/ai_test.m4a b/CustomKeyboard/Resource/ai_test.m4a
new file mode 100644
index 0000000..9039275
Binary files /dev/null and b/CustomKeyboard/Resource/ai_test.m4a differ
diff --git a/CustomKeyboard/View/KBChatMessageCell.h b/CustomKeyboard/View/KBChatMessageCell.h
new file mode 100644
index 0000000..1414a7c
--- /dev/null
+++ b/CustomKeyboard/View/KBChatMessageCell.h
@@ -0,0 +1,17 @@
+//
+// KBChatMessageCell.h
+// CustomKeyboard
+//
+
+#import
+@class KBChatMessage;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface KBChatMessageCell : UITableViewCell
+
+- (void)kb_configureWithMessage:(KBChatMessage *)message;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/CustomKeyboard/View/KBChatMessageCell.m b/CustomKeyboard/View/KBChatMessageCell.m
new file mode 100644
index 0000000..fc4ef37
--- /dev/null
+++ b/CustomKeyboard/View/KBChatMessageCell.m
@@ -0,0 +1,194 @@
+//
+// KBChatMessageCell.m
+// CustomKeyboard
+//
+
+#import "KBChatMessageCell.h"
+#import "KBChatMessage.h"
+#import "Masonry.h"
+
+@interface KBChatMessageCell ()
+@property (nonatomic, strong) UIImageView *avatarView;
+@property (nonatomic, strong) UILabel *nameLabel;
+@property (nonatomic, strong) UIView *bubbleView;
+@property (nonatomic, strong) UILabel *messageLabel;
+@property (nonatomic, strong) UIImageView *audioIconView;
+@property (nonatomic, strong) UILabel *audioLabel;
+@end
+
+@implementation KBChatMessageCell
+
+- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
+ if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
+ self.backgroundColor = [UIColor clearColor];
+ self.selectionStyle = UITableViewCellSelectionStyleNone;
+
+ [self.contentView addSubview:self.avatarView];
+ [self.contentView addSubview:self.nameLabel];
+ [self.contentView addSubview:self.bubbleView];
+ [self.bubbleView addSubview:self.messageLabel];
+ [self.bubbleView addSubview:self.audioIconView];
+ [self.bubbleView addSubview:self.audioLabel];
+ }
+ return self;
+}
+
+- (void)kb_configureWithMessage:(KBChatMessage *)message {
+ BOOL outgoing = message.outgoing;
+ BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
+ UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
+ UIColor *textColor = outgoing ? [UIColor whiteColor] : [UIColor colorWithHex:0x1B1F1A];
+
+ self.bubbleView.backgroundColor = bubbleColor;
+ self.messageLabel.textColor = textColor;
+ self.audioLabel.textColor = textColor;
+ self.audioIconView.tintColor = textColor;
+ self.messageLabel.text = message.text ?: @"";
+ self.audioLabel.text =
+ (message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
+ self.messageLabel.hidden = audioMessage;
+ self.audioIconView.hidden = !audioMessage;
+ self.audioLabel.hidden = !audioMessage;
+
+ UIImage *avatarImage = message.avatarImage;
+ if (!avatarImage) {
+ avatarImage = [self kb_defaultAvatarImage];
+ }
+ self.avatarView.image = avatarImage;
+ self.avatarView.backgroundColor =
+ avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0];
+ self.nameLabel.hidden = outgoing;
+ self.nameLabel.text =
+ (message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
+
+ CGFloat avatarSize = 28.0;
+ [self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.width.height.mas_equalTo(avatarSize);
+ make.top.equalTo(self.contentView.mas_top).offset(6);
+ if (outgoing) {
+ make.right.equalTo(self.contentView.mas_right).offset(-8);
+ } else {
+ make.left.equalTo(self.contentView.mas_left).offset(8);
+ }
+ }];
+
+ if (outgoing) {
+ [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.top.equalTo(self.contentView.mas_top).offset(0);
+ make.left.equalTo(self.contentView.mas_left);
+ }];
+ } else {
+ [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.left.equalTo(self.avatarView.mas_right).offset(6);
+ make.top.equalTo(self.contentView.mas_top).offset(2);
+ make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
+ }];
+ }
+
+ [self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
+ if (outgoing) {
+ make.top.equalTo(self.contentView.mas_top).offset(6);
+ make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
+ make.right.equalTo(self.avatarView.mas_left).offset(-6);
+ } else {
+ make.top.equalTo(self.nameLabel.mas_bottom).offset(2);
+ make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
+ make.left.equalTo(self.avatarView.mas_right).offset(6);
+ make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
+ }
+ }];
+
+ if (audioMessage) {
+ [self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.left.equalTo(self.bubbleView.mas_left).offset(10);
+ make.centerY.equalTo(self.bubbleView);
+ make.width.height.mas_equalTo(16);
+ }];
+ [self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.left.equalTo(self.audioIconView.mas_right).offset(6);
+ make.centerY.equalTo(self.bubbleView);
+ make.right.equalTo(self.bubbleView.mas_right).offset(-10);
+ make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8);
+ make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8);
+ }];
+ } else {
+ [self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
+ make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
+ }];
+ }
+}
+
+#pragma mark - Lazy
+
+- (UIImageView *)avatarView {
+ if (!_avatarView) {
+ _avatarView = [[UIImageView alloc] init];
+ _avatarView.contentMode = UIViewContentModeScaleAspectFill;
+ _avatarView.layer.cornerRadius = 14;
+ _avatarView.layer.masksToBounds = YES;
+ _avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
+ _avatarView.tintColor = [UIColor colorWithHex:0xB9BDC8];
+ }
+ return _avatarView;
+}
+
+- (UILabel *)nameLabel {
+ if (!_nameLabel) {
+ _nameLabel = [[UILabel alloc] init];
+ _nameLabel.font = [UIFont systemFontOfSize:11];
+ _nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A];
+ _nameLabel.numberOfLines = 1;
+ }
+ return _nameLabel;
+}
+
+- (UIView *)bubbleView {
+ if (!_bubbleView) {
+ _bubbleView = [[UIView alloc] init];
+ _bubbleView.layer.cornerRadius = 12;
+ _bubbleView.layer.masksToBounds = YES;
+ }
+ return _bubbleView;
+}
+
+- (UILabel *)messageLabel {
+ if (!_messageLabel) {
+ _messageLabel = [[UILabel alloc] init];
+ _messageLabel.font = [UIFont systemFontOfSize:14];
+ _messageLabel.numberOfLines = 0;
+ }
+ return _messageLabel;
+}
+
+- (UIImageView *)audioIconView {
+ if (!_audioIconView) {
+ _audioIconView = [[UIImageView alloc] init];
+ _audioIconView.contentMode = UIViewContentModeScaleAspectFit;
+ _audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A];
+ UIImage *icon = nil;
+ if (@available(iOS 13.0, *)) {
+ icon = [UIImage systemImageNamed:@"waveform"];
+ }
+ _audioIconView.image = icon;
+ }
+ return _audioIconView;
+}
+
+- (UILabel *)audioLabel {
+ if (!_audioLabel) {
+ _audioLabel = [[UILabel alloc] init];
+ _audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
+ _audioLabel.numberOfLines = 1;
+ }
+ return _audioLabel;
+}
+
+- (UIImage *)kb_defaultAvatarImage {
+ if (@available(iOS 13.0, *)) {
+ return [UIImage systemImageNamed:@"person.circle.fill"];
+ }
+ return nil;
+}
+
+@end
diff --git a/CustomKeyboard/View/KBChatPanelView.h b/CustomKeyboard/View/KBChatPanelView.h
new file mode 100644
index 0000000..255ffec
--- /dev/null
+++ b/CustomKeyboard/View/KBChatPanelView.h
@@ -0,0 +1,27 @@
+//
+// KBChatPanelView.h
+// CustomKeyboard
+//
+
+#import
+@class KBChatPanelView, KBChatMessage;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol KBChatPanelViewDelegate
+@optional
+- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
+- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
+@end
+
+@interface KBChatPanelView : UIView
+
+@property (nonatomic, weak) id delegate;
+
+@property (nonatomic, strong, readonly) UITableView *tableView;
+
+- (void)kb_reloadWithMessages:(NSArray *)messages;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/CustomKeyboard/View/KBChatPanelView.m b/CustomKeyboard/View/KBChatPanelView.m
new file mode 100644
index 0000000..bfb25b2
--- /dev/null
+++ b/CustomKeyboard/View/KBChatPanelView.m
@@ -0,0 +1,97 @@
+//
+// KBChatPanelView.m
+// CustomKeyboard
+//
+
+#import "KBChatPanelView.h"
+#import "KBChatMessage.h"
+#import "KBChatMessageCell.h"
+#import "Masonry.h"
+
+@interface KBChatPanelView ()
+@property (nonatomic, strong) UITableView *tableViewInternal;
+@property (nonatomic, copy) NSArray *messages;
+@end
+
+@implementation KBChatPanelView
+
+- (instancetype)initWithFrame:(CGRect)frame {
+ if (self = [super initWithFrame:frame]) {
+ self.backgroundColor = [UIColor colorWithHex:0xD1D3DB];
+
+ [self addSubview:self.tableViewInternal];
+
+ [self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
+ make.left.right.equalTo(self);
+ make.top.equalTo(self.mas_top).offset(8);
+ make.bottom.equalTo(self.mas_bottom).offset(-8);
+ }];
+ }
+ return self;
+}
+
+#pragma mark - Public
+
+- (void)kb_reloadWithMessages:(NSArray *)messages {
+ self.messages = messages ?: @[];
+ [self.tableViewInternal reloadData];
+ if (self.messages.count > 0) {
+ NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
+ [self.tableViewInternal scrollToRowAtIndexPath:indexPath
+ atScrollPosition:UITableViewScrollPositionBottom
+ animated:YES];
+ }
+}
+
+#pragma mark - UITableViewDataSource
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
+ return self.messages.count;
+}
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
+ KBChatMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(KBChatMessageCell.class)];
+ KBChatMessage *msg = self.messages[indexPath.row];
+ [cell kb_configureWithMessage:msg];
+ return cell;
+}
+
+#pragma mark - UITableViewDelegate
+
+- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
+ return UITableViewAutomaticDimension;
+}
+
+- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
+ return 44.0;
+}
+
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
+ if (indexPath.row >= self.messages.count) { return; }
+ KBChatMessage *msg = self.messages[indexPath.row];
+ if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
+ [self.delegate chatPanelView:self didTapMessage:msg];
+ }
+}
+
+#pragma mark - Lazy
+
+- (UITableView *)tableViewInternal {
+ if (!_tableViewInternal) {
+ _tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
+ _tableViewInternal.backgroundColor = [UIColor clearColor];
+ _tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
+ _tableViewInternal.dataSource = self;
+ _tableViewInternal.delegate = self;
+ _tableViewInternal.estimatedRowHeight = 44.0;
+ _tableViewInternal.rowHeight = UITableViewAutomaticDimension;
+ [_tableViewInternal registerClass:KBChatMessageCell.class forCellReuseIdentifier:NSStringFromClass(KBChatMessageCell.class)];
+ }
+ return _tableViewInternal;
+}
+
+#pragma mark - Expose
+
+- (UITableView *)tableView { return self.tableViewInternal; }
+
+@end
diff --git a/CustomKeyboard/View/KBToolBar.h b/CustomKeyboard/View/KBToolBar.h
index 5bb4553..1b8eeab 100644
--- a/CustomKeyboard/View/KBToolBar.h
+++ b/CustomKeyboard/View/KBToolBar.h
@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, weak, nullable) id delegate;
-/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
+/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
@property (nonatomic, copy) NSArray *leftButtonTitles;
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
diff --git a/CustomKeyboard/View/KBToolBar.m b/CustomKeyboard/View/KBToolBar.m
index 3eeec40..b15106b 100644
--- a/CustomKeyboard/View/KBToolBar.m
+++ b/CustomKeyboard/View/KBToolBar.m
@@ -26,11 +26,12 @@ static NSString * const kKBAIKeyIdentifier = @"ai";
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
static const CGFloat kKBAIButtonWidth = 40;
static const CGFloat kKBAIButtonHeight = 40;
+static const NSInteger kKBVoiceButtonIndex = 1;
- (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor];
- _leftButtonTitles = @[@"AI"]; // 默认标题
+ _leftButtonTitles = @[@"AI", KBLocalized(@"语音")]; // 默认标题
[self setupUI];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(kb_undoStateChanged)
@@ -68,6 +69,7 @@ static const CGFloat kKBAIButtonHeight = 40;
}
}];
[self kb_updateAIButtonAppearance];
+ [self kb_updateVoiceButtonAppearance];
}
#pragma mark - 视图搭建
@@ -171,6 +173,7 @@ static const CGFloat kKBAIButtonHeight = 40;
- (void)kb_applyTheme {
[self kb_updateAIButtonAppearance];
+ [self kb_updateVoiceButtonAppearance];
[self kb_updateUndoButtonAppearance];
}
@@ -208,6 +211,16 @@ static const CGFloat kKBAIButtonHeight = 40;
}
}
+- (void)kb_updateVoiceButtonAppearance {
+ UIButton *voiceButton = [self kb_voiceButton];
+ if (!voiceButton) { return; }
+
+ voiceButton.backgroundColor = [UIColor colorWithHex:0xE53935];
+ [voiceButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
+ voiceButton.layer.cornerRadius = 16;
+ voiceButton.layer.masksToBounds = YES;
+}
+
- (void)kb_updateUndoButtonAppearance {
if (!self.undoButtonInternal) { return; }
@@ -306,6 +319,11 @@ static const CGFloat kKBAIButtonHeight = 40;
return self.leftButtonsInternal[0];
}
+- (UIButton *)kb_voiceButton {
+ if (self.leftButtonsInternal.count <= kKBVoiceButtonIndex) { return nil; }
+ return self.leftButtonsInternal[kKBVoiceButtonIndex];
+}
+
#pragma mark - Globe (Input Mode Switch)
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
diff --git a/Shared/KBAPI.h b/Shared/KBAPI.h
index 9e90d34..4cdea9c 100644
--- a/Shared/KBAPI.h
+++ b/Shared/KBAPI.h
@@ -69,6 +69,7 @@
/// AI
#define API_AI_TALK @"/chat/talk"
+#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
diff --git a/Shared/KBConfig.h b/Shared/KBConfig.h
index 171cfaa..ed10b90 100644
--- a/Shared/KBConfig.h
+++ b/Shared/KBConfig.h
@@ -27,6 +27,9 @@
/// 键盘 -> 主 App 订阅页预填充数据(用于免二次请求)
#define AppGroup_SubscriptionPrefillPayload @"AppGroup_SubscriptionPrefillPayload"
+/// 用户头像 URL(主 App 写入,键盘扩展读取)
+#define AppGroup_UserAvatarURL @"AppGroup_UserAvatarURL"
+
/// 皮肤图标加载模式:
/// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon")
/// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a")
diff --git a/Shared/KBVoiceBridgeNotification.h b/Shared/KBVoiceBridgeNotification.h
new file mode 100644
index 0000000..f2ce7f7
--- /dev/null
+++ b/Shared/KBVoiceBridgeNotification.h
@@ -0,0 +1,26 @@
+//
+// KBVoiceBridgeNotification.h
+// 通用的语音录制桥接常量(App 与键盘扩展共享)
+//
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// 扩展 -> 主 App:请求开始录音
+extern NSString * const KBDarwinVoiceStartRequest; // "com.loveKey.nyx.voice.start"
+/// 扩展 -> 主 App:请求停止录音
+extern NSString * const KBDarwinVoiceStopRequest; // "com.loveKey.nyx.voice.stop"
+/// 主 App -> 扩展:录音文件已就绪
+extern NSString * const KBDarwinVoiceReady; // "com.loveKey.nyx.voice.ready"
+/// 主 App -> 扩展:录音失败
+extern NSString * const KBDarwinVoiceFailed; // "com.loveKey.nyx.voice.failed"
+
+/// App Group: 录音文件路径(NSString)
+extern NSString * const KBVoiceBridgeFilePathKey;
+/// App Group: 录音错误信息(NSString)
+extern NSString * const KBVoiceBridgeErrorKey;
+/// App Group: 录音时间戳(NSNumber)
+extern NSString * const KBVoiceBridgeTimestampKey;
+
+NS_ASSUME_NONNULL_END
diff --git a/Shared/KBVoiceBridgeNotification.m b/Shared/KBVoiceBridgeNotification.m
new file mode 100644
index 0000000..89fa884
--- /dev/null
+++ b/Shared/KBVoiceBridgeNotification.m
@@ -0,0 +1,14 @@
+//
+// KBVoiceBridgeNotification.m
+//
+
+#import "KBVoiceBridgeNotification.h"
+
+NSString * const KBDarwinVoiceStartRequest = @"com.loveKey.nyx.voice.start";
+NSString * const KBDarwinVoiceStopRequest = @"com.loveKey.nyx.voice.stop";
+NSString * const KBDarwinVoiceReady = @"com.loveKey.nyx.voice.ready";
+NSString * const KBDarwinVoiceFailed = @"com.loveKey.nyx.voice.failed";
+
+NSString * const KBVoiceBridgeFilePathKey = @"kb_voice_file_path";
+NSString * const KBVoiceBridgeErrorKey = @"kb_voice_error";
+NSString * const KBVoiceBridgeTimestampKey = @"kb_voice_ts";
diff --git a/Shared/KBVoiceRecordManager.h b/Shared/KBVoiceRecordManager.h
new file mode 100644
index 0000000..c8099af
--- /dev/null
+++ b/Shared/KBVoiceRecordManager.h
@@ -0,0 +1,21 @@
+//
+// KBVoiceRecordManager.h
+// 主 App 录音管理(用于键盘桥接)
+//
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface KBVoiceRecordManager : NSObject
+
++ (instancetype)shared;
+
+- (void)startRecording;
+- (void)stopRecording;
+
+@property (nonatomic, assign, readonly, getter=isRecording) BOOL recording;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Shared/KBVoiceRecordManager.m b/Shared/KBVoiceRecordManager.m
new file mode 100644
index 0000000..af0abc4
--- /dev/null
+++ b/Shared/KBVoiceRecordManager.m
@@ -0,0 +1,312 @@
+//
+// KBVoiceRecordManager.m
+//
+
+#import "KBVoiceRecordManager.h"
+#import "KBConfig.h"
+#import "KBVoiceBridgeNotification.h"
+#import "KBHUD.h"
+#import
+
+static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo);
+
+@interface KBVoiceRecordManager ()
+@property (nonatomic, strong) AVAudioRecorder *recorder;
+@property (nonatomic, strong) NSURL *recordURL;
+@property (nonatomic, assign, readwrite, getter=isRecording) BOOL recording;
+@property (nonatomic, assign) BOOL pendingStart;
+@end
+
+@implementation KBVoiceRecordManager
+
++ (instancetype)shared {
+ static KBVoiceRecordManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBVoiceRecordManager new]; });
+ return m;
+}
+
+- (instancetype)init {
+ if (self = [super init]) {
+ CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
+ (__bridge const void *)(self),
+ KBVoiceBridgeDarwinCallback,
+ (__bridge CFStringRef)KBDarwinVoiceStartRequest,
+ NULL,
+ CFNotificationSuspensionBehaviorDeliverImmediately);
+ CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
+ (__bridge const void *)(self),
+ KBVoiceBridgeDarwinCallback,
+ (__bridge CFStringRef)KBDarwinVoiceStopRequest,
+ NULL,
+ CFNotificationSuspensionBehaviorDeliverImmediately);
+ }
+ return self;
+}
+
+- (void)dealloc {
+ CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
+ (__bridge const void *)(self),
+ (__bridge CFStringRef)KBDarwinVoiceStartRequest,
+ NULL);
+ CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
+ (__bridge const void *)(self),
+ (__bridge CFStringRef)KBDarwinVoiceStopRequest,
+ NULL);
+}
+
+static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
+ KBVoiceRecordManager *self = (__bridge KBVoiceRecordManager *)observer;
+ if (!self) { return; }
+ NSString *n = (__bridge NSString *)name;
+ NSLog(@"[KBVoiceBridge][App] Darwin received: %@", n);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if ([n isEqualToString:KBDarwinVoiceStartRequest]) {
+ [self startRecording];
+ } else if ([n isEqualToString:KBDarwinVoiceStopRequest]) {
+ [self stopRecording];
+ }
+ });
+}
+
+#pragma mark - Public
+
+- (void)startRecording {
+ if (self.isRecording) {
+ NSLog(@"[KBVoiceBridge][App] startRecording already recording, stop then restart");
+ [self stopRecording];
+// return;
+ }
+ NSLog(@"[KBVoiceBridge][App] startRecording begin");
+ [self kb_clearSharedState];
+
+ AVAudioSession *session = [AVAudioSession sharedInstance];
+ AVAudioSessionRecordPermission permission = session.recordPermission;
+ if (permission == AVAudioSessionRecordPermissionDenied) {
+ NSLog(@"[KBVoiceBridge][App] recordPermission denied");
+ [self kb_postFailed:KBLocalized(@"麦克风权限未开启")];
+ return;
+ }
+ if (permission == AVAudioSessionRecordPermissionUndetermined) {
+ NSLog(@"[KBVoiceBridge][App] recordPermission undetermined, requesting");
+ self.pendingStart = YES;
+ __weak typeof(self) weakSelf = self;
+ [session requestRecordPermission:^(BOOL granted) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) { return; }
+ if (!self.pendingStart) { return; }
+ self.pendingStart = NO;
+ if (!granted) {
+ NSLog(@"[KBVoiceBridge][App] recordPermission request denied");
+ [self kb_postFailed:KBLocalized(@"麦克风权限未开启")];
+ return;
+ }
+ NSLog(@"[KBVoiceBridge][App] recordPermission request granted");
+ [self startRecording];
+ });
+ }];
+ return;
+ }
+
+ NSError *error = nil;
+ if (@available(iOS 10.0, *)) {
+ [session setCategory:AVAudioSessionCategoryPlayAndRecord
+ mode:AVAudioSessionModeDefault
+ options:AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth
+ error:&error];
+ } else {
+ [session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error];
+ }
+ if (error) {
+ NSLog(@"[KBVoiceBridge][App] setCategory error: %@", error.localizedDescription);
+ [self kb_postFailed:KBLocalized(@"麦克风初始化失败")];
+ return;
+ }
+ [session setActive:YES error:&error];
+ if (error) {
+ NSLog(@"[KBVoiceBridge][App] setActive error: %@", error.localizedDescription);
+ [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"麦克风启动失败")];
+ return;
+ }
+ if (!session.isInputAvailable) {
+ NSLog(@"[KBVoiceBridge][App] input not available");
+ [self kb_postFailed:KBLocalized(@"麦克风不可用")];
+ return;
+ }
+
+ NSURL *aacURL = [self kb_voiceFileURLWithExtension:@"m4a"];
+ if (!aacURL) {
+ NSLog(@"[KBVoiceBridge][App] app group not configured");
+ [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")];
+ return;
+ }
+ NSDictionary *aacSettings = [self kb_voiceRecordSettingsAAC];
+ if ([self kb_tryStartRecorderWithSettings:aacSettings fileURL:aacURL error:&error]) {
+ NSLog(@"[KBVoiceBridge][App] recorder started (aac) url=%@", aacURL);
+ self.recordURL = aacURL;
+ self.recording = YES;
+ return;
+ }
+
+ NSURL *pcmURL = [self kb_voiceFileURLWithExtension:@"caf"];
+ if (!pcmURL) {
+ NSLog(@"[KBVoiceBridge][App] app group not configured");
+ [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")];
+ return;
+ }
+ NSDictionary *pcmSettings = [self kb_voiceRecordSettingsPCM];
+ error = nil;
+ if ([self kb_tryStartRecorderWithSettings:pcmSettings fileURL:pcmURL error:&error]) {
+ NSLog(@"[KBVoiceBridge][App] recorder started (pcm) url=%@", pcmURL);
+ self.recordURL = pcmURL;
+ self.recording = YES;
+ return;
+ }
+
+ NSLog(@"[KBVoiceBridge][App] recorder start failed: %@", error.localizedDescription);
+ NSString *tip = error.localizedDescription ?: KBLocalized(@"录音启动失败,可能是系统限制或宿主 App 不允许录音");
+ [self kb_postFailed:tip];
+}
+
+- (void)stopRecording {
+ self.pendingStart = NO;
+ NSLog(@"[KBVoiceBridge][App] stopRecording");
+ if (self.recorder.isRecording) {
+ [self.recorder stop];
+ }
+}
+
+#pragma mark - AVAudioRecorderDelegate
+
+- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag {
+ [[AVAudioSession sharedInstance] setActive:NO error:nil];
+ NSLog(@"[KBVoiceBridge][App] finishRecording flag=%d url=%@", flag, recorder.url);
+ NSURL *fileURL = recorder.url ?: self.recordURL;
+ self.recorder = nil;
+ self.recordURL = nil;
+ self.recording = NO;
+
+ if (!flag || !fileURL) {
+ [self kb_postFailed:KBLocalized(@"录音失败")];
+ return;
+ }
+
+ [self kb_saveSharedFileURL:fileURL];
+ [self kb_postDarwin:KBDarwinVoiceReady];
+}
+
+- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error {
+ [[AVAudioSession sharedInstance] setActive:NO error:nil];
+ NSLog(@"[KBVoiceBridge][App] encodeError: %@", error.localizedDescription);
+ self.recorder = nil;
+ self.recordURL = nil;
+ self.recording = NO;
+ [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"录音失败")];
+}
+
+#pragma mark - Helpers
+
+- (NSDictionary *)kb_voiceRecordSettingsAAC {
+ return @{AVFormatIDKey: @(kAudioFormatMPEG4AAC),
+ AVSampleRateKey: @(16000),
+ AVNumberOfChannelsKey: @(1),
+ AVEncoderAudioQualityKey: @(AVAudioQualityMedium)};
+}
+
+- (NSDictionary *)kb_voiceRecordSettingsPCM {
+ return @{AVFormatIDKey: @(kAudioFormatLinearPCM),
+ AVSampleRateKey: @(16000),
+ AVNumberOfChannelsKey: @(1),
+ AVLinearPCMBitDepthKey: @(16),
+ AVLinearPCMIsFloatKey: @(NO),
+ AVLinearPCMIsBigEndianKey: @(NO)};
+}
+
+- (NSURL *)kb_voiceFileURLWithExtension:(NSString *)ext {
+ NSURL *dirURL = [self kb_voiceDirectoryURL];
+ if (!dirURL) {
+ return nil;
+ }
+ NSTimeInterval ts = [[NSDate date] timeIntervalSince1970] * 1000;
+ NSString *safeExt = (ext.length > 0) ? ext : @"m4a";
+ NSString *fileName = [NSString stringWithFormat:@"kb_voice_%lld.%@", (long long)ts, safeExt];
+ return [dirURL URLByAppendingPathComponent:fileName];
+}
+
+- (NSURL *)kb_voiceDirectoryURL {
+ NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
+ if (!containerURL) {
+ return nil;
+ }
+ NSURL *dirURL = [containerURL URLByAppendingPathComponent:@"voice" isDirectory:YES];
+ if (dirURL && ![[NSFileManager defaultManager] fileExistsAtPath:dirURL.path]) {
+ [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil];
+ }
+ return dirURL;
+}
+
+- (BOOL)kb_tryStartRecorderWithSettings:(NSDictionary *)settings fileURL:(NSURL *)fileURL error:(NSError **)error {
+ AVAudioRecorder *recorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:settings error:error];
+ if (*error || !recorder) {
+ NSLog(@"[KBVoiceBridge][App] create recorder failed: %@", (*error).localizedDescription);
+ return NO;
+ }
+ recorder.delegate = self;
+ recorder.meteringEnabled = YES;
+ if (![recorder prepareToRecord]) {
+ NSLog(@"[KBVoiceBridge][App] prepareToRecord failed");
+ return NO;
+ }
+ if (![recorder record]) {
+ NSLog(@"[KBVoiceBridge][App] record returned NO");
+ return NO;
+ }
+ self.recorder = recorder;
+ return YES;
+}
+
+- (NSUserDefaults *)kb_voiceUserDefaults {
+ return [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
+}
+
+- (void)kb_clearSharedState {
+ NSUserDefaults *ud = [self kb_voiceUserDefaults];
+ [ud removeObjectForKey:KBVoiceBridgeFilePathKey];
+ [ud removeObjectForKey:KBVoiceBridgeErrorKey];
+ [ud removeObjectForKey:KBVoiceBridgeTimestampKey];
+ [ud synchronize];
+}
+
+- (void)kb_saveSharedFileURL:(NSURL *)fileURL {
+ if (!fileURL) { return; }
+ NSUserDefaults *ud = [self kb_voiceUserDefaults];
+ [ud setObject:fileURL.path ?: @"" forKey:KBVoiceBridgeFilePathKey];
+ [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey];
+ [ud removeObjectForKey:KBVoiceBridgeErrorKey];
+ [ud synchronize];
+}
+
+- (void)kb_saveSharedError:(NSString *)message {
+ NSUserDefaults *ud = [self kb_voiceUserDefaults];
+ [ud setObject:message ?: @"" forKey:KBVoiceBridgeErrorKey];
+ [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey];
+ [ud removeObjectForKey:KBVoiceBridgeFilePathKey];
+ [ud synchronize];
+}
+
+- (void)kb_postFailed:(NSString *)message {
+ [self kb_saveSharedError:message];
+ [self kb_postDarwin:KBDarwinVoiceFailed];
+
+ if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive) {
+ NSString *tip = message.length > 0 ? message : KBLocalized(@"录音失败");
+ [KBHUD showInfo:tip];
+ }
+}
+
+- (void)kb_postDarwin:(NSString *)name {
+ CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
+ (__bridge CFStringRef)name,
+ NULL, NULL, true);
+}
+
+@end
diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj
index 123237f..22a9f75 100644
--- a/keyBoard.xcodeproj/project.pbxproj
+++ b/keyBoard.xcodeproj/project.pbxproj
@@ -52,6 +52,7 @@
0459D1B42EBA284C00F2D189 /* KBSkinCenterVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */; };
0459D1B72EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; };
0459D1B82EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; };
+ 0460866B2F18D75500757C95 /* ai_test.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0460866A2F18D75500757C95 /* ai_test.m4a */; };
046131142ECF454500A6FADF /* KBKeyPreviewView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046131132ECF454500A6FADF /* KBKeyPreviewView.m */; };
0477BDF02EBB76E30055D639 /* HomeSheetVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDEF2EBB76E30055D639 /* HomeSheetVC.m */; };
0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDF22EBB7B850055D639 /* KBDirectionIndicatorView.m */; };
@@ -182,7 +183,6 @@
04FC95B22EB0B2CC007BD342 /* KBSettingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95B12EB0B2CC007BD342 /* KBSettingView.m */; };
04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95C82EB1E4C9007BD342 /* BaseNavigationController.m */; };
04FC95CC2EB1E780007BD342 /* BaseTabBarController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95CB2EB1E780007BD342 /* BaseTabBarController.m */; };
- 04FC95CF2EB1E7A1007BD342 /* HomeVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */; };
04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D12EB1E7AE007BD342 /* MyVC.m */; };
04FC95D72EB1EA16007BD342 /* BaseTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D62EB1EA16007BD342 /* BaseTableView.m */; };
04FC95D82EB1EA16007BD342 /* BaseCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D42EB1EA16007BD342 /* BaseCell.m */; };
@@ -221,9 +221,15 @@
A1B2C4002EB4A0A100000004 /* KBAuthManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4002EB4A0A100000002 /* KBAuthManager.m */; };
A1B2C4202EB4B7A100000001 /* KBKeyboardPermissionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */; };
A1B2C4212EB4B7A100000001 /* KBKeyboardPermissionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */; };
+ A1B2C5052F31001000000001 /* KBVoiceBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */; };
+ A1B2C5062F31001000000001 /* KBVoiceBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */; };
+ A1B2C5072F31001000000001 /* KBVoiceRecordManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */; };
A1B2C9032FBD000100000001 /* KBBackspaceLongPressHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9022FBD000100000001 /* KBBackspaceLongPressHandler.m */; };
A1B2C9052FBD000200000001 /* KBBackspaceUndoManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9042FBD000200000001 /* KBBackspaceUndoManager.m */; };
A1B2C9092FBD000200000005 /* KBInputBufferManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9082FBD000200000004 /* KBInputBufferManager.m */; };
+ A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9212FC9000100000001 /* KBChatMessage.m */; };
+ A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9232FC9000100000001 /* KBChatMessageCell.m */; };
+ A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9252FC9000100000001 /* KBChatPanelView.m */; };
A1B2D7022EB8C00100000001 /* KBLangTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2D7012EB8C00100000001 /* KBLangTestVC.m */; };
A1B2E1012EBC7AAA00000001 /* KBTopThreeView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0022EBC7AAA00000001 /* KBTopThreeView.m */; };
A1B2E1022EBC7AAA00000001 /* HomeHotCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0042EBC7AAA00000001 /* HomeHotCell.m */; };
@@ -323,6 +329,7 @@
0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinCenterVC.m; sourceTree = ""; };
0459D1B52EBA287900F2D189 /* KBSkinManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinManager.h; sourceTree = ""; };
0459D1B62EBA287900F2D189 /* KBSkinManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinManager.m; sourceTree = ""; };
+ 0460866A2F18D75500757C95 /* ai_test.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ai_test.m4a; sourceTree = ""; };
046131122ECF454500A6FADF /* KBKeyPreviewView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyPreviewView.h; sourceTree = ""; };
046131132ECF454500A6FADF /* KBKeyPreviewView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyPreviewView.m; sourceTree = ""; };
0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeSheetVC.h; sourceTree = ""; };
@@ -557,8 +564,6 @@
04FC95C82EB1E4C9007BD342 /* BaseNavigationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseNavigationController.m; sourceTree = ""; };
04FC95CA2EB1E780007BD342 /* BaseTabBarController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseTabBarController.h; sourceTree = ""; };
04FC95CB2EB1E780007BD342 /* BaseTabBarController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseTabBarController.m; sourceTree = ""; };
- 04FC95CD2EB1E7A1007BD342 /* HomeVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeVC.h; sourceTree = ""; };
- 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HomeVC.m; sourceTree = ""; };
04FC95D02EB1E7AE007BD342 /* MyVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MyVC.h; sourceTree = ""; };
04FC95D12EB1E7AE007BD342 /* MyVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MyVC.m; sourceTree = ""; };
04FC95D32EB1EA16007BD342 /* BaseCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseCell.h; sourceTree = ""; };
@@ -633,12 +638,22 @@
A1B2C4002EB4A0A100000002 /* KBAuthManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAuthManager.m; sourceTree = ""; };
A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardPermissionManager.m; sourceTree = ""; };
A1B2C4232EB4B7A100000001 /* KBKeyboardPermissionManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyboardPermissionManager.h; sourceTree = ""; };
+ A1B2C5012F31001000000001 /* KBVoiceBridgeNotification.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceBridgeNotification.h; sourceTree = ""; };
+ A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceBridgeNotification.m; sourceTree = ""; };
+ A1B2C5032F31001000000001 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = ""; };
+ A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = ""; };
A1B2C9012FBD000100000001 /* KBBackspaceLongPressHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceLongPressHandler.h; sourceTree = ""; };
A1B2C9022FBD000100000001 /* KBBackspaceLongPressHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceLongPressHandler.m; sourceTree = ""; };
A1B2C9032FBD000200000001 /* KBBackspaceUndoManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceUndoManager.h; sourceTree = ""; };
A1B2C9042FBD000200000001 /* KBBackspaceUndoManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceUndoManager.m; sourceTree = ""; };
A1B2C9072FBD000200000003 /* KBInputBufferManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBInputBufferManager.h; sourceTree = ""; };
A1B2C9082FBD000200000004 /* KBInputBufferManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBInputBufferManager.m; sourceTree = ""; };
+ A1B2C9202FC9000100000001 /* KBChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessage.h; sourceTree = ""; };
+ A1B2C9212FC9000100000001 /* KBChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessage.m; sourceTree = ""; };
+ A1B2C9222FC9000100000001 /* KBChatMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageCell.h; sourceTree = ""; };
+ A1B2C9232FC9000100000001 /* KBChatMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageCell.m; sourceTree = ""; };
+ A1B2C9242FC9000100000001 /* KBChatPanelView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatPanelView.h; sourceTree = ""; };
+ A1B2C9252FC9000100000001 /* KBChatPanelView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatPanelView.m; sourceTree = ""; };
A1B2D7002EB8C00100000001 /* KBLangTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBLangTestVC.h; sourceTree = ""; };
A1B2D7012EB8C00100000001 /* KBLangTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBLangTestVC.m; sourceTree = ""; };
A1B2E0012EBC7AAA00000001 /* KBTopThreeView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTopThreeView.h; sourceTree = ""; };
@@ -686,6 +701,7 @@
041007D02ECE010100D203BB /* Resource */ = {
isa = PBXGroup;
children = (
+ 0460866A2F18D75500757C95 /* ai_test.m4a */,
04E161812F10E6470022C23B /* normal_hei_them.zip */,
04E161822F10E6470022C23B /* normal_them.zip */,
A1B2C3EC2F20000000000001 /* kb_words.txt */,
@@ -1203,6 +1219,10 @@
A1B2C3F22EB35A9900000001 /* KBFullAccessGuideView.m */,
04FC95B02EB0B2CC007BD342 /* KBSettingView.h */,
04FC95B12EB0B2CC007BD342 /* KBSettingView.m */,
+ A1B2C9242FC9000100000001 /* KBChatPanelView.h */,
+ A1B2C9252FC9000100000001 /* KBChatPanelView.m */,
+ A1B2C9222FC9000100000001 /* KBChatMessageCell.h */,
+ A1B2C9232FC9000100000001 /* KBChatMessageCell.m */,
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */,
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */,
049FB23A2EC4766700FAB05D /* Function */,
@@ -1248,6 +1268,8 @@
children = (
04FC95642EB0546C007BD342 /* KBKey.h */,
04FC95652EB0546C007BD342 /* KBKey.m */,
+ A1B2C9202FC9000100000001 /* KBChatMessage.h */,
+ A1B2C9212FC9000100000001 /* KBChatMessage.m */,
04FEDC202F00020000999999 /* KBKeyboardSubscriptionProduct.h */,
04FEDC212F00020000999999 /* KBKeyboardSubscriptionProduct.m */,
04FEDC232F10000100000001 /* KBKeyboardLayoutConfig.h */,
@@ -1293,8 +1315,6 @@
04FC95B52EB1E3B1007BD342 /* VC */ = {
isa = PBXGroup;
children = (
- 04FC95CD2EB1E7A1007BD342 /* HomeVC.h */,
- 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */,
0477BE022EBC83130055D639 /* HomeMainVC.h */,
0477BE032EBC83130055D639 /* HomeMainVC.m */,
0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */,
@@ -1651,6 +1671,10 @@
0459D1B62EBA287900F2D189 /* KBSkinManager.m */,
049FB23D2EC4B6EF00FAB05D /* KBULBridgeNotification.h */,
049FB23E2EC4B6EF00FAB05D /* KBULBridgeNotification.m */,
+ A1B2C5012F31001000000001 /* KBVoiceBridgeNotification.h */,
+ A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */,
+ A1B2C5032F31001000000001 /* KBVoiceRecordManager.h */,
+ A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */,
04D1F6B02EDFF10A00B12345 /* KBSkinInstallBridge.h */,
04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */,
04791F8C2ED469C0004E8522 /* KBHostAppLauncher.h */,
@@ -1818,6 +1842,7 @@
04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */,
04E161842F10E6470022C23B /* normal_them.zip in Resources */,
04A9FE202EB893F10020DB6D /* Localizable.strings in Resources */,
+ 0460866B2F18D75500757C95 /* ai_test.m4a in Resources */,
041007D42ECE012500D203BB /* 002.zip in Resources */,
041007D22ECE012000D203BB /* KBSkinIconMap.strings in Resources */,
A1B2C3ED2F20000000000001 /* kb_words.txt in Resources */,
@@ -1924,6 +1949,9 @@
0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */,
04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */,
04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */,
+ A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */,
+ A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */,
+ A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */,
A1B2C3EB2F20000000000001 /* KBSuggestionBarView.m in Sources */,
04FC95792EB09BC8007BD342 /* KBKeyBoardMainView.m in Sources */,
04FEDAB32EEDB05000123456 /* KBEmojiPanelView.m in Sources */,
@@ -1955,6 +1983,7 @@
A1B2C9052FBD000200000001 /* KBBackspaceUndoManager.m in Sources */,
A1B2C9092FBD000200000005 /* KBInputBufferManager.m in Sources */,
049FB23F2EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */,
+ A1B2C5052F31001000000001 /* KBVoiceBridgeNotification.m in Sources */,
04791F992ED49CE7004E8522 /* KBFont.m in Sources */,
04FC956D2EB054B7007BD342 /* KBKeyboardView.m in Sources */,
04FC95672EB0546C007BD342 /* KBKey.m in Sources */,
@@ -2046,6 +2075,8 @@
0477BDF72EBC63A80055D639 /* KBTestVC.m in Sources */,
04122F7E2EC5FC5500EF7AB3 /* KBJfPayCell.m in Sources */,
049FB2402EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */,
+ A1B2C5062F31001000000001 /* KBVoiceBridgeNotification.m in Sources */,
+ A1B2C5072F31001000000001 /* KBVoiceRecordManager.m in Sources */,
04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */,
048908DD2EBF67EB00FABA60 /* KBSearchResultVC.m in Sources */,
05A1B2D12F5B1A2B3C4D5E60 /* KBSearchVM.m in Sources */,
@@ -2081,7 +2112,6 @@
0498BD7E2EE04F9C006CC1D5 /* KBTag.m in Sources */,
04791F922ED48010004E8522 /* KBNoticeVC.m in Sources */,
04FC970F2EB334F8007BD342 /* KBWebImageManager.m in Sources */,
- 04FC95CF2EB1E7A1007BD342 /* HomeVC.m in Sources */,
0498BDDE2EE81508006CC1D5 /* KBShopVM.m in Sources */,
049FB2112EC1F72F00FAB05D /* KBMyListCell.m in Sources */,
A1B2D7022EB8C00100000001 /* KBLangTestVC.m in Sources */,
@@ -2263,6 +2293,9 @@
INFOPLIST_FILE = keyBoard/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Key of Love";
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.loveKey.nyx.keyboard\";\n CFBundleURLSchemes = (\n kbkeyboardAppExtension\n );\n}";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要使用麦克风进行语音输入";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "保存图片需要写入您的相册";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "更换头像需要访问您的相册";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
@@ -2311,6 +2344,9 @@
INFOPLIST_FILE = keyBoard/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Key of Love";
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.loveKey.nyx.keyboard\";\n CFBundleURLSchemes = (\n kbkeyboardAppExtension\n );\n}";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要使用麦克风进行语音输入";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "保存图片需要写入您的相册";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "更换头像需要访问您的相册";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
diff --git a/keyBoard/AppDelegate.m b/keyBoard/AppDelegate.m
index 0af4a55..1d40b03 100644
--- a/keyBoard/AppDelegate.m
+++ b/keyBoard/AppDelegate.m
@@ -25,6 +25,7 @@
#import "KBUserSessionManager.h"
#import "KBLoginVC.h"
#import "KBConfig.h"
+#import "KBVoiceRecordManager.h"
static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0;
@@ -61,6 +62,8 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0;
// 主工程默认开启网络总开关(键盘扩展仍需用户允许完全访问后再行开启)
[KBNetworkManager shared].enabled = YES;
+ // 预热语音录制管理器(注册 Darwin 通知,响应键盘录音请求)
+ [KBVoiceRecordManager shared];
/// 获取网络权限
[self getNetJudge];
/// 触发一次简单网络请求,用于拉起系统的蜂窝数据权限弹窗
@@ -190,6 +193,19 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0;
} else if ([host isEqualToString:@"settings"]) { // kbkeyboard://settings
[self kb_openAppSettings];
return YES;
+ } else if ([host isEqualToString:@"voice"]) { // kbkeyboard://voice?action=start|stop
+ NSDictionary *params = [self kb_queryParametersFromURL:url];
+ NSString *action = params[@"action"].lowercaseString;
+ NSLog(@"[KBVoiceBridge][App] openURL voice action=%@", action);
+ if ([action isEqualToString:@"start"]) {
+ [[KBVoiceRecordManager shared] startRecording];
+ return YES;
+ }
+ if ([action isEqualToString:@"stop"]) {
+ [[KBVoiceRecordManager shared] stopRecording];
+ return YES;
+ }
+ return YES;
}else if ([host isEqualToString:@"recharge"]) { // kbkeyboard://recharge
NSDictionary *params = [self kb_queryParametersFromURL:url];
NSString *productId = params[@"productId"];
diff --git a/keyBoard/Class/Base/VC/BaseTabBarController.m b/keyBoard/Class/Base/VC/BaseTabBarController.m
index f7a70d7..16ad77b 100644
--- a/keyBoard/Class/Base/VC/BaseTabBarController.m
+++ b/keyBoard/Class/Base/VC/BaseTabBarController.m
@@ -6,7 +6,6 @@
//
#import "BaseTabBarController.h"
-#import "HomeVC.h"
#import "HomeMainVC.h"
#import "MyVC.h"
#import "KBShopVC.h"
diff --git a/keyBoard/Class/Home/VC/HomeMainVC.m b/keyBoard/Class/Home/VC/HomeMainVC.m
index 387de89..5b0bfb7 100644
--- a/keyBoard/Class/Home/VC/HomeMainVC.m
+++ b/keyBoard/Class/Home/VC/HomeMainVC.m
@@ -10,10 +10,13 @@
#import "KBPanModalView.h"
#import "KBGuideVC.h" // 首次安装指引页
#import "WMDragView.h"
+#import "KBMyVM.h"
+#import "KBUserSessionManager.h"
@interface HomeMainVC ()
@property (nonatomic, strong) HomeHeadView *headView;
@property (nonatomic, strong) KBPanModalView *simplePanModalView;
+@property (nonatomic, strong) KBMyVM *viewModel;
/// 权限按钮
@property (nonatomic, strong) WMDragView *keyPermissButton;
@@ -65,6 +68,17 @@
// NSLog(@"[MainApp] 写入完成");
}
+- (void)viewWillAppear:(BOOL)animated {
+ [super viewWillAppear:animated];
+ if (![KBUserSessionManager shared].isLoggedIn) {
+ return;
+ }
+ if (!self.viewModel) {
+ self.viewModel = [[KBMyVM alloc] init];
+ }
+ [self.viewModel fetchUserDetailWithCompletion:nil];
+}
+
// 在界面可见后做“首次安装”检查,避免在 viewDidLoad 期间 push 引起的转场警告
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
diff --git a/keyBoard/Class/Home/VC/HomeVC.h b/keyBoard/Class/Home/VC/HomeVC.h
deleted file mode 100644
index 23d76f4..0000000
--- a/keyBoard/Class/Home/VC/HomeVC.h
+++ /dev/null
@@ -1,16 +0,0 @@
-//
-// HomeVC.h
-// keyBoard
-//
-// Created by Mac on 2025/10/29.
-//
-
-#import
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface HomeVC : UIViewController
-
-@end
-
-NS_ASSUME_NONNULL_END
diff --git a/keyBoard/Class/Home/VC/HomeVC.m b/keyBoard/Class/Home/VC/HomeVC.m
deleted file mode 100644
index d6badcb..0000000
--- a/keyBoard/Class/Home/VC/HomeVC.m
+++ /dev/null
@@ -1,61 +0,0 @@
-//
-// HomeVC.m
-// keyBoard
-//
-// Created by Mac on 2025/10/29.
-//
-
-#import "HomeVC.h"
-#import "HomeHeadView.h"
-#import "HomeSheetVC.h"
-
-@interface HomeVC ()
-@property (nonatomic, strong) HomeHeadView *headView;
-
-@end
-
-@implementation HomeVC
-
-- (void)viewDidLoad {
- [super viewDidLoad];
- self.view.backgroundColor = [UIColor yellowColor];
- CGFloat topV = (500);
-
- [self.view addSubview:self.headView];
- [self setupMas:topV];
- // 创建sheetVC
- HomeSheetVC *vc = [[HomeSheetVC alloc] init];
- // 使用宏,避免误写成函数指针判断导致恒为 true
-// if (KB_DEVICE_HAS_NOTCH) {
-// vc.minHeight = KB_SCREEN_HEIGHT - topV - 34;
-// }else{
- vc.minHeight = KB_SCREEN_HEIGHT - topV;
-//
-// }
- vc.topInset = 100;
- [self presentPanModal:vc];
-}
-
-- (void)setupMas:(CGFloat)headViewTopV{
- [self.headView mas_makeConstraints:^(MASConstraintMaker *make) {
- make.left.right.equalTo(self.view);
- make.top.equalTo(self.view);
- make.height.mas_equalTo(headViewTopV);
- }];
-}
-
-- (void)viewWillAppear:(BOOL)animated{
- [super viewWillAppear:animated];
-
-}
-
-
-#pragma mark - lazy
-- (HomeHeadView *)headView{
- if (!_headView) {
- _headView = [[HomeHeadView alloc] init];
- }
- return _headView;
-}
-
-@end
diff --git a/keyBoard/Class/Me/VM/KBMyVM.m b/keyBoard/Class/Me/VM/KBMyVM.m
index 2cc7c15..5a92ac6 100644
--- a/keyBoard/Class/Me/VM/KBMyVM.m
+++ b/keyBoard/Class/Me/VM/KBMyVM.m
@@ -47,6 +47,13 @@ NSString * const KBUserCharacterDeletedNotification = @"KBUserCharacterDeletedNo
}
KBUser *user = [KBUser mj_objectWithKeyValues:(NSDictionary *)dataObj];
+ NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
+ if (user.avatarUrl.length > 0) {
+ [sharedDefaults setObject:user.avatarUrl forKey:AppGroup_UserAvatarURL];
+ } else {
+ [sharedDefaults removeObjectForKey:AppGroup_UserAvatarURL];
+ }
+ [sharedDefaults synchronize];
if (completion) completion(user, nil);
}];
}
diff --git a/keyBoard/Class/Network/KBNetworkManager.h b/keyBoard/Class/Network/KBNetworkManager.h
index e1b93de..71285fd 100644
--- a/keyBoard/Class/Network/KBNetworkManager.h
+++ b/keyBoard/Class/Network/KBNetworkManager.h
@@ -100,6 +100,14 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
headers:(nullable NSDictionary *)headers
completion:(KBNetworkCompletion)completion;
+/// 上传本地文件(multipart/form-data)
+- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
+ fileURL:(NSURL *)fileURL
+ name:(NSString *)name
+ mimeType:(NSString *)mimeType
+ parameters:(nullable NSDictionary *)parameters
+ headers:(nullable NSDictionary *)headers
+ completion:(KBNetworkCompletion)completion;
@end
diff --git a/keyBoard/Class/Network/KBNetworkManager.m b/keyBoard/Class/Network/KBNetworkManager.m
index 71f41fc..c8e5a48 100644
--- a/keyBoard/Class/Network/KBNetworkManager.m
+++ b/keyBoard/Class/Network/KBNetworkManager.m
@@ -323,6 +323,42 @@ autoShowBusinessError:YES
completion:completion];
}
+- (NSURLSessionDataTask *)uploadFile:(NSString *)path
+ fileURL:(NSURL *)fileURL
+ name:(NSString *)name
+ mimeType:(NSString *)mimeType
+ parameters:(NSDictionary *)parameters
+ headers:(NSDictionary *)headers
+ completion:(KBNetworkCompletion)completion {
+ if (!fileURL || !fileURL.isFileURL) {
+ if (completion) {
+ NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
+ code:KBNetworkErrorInvalidResponse
+ userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}];
+ completion(nil, nil, e);
+ }
+ return nil;
+ }
+ NSError *readError = nil;
+ NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&readError];
+ if (readError || data.length == 0) {
+ if (completion) {
+ NSError *e = readError ?: [NSError errorWithDomain:KBNetworkErrorDomain
+ code:KBNetworkErrorInvalidResponse
+ userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Empty file data")}];
+ completion(nil, nil, e);
+ }
+ return nil;
+ }
+ NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
+ return [self uploadFile:path
+ fileData:data
+ fileName:fileName
+ mimeType:mimeType
+ headers:headers
+ completion:completion];
+}
+
#pragma mark - Core
diff --git a/keyBoard/Info.plist b/keyBoard/Info.plist
index 94ec165..f4ed86f 100644
--- a/keyBoard/Info.plist
+++ b/keyBoard/Info.plist
@@ -15,16 +15,18 @@
+ UIBackgroundModes
+
+ audio
+
NSAppTransportSecurity
NSAllowsArbitraryLoads
-
- NSPhotoLibraryUsageDescription
- 更换头像需要访问您的相册
-
- NSPhotoLibraryAddUsageDescription
- 保存图片需要写入您的相册
+ UIBackgroundModes
+
+ audio
+