2025-10-27 19:42:27 +08:00
|
|
|
|
//
|
|
|
|
|
|
// KeyboardViewController.m
|
|
|
|
|
|
// CustomKeyboard
|
|
|
|
|
|
//
|
|
|
|
|
|
// Created by Mac on 2025/10/27.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "KeyboardViewController.h"
|
2025-10-28 15:10:38 +08:00
|
|
|
|
#import "KBKeyBoardMainView.h"
|
|
|
|
|
|
|
2025-10-31 16:06:54 +08:00
|
|
|
|
#import "KBAuthManager.h"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
#import "KBBackspaceUndoManager.h"
|
2025-11-03 13:25:41 +08:00
|
|
|
|
#import "KBFullAccessManager.h"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
#import "KBFunctionView.h"
|
2025-11-24 19:58:19 +08:00
|
|
|
|
#import "KBHostAppLauncher.h"
|
2025-12-26 11:47:44 +08:00
|
|
|
|
#import "KBInputBufferManager.h"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
#import "KBKey.h"
|
|
|
|
|
|
#import "KBKeyboardSubscriptionProduct.h"
|
|
|
|
|
|
#import "KBKeyboardSubscriptionView.h"
|
|
|
|
|
|
#import "KBSettingView.h"
|
2026-01-15 18:16:56 +08:00
|
|
|
|
#import "KBChatMessage.h"
|
|
|
|
|
|
#import "KBChatPanelView.h"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
#import "KBSkinInstallBridge.h"
|
|
|
|
|
|
#import "KBSkinManager.h"
|
2025-12-22 12:54:28 +08:00
|
|
|
|
#import "KBSuggestionEngine.h"
|
2026-01-15 18:16:56 +08:00
|
|
|
|
#import "KBNetworkManager.h"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
#import "Masonry.h"
|
|
|
|
|
|
#import "UIImage+KBColor.h"
|
2026-01-15 18:16:56 +08:00
|
|
|
|
#import <AVFoundation/AVFoundation.h>
|
2026-01-09 19:13:35 +08:00
|
|
|
|
|
|
|
|
|
|
// #import "KBLog.h"
|
2025-11-20 14:27:57 +08:00
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin
|
|
|
|
|
|
// 方法。
|
2025-11-20 14:27:57 +08:00
|
|
|
|
@interface KeyboardViewController (KBSkinShopBridge)
|
|
|
|
|
|
- (void)kb_consumePendingShopSkin;
|
|
|
|
|
|
@end
|
2025-10-27 19:42:27 +08:00
|
|
|
|
|
2025-12-22 12:54:28 +08:00
|
|
|
|
// 以 375 宽设计稿为基准的键盘总高度
|
|
|
|
|
|
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
|
2026-01-15 18:16:56 +08:00
|
|
|
|
static const CGFloat kKBChatPanelHeight = 180;
|
|
|
|
|
|
static const NSUInteger kKBChatMessageLimit = 6;
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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";
|
2025-10-27 19:42:27 +08:00
|
|
|
|
|
2025-11-20 14:27:57 +08:00
|
|
|
|
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
2026-01-09 19:13:35 +08:00
|
|
|
|
void *observer, CFStringRef name,
|
2025-11-20 14:27:57 +08:00
|
|
|
|
const void *object,
|
|
|
|
|
|
CFDictionaryRef userInfo) {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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,
|
2026-01-15 18:16:56 +08:00
|
|
|
|
KBKeyboardSubscriptionViewDelegate,
|
|
|
|
|
|
KBChatPanelViewDelegate>
|
2026-01-09 19:13:35 +08:00
|
|
|
|
@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; // 背景图(在底层)
|
2026-01-15 18:16:56 +08:00
|
|
|
|
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
2026-01-09 19:13:35 +08:00
|
|
|
|
@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;
|
2026-01-15 18:16:56 +08:00
|
|
|
|
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
|
|
|
|
|
|
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
|
2026-01-09 19:13:35 +08:00
|
|
|
|
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
|
|
|
|
|
|
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
|
|
|
|
|
|
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
|
|
|
|
|
|
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
|
2026-01-15 18:16:56 +08:00
|
|
|
|
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
|
|
|
|
|
|
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
|
|
|
|
|
@property(nonatomic, strong) NSCache<NSString *, UIImage *> *chatAvatarCache;
|
|
|
|
|
|
@property(nonatomic, assign) BOOL chatPanelVisible;
|
2025-10-27 19:42:27 +08:00
|
|
|
|
@end
|
|
|
|
|
|
|
|
|
|
|
|
@implementation KeyboardViewController
|
|
|
|
|
|
|
2025-10-30 20:23:34 +08:00
|
|
|
|
{
|
2026-01-09 19:13:35 +08:00
|
|
|
|
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
2025-10-30 20:23:34 +08:00
|
|
|
|
}
|
2025-10-27 19:42:27 +08:00
|
|
|
|
|
|
|
|
|
|
- (void)viewDidLoad {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[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];
|
|
|
|
|
|
}
|
2025-11-04 21:01:46 +08:00
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-12-26 13:55:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)viewWillDisappear:(BOOL)animated {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[super viewWillDisappear:animated];
|
|
|
|
|
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
2025-12-26 11:47:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:07:11 +08:00
|
|
|
|
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[super traitCollectionDidChange:previousTraitCollection];
|
|
|
|
|
|
if (@available(iOS 13.0, *)) {
|
|
|
|
|
|
if (previousTraitCollection.userInterfaceStyle !=
|
|
|
|
|
|
self.traitCollection.userInterfaceStyle) {
|
|
|
|
|
|
[self kb_applyDefaultSkinIfNeeded];
|
2026-01-09 13:07:11 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
2026-01-09 13:07:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:47:44 +08:00
|
|
|
|
- (void)textDidChange:(id<UITextInput>)textInput {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[super textDidChange:textInput];
|
|
|
|
|
|
[[KBInputBufferManager shared]
|
|
|
|
|
|
updateFromExternalContextBefore:self.textDocumentProxy
|
|
|
|
|
|
.documentContextBeforeInput
|
|
|
|
|
|
after:self.textDocumentProxy
|
|
|
|
|
|
.documentContextAfterInput];
|
2025-11-27 15:34:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-27 19:42:27 +08:00
|
|
|
|
- (void)setupUI {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
|
|
|
|
|
|
|
|
|
|
|
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
|
|
|
|
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
|
|
|
|
|
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
2026-01-15 18:16:56 +08:00
|
|
|
|
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
|
|
|
|
|
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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;
|
2025-11-05 18:10:56 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 内容容器:横屏时保持竖屏宽度,居中显示
|
|
|
|
|
|
[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) {
|
2026-01-15 18:16:56 +08:00
|
|
|
|
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);
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}];
|
2026-01-15 18:16:56 +08:00
|
|
|
|
self.chatPanelView.hidden = YES;
|
2025-10-27 19:42:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:10:38 +08:00
|
|
|
|
#pragma mark - Private
|
2025-10-28 14:30:03 +08:00
|
|
|
|
|
2025-12-22 12:54:28 +08:00
|
|
|
|
// MARK: - Suggestions
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_clearCurrentWord {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
self.currentWord = @"";
|
|
|
|
|
|
[self.keyBoardMainView kb_setSuggestions:@[]];
|
|
|
|
|
|
self.suppressSuggestions = NO;
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
|
|
|
|
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
|
|
|
|
|
resetSuppression];
|
|
|
|
|
|
});
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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;
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
idx -= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
NSUInteger start = (NSUInteger)(idx + 1);
|
|
|
|
|
|
if (start >= context.length) {
|
|
|
|
|
|
return @"";
|
|
|
|
|
|
}
|
|
|
|
|
|
return [context substringFromIndex:start];
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (BOOL)kb_isAlphabeticString:(NSString *)text {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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;
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
return YES;
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_updateSuggestionsForCurrentWord {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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]];
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
return result.copy;
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:10:38 +08:00
|
|
|
|
/// 切换显示功能面板/键盘主视图
|
|
|
|
|
|
- (void)showFunctionPanel:(BOOL)show {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
// 简单显隐切换,复用相同的布局区域
|
2026-01-15 18:16:56 +08:00
|
|
|
|
if (show) {
|
|
|
|
|
|
[self showChatPanel:NO];
|
|
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 18:02:10 +08:00
|
|
|
|
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
|
|
|
|
|
- (void)showSettingView:(BOOL)show {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (show) {
|
2026-01-15 18:16:56 +08:00
|
|
|
|
[self showChatPanel:NO];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[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);
|
2025-10-28 18:02:10 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (w <= 0) {
|
|
|
|
|
|
w = [self kb_portraitWidth];
|
2025-12-18 13:20:49 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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);
|
2025-12-18 13:20:49 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (w <= 0) {
|
|
|
|
|
|
w = [self kb_portraitWidth];
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[UIView animateWithDuration:0.22
|
|
|
|
|
|
delay:0
|
|
|
|
|
|
options:UIViewAnimationOptionCurveEaseIn
|
|
|
|
|
|
animations:^{
|
|
|
|
|
|
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
completion:^(BOOL finished) {
|
|
|
|
|
|
self.settingView.hidden = YES;
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:16:56 +08:00
|
|
|
|
/// 显示/隐藏聊天面板(覆盖整个键盘区域)
|
|
|
|
|
|
- (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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)hideSubscriptionPanel {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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:^{
|
2025-12-17 16:22:41 +08:00
|
|
|
|
panel.alpha = 0.0;
|
|
|
|
|
|
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
completion:^(BOOL finished) {
|
2025-12-17 16:22:41 +08:00
|
|
|
|
panel.hidden = YES;
|
|
|
|
|
|
panel.alpha = 1.0;
|
|
|
|
|
|
panel.transform = CGAffineTransformIdentity;
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}];
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:10:38 +08:00
|
|
|
|
// MARK: - KBKeyBoardMainViewDelegate
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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:
|
2026-01-15 18:16:56 +08:00
|
|
|
|
if (self.chatPanelVisible) {
|
|
|
|
|
|
[self kb_handleChatSendAction];
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[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;
|
|
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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) {
|
2026-01-15 18:16:56 +08:00
|
|
|
|
[self showChatPanel:NO];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[self showFunctionPanel:YES];
|
|
|
|
|
|
[self kb_clearCurrentWord];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-15 18:16:56 +08:00
|
|
|
|
if (index == 1) {
|
|
|
|
|
|
[self showFunctionPanel:NO];
|
|
|
|
|
|
[self showChatPanel:YES];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[self showFunctionPanel:NO];
|
2026-01-15 18:16:56 +08:00
|
|
|
|
[self showChatPanel:NO];
|
2025-10-28 15:10:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 18:02:10 +08:00
|
|
|
|
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[KBMaiPointReporter sharedReporter]
|
|
|
|
|
|
reportClickWithEventName:@"click_keyboard_settings_btn"
|
|
|
|
|
|
pageId:@"keyboard_main_panel"
|
|
|
|
|
|
elementId:@"settings_btn"
|
|
|
|
|
|
extra:nil
|
|
|
|
|
|
completion:nil];
|
|
|
|
|
|
[self showSettingView:YES];
|
2025-10-28 18:02:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-12-15 13:24:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 19:21:08 +08:00
|
|
|
|
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[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 {
|
2026-01-13 16:55:24 +08:00
|
|
|
|
// [[KBMaiPointReporter sharedReporter]
|
|
|
|
|
|
// reportClickWithEventName:@"click_keyboard_emoji_search_btn"
|
|
|
|
|
|
// pageId:@"keyboard_main_panel"
|
|
|
|
|
|
// elementId:@"emoji_search_btn"
|
|
|
|
|
|
// extra:nil
|
|
|
|
|
|
// completion:nil];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
|
|
|
|
|
didSelectSuggestion:(NSString *)suggestion {
|
|
|
|
|
|
if (suggestion.length == 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
|
2026-01-13 16:55:24 +08:00
|
|
|
|
// [[KBMaiPointReporter sharedReporter]
|
|
|
|
|
|
// reportClickWithEventName:@"click_keyboard_suggestion_item"
|
|
|
|
|
|
// pageId:@"keyboard_main_panel"
|
|
|
|
|
|
// elementId:@"suggestion_item"
|
|
|
|
|
|
// extra:extra
|
|
|
|
|
|
// completion:nil];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
|
|
|
|
|
NSString *current = self.currentWord ?: @"";
|
|
|
|
|
|
if (current.length > 0) {
|
|
|
|
|
|
for (NSUInteger i = 0; i < current.length; i++) {
|
|
|
|
|
|
[self.textDocumentProxy deleteBackward];
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
[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];
|
2025-12-22 12:54:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:18:12 +08:00
|
|
|
|
// MARK: - KBFunctionViewDelegate
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (void)functionView:(KBFunctionView *)functionView
|
|
|
|
|
|
didTapToolActionAtIndex:(NSInteger)index {
|
|
|
|
|
|
// 需求:当 index == 0 时,切回键盘主视图
|
|
|
|
|
|
if (index == 0) {
|
|
|
|
|
|
[self showFunctionPanel:NO];
|
|
|
|
|
|
}
|
2025-10-28 15:18:12 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-11-26 21:16:56 +08:00
|
|
|
|
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
|
|
|
|
|
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
|
|
|
|
|
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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进行充值"];
|
|
|
|
|
|
}
|
2025-11-26 21:16:56 +08:00
|
|
|
|
}
|
2025-10-28 15:18:12 +08:00
|
|
|
|
|
2025-12-17 17:01:49 +08:00
|
|
|
|
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[self showSubscriptionPanel];
|
2025-12-17 17:01:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:16:56 +08:00
|
|
|
|
#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<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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 16:22:41 +08:00
|
|
|
|
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
|
|
|
|
|
|
|
|
|
|
|
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[KBMaiPointReporter sharedReporter]
|
|
|
|
|
|
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
|
|
|
|
|
|
pageId:@"keyboard_subscription_panel"
|
|
|
|
|
|
elementId:@"close_btn"
|
|
|
|
|
|
extra:nil
|
|
|
|
|
|
completion:nil];
|
|
|
|
|
|
[self hideSubscriptionPanel];
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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];
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:10:38 +08:00
|
|
|
|
#pragma mark - lazy
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (KBKeyBoardMainView *)keyBoardMainView {
|
|
|
|
|
|
if (!_keyBoardMainView) {
|
|
|
|
|
|
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
|
|
|
|
|
_keyBoardMainView.delegate = self;
|
|
|
|
|
|
}
|
|
|
|
|
|
return _keyBoardMainView;
|
2025-10-28 15:10:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (KBFunctionView *)functionView {
|
|
|
|
|
|
if (!_functionView) {
|
|
|
|
|
|
_functionView = [[KBFunctionView alloc] init];
|
|
|
|
|
|
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
|
|
|
|
|
}
|
|
|
|
|
|
return _functionView;
|
2025-10-28 15:10:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 18:02:10 +08:00
|
|
|
|
- (KBSettingView *)settingView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (!_settingView) {
|
|
|
|
|
|
_settingView = [[KBSettingView alloc] init];
|
|
|
|
|
|
}
|
|
|
|
|
|
return _settingView;
|
2025-10-28 18:02:10 +08:00
|
|
|
|
}
|
2025-10-28 15:10:38 +08:00
|
|
|
|
|
2026-01-15 18:16:56 +08:00
|
|
|
|
- (KBChatPanelView *)chatPanelView {
|
|
|
|
|
|
if (!_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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 16:22:41 +08:00
|
|
|
|
- (KBKeyboardSubscriptionView *)subscriptionView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (!_subscriptionView) {
|
|
|
|
|
|
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
|
|
|
|
|
_subscriptionView.delegate = self;
|
|
|
|
|
|
_subscriptionView.hidden = YES;
|
|
|
|
|
|
_subscriptionView.alpha = 0.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return _subscriptionView;
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 18:02:10 +08:00
|
|
|
|
#pragma mark - Actions
|
|
|
|
|
|
|
2025-12-17 16:22:41 +08:00
|
|
|
|
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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")];
|
|
|
|
|
|
}
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (value.length == 0) {
|
|
|
|
|
|
return @"";
|
|
|
|
|
|
}
|
|
|
|
|
|
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
|
|
|
|
|
NSMutableCharacterSet *allowed =
|
|
|
|
|
|
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
|
|
|
|
|
[allowed removeCharactersInString:reserved];
|
|
|
|
|
|
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
|
|
|
|
|
|
?: @"";
|
2025-12-17 16:22:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 18:02:10 +08:00
|
|
|
|
- (void)onTapSettingsBack {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[[KBMaiPointReporter sharedReporter]
|
|
|
|
|
|
reportClickWithEventName:@"click_keyboard_settings_back_btn"
|
|
|
|
|
|
pageId:@"keyboard_settings"
|
|
|
|
|
|
elementId:@"back_btn"
|
|
|
|
|
|
extra:nil
|
|
|
|
|
|
completion:nil];
|
|
|
|
|
|
[self showSettingView:NO];
|
2025-10-28 18:02:10 +08:00
|
|
|
|
}
|
2025-10-28 15:18:12 +08:00
|
|
|
|
|
2025-11-20 14:27:57 +08:00
|
|
|
|
- (void)dealloc {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
CFNotificationCenterRemoveObserver(
|
|
|
|
|
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
|
|
|
|
|
(__bridge const void *)(self),
|
|
|
|
|
|
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
|
2025-11-20 14:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App
|
|
|
|
|
|
// 决定是否真的弹登录)。
|
2025-10-30 20:23:34 +08:00
|
|
|
|
- (void)viewDidAppear:(BOOL)animated {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[super viewDidAppear:animated];
|
|
|
|
|
|
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
|
|
|
|
|
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
|
|
|
|
|
// // 仅在未登录时尝试拉起主App登录
|
|
|
|
|
|
// if (!KBAuthManager.shared.isLoggedIn) {
|
|
|
|
|
|
// [self kb_tryOpenContainerForLoginIfNeeded];
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
2025-10-30 20:23:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 13:11:23 +08:00
|
|
|
|
- (void)viewDidLayoutSubviews {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[super viewDidLayoutSubviews];
|
|
|
|
|
|
[self kb_updateKeyboardLayoutIfNeeded];
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (void)viewWillTransitionToSize:(CGSize)size
|
|
|
|
|
|
withTransitionCoordinator:
|
|
|
|
|
|
(id<UIViewControllerTransitionCoordinator>)coordinator {
|
|
|
|
|
|
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
|
|
|
|
|
__weak typeof(self) weakSelf = self;
|
|
|
|
|
|
[coordinator
|
|
|
|
|
|
animateAlongsideTransition:^(
|
|
|
|
|
|
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
2026-01-07 13:11:23 +08:00
|
|
|
|
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
completion:^(
|
|
|
|
|
|
__unused id<
|
|
|
|
|
|
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
2026-01-07 13:11:23 +08:00
|
|
|
|
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}];
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 14:11:44 +08:00
|
|
|
|
//- (void)kb_tryOpenContainerForLoginIfNeeded {
|
|
|
|
|
|
// // 使用与主 App 一致的自定义 Scheme
|
2026-01-09 19:13:35 +08:00
|
|
|
|
// NSURL *url = [NSURL URLWithString:[NSString
|
|
|
|
|
|
// stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url)
|
|
|
|
|
|
// return; KBWeakSelf [self.extensionContext openURL:url
|
|
|
|
|
|
// completionHandler:^(__unused BOOL success) {
|
2025-11-13 14:11:44 +08:00
|
|
|
|
// // 即使失败也不重复尝试;避免打扰。
|
|
|
|
|
|
// __unused typeof(weakSelf) selfStrong = weakSelf;
|
|
|
|
|
|
// }];
|
|
|
|
|
|
//}
|
2025-11-04 21:01:46 +08:00
|
|
|
|
|
|
|
|
|
|
#pragma mark - Theme
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_applyTheme {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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];
|
2025-11-04 21:01:46 +08:00
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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;
|
|
|
|
|
|
BOOL hasImg = (img != nil);
|
|
|
|
|
|
// 触发键区按主题重绘
|
|
|
|
|
|
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
|
|
|
|
|
// method declared in KBKeyBoardMainView.h
|
2025-11-04 21:01:46 +08:00
|
|
|
|
#pragma clang diagnostic push
|
|
|
|
|
|
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
2025-11-04 21:01:46 +08:00
|
|
|
|
#pragma clang diagnostic pop
|
2026-01-09 19:13:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2025-11-04 21:01:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 20:04:23 +08:00
|
|
|
|
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
NSString *skinId = theme.skinId ?: @"";
|
|
|
|
|
|
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
|
|
|
|
|
|
return YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
|
|
|
|
|
|
return YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
return [skinId isEqualToString:kKBDefaultSkinIdDark];
|
2026-01-09 13:07:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (BOOL)kb_isDarkModeActive {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (@available(iOS 13.0, *)) {
|
|
|
|
|
|
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
|
|
|
|
|
}
|
|
|
|
|
|
return NO;
|
2026-01-09 13:07:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)kb_defaultSkinIdForCurrentStyle {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
|
|
|
|
|
|
: kKBDefaultSkinIdLight;
|
2026-01-09 13:07:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
|
|
|
|
|
|
: kKBDefaultSkinZipNameLight;
|
2026-01-08 20:04:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:13:35 +08:00
|
|
|
|
- (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;
|
2026-01-08 20:04:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 18:57:17 +08:00
|
|
|
|
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
|
|
|
|
|
|
backgroundImage:(UIImage *)image {
|
|
|
|
|
|
#if DEBUG
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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"]);
|
2026-01-08 18:57:17 +08:00
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 14:27:57 +08:00
|
|
|
|
- (void)kb_consumePendingShopSkin {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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(
|
|
|
|
|
|
@"皮肤已更新,立即体验吧")];
|
|
|
|
|
|
}];
|
2025-11-20 14:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 17:23:22 +08:00
|
|
|
|
#pragma mark - Default Skin
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_applyDefaultSkinIfNeeded {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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){
|
|
|
|
|
|
// 已通过通知触发主题刷新,这里无需额外处理
|
|
|
|
|
|
}];
|
2026-01-08 17:23:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 13:11:23 +08:00
|
|
|
|
#pragma mark - Layout Helpers
|
|
|
|
|
|
|
|
|
|
|
|
- (CGFloat)kb_portraitWidth {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
CGSize s = [UIScreen mainScreen].bounds.size;
|
|
|
|
|
|
return MIN(s.width, s.height);
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (width <= 0) {
|
|
|
|
|
|
width = KB_DESIGN_WIDTH;
|
|
|
|
|
|
}
|
2026-01-15 18:16:56 +08:00
|
|
|
|
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;
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_updateKeyboardLayoutIfNeeded {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
|
|
|
|
|
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
2026-01-15 18:16:56 +08:00
|
|
|
|
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
|
|
|
|
|
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
2026-01-09 19:13:35 +08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
2026-01-15 18:16:56 +08:00
|
|
|
|
if (self.keyBoardMainHeightConstraint) {
|
|
|
|
|
|
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self.chatPanelHeightConstraint) {
|
|
|
|
|
|
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
|
|
|
|
|
}
|
2026-01-09 19:13:35 +08:00
|
|
|
|
[self.view layoutIfNeeded];
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 21:01:46 +08:00
|
|
|
|
#pragma mark - Lazy
|
|
|
|
|
|
|
2026-01-07 13:11:23 +08:00
|
|
|
|
- (UIView *)contentView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (!_contentView) {
|
|
|
|
|
|
_contentView = [[UIView alloc] init];
|
|
|
|
|
|
_contentView.backgroundColor = [UIColor clearColor];
|
|
|
|
|
|
}
|
|
|
|
|
|
return _contentView;
|
2026-01-07 13:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 21:01:46 +08:00
|
|
|
|
- (UIImageView *)bgImageView {
|
2026-01-09 19:13:35 +08:00
|
|
|
|
if (!_bgImageView) {
|
|
|
|
|
|
_bgImageView = [[UIImageView alloc] init];
|
|
|
|
|
|
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
|
|
|
|
|
_bgImageView.clipsToBounds = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
return _bgImageView;
|
2025-11-04 21:01:46 +08:00
|
|
|
|
}
|
2025-10-28 15:18:12 +08:00
|
|
|
|
@end
|