2192 lines
79 KiB
Objective-C
2192 lines
79 KiB
Objective-C
//
|
||
// KeyboardViewController.m
|
||
// CustomKeyboard
|
||
//
|
||
// Created by Mac on 2025/10/27.
|
||
//
|
||
|
||
#import "KeyboardViewController.h"
|
||
#import "KBKeyBoardMainView.h"
|
||
|
||
#import "KBAuthManager.h"
|
||
#import "KBBackspaceUndoManager.h"
|
||
#import "KBFullAccessManager.h"
|
||
#import "KBFunctionView.h"
|
||
#import "KBHostAppLauncher.h"
|
||
#import "KBInputBufferManager.h"
|
||
#import "KBKey.h"
|
||
#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 <AVFoundation/AVFoundation.h>
|
||
#import <SDWebImage/SDWebImage.h>
|
||
|
||
// #import "KBLog.h"
|
||
|
||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin
|
||
// 方法。
|
||
@interface KeyboardViewController (KBSkinShopBridge)
|
||
- (void)kb_consumePendingShopSkin;
|
||
@end
|
||
|
||
// 以 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";
|
||
static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them";
|
||
|
||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||
void *observer, CFStringRef name,
|
||
const void *object,
|
||
CFDictionaryRef userInfo) {
|
||
KeyboardViewController *strongSelf =
|
||
(__bridge KeyboardViewController *)observer;
|
||
if (!strongSelf) {
|
||
return;
|
||
}
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||
[strongSelf kb_consumePendingShopSkin];
|
||
}
|
||
});
|
||
}
|
||
|
||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
|
||
KBFunctionViewDelegate,
|
||
KBKeyboardSubscriptionViewDelegate,
|
||
KBChatPanelViewDelegate>
|
||
@property(nonatomic, strong)
|
||
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||
@property(nonatomic, strong) UIView *contentView;
|
||
@property(nonatomic, strong) KBKeyBoardMainView
|
||
*keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||
@property(nonatomic, strong)
|
||
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
|
||
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||
@property(nonatomic, strong) UIImageView *personaAvatarImageView; // 语音模式下显示的 persona 小头像
|
||
@property(nonatomic, strong) UIImageView *personaGrayImageView; // 语音模式下显示的 persona 小头像
|
||
|
||
@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<KBChatMessage *> *chatMessages;
|
||
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||
@property(nonatomic, strong) NSCache<NSString *, UIImage *> *chatAvatarCache;
|
||
@property(nonatomic, assign) BOOL chatPanelVisible;
|
||
@end
|
||
|
||
@implementation KeyboardViewController
|
||
|
||
{
|
||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||
}
|
||
|
||
- (void)viewDidLoad {
|
||
[super viewDidLoad];
|
||
// 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
[self setupUI];
|
||
self.suggestionEngine = [KBSuggestionEngine shared];
|
||
self.currentWord = @"";
|
||
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
||
[KBHUD setContainerView:self.view];
|
||
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
||
[[KBFullAccessManager shared] bindInputController:self];
|
||
__unused id token = [[NSNotificationCenter defaultCenter]
|
||
addObserverForName:KBFullAccessChangedNotification
|
||
object:nil
|
||
queue:[NSOperationQueue mainQueue]
|
||
usingBlock:^(__unused NSNotification *_Nonnull note){
|
||
// 如需,可在此刷新与完全访问相关的 UI
|
||
}];
|
||
|
||
// 皮肤变化时,立即应用
|
||
__unused id token2 = [[NSNotificationCenter defaultCenter]
|
||
addObserverForName:KBSkinDidChangeNotification
|
||
object:nil
|
||
queue:[NSOperationQueue mainQueue]
|
||
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||
[self kb_applyTheme];
|
||
}];
|
||
[self kb_applyTheme];
|
||
CFNotificationCenterAddObserver(
|
||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||
(__bridge const void *)(self), KBSkinInstallNotificationCallback,
|
||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL,
|
||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||
[self kb_consumePendingShopSkin];
|
||
[self kb_applyDefaultSkinIfNeeded];
|
||
}
|
||
|
||
- (void)viewWillAppear:(BOOL)animated {
|
||
[super viewWillAppear:animated];
|
||
// 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
[[KBInputBufferManager shared] resetWithText:@""];
|
||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||
// 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新
|
||
// liveText,不要把它当作全文 manualSnapshot。
|
||
[[KBInputBufferManager shared]
|
||
updateFromExternalContextBefore:self.textDocumentProxy
|
||
.documentContextBeforeInput
|
||
after:self.textDocumentProxy
|
||
.documentContextAfterInput];
|
||
}
|
||
|
||
- (void)viewWillDisappear:(BOOL)animated {
|
||
[super viewWillDisappear:animated];
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
// 清理 persona 头像内存
|
||
[self kb_hidePersonaAvatar];
|
||
}
|
||
|
||
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
|
||
[super traitCollectionDidChange:previousTraitCollection];
|
||
if (@available(iOS 13.0, *)) {
|
||
if (previousTraitCollection.userInterfaceStyle !=
|
||
self.traitCollection.userInterfaceStyle) {
|
||
[self kb_applyDefaultSkinIfNeeded];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)textDidChange:(id<UITextInput>)textInput {
|
||
[super textDidChange:textInput];
|
||
[[KBInputBufferManager shared]
|
||
updateFromExternalContextBefore:self.textDocumentProxy
|
||
.documentContextBeforeInput
|
||
after:self.textDocumentProxy
|
||
.documentContextAfterInput];
|
||
}
|
||
|
||
- (void)setupUI {
|
||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||
|
||
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
||
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);
|
||
|
||
NSLayoutConstraint *h =
|
||
[self.view.heightAnchor constraintEqualToConstant:keyboardHeight];
|
||
NSLayoutConstraint *w =
|
||
[self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||
self.kb_heightConstraint = h;
|
||
self.kb_widthConstraint = w;
|
||
|
||
h.priority = UILayoutPriorityRequired;
|
||
w.priority = UILayoutPriorityRequired;
|
||
[NSLayoutConstraint activateConstraints:@[ h, w ]];
|
||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||
UIInputView *iv = (UIInputView *)self.view;
|
||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||
iv.allowsSelfSizing = NO;
|
||
}
|
||
}
|
||
// 内容容器:横屏时保持竖屏宽度,居中显示
|
||
[self.view addSubview:self.contentView];
|
||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.centerX.equalTo(self.view);
|
||
make.bottom.equalTo(self.view);
|
||
self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth);
|
||
self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight);
|
||
}];
|
||
|
||
// 背景图铺底(仅在内容容器内)
|
||
[self.contentView addSubview:self.bgImageView];
|
||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.edges.equalTo(self.contentView);
|
||
}];
|
||
// 预置功能面板(默认隐藏),与键盘区域共享相同布局
|
||
self.functionView.hidden = YES;
|
||
[self.contentView addSubview:self.functionView];
|
||
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.edges.equalTo(self.contentView);
|
||
}];
|
||
|
||
[self.contentView addSubview:self.keyBoardMainView];
|
||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
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
|
||
|
||
// MARK: - Suggestions
|
||
|
||
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
|
||
if (text.length == 0) {
|
||
return;
|
||
}
|
||
if ([self kb_isAlphabeticString:text]) {
|
||
NSString *current = self.currentWord ?: @"";
|
||
self.currentWord = [current stringByAppendingString:text];
|
||
self.suppressSuggestions = NO;
|
||
[self kb_updateSuggestionsForCurrentWord];
|
||
} else {
|
||
[self kb_clearCurrentWord];
|
||
}
|
||
}
|
||
|
||
- (void)kb_clearCurrentWord {
|
||
self.currentWord = @"";
|
||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||
self.suppressSuggestions = NO;
|
||
}
|
||
|
||
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||
resetSuppression];
|
||
});
|
||
}
|
||
|
||
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||
(BOOL)resetSuppression {
|
||
NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||
NSString *word = [self kb_extractTrailingWordFromContext:context];
|
||
self.currentWord = word ?: @"";
|
||
if (resetSuppression) {
|
||
self.suppressSuggestions = NO;
|
||
}
|
||
[self kb_updateSuggestionsForCurrentWord];
|
||
}
|
||
|
||
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
|
||
if (context.length == 0) {
|
||
return @"";
|
||
}
|
||
static NSCharacterSet *letters = nil;
|
||
static dispatch_once_t onceToken;
|
||
dispatch_once(&onceToken, ^{
|
||
letters = [NSCharacterSet
|
||
characterSetWithCharactersInString:
|
||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||
});
|
||
|
||
NSInteger idx = (NSInteger)context.length - 1;
|
||
while (idx >= 0) {
|
||
unichar ch = [context characterAtIndex:(NSUInteger)idx];
|
||
if (![letters characterIsMember:ch]) {
|
||
break;
|
||
}
|
||
idx -= 1;
|
||
}
|
||
NSUInteger start = (NSUInteger)(idx + 1);
|
||
if (start >= context.length) {
|
||
return @"";
|
||
}
|
||
return [context substringFromIndex:start];
|
||
}
|
||
|
||
- (BOOL)kb_isAlphabeticString:(NSString *)text {
|
||
if (text.length == 0) {
|
||
return NO;
|
||
}
|
||
static NSCharacterSet *letters = nil;
|
||
static dispatch_once_t onceToken;
|
||
dispatch_once(&onceToken, ^{
|
||
letters = [NSCharacterSet
|
||
characterSetWithCharactersInString:
|
||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||
});
|
||
for (NSUInteger i = 0; i < text.length; i++) {
|
||
if (![letters characterIsMember:[text characterAtIndex:i]]) {
|
||
return NO;
|
||
}
|
||
}
|
||
return YES;
|
||
}
|
||
|
||
- (void)kb_updateSuggestionsForCurrentWord {
|
||
NSString *prefix = self.currentWord ?: @"";
|
||
if (prefix.length == 0) {
|
||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||
return;
|
||
}
|
||
if (self.suppressSuggestions) {
|
||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||
return;
|
||
}
|
||
NSArray<NSString *> *items =
|
||
[self.suggestionEngine suggestionsForPrefix:prefix limit:5];
|
||
NSArray<NSString *> *cased = [self kb_applyCaseToSuggestions:items
|
||
prefix:prefix];
|
||
[self.keyBoardMainView kb_setSuggestions:cased];
|
||
}
|
||
|
||
- (NSArray<NSString *> *)kb_applyCaseToSuggestions:(NSArray<NSString *> *)items
|
||
prefix:(NSString *)prefix {
|
||
if (items.count == 0 || prefix.length == 0) {
|
||
return items;
|
||
}
|
||
BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString];
|
||
BOOL firstUpper = [[prefix substringToIndex:1]
|
||
isEqualToString:[[prefix substringToIndex:1] uppercaseString]];
|
||
|
||
if (!allUpper && !firstUpper) {
|
||
return items;
|
||
}
|
||
|
||
NSMutableArray<NSString *> *result =
|
||
[NSMutableArray arrayWithCapacity:items.count];
|
||
for (NSString *word in items) {
|
||
if (allUpper) {
|
||
[result addObject:word.uppercaseString];
|
||
} else {
|
||
NSString *first = [[word substringToIndex:1] uppercaseString];
|
||
NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @"";
|
||
[result addObject:[first stringByAppendingString:rest]];
|
||
}
|
||
}
|
||
return result.copy;
|
||
}
|
||
|
||
/// 切换显示功能面板/键盘主视图
|
||
- (void)showFunctionPanel:(BOOL)show {
|
||
// 简单显隐切换,复用相同的布局区域
|
||
if (show) {
|
||
[self showChatPanel:NO];
|
||
}
|
||
self.functionView.hidden = !show;
|
||
self.keyBoardMainView.hidden = show;
|
||
|
||
if (show) {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportPageExposureWithEventName:@"enter_keyboard_function_panel"
|
||
pageId:@"keyboard_function_panel"
|
||
extra:nil
|
||
completion:nil];
|
||
[self hideSubscriptionPanel];
|
||
} else {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportPageExposureWithEventName:@"enter_keyboard_main_panel"
|
||
pageId:@"keyboard_main_panel"
|
||
extra:nil
|
||
completion:nil];
|
||
}
|
||
|
||
// 可选:把当前显示的视图置顶,避免层级遮挡
|
||
if (show) {
|
||
[self.contentView bringSubviewToFront:self.functionView];
|
||
} else {
|
||
[self.contentView bringSubviewToFront:self.keyBoardMainView];
|
||
}
|
||
}
|
||
|
||
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||
- (void)showSettingView:(BOOL)show {
|
||
if (show) {
|
||
[self showChatPanel:NO];
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportPageExposureWithEventName:@"enter_keyboard_settings"
|
||
pageId:@"keyboard_settings"
|
||
extra:nil
|
||
completion:nil];
|
||
// if (!self.settingView) {
|
||
self.settingView = [[KBSettingView alloc] init];
|
||
self.settingView.hidden = YES;
|
||
[self.contentView addSubview:self.settingView];
|
||
[self.settingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
// 与键盘主视图完全等同的区域,保证高度、宽度一致
|
||
make.edges.equalTo(self.contentView);
|
||
}];
|
||
[self.settingView.backButton addTarget:self
|
||
action:@selector(onTapSettingsBack)
|
||
forControlEvents:UIControlEventTouchUpInside];
|
||
// }
|
||
[self.contentView bringSubviewToFront:self.settingView];
|
||
// 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算
|
||
[self.contentView layoutIfNeeded];
|
||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||
if (w <= 0) {
|
||
w = CGRectGetWidth(self.contentView.bounds);
|
||
}
|
||
if (w <= 0) {
|
||
w = [self kb_portraitWidth];
|
||
}
|
||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||
self.settingView.hidden = NO;
|
||
[UIView animateWithDuration:0.25
|
||
delay:0
|
||
options:UIViewAnimationOptionCurveEaseOut
|
||
animations:^{
|
||
self.settingView.transform = CGAffineTransformIdentity;
|
||
}
|
||
completion:nil];
|
||
} else {
|
||
if (!self.settingView || self.settingView.hidden)
|
||
return;
|
||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||
if (w <= 0) {
|
||
w = CGRectGetWidth(self.contentView.bounds);
|
||
}
|
||
if (w <= 0) {
|
||
w = [self kb_portraitWidth];
|
||
}
|
||
[UIView animateWithDuration:0.22
|
||
delay:0
|
||
options:UIViewAnimationOptionCurveEaseIn
|
||
animations:^{
|
||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||
}
|
||
completion:^(BOOL finished) {
|
||
self.settingView.hidden = YES;
|
||
}];
|
||
}
|
||
}
|
||
|
||
/// 显示/隐藏聊天面板(覆盖整个键盘区域)
|
||
- (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]) {
|
||
// 未开启完全访问:保持原有引导路径
|
||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||
return;
|
||
}
|
||
// 点击充值要先判断是否登录
|
||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App
|
||
// 负责完成登录
|
||
if (!KBAuthManager.shared.isLoggedIn) {
|
||
NSString *schemeStr =
|
||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||
return;
|
||
}
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportPageExposureWithEventName:@"enter_keyboard_subscription_panel"
|
||
pageId:@"keyboard_subscription_panel"
|
||
extra:nil
|
||
completion:nil];
|
||
[self showFunctionPanel:NO];
|
||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||
if (!panel.superview) {
|
||
panel.hidden = YES;
|
||
[self.contentView addSubview:panel];
|
||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.edges.equalTo(self.contentView);
|
||
}];
|
||
}
|
||
[self.contentView bringSubviewToFront:panel];
|
||
panel.hidden = NO;
|
||
panel.alpha = 0.0;
|
||
CGFloat height = CGRectGetHeight(self.contentView.bounds);
|
||
if (height <= 0) {
|
||
height = 260;
|
||
}
|
||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||
[panel refreshProductsIfNeeded];
|
||
[UIView animateWithDuration:0.25
|
||
delay:0
|
||
options:UIViewAnimationOptionCurveEaseOut
|
||
animations:^{
|
||
panel.alpha = 1.0;
|
||
panel.transform = CGAffineTransformIdentity;
|
||
}
|
||
completion:nil];
|
||
}
|
||
|
||
- (void)hideSubscriptionPanel {
|
||
if (!self.subscriptionView || self.subscriptionView.hidden) {
|
||
return;
|
||
}
|
||
CGFloat height = CGRectGetHeight(self.subscriptionView.bounds);
|
||
if (height <= 0) {
|
||
height = CGRectGetHeight(self.contentView.bounds);
|
||
}
|
||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||
[UIView animateWithDuration:0.22
|
||
delay:0
|
||
options:UIViewAnimationOptionCurveEaseIn
|
||
animations:^{
|
||
panel.alpha = 0.0;
|
||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||
}
|
||
completion:^(BOOL finished) {
|
||
panel.hidden = YES;
|
||
panel.alpha = 1.0;
|
||
panel.transform = CGAffineTransformIdentity;
|
||
}];
|
||
}
|
||
|
||
// MARK: - KBKeyBoardMainViewDelegate
|
||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||
didTapKey:(KBKey *)key {
|
||
switch (key.type) {
|
||
case KBKeyTypeCharacter: {
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
NSString *text = key.output ?: key.title ?: @"";
|
||
[self.textDocumentProxy insertText:text];
|
||
[self kb_updateCurrentWordWithInsertedText:text];
|
||
[[KBInputBufferManager shared] appendText:text];
|
||
} break;
|
||
case KBKeyTypeBackspace:
|
||
[[KBInputBufferManager shared]
|
||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||
[[KBInputBufferManager shared]
|
||
prepareSnapshotForDeleteWithContextBefore:
|
||
self.textDocumentProxy.documentContextBeforeInput
|
||
after:
|
||
self.textDocumentProxy
|
||
.documentContextAfterInput];
|
||
[[KBBackspaceUndoManager shared]
|
||
captureAndDeleteBackwardFromProxy:self.textDocumentProxy
|
||
count:1];
|
||
[self kb_scheduleContextRefreshResetSuppression:NO];
|
||
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
|
||
break;
|
||
case KBKeyTypeSpace:
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
[self.textDocumentProxy insertText:@" "];
|
||
[self kb_clearCurrentWord];
|
||
[[KBInputBufferManager shared] appendText:@" "];
|
||
break;
|
||
case KBKeyTypeReturn:
|
||
if (self.chatPanelVisible) {
|
||
[self kb_handleChatSendAction];
|
||
break;
|
||
}
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
[self.textDocumentProxy insertText:@"\n"];
|
||
[self kb_clearCurrentWord];
|
||
[[KBInputBufferManager shared] appendText:@"\n"];
|
||
break;
|
||
case KBKeyTypeGlobe:
|
||
[self advanceToNextInputMode];
|
||
break;
|
||
case KBKeyTypeCustom:
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
// 点击自定义键切换到功能面板
|
||
[self showFunctionPanel:YES];
|
||
[self kb_clearCurrentWord];
|
||
break;
|
||
case KBKeyTypeModeChange:
|
||
case KBKeyTypeShift:
|
||
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
||
break;
|
||
}
|
||
}
|
||
|
||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||
didTapToolActionAtIndex:(NSInteger)index {
|
||
NSDictionary *extra = @{@"index" : @(index)};
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_toolbar_action"
|
||
pageId:@"keyboard_main_panel"
|
||
elementId:@"toolbar_action"
|
||
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];
|
||
// 显示 persona 头像
|
||
[self kb_showPersonaAvatarOnBgImageView];
|
||
return;
|
||
}
|
||
[self showFunctionPanel:NO];
|
||
[self showChatPanel:NO];
|
||
}
|
||
|
||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_settings_btn"
|
||
pageId:@"keyboard_main_panel"
|
||
elementId:@"settings_btn"
|
||
extra:nil
|
||
completion:nil];
|
||
[self showSettingView:YES];
|
||
}
|
||
|
||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||
didSelectEmoji:(NSString *)emoji {
|
||
if (emoji.length == 0) {
|
||
return;
|
||
}
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
[self.textDocumentProxy insertText:emoji];
|
||
[self kb_clearCurrentWord];
|
||
[[KBInputBufferManager shared] appendText:emoji];
|
||
}
|
||
|
||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_undo_btn"
|
||
pageId:@"keyboard_main_panel"
|
||
elementId:@"undo_btn"
|
||
extra:nil
|
||
completion:nil];
|
||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||
[self kb_scheduleContextRefreshResetSuppression:YES];
|
||
}
|
||
|
||
- (void)keyBoardMainViewDidTapEmojiSearch:
|
||
(KBKeyBoardMainView *)keyBoardMainView {
|
||
// [[KBMaiPointReporter sharedReporter]
|
||
// reportClickWithEventName:@"click_keyboard_emoji_search_btn"
|
||
// pageId:@"keyboard_main_panel"
|
||
// elementId:@"emoji_search_btn"
|
||
// extra:nil
|
||
// completion:nil];
|
||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
||
}
|
||
|
||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||
didSelectSuggestion:(NSString *)suggestion {
|
||
if (suggestion.length == 0) {
|
||
return;
|
||
}
|
||
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
|
||
// [[KBMaiPointReporter sharedReporter]
|
||
// reportClickWithEventName:@"click_keyboard_suggestion_item"
|
||
// pageId:@"keyboard_main_panel"
|
||
// elementId:@"suggestion_item"
|
||
// extra:extra
|
||
// completion:nil];
|
||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||
NSString *current = self.currentWord ?: @"";
|
||
if (current.length > 0) {
|
||
for (NSUInteger i = 0; i < current.length; i++) {
|
||
[self.textDocumentProxy deleteBackward];
|
||
}
|
||
}
|
||
[self.textDocumentProxy insertText:suggestion];
|
||
self.currentWord = suggestion;
|
||
[self.suggestionEngine recordSelection:suggestion];
|
||
self.suppressSuggestions = YES;
|
||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||
[[KBInputBufferManager shared] replaceTailWithText:suggestion
|
||
deleteCount:current.length];
|
||
}
|
||
|
||
// MARK: - KBFunctionViewDelegate
|
||
- (void)functionView:(KBFunctionView *)functionView
|
||
didTapToolActionAtIndex:(NSInteger)index {
|
||
// 需求:当 index == 0 时,切回键盘主视图
|
||
if (index == 0) {
|
||
[self showFunctionPanel:NO];
|
||
}
|
||
}
|
||
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||
didRightTapToolActionAtIndex:(NSInteger)index {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_function_right_action"
|
||
pageId:@"keyboard_function_panel"
|
||
elementId:@"right_action"
|
||
extra:@{@"action" : @"login_or_recharge"}
|
||
completion:nil];
|
||
if (!KBAuthManager.shared.isLoggedIn) {
|
||
NSString *schemeStr =
|
||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||
return;
|
||
}
|
||
NSString *schemeStr =
|
||
[NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||
//
|
||
// if (!ul && !scheme) { return; }
|
||
//
|
||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||
|
||
if (!ok) {
|
||
// 失败兜底:给个文案提示
|
||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
||
}
|
||
}
|
||
|
||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||
[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];
|
||
}
|
||
|
||
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||
if (!message) return;
|
||
|
||
// 如果有 audioData,直接播放
|
||
if (message.audioData && message.audioData.length > 0) {
|
||
[self kb_playChatAudioData:message.audioData];
|
||
return;
|
||
}
|
||
|
||
// 如果有 audioFilePath,播放文件
|
||
if (message.audioFilePath.length > 0) {
|
||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[Keyboard] 没有音频数据可播放");
|
||
}
|
||
|
||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
|
||
// 清空 chatPanelView 内部的消息
|
||
[self.chatPanelView kb_reloadWithMessages:@[]];
|
||
if (self.chatAudioPlayer.isPlaying) {
|
||
[self.chatAudioPlayer stop];
|
||
}
|
||
self.chatAudioPlayer = nil;
|
||
[self showChatPanel:NO];
|
||
// 隐藏 persona 头像
|
||
[self kb_hidePersonaAvatar];
|
||
}
|
||
|
||
#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;
|
||
}
|
||
NSLog(@"[Keyboard] ========== kb_sendChatText ==========");
|
||
NSLog(@"[Keyboard] chatPanelView=%p", self.chatPanelView);
|
||
|
||
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
|
||
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||
[self.chatPanelView kb_addUserMessage:text];
|
||
[self kb_prefetchAvatarForMessage:outgoing];
|
||
|
||
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
||
return;
|
||
}
|
||
|
||
// 添加 loading 消息
|
||
NSLog(@"[Keyboard] 准备添加 loading 消息,chatPanelView=%p", self.chatPanelView);
|
||
[self.chatPanelView kb_addLoadingAssistantMessage];
|
||
|
||
// 调用新的聊天接口
|
||
[self kb_requestChatMessageWithContent: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 kb_reloadChatRowForMessage:message];
|
||
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 kb_reloadChatRowForMessage:message];
|
||
});
|
||
}];
|
||
}
|
||
|
||
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
||
NSLog(@"[Keyboard] ========== kb_reloadChatRowForMessage ==========");
|
||
// 不再使用 self.chatMessages,直接刷新 tableView
|
||
UITableView *tableView = self.chatPanelView.tableView;
|
||
if (!tableView) {
|
||
NSLog(@"[Keyboard] tableView 为空,跳过");
|
||
return;
|
||
}
|
||
// 刷新整个 tableView
|
||
NSLog(@"[Keyboard] 调用 tableView reloadData");
|
||
[tableView reloadData];
|
||
}
|
||
|
||
- (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(@"未获取到音频文件")];
|
||
});
|
||
}];
|
||
}
|
||
|
||
#pragma mark - New Chat API (with typewriter effect and audio preload)
|
||
|
||
/// 调用新的聊天接口(返回文本和 audioId)
|
||
- (void)kb_requestChatMessageWithContent:(NSString *)content {
|
||
NSLog(@"[Keyboard] ========== kb_requestChatMessageWithContent ==========");
|
||
NSLog(@"[Keyboard] 请求内容: %@", content);
|
||
|
||
if (content.length == 0) {
|
||
NSLog(@"[Keyboard] ❌ 内容为空,移除 loading");
|
||
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||
return;
|
||
}
|
||
|
||
// 从 AppGroup 获取选中的 persona companionId
|
||
NSInteger companionId = [self kb_selectedCompanionId];
|
||
|
||
NSString *encodedContent =
|
||
[content stringByAddingPercentEncodingWithAllowedCharacters:
|
||
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||
NSString *path = [NSString
|
||
stringWithFormat:@"%@?content=%@&companionId=%ld", API_AI_CHAT_MESSAGE,
|
||
encodedContent ?: @"", (long)companionId];
|
||
NSDictionary *params = @{
|
||
@"content" : content ?: @"",
|
||
@"companionId" : @(companionId)
|
||
};
|
||
|
||
NSLog(@"[Keyboard] 发送聊天请求: path=%@, companionId=%ld", path, (long)companionId);
|
||
|
||
__weak typeof(self) weakSelf = self;
|
||
[[KBNetworkManager shared] POST:path
|
||
jsonBody:params
|
||
headers:nil
|
||
completion:^(NSDictionary *json, NSURLResponse *response,
|
||
NSError *error) {
|
||
NSLog(@"[Keyboard] ========== 聊天响应回调 ==========");
|
||
NSLog(@"[Keyboard] error: %@", error);
|
||
NSLog(@"[Keyboard] json: %@", json);
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
__strong typeof(weakSelf) self = weakSelf;
|
||
if (!self) {
|
||
NSLog(@"[Keyboard] ❌ self 为空");
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[Keyboard] 回调中 chatPanelView=%p", self.chatPanelView);
|
||
|
||
if (error) {
|
||
NSLog(@"[Keyboard] ❌ 请求失败: %@", error.localizedDescription);
|
||
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||
NSString *tip = error.localizedDescription ?: KBLocalized(@"请求失败");
|
||
[KBHUD showInfo:tip];
|
||
return;
|
||
}
|
||
|
||
// 解析返回数据
|
||
NSString *text = [self kb_chatMessageTextFromJSON:json];
|
||
NSString *audioId = [self kb_chatMessageAudioIdFromJSON:json];
|
||
|
||
NSLog(@"[Keyboard] ✅ 解析结果: text=%@, audioId=%@", text, audioId);
|
||
|
||
if (text.length == 0) {
|
||
NSLog(@"[Keyboard] ❌ 文本为空,移除 loading");
|
||
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||
[KBHUD showInfo:KBLocalized(@"未获取到回复内容")];
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[Keyboard] 准备调用 kb_addAssistantMessage, chatPanelView=%p", self.chatPanelView);
|
||
// 添加 AI 消息(带打字机效果)
|
||
[self.chatPanelView kb_addAssistantMessage:text audioId:audioId];
|
||
NSLog(@"[Keyboard] kb_addAssistantMessage 调用完成");
|
||
|
||
// 如果有 audioId,开始预加载音频
|
||
if (audioId.length > 0) {
|
||
NSDate *startTime = [NSDate date];
|
||
[self kb_preloadAudioWithAudioId:audioId startTime:startTime];
|
||
}
|
||
});
|
||
}];
|
||
}
|
||
|
||
/// 从 AppGroup 获取选中的 persona companionId
|
||
- (NSInteger)kb_selectedCompanionId {
|
||
NSDictionary *persona = [self kb_selectedPersonaFromAppGroup];
|
||
if (persona) {
|
||
// 主 App 保存的字段名是 personaId
|
||
id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"];
|
||
if ([companionIdObj respondsToSelector:@selector(integerValue)]) {
|
||
NSInteger companionId = [companionIdObj integerValue];
|
||
NSLog(@"[Keyboard] 从 AppGroup 获取 companionId: %ld", (long)companionId);
|
||
return companionId;
|
||
}
|
||
}
|
||
NSLog(@"[Keyboard] 未找到 persona,使用默认 companionId: 0");
|
||
return 0; // 默认值
|
||
}
|
||
|
||
/// 解析聊天消息文本
|
||
- (NSString *)kb_chatMessageTextFromJSON:(NSDictionary *)json {
|
||
NSLog(@"[Keyboard] ========== kb_chatMessageTextFromJSON ==========");
|
||
NSLog(@"[Keyboard] 输入 json 类型: %@", NSStringFromClass([json class]));
|
||
|
||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||
NSLog(@"[Keyboard] ❌ json 不是字典类型");
|
||
return @"";
|
||
}
|
||
|
||
id dataObj = json[@"data"];
|
||
NSLog(@"[Keyboard] data 字段类型: %@, 值: %@", NSStringFromClass([dataObj class]), dataObj);
|
||
|
||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||
NSDictionary *data = (NSDictionary *)dataObj;
|
||
NSLog(@"[Keyboard] data 字典内容: %@", data);
|
||
|
||
// 优先读取 aiResponse 字段(后端实际返回的字段名)
|
||
NSArray *dataKeys = @[@"aiResponse", @"content", @"text", @"message"];
|
||
for (NSString *key in dataKeys) {
|
||
id value = data[key];
|
||
NSLog(@"[Keyboard] 检查 data.%@ = %@ (类型: %@)", key, value, NSStringFromClass([value class]));
|
||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||
NSLog(@"[Keyboard] ✅ 从 data.%@ 解析到文本: %@", key, value);
|
||
return (NSString *)value;
|
||
}
|
||
}
|
||
NSLog(@"[Keyboard] ❌ data 字典中没有找到有效文本");
|
||
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
||
NSLog(@"[Keyboard] data 是字符串: %@", dataObj);
|
||
return (NSString *)dataObj;
|
||
} else {
|
||
NSLog(@"[Keyboard] ❌ data 字段类型不支持: %@", NSStringFromClass([dataObj class]));
|
||
}
|
||
|
||
return @"";
|
||
}
|
||
|
||
/// 解析聊天消息 audioId
|
||
- (NSString *)kb_chatMessageAudioIdFromJSON:(NSDictionary *)json {
|
||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||
|
||
id dataObj = json[@"data"];
|
||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||
NSDictionary *data = (NSDictionary *)dataObj;
|
||
NSString *audioId = data[@"audioId"];
|
||
if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) {
|
||
return audioId;
|
||
}
|
||
}
|
||
|
||
// 兼容其他字段名
|
||
NSArray *keys = @[@"audioId", @"audio_id"];
|
||
for (NSString *key in keys) {
|
||
id value = json[key];
|
||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||
return (NSString *)value;
|
||
}
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
#pragma mark - Audio Preload
|
||
|
||
/// 预加载音频(轮询获取 audioURL)
|
||
- (void)kb_preloadAudioWithAudioId:(NSString *)audioId startTime:(NSDate *)startTime {
|
||
if (audioId.length == 0) return;
|
||
|
||
NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId);
|
||
|
||
// 开始轮询(最多10次,每次间隔1秒,共10秒)
|
||
[self kb_pollAudioURLWithAudioId:audioId retryCount:0 maxRetries:10 startTime:startTime];
|
||
}
|
||
|
||
/// 轮询获取 audioURL
|
||
- (void)kb_pollAudioURLWithAudioId:(NSString *)audioId
|
||
retryCount:(NSInteger)retryCount
|
||
maxRetries:(NSInteger)maxRetries
|
||
startTime:(NSDate *)startTime {
|
||
|
||
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
||
|
||
__weak typeof(self) weakSelf = self;
|
||
[[KBNetworkManager shared] GET:path
|
||
parameters:nil
|
||
headers:nil
|
||
completion:^(NSDictionary *json, NSURLResponse *response,
|
||
NSError *error) {
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
__strong typeof(weakSelf) self = weakSelf;
|
||
if (!self) return;
|
||
|
||
// 解析 audioURL
|
||
NSString *audioURL = nil;
|
||
if ([json isKindOfClass:[NSDictionary class]]) {
|
||
id dataObj = json[@"data"];
|
||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||
NSDictionary *dataDict = (NSDictionary *)dataObj;
|
||
id audioUrlObj = dataDict[@"audioUrl"] ?: dataDict[@"url"];
|
||
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||
audioURL = (NSString *)audioUrlObj;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果成功获取到 audioURL
|
||
if (audioURL.length > 0) {
|
||
NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime];
|
||
NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed);
|
||
// 下载音频
|
||
[self kb_downloadPreloadAudioFromURL:audioURL startTime:startTime];
|
||
return;
|
||
}
|
||
|
||
// 如果还没达到最大重试次数,继续轮询
|
||
if (retryCount < maxRetries - 1) {
|
||
NSLog(@"[Keyboard] 预加载音频未就绪,1秒后重试 (%ld/%ld)", (long)(retryCount + 1), (long)maxRetries);
|
||
|
||
// 1秒后重试
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
|
||
dispatch_get_main_queue(), ^{
|
||
[self kb_pollAudioURLWithAudioId:audioId
|
||
retryCount:retryCount + 1
|
||
maxRetries:maxRetries
|
||
startTime:startTime];
|
||
});
|
||
} else {
|
||
NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime];
|
||
NSLog(@"[Keyboard] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed);
|
||
}
|
||
});
|
||
}];
|
||
}
|
||
|
||
/// 下载预加载音频
|
||
- (void)kb_downloadPreloadAudioFromURL:(NSString *)urlString startTime:(NSDate *)startTime {
|
||
if (urlString.length == 0) return;
|
||
|
||
__weak typeof(self) weakSelf = self;
|
||
[[KBNetworkManager shared] GETData:urlString
|
||
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 || !data || data.length == 0) {
|
||
NSLog(@"[Keyboard] 预加载:下载音频失败: %@", error.localizedDescription ?: @"");
|
||
return;
|
||
}
|
||
|
||
// 计算音频时长
|
||
NSError *playerError = nil;
|
||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||
NSTimeInterval duration = 0;
|
||
if (!playerError && player) {
|
||
duration = player.duration;
|
||
}
|
||
|
||
// 更新最后一条 AI 消息的音频数据
|
||
[self.chatPanelView kb_updateLastAssistantMessageWithAudioData:data duration:duration];
|
||
|
||
NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime];
|
||
NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", duration, totalElapsed);
|
||
});
|
||
}];
|
||
}
|
||
|
||
- (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<KBChatMessage *> *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<NSString *> *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<NSString *> *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<NSString *> *)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];
|
||
}
|
||
|
||
/// 播放音频数据
|
||
- (void)kb_playChatAudioData:(NSData *)audioData {
|
||
if (!audioData || audioData.length == 0) {
|
||
NSLog(@"[Keyboard] 音频数据为空");
|
||
return;
|
||
}
|
||
|
||
// 如果正在播放,先停止
|
||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||
[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] initWithData:audioData error:&playerError];
|
||
if (playerError || !player) {
|
||
NSLog(@"[Keyboard] 音频播放器初始化失败: %@", playerError.localizedDescription);
|
||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||
return;
|
||
}
|
||
|
||
self.chatAudioPlayer = player;
|
||
player.volume = 1.0;
|
||
[player prepareToPlay];
|
||
[player play];
|
||
|
||
NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration);
|
||
}
|
||
|
||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||
|
||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
|
||
pageId:@"keyboard_subscription_panel"
|
||
elementId:@"close_btn"
|
||
extra:nil
|
||
completion:nil];
|
||
[self hideSubscriptionPanel];
|
||
}
|
||
|
||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view
|
||
didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||
if ([product.productId isKindOfClass:NSString.class] &&
|
||
product.productId.length > 0) {
|
||
extra[@"product_id"] = product.productId;
|
||
}
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_subscription_product_btn"
|
||
pageId:@"keyboard_subscription_panel"
|
||
elementId:@"product_btn"
|
||
extra:extra.copy
|
||
completion:nil];
|
||
[self hideSubscriptionPanel];
|
||
[self kb_openRechargeForProduct:product];
|
||
}
|
||
|
||
#pragma mark - lazy
|
||
- (KBKeyBoardMainView *)keyBoardMainView {
|
||
if (!_keyBoardMainView) {
|
||
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
||
_keyBoardMainView.delegate = self;
|
||
}
|
||
return _keyBoardMainView;
|
||
}
|
||
|
||
- (KBFunctionView *)functionView {
|
||
if (!_functionView) {
|
||
_functionView = [[KBFunctionView alloc] init];
|
||
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
||
}
|
||
return _functionView;
|
||
}
|
||
|
||
- (KBSettingView *)settingView {
|
||
if (!_settingView) {
|
||
_settingView = [[KBSettingView alloc] init];
|
||
}
|
||
return _settingView;
|
||
}
|
||
|
||
- (KBChatPanelView *)chatPanelView {
|
||
if (!_chatPanelView) {
|
||
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
|
||
_chatPanelView = [[KBChatPanelView alloc] init];
|
||
_chatPanelView.delegate = self;
|
||
}
|
||
return _chatPanelView;
|
||
}
|
||
|
||
- (NSMutableArray<KBChatMessage *> *)chatMessages {
|
||
if (!_chatMessages) {
|
||
_chatMessages = [NSMutableArray array];
|
||
}
|
||
return _chatMessages;
|
||
}
|
||
|
||
- (NSCache<NSString *, UIImage *> *)chatAvatarCache {
|
||
if (!_chatAvatarCache) {
|
||
_chatAvatarCache = [[NSCache alloc] init];
|
||
}
|
||
return _chatAvatarCache;
|
||
}
|
||
|
||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||
if (!_subscriptionView) {
|
||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||
_subscriptionView.delegate = self;
|
||
_subscriptionView.hidden = YES;
|
||
_subscriptionView.alpha = 0.0;
|
||
}
|
||
return _subscriptionView;
|
||
}
|
||
|
||
- (UIImageView *)personaAvatarImageView {
|
||
if (!_personaAvatarImageView) {
|
||
_personaAvatarImageView = [[UIImageView alloc] init];
|
||
_personaAvatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||
_personaAvatarImageView.clipsToBounds = YES;
|
||
_personaAvatarImageView.hidden = YES;
|
||
}
|
||
return _personaAvatarImageView;
|
||
}
|
||
- (UIImageView *)personaGrayImageView{
|
||
if (!_personaGrayImageView) {
|
||
_personaGrayImageView = [[UIImageView alloc] init];
|
||
_personaAvatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||
|
||
}
|
||
return _personaGrayImageView;
|
||
}
|
||
|
||
#pragma mark - Persona Avatar
|
||
|
||
/// 从 AppGroup 读取选中的 persona 信息
|
||
- (NSDictionary *)kb_selectedPersonaFromAppGroup {
|
||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||
NSDictionary *personaDict = [ud objectForKey:@"AppGroup_SelectedPersona"];
|
||
if ([personaDict isKindOfClass:[NSDictionary class]]) {
|
||
return personaDict;
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
/// 在 bgImageView 上显示 persona 头像
|
||
- (void)kb_showPersonaAvatarOnBgImageView {
|
||
// 检查是否有完全访问权限
|
||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||
NSLog(@"[Keyboard] 未开启完全访问,无法显示 persona 头像");
|
||
return;
|
||
}
|
||
|
||
// 从 AppGroup 共享目录读取预处理好的小图片
|
||
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||
if (!containerURL) {
|
||
NSLog(@"[Keyboard] 无法获取 AppGroup 容器目录");
|
||
return;
|
||
}
|
||
|
||
NSString *imagePath = [[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"];
|
||
if (![[NSFileManager defaultManager] fileExistsAtPath:imagePath]) {
|
||
NSLog(@"[Keyboard] persona 封面图文件不存在: %@", imagePath);
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[Keyboard] 准备从本地加载 persona 封面图: %@", imagePath);
|
||
|
||
// 添加视图到 contentView,与 bgImageView 尺寸一致
|
||
if (!self.personaAvatarImageView.superview) {
|
||
[self.contentView insertSubview:self.personaAvatarImageView aboveSubview:self.bgImageView];
|
||
[self.personaAvatarImageView addSubview:self.personaGrayImageView];
|
||
[self.personaAvatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.edges.equalTo(self.bgImageView);
|
||
}];
|
||
[self.personaGrayImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.right.bottom.equalTo(self.personaAvatarImageView);
|
||
make.height.mas_equalTo(self.keyBoardMainView);
|
||
}];
|
||
}
|
||
|
||
// 先清理旧图片
|
||
self.personaAvatarImageView.image = nil;
|
||
|
||
// 从本地文件加载图片(已经是缩小后的小图片,内存占用很小)
|
||
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
|
||
if (image) {
|
||
self.personaAvatarImageView.image = image;
|
||
self.personaAvatarImageView.hidden = NO;
|
||
NSLog(@"[Keyboard] persona 封面图加载成功");
|
||
} else {
|
||
NSLog(@"[Keyboard] persona 封面图加载失败");
|
||
}
|
||
}
|
||
|
||
/// 隐藏 persona 头像
|
||
- (void)kb_hidePersonaAvatar {
|
||
self.personaAvatarImageView.hidden = YES;
|
||
self.personaAvatarImageView.image = nil;
|
||
}
|
||
|
||
#pragma mark - Actions
|
||
|
||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] ||
|
||
product.productId.length == 0) {
|
||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||
return;
|
||
}
|
||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||
NSString *title = [product displayTitle];
|
||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||
NSMutableArray<NSString *> *params =
|
||
[NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||
if (encodedId.length) {
|
||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||
}
|
||
if (encodedTitle.length) {
|
||
[params
|
||
addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||
}
|
||
NSString *query = [params componentsJoinedByString:@"&"];
|
||
NSString *urlString = [NSString
|
||
stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme
|
||
fromResponder:self.view];
|
||
if (!success) {
|
||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||
}
|
||
}
|
||
|
||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||
if (value.length == 0) {
|
||
return @"";
|
||
}
|
||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||
NSMutableCharacterSet *allowed =
|
||
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||
[allowed removeCharactersInString:reserved];
|
||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
|
||
?: @"";
|
||
}
|
||
|
||
- (void)onTapSettingsBack {
|
||
[[KBMaiPointReporter sharedReporter]
|
||
reportClickWithEventName:@"click_keyboard_settings_back_btn"
|
||
pageId:@"keyboard_settings"
|
||
elementId:@"back_btn"
|
||
extra:nil
|
||
completion:nil];
|
||
[self showSettingView:NO];
|
||
}
|
||
|
||
- (void)dealloc {
|
||
CFNotificationCenterRemoveObserver(
|
||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||
(__bridge const void *)(self),
|
||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
|
||
}
|
||
|
||
// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App
|
||
// 决定是否真的弹登录)。
|
||
- (void)viewDidAppear:(BOOL)animated {
|
||
[super viewDidAppear:animated];
|
||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||
// // 仅在未登录时尝试拉起主App登录
|
||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||
// }
|
||
// }
|
||
}
|
||
|
||
- (void)viewDidLayoutSubviews {
|
||
[super viewDidLayoutSubviews];
|
||
[self kb_updateKeyboardLayoutIfNeeded];
|
||
}
|
||
|
||
- (void)viewWillTransitionToSize:(CGSize)size
|
||
withTransitionCoordinator:
|
||
(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
||
__weak typeof(self) weakSelf = self;
|
||
[coordinator
|
||
animateAlongsideTransition:^(
|
||
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||
}
|
||
completion:^(
|
||
__unused id<
|
||
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||
}];
|
||
}
|
||
|
||
//- (void)kb_tryOpenContainerForLoginIfNeeded {
|
||
// // 使用与主 App 一致的自定义 Scheme
|
||
// NSURL *url = [NSURL URLWithString:[NSString
|
||
// stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url)
|
||
// return; KBWeakSelf [self.extensionContext openURL:url
|
||
// completionHandler:^(__unused BOOL success) {
|
||
// // 即使失败也不重复尝试;避免打扰。
|
||
// __unused typeof(weakSelf) selfStrong = weakSelf;
|
||
// }];
|
||
//}
|
||
|
||
#pragma mark - Theme
|
||
|
||
- (void)kb_applyTheme {
|
||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||
UIImage *img = [[KBSkinManager shared] currentBackgroundImage];
|
||
BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t];
|
||
BOOL isDarkMode = [self kb_isDarkModeActive];
|
||
CGSize size = self.bgImageView.bounds.size;
|
||
if (isDefaultTheme) {
|
||
if (isDarkMode) {
|
||
// 暗黑模式:直接使用背景色,不使用图片渲染
|
||
// 这样可以避免图片渲染时的色彩空间转换导致颜色不一致
|
||
img = nil;
|
||
self.bgImageView.image = nil;
|
||
// 使用与系统键盘底部完全相同的颜色
|
||
if (@available(iOS 13.0, *)) {
|
||
// iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E)
|
||
// 但为了完美匹配,我们使用动态颜色并直接设置为背景
|
||
UIColor *kbBgColor =
|
||
[UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||
UITraitCollection *_Nonnull traitCollection) {
|
||
if (traitCollection.userInterfaceStyle ==
|
||
UIUserInterfaceStyleDark) {
|
||
// 暗黑模式下系统键盘实际背景色
|
||
return [UIColor colorWithRed:43.0 / 255.0
|
||
green:43.0 / 255.0
|
||
blue:43.0 / 255.0
|
||
alpha:1.0];
|
||
} else {
|
||
return [UIColor colorWithRed:209.0 / 255.0
|
||
green:211.0 / 255.0
|
||
blue:219.0 / 255.0
|
||
alpha:1.0];
|
||
}
|
||
}];
|
||
self.contentView.backgroundColor = kbBgColor;
|
||
self.bgImageView.backgroundColor = kbBgColor;
|
||
} else {
|
||
UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0
|
||
green:43.0 / 255.0
|
||
blue:43.0 / 255.0
|
||
alpha:1.0];
|
||
self.contentView.backgroundColor = darkColor;
|
||
self.bgImageView.backgroundColor = darkColor;
|
||
}
|
||
} else {
|
||
// 浅色模式:使用渐变图片
|
||
if (size.width <= 0 || size.height <= 0) {
|
||
[self.view layoutIfNeeded];
|
||
size = self.bgImageView.bounds.size;
|
||
}
|
||
if (size.width <= 0 || size.height <= 0) {
|
||
size = self.view.bounds.size;
|
||
}
|
||
if (size.width <= 0 || size.height <= 0) {
|
||
size = [UIScreen mainScreen].bounds.size;
|
||
}
|
||
UIColor *topColor = [UIColor colorWithHex:0xDEDFE4];
|
||
UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB];
|
||
img = [self kb_defaultGradientImageWithSize:size
|
||
topColor:topColor
|
||
bottomColor:bottomColor];
|
||
self.contentView.backgroundColor = [UIColor clearColor];
|
||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||
}
|
||
NSLog(@"===");
|
||
} else {
|
||
// 自定义皮肤:清除背景色,使用皮肤图片
|
||
self.contentView.backgroundColor = [UIColor clearColor];
|
||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||
}
|
||
NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil));
|
||
[self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img];
|
||
self.bgImageView.image = img;
|
||
self.personaGrayImageView.image = img;
|
||
|
||
// [self.chatPanelView kb_setBackgroundImage:img];
|
||
BOOL hasImg = (img != nil);
|
||
// 触发键区按主题重绘
|
||
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||
// method declared in KBKeyBoardMainView.h
|
||
#pragma clang diagnostic push
|
||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||
#pragma clang diagnostic pop
|
||
}
|
||
if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||
#pragma clang diagnostic push
|
||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||
[self.functionView performSelector:@selector(kb_applyTheme)];
|
||
#pragma clang diagnostic pop
|
||
}
|
||
}
|
||
|
||
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
|
||
NSString *skinId = theme.skinId ?: @"";
|
||
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
|
||
return YES;
|
||
}
|
||
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
|
||
return YES;
|
||
}
|
||
return [skinId isEqualToString:kKBDefaultSkinIdDark];
|
||
}
|
||
|
||
- (BOOL)kb_isDarkModeActive {
|
||
if (@available(iOS 13.0, *)) {
|
||
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
- (NSString *)kb_defaultSkinIdForCurrentStyle {
|
||
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
|
||
: kKBDefaultSkinIdLight;
|
||
}
|
||
|
||
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
|
||
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
|
||
: kKBDefaultSkinZipNameLight;
|
||
}
|
||
|
||
- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size
|
||
topColor:(UIColor *)topColor
|
||
bottomColor:(UIColor *)bottomColor {
|
||
if (size.width <= 0 || size.height <= 0) {
|
||
return nil;
|
||
}
|
||
|
||
// 将动态颜色解析为当前 trait collection 下的具体颜色值
|
||
// 否则在 UIGraphicsBeginImageContextWithOptions 中渲染时会使用默认的浅色模式
|
||
UIColor *resolvedTopColor = topColor;
|
||
UIColor *resolvedBottomColor = bottomColor;
|
||
if (@available(iOS 13.0, *)) {
|
||
resolvedTopColor =
|
||
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||
resolvedBottomColor =
|
||
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||
}
|
||
|
||
CAGradientLayer *layer = [CAGradientLayer layer];
|
||
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||
layer.colors =
|
||
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||
|
||
UIGraphicsBeginImageContextWithOptions(size, YES, 0);
|
||
[layer renderInContext:UIGraphicsGetCurrentContext()];
|
||
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
||
UIGraphicsEndImageContext();
|
||
return image;
|
||
}
|
||
|
||
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
|
||
backgroundImage:(UIImage *)image {
|
||
#if DEBUG
|
||
NSString *skinId = theme.skinId ?: @"";
|
||
NSString *name = theme.name ?: @"";
|
||
NSMutableArray<NSString *> *roots = [NSMutableArray array];
|
||
NSURL *containerURL = [[NSFileManager defaultManager]
|
||
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||
if (containerURL.path.length > 0) {
|
||
[roots addObject:containerURL.path];
|
||
}
|
||
NSString *cacheRoot = NSSearchPathForDirectoriesInDomains(
|
||
NSCachesDirectory, NSUserDomainMask, YES)
|
||
.firstObject;
|
||
if (cacheRoot.length > 0) {
|
||
[roots addObject:cacheRoot];
|
||
}
|
||
|
||
NSFileManager *fm = [NSFileManager defaultManager];
|
||
NSMutableArray<NSString *> *lines = [NSMutableArray array];
|
||
for (NSString *root in roots) {
|
||
NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"]
|
||
stringByAppendingPathComponent:skinId];
|
||
iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"];
|
||
BOOL isDir = NO;
|
||
BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
|
||
NSArray *contents =
|
||
exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil;
|
||
NSUInteger count = contents.count;
|
||
BOOL hasQ =
|
||
exists &&
|
||
[fm fileExistsAtPath:[iconsDir
|
||
stringByAppendingPathComponent:@"key_q.png"]];
|
||
BOOL hasQUp =
|
||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||
@"key_q_up.png"]];
|
||
BOOL hasDel =
|
||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||
@"key_del.png"]];
|
||
BOOL hasShift =
|
||
exists &&
|
||
[fm fileExistsAtPath:[iconsDir
|
||
stringByAppendingPathComponent:@"key_up.png"]];
|
||
BOOL hasShiftUpper =
|
||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||
@"key_up_upper.png"]];
|
||
NSString *line = [NSString
|
||
stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d "
|
||
@"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d",
|
||
root, iconsDir, exists, count, hasQ, hasQUp, hasDel,
|
||
hasShift, hasShiftUpper];
|
||
[lines addObject:line];
|
||
}
|
||
|
||
NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name,
|
||
(image != nil), [lines componentsJoinedByString:@"\n"]);
|
||
#endif
|
||
}
|
||
|
||
- (void)kb_consumePendingShopSkin {
|
||
KBWeakSelf [KBSkinInstallBridge
|
||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||
completion:^(BOOL success,
|
||
NSError *_Nullable error) {
|
||
if (!success) {
|
||
if (error) {
|
||
NSLog(@"[Keyboard] skin request failed: %@",
|
||
error);
|
||
[KBHUD
|
||
showInfo:
|
||
KBLocalized(
|
||
@"皮肤资源准备失败,请稍后再试")];
|
||
}
|
||
return;
|
||
}
|
||
[weakSelf kb_applyTheme];
|
||
[KBHUD showInfo:KBLocalized(
|
||
@"皮肤已更新,立即体验吧")];
|
||
}];
|
||
}
|
||
|
||
#pragma mark - Default Skin
|
||
|
||
- (void)kb_applyDefaultSkinIfNeeded {
|
||
NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload];
|
||
if (pending.count > 0) {
|
||
return;
|
||
}
|
||
|
||
NSString *currentId = [KBSkinManager shared].current.skinId ?: @"";
|
||
BOOL isDefault =
|
||
(currentId.length == 0 || [currentId isEqualToString:@"default"]);
|
||
BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight];
|
||
BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark];
|
||
if (!isDefault && !isLightDefault && !isDarkDefault) {
|
||
// 用户已应用自定义皮肤:不随深色模式切换默认皮肤
|
||
return;
|
||
}
|
||
NSString *targetId = [self kb_defaultSkinIdForCurrentStyle];
|
||
NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle];
|
||
if (currentId.length > 0 && [currentId isEqualToString:targetId]) {
|
||
return;
|
||
}
|
||
|
||
NSError *applyError = nil;
|
||
if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId
|
||
error:&applyError]) {
|
||
return;
|
||
}
|
||
|
||
[KBSkinInstallBridge publishBundleSkinRequestWithId:targetId
|
||
name:targetId
|
||
zipName:targetZip
|
||
iconShortNames:nil];
|
||
[KBSkinInstallBridge
|
||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||
completion:^(__unused BOOL success,
|
||
__unused NSError *_Nullable error){
|
||
// 已通过通知触发主题刷新,这里无需额外处理
|
||
}];
|
||
}
|
||
|
||
#pragma mark - Layout Helpers
|
||
|
||
- (CGFloat)kb_portraitWidth {
|
||
CGSize s = [UIScreen mainScreen].bounds.size;
|
||
return MIN(s.width, s.height);
|
||
}
|
||
|
||
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
|
||
if (width <= 0) {
|
||
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);
|
||
}
|
||
if (containerWidth <= 0) {
|
||
containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||
}
|
||
|
||
BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5);
|
||
BOOL heightChanged =
|
||
(fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5);
|
||
if (!widthChanged && !heightChanged && containerWidth > 0 &&
|
||
self.kb_widthConstraint.constant == containerWidth) {
|
||
return;
|
||
}
|
||
self.kb_lastPortraitWidth = portraitWidth;
|
||
self.kb_lastKeyboardHeight = keyboardHeight;
|
||
|
||
if (self.kb_heightConstraint) {
|
||
self.kb_heightConstraint.constant = keyboardHeight;
|
||
}
|
||
if (containerWidth > 0 && self.kb_widthConstraint) {
|
||
self.kb_widthConstraint.constant = containerWidth;
|
||
}
|
||
if (self.contentWidthConstraint) {
|
||
[self.contentWidthConstraint setOffset:portraitWidth];
|
||
}
|
||
if (self.contentHeightConstraint) {
|
||
[self.contentHeightConstraint setOffset:keyboardHeight];
|
||
}
|
||
if (self.keyBoardMainHeightConstraint) {
|
||
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
||
}
|
||
if (self.chatPanelHeightConstraint) {
|
||
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
||
}
|
||
[self.view layoutIfNeeded];
|
||
}
|
||
|
||
#pragma mark - Lazy
|
||
|
||
- (UIView *)contentView {
|
||
if (!_contentView) {
|
||
_contentView = [[UIView alloc] init];
|
||
_contentView.backgroundColor = [UIColor clearColor];
|
||
}
|
||
return _contentView;
|
||
}
|
||
|
||
- (UIImageView *)bgImageView {
|
||
if (!_bgImageView) {
|
||
_bgImageView = [[UIImageView alloc] init];
|
||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||
_bgImageView.clipsToBounds = YES;
|
||
}
|
||
return _bgImageView;
|
||
}
|
||
@end
|