Compare commits
1 Commits
dev_st
...
dev_lottie
| Author | SHA1 | Date | |
|---|---|---|---|
| cb3c9f184a |
@@ -1,17 +0,0 @@
|
|||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
|
||||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
|
||||||
request = "set_developer_dir";
|
|
||||||
}) because we are not connected to CoreSimulatorService.
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
|
||||||
request = "notification_subscription";
|
|
||||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
|
||||||
}) because we are not connected to CoreSimulatorService.
|
|
||||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
|
||||||
request = "notification_subscription";
|
|
||||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
|
||||||
}) because we are not connected to CoreSimulatorService.
|
|
||||||
@@ -2,17 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.associated-domains</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>applinks:app.tknb.net</string>
|
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.com.loveKey.nyx</string>
|
|
||||||
</array>
|
|
||||||
<key>keychain-access-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>kbkeyboardAppExtension</string>
|
|
||||||
</array>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>需要使用麦克风进行语音输入</string>
|
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "App_icon.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "App_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "App_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "切图 271@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "切图 271@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "back_keybord_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "back_keybord_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "buy_sel_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "buy_sel_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "close_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "close_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_ai_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_ai_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_chat_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_chat_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_emotion_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_emotion_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_keyboard_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "home_keyboard_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "light"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "kb_del_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "light"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "kb_del_icon@2x 1.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "切图 256@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "kb_del_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "light"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "kb_del_icon@3x 1.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "切图 256@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1008 B |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "kb_zt_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "kb_zt_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1014 B |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "key_revoke@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "key_revoke@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "keybord_bg_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "keybord_bg_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 486 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "upgrad_vip_icon@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "upgrad_vip_icon@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 39 KiB |
@@ -1,46 +0,0 @@
|
|||||||
//
|
|
||||||
// KBEmojiDataProvider.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
FOUNDATION_EXPORT NSString * const KBEmojiRecentsDidChangeNotification;
|
|
||||||
|
|
||||||
@class KBEmojiCategory, KBEmojiItem;
|
|
||||||
|
|
||||||
@interface KBEmojiItem : NSObject <NSCopying>
|
|
||||||
@property (nonatomic, copy, readonly) NSString *value;
|
|
||||||
@property (nonatomic, copy, readonly) NSString *name;
|
|
||||||
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBEmojiCategory : NSObject
|
|
||||||
@property (nonatomic, copy, readonly) NSString *identifier;
|
|
||||||
@property (nonatomic, copy, readonly) NSString *displayTitle;
|
|
||||||
@property (nonatomic, copy, readonly) NSString *iconSymbol;
|
|
||||||
@property (nonatomic, assign, readonly, getter=isDynamic) BOOL dynamic;
|
|
||||||
@property (nonatomic, copy, readonly) NSArray<KBEmojiItem *> *items;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBEmojiDataProvider : NSObject
|
|
||||||
|
|
||||||
+ (instancetype)shared;
|
|
||||||
|
|
||||||
/// 所有分类(按系统顺序),包含“常用”。
|
|
||||||
@property (nonatomic, copy, readonly) NSArray<KBEmojiCategory *> *categories;
|
|
||||||
|
|
||||||
/// 记录一次 emoji 选择,并刷新“常用”分类。
|
|
||||||
- (void)recordEmojiSelection:(NSString *)emoji;
|
|
||||||
|
|
||||||
/// 重新加载 JSON(若首次调用)。
|
|
||||||
- (void)reloadIfNeeded;
|
|
||||||
|
|
||||||
/// 更新当前语言对应的分类标题。
|
|
||||||
- (void)refreshLocalizedTitles;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
//
|
|
||||||
// KBEmojiDataProvider.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBEmojiDataProvider.h"
|
|
||||||
#import "KBLocalizationManager.h"
|
|
||||||
#import "KBConfig.h"
|
|
||||||
|
|
||||||
NSString * const KBEmojiRecentsDidChangeNotification = @"KBEmojiRecentsDidChangeNotification";
|
|
||||||
|
|
||||||
static NSString * const kKBEmojiJSONFileName = @"emoji_categories";
|
|
||||||
static NSString * const kKBEmojiRecentsStoreKey = @"KBEmojiRecentEmojis";
|
|
||||||
static NSString * const kKBEmojiRecentsCategoryId = @"recents";
|
|
||||||
static const NSUInteger kKBEmojiRecentsLimit = 32;
|
|
||||||
|
|
||||||
#pragma mark - Model Implementations
|
|
||||||
|
|
||||||
@interface KBEmojiItem ()
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *value;
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *name;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBEmojiItem
|
|
||||||
|
|
||||||
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_value = [value copy];
|
|
||||||
_name = [name copy];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (id)copyWithZone:(NSZone *)zone {
|
|
||||||
KBEmojiItem *item = [[[self class] allocWithZone:zone] initWithValue:self.value name:self.name];
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBEmojiCategory ()
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *identifier;
|
|
||||||
@property (nonatomic, copy) NSDictionary<NSString *, NSString *> *titleMap;
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *displayTitle;
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *iconSymbol;
|
|
||||||
@property (nonatomic, assign, readwrite, getter=isDynamic) BOOL dynamic;
|
|
||||||
@property (nonatomic, copy, readwrite) NSArray<KBEmojiItem *> *items;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBEmojiCategory
|
|
||||||
|
|
||||||
- (void)refreshDisplayTitleForLanguage:(NSString *)lang {
|
|
||||||
if (lang.length == 0) {
|
|
||||||
lang = KBLanguageCodeEnglish;
|
|
||||||
}
|
|
||||||
NSString *title = self.titleMap[lang];
|
|
||||||
if (title.length == 0) {
|
|
||||||
if ([lang.lowercaseString hasPrefix:@"zh"]) {
|
|
||||||
title = self.titleMap[@"zh-Hans"] ?: self.titleMap[@"zh-hans"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (title.length == 0) {
|
|
||||||
title = self.titleMap[@"en"];
|
|
||||||
}
|
|
||||||
if (title.length == 0) {
|
|
||||||
title = self.titleMap.allValues.firstObject;
|
|
||||||
}
|
|
||||||
self.displayTitle = title ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark - Data Provider
|
|
||||||
|
|
||||||
@interface KBEmojiDataProvider ()
|
|
||||||
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categoriesInternal;
|
|
||||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, KBEmojiItem *> *itemLookup;
|
|
||||||
@property (nonatomic, strong) NSMutableOrderedSet<NSString *> *recentValues;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBEmojiDataProvider
|
|
||||||
|
|
||||||
+ (instancetype)shared {
|
|
||||||
static KBEmojiDataProvider *m;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
m = [KBEmojiDataProvider new];
|
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:m
|
|
||||||
selector:@selector(onLocalizationChanged:)
|
|
||||||
name:KBLocalizationDidChangeNotification
|
|
||||||
object:nil];
|
|
||||||
});
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<KBEmojiCategory *> *)categories {
|
|
||||||
[self reloadIfNeeded];
|
|
||||||
return self.categoriesInternal ?: @[];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)reloadIfNeeded {
|
|
||||||
if (self.categoriesInternal.count > 0) { return; }
|
|
||||||
[self loadEmojiJSON];
|
|
||||||
[self refreshLocalizedTitles];
|
|
||||||
[self loadRecentsFromStore];
|
|
||||||
[self rebuildRecentsCategory];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)loadEmojiJSON {
|
|
||||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBEmojiJSONFileName ofType:@"json"];
|
|
||||||
if (path.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSData *data = [NSData dataWithContentsOfFile:path];
|
|
||||||
if (data.length == 0) { return; }
|
|
||||||
|
|
||||||
NSError *err = nil;
|
|
||||||
NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
|
|
||||||
if (!root || err) {
|
|
||||||
NSLog(@"[Emoji] failed to parse json: %@", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSArray *catArray = root[@"categories"];
|
|
||||||
if (![catArray isKindOfClass:NSArray.class]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableArray<KBEmojiCategory *> *tmpCats = [NSMutableArray arrayWithCapacity:catArray.count];
|
|
||||||
self.itemLookup = [NSMutableDictionary dictionary];
|
|
||||||
|
|
||||||
for (NSDictionary *catDict in catArray) {
|
|
||||||
if (![catDict isKindOfClass:NSDictionary.class]) continue;
|
|
||||||
KBEmojiCategory *category = [KBEmojiCategory new];
|
|
||||||
category.identifier = catDict[@"id"] ?: @"";
|
|
||||||
NSDictionary *titleMap = catDict[@"title"];
|
|
||||||
if ([titleMap isKindOfClass:NSDictionary.class]) {
|
|
||||||
category.titleMap = titleMap;
|
|
||||||
} else {
|
|
||||||
category.titleMap = @{};
|
|
||||||
}
|
|
||||||
NSString *iconKey = catDict[@"icon"];
|
|
||||||
category.iconSymbol = [self symbolForIconKey:iconKey];
|
|
||||||
NSString *type = catDict[@"type"];
|
|
||||||
category.dynamic = [type.lowercaseString isEqualToString:@"dynamic"];
|
|
||||||
|
|
||||||
NSArray *emojiArray = catDict[@"emojis"];
|
|
||||||
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:[emojiArray count]];
|
|
||||||
if ([emojiArray isKindOfClass:NSArray.class]) {
|
|
||||||
for (NSDictionary *emojiDict in emojiArray) {
|
|
||||||
if (![emojiDict isKindOfClass:NSDictionary.class]) continue;
|
|
||||||
NSString *value = emojiDict[@"value"];
|
|
||||||
if (value.length == 0) continue;
|
|
||||||
NSString *name = emojiDict[@"name"] ?: @"";
|
|
||||||
KBEmojiItem *item = [[KBEmojiItem alloc] initWithValue:value name:name];
|
|
||||||
[items addObject:item];
|
|
||||||
if (value.length > 0) {
|
|
||||||
self.itemLookup[value] = item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
category.items = items.copy;
|
|
||||||
[tmpCats addObject:category];
|
|
||||||
}
|
|
||||||
self.categoriesInternal = tmpCats.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)symbolForIconKey:(NSString *)key {
|
|
||||||
static NSDictionary<NSString *, NSString *> *map;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
map = @{
|
|
||||||
@"emoji_tab_recent": @"🕘",
|
|
||||||
@"emoji_tab_people": @"😊",
|
|
||||||
@"emoji_tab_nature": @"🌿",
|
|
||||||
@"emoji_tab_food": @"🍔",
|
|
||||||
@"emoji_tab_activity": @"🏀",
|
|
||||||
@"emoji_tab_travel": @"✈️",
|
|
||||||
@"emoji_tab_objects": @"💡",
|
|
||||||
@"emoji_tab_symbols": @"♾",
|
|
||||||
@"emoji_tab_flags": @"🏳️"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
NSString *symbol = map[key];
|
|
||||||
return symbol.length ? symbol : @"●";
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)refreshLocalizedTitles {
|
|
||||||
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
|
||||||
for (KBEmojiCategory *cat in self.categoriesInternal) {
|
|
||||||
[cat refreshDisplayTitleForLanguage:lang];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)onLocalizationChanged:(__unused NSNotification *)note {
|
|
||||||
[self refreshLocalizedTitles];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)recordEmojiSelection:(NSString *)emoji {
|
|
||||||
if (emoji.length == 0) return;
|
|
||||||
[self reloadIfNeeded];
|
|
||||||
if (!self.recentValues) {
|
|
||||||
self.recentValues = [NSMutableOrderedSet orderedSet];
|
|
||||||
}
|
|
||||||
[self.recentValues removeObject:emoji];
|
|
||||||
[self.recentValues insertObject:emoji atIndex:0];
|
|
||||||
while (self.recentValues.count > kKBEmojiRecentsLimit) {
|
|
||||||
[self.recentValues removeObjectAtIndex:self.recentValues.count - 1];
|
|
||||||
}
|
|
||||||
[self saveRecentsToStore];
|
|
||||||
[self rebuildRecentsCategory];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)loadRecentsFromStore {
|
|
||||||
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
|
||||||
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
|
|
||||||
NSArray *stored = [defs objectForKey:kKBEmojiRecentsStoreKey];
|
|
||||||
NSMutableOrderedSet *set = [NSMutableOrderedSet orderedSet];
|
|
||||||
if ([stored isKindOfClass:NSArray.class]) {
|
|
||||||
for (id obj in stored) {
|
|
||||||
if (![obj isKindOfClass:NSString.class]) continue;
|
|
||||||
NSString *str = (NSString *)obj;
|
|
||||||
if (str.length == 0) continue;
|
|
||||||
[set addObject:str];
|
|
||||||
if (set.count >= kKBEmojiRecentsLimit) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.recentValues = set;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)saveRecentsToStore {
|
|
||||||
if (!self.recentValues) return;
|
|
||||||
NSArray *arr = self.recentValues.array;
|
|
||||||
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
|
||||||
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
|
|
||||||
[defs setObject:arr forKey:kKBEmojiRecentsStoreKey];
|
|
||||||
[defs synchronize];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)rebuildRecentsCategory {
|
|
||||||
KBEmojiCategory *recent = [self categoryForIdentifier:kKBEmojiRecentsCategoryId];
|
|
||||||
if (!recent) return;
|
|
||||||
NSArray<NSString *> *values = self.recentValues.array ?: @[];
|
|
||||||
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:values.count];
|
|
||||||
for (NSString *value in values) {
|
|
||||||
KBEmojiItem *item = self.itemLookup[value];
|
|
||||||
if (!item) {
|
|
||||||
item = [[KBEmojiItem alloc] initWithValue:value name:@""];
|
|
||||||
}
|
|
||||||
[items addObject:item];
|
|
||||||
}
|
|
||||||
recent.items = items.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBEmojiCategory *)categoryForIdentifier:(NSString *)identifier {
|
|
||||||
if (identifier.length == 0) return nil;
|
|
||||||
for (KBEmojiCategory *cat in self.categoriesInternal) {
|
|
||||||
if ([cat.identifier isEqualToString:identifier]) {
|
|
||||||
return cat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
//
|
|
||||||
// KBSuggestionEngine.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
/// Simple local suggestion engine (prefix match + lightweight ranking).
|
|
||||||
@interface KBSuggestionEngine : NSObject
|
|
||||||
|
|
||||||
+ (instancetype)shared;
|
|
||||||
|
|
||||||
/// Returns suggestions for prefix (lowercase expected), limited by count.
|
|
||||||
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit;
|
|
||||||
|
|
||||||
/// Record a selection to slightly boost ranking next time.
|
|
||||||
- (void)recordSelection:(NSString *)word;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//
|
|
||||||
// KBSuggestionEngine.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBSuggestionEngine.h"
|
|
||||||
#import "KBConfig.h"
|
|
||||||
|
|
||||||
@interface KBSuggestionEngine ()
|
|
||||||
@property (nonatomic, copy) NSArray<NSString *> *words;
|
|
||||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *selectionCounts;
|
|
||||||
@property (nonatomic, strong) NSSet<NSString *> *priorityWords;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBSuggestionEngine
|
|
||||||
|
|
||||||
+ (instancetype)shared {
|
|
||||||
static KBSuggestionEngine *engine;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
engine = [[KBSuggestionEngine alloc] init];
|
|
||||||
});
|
|
||||||
return engine;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_selectionCounts = [NSMutableDictionary dictionary];
|
|
||||||
NSArray<NSString *> *defaults = [self.class kb_defaultWords];
|
|
||||||
_priorityWords = [NSSet setWithArray:defaults];
|
|
||||||
_words = [self kb_loadWords];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
|
||||||
if (prefix.length == 0 || limit == 0) { return @[]; }
|
|
||||||
NSString *lower = prefix.lowercaseString;
|
|
||||||
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
|
||||||
|
|
||||||
for (NSString *word in self.words) {
|
|
||||||
if ([word hasPrefix:lower]) {
|
|
||||||
[matches addObject:word];
|
|
||||||
if (matches.count >= limit * 3) {
|
|
||||||
// Avoid scanning too many matches for long lists.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.count == 0) { return @[]; }
|
|
||||||
|
|
||||||
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
|
||||||
NSInteger ca = self.selectionCounts[a].integerValue;
|
|
||||||
NSInteger cb = self.selectionCounts[b].integerValue;
|
|
||||||
if (ca != cb) {
|
|
||||||
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
|
||||||
}
|
|
||||||
BOOL pa = [self.priorityWords containsObject:a];
|
|
||||||
BOOL pb = [self.priorityWords containsObject:b];
|
|
||||||
if (pa != pb) {
|
|
||||||
return pa ? NSOrderedAscending : NSOrderedDescending;
|
|
||||||
}
|
|
||||||
return [a compare:b];
|
|
||||||
}];
|
|
||||||
|
|
||||||
if (matches.count > limit) {
|
|
||||||
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
|
||||||
}
|
|
||||||
return matches.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)recordSelection:(NSString *)word {
|
|
||||||
if (word.length == 0) { return; }
|
|
||||||
NSString *key = word.lowercaseString;
|
|
||||||
NSInteger count = self.selectionCounts[key].integerValue + 1;
|
|
||||||
self.selectionCounts[key] = @(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Defaults
|
|
||||||
|
|
||||||
- (NSArray<NSString *> *)kb_loadWords {
|
|
||||||
NSMutableOrderedSet<NSString *> *set = [[NSMutableOrderedSet alloc] init];
|
|
||||||
[set addObjectsFromArray:[self.class kb_defaultWords]];
|
|
||||||
|
|
||||||
NSArray<NSString *> *paths = [self kb_wordListPaths];
|
|
||||||
for (NSString *path in paths) {
|
|
||||||
if (path.length == 0) { continue; }
|
|
||||||
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
|
|
||||||
if (content.length == 0) { continue; }
|
|
||||||
NSArray<NSString *> *lines = [content componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
|
||||||
for (NSString *line in lines) {
|
|
||||||
NSString *word = [self kb_sanitizedWordFromLine:line];
|
|
||||||
if (word.length == 0) { continue; }
|
|
||||||
[set addObject:word];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSArray<NSString *> *result = set.array ?: @[];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<NSString *> *)kb_wordListPaths {
|
|
||||||
NSMutableArray<NSString *> *paths = [NSMutableArray array];
|
|
||||||
// 1) App Group override (allows server-downloaded large list).
|
|
||||||
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
|
||||||
if (containerURL.path.length > 0) {
|
|
||||||
NSString *groupPath = [[containerURL path] stringByAppendingPathComponent:@"kb_words.txt"];
|
|
||||||
[paths addObject:groupPath];
|
|
||||||
}
|
|
||||||
// 2) Bundle fallback.
|
|
||||||
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"kb_words" ofType:@"txt"];
|
|
||||||
if (bundlePath.length > 0) {
|
|
||||||
[paths addObject:bundlePath];
|
|
||||||
}
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_sanitizedWordFromLine:(NSString *)line {
|
|
||||||
NSString *trimmed = [[line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
|
|
||||||
if (trimmed.length == 0) { return @""; }
|
|
||||||
static NSCharacterSet *letters = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
letters = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz"];
|
|
||||||
});
|
|
||||||
for (NSUInteger i = 0; i < trimmed.length; i++) {
|
|
||||||
if (![letters characterIsMember:[trimmed characterAtIndex:i]]) {
|
|
||||||
return @"";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (NSArray<NSString *> *)kb_defaultWords {
|
|
||||||
return @[
|
|
||||||
@"a", @"an", @"and", @"are", @"as", @"at",
|
|
||||||
@"app", @"ap", @"apple", @"apply", @"april", @"application",
|
|
||||||
@"about", @"above", @"after", @"again", @"against", @"all",
|
|
||||||
@"am", @"among", @"amount", @"any", @"around",
|
|
||||||
@"be", @"because", @"been", @"before", @"being", @"below",
|
|
||||||
@"best", @"between", @"both", @"but", @"by",
|
|
||||||
@"can", @"could", @"come", @"common", @"case",
|
|
||||||
@"do", @"does", @"down", @"day",
|
|
||||||
@"each", @"early", @"end", @"even", @"every",
|
|
||||||
@"for", @"from", @"first", @"found", @"free",
|
|
||||||
@"get", @"good", @"great", @"go",
|
|
||||||
@"have", @"has", @"had", @"help", @"how",
|
|
||||||
@"in", @"is", @"it", @"if", @"into",
|
|
||||||
@"just", @"keep", @"kind", @"know",
|
|
||||||
@"like", @"look", @"long", @"last",
|
|
||||||
@"make", @"more", @"most", @"my",
|
|
||||||
@"new", @"no", @"not", @"now",
|
|
||||||
@"of", @"on", @"one", @"or", @"other", @"our", @"out",
|
|
||||||
@"people", @"place", @"please",
|
|
||||||
@"quick", @"quite",
|
|
||||||
@"right", @"read", @"real",
|
|
||||||
@"see", @"say", @"some", @"such", @"so",
|
|
||||||
@"the", @"to", @"this", @"that", @"them", @"then", @"there", @"they", @"these", @"time",
|
|
||||||
@"use", @"up", @"under",
|
|
||||||
@"very",
|
|
||||||
@"we", @"with", @"what", @"when", @"where", @"who", @"why", @"will", @"would",
|
|
||||||
@"you", @"your"
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
//
|
|
||||||
// KBChatMessage.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBChatMessage : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, copy) NSString *text;
|
|
||||||
@property (nonatomic, assign) BOOL outgoing;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *audioFilePath;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *avatarURL;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *displayName;
|
|
||||||
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
|
||||||
|
|
||||||
/// 是否处于加载状态
|
|
||||||
@property (nonatomic, assign) BOOL isLoading;
|
|
||||||
/// 是否完成(用于打字机效果)
|
|
||||||
@property (nonatomic, assign) BOOL isComplete;
|
|
||||||
/// 是否需要打字机效果
|
|
||||||
@property (nonatomic, assign) BOOL needsTypewriterEffect;
|
|
||||||
/// 音频 ID(用于异步加载音频)
|
|
||||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
|
||||||
/// 音频数据(缓存)
|
|
||||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
|
||||||
/// 音频时长(秒)
|
|
||||||
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
|
||||||
|
|
||||||
+ (instancetype)messageWithText:(NSString *)text
|
|
||||||
outgoing:(BOOL)outgoing
|
|
||||||
audioFilePath:(nullable NSString *)audioFilePath;
|
|
||||||
|
|
||||||
/// 创建用户消息
|
|
||||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
|
||||||
|
|
||||||
/// 创建 AI 消息(带 audioId)
|
|
||||||
+ (instancetype)assistantMessageWithText:(NSString *)text
|
|
||||||
audioId:(nullable NSString *)audioId;
|
|
||||||
|
|
||||||
/// 创建加载中的 AI 消息
|
|
||||||
+ (instancetype)loadingAssistantMessage;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
//
|
|
||||||
// KBChatMessage.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBChatMessage.h"
|
|
||||||
|
|
||||||
@implementation KBChatMessage
|
|
||||||
|
|
||||||
+ (instancetype)messageWithText:(NSString *)text
|
|
||||||
outgoing:(BOOL)outgoing
|
|
||||||
audioFilePath:(NSString *)audioFilePath {
|
|
||||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
|
||||||
msg.text = text ?: @"";
|
|
||||||
msg.outgoing = outgoing;
|
|
||||||
msg.audioFilePath = audioFilePath;
|
|
||||||
msg.isComplete = YES;
|
|
||||||
msg.isLoading = NO;
|
|
||||||
msg.needsTypewriterEffect = NO;
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)userMessageWithText:(NSString *)text {
|
|
||||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
|
||||||
msg.text = text ?: @"";
|
|
||||||
msg.outgoing = YES;
|
|
||||||
msg.isComplete = YES;
|
|
||||||
msg.isLoading = NO;
|
|
||||||
msg.needsTypewriterEffect = NO;
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)assistantMessageWithText:(NSString *)text
|
|
||||||
audioId:(NSString *)audioId {
|
|
||||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
|
||||||
msg.text = text ?: @"";
|
|
||||||
msg.outgoing = NO;
|
|
||||||
msg.audioId = audioId;
|
|
||||||
msg.isComplete = NO;
|
|
||||||
msg.isLoading = NO;
|
|
||||||
msg.needsTypewriterEffect = YES;
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)loadingAssistantMessage {
|
|
||||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
|
||||||
msg.text = @"";
|
|
||||||
msg.outgoing = NO;
|
|
||||||
msg.isComplete = NO;
|
|
||||||
msg.isLoading = YES;
|
|
||||||
msg.needsTypewriterEffect = NO;
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -16,35 +16,17 @@ typedef NS_ENUM(NSInteger, KBKeyType) {
|
|||||||
KBKeyTypeSpace, // 空格
|
KBKeyTypeSpace, // 空格
|
||||||
KBKeyTypeReturn, // 回车/发送
|
KBKeyTypeReturn, // 回车/发送
|
||||||
KBKeyTypeGlobe, // 系统地球键
|
KBKeyTypeGlobe, // 系统地球键
|
||||||
KBKeyTypeCustom, // 自定义功能占位(如 AI/Emoji)
|
KBKeyTypeCustom, // 自定义功能占位
|
||||||
KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换
|
KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换
|
||||||
};
|
};
|
||||||
|
|
||||||
FOUNDATION_EXPORT NSString * const KBKeyIdentifierEmojiPanel;
|
|
||||||
|
|
||||||
/// 字母键的大小写变体标记(非字母键使用 KBKeyCaseVariantNone)
|
|
||||||
typedef NS_ENUM(NSInteger, KBKeyCaseVariant) {
|
|
||||||
KBKeyCaseVariantNone = 0,
|
|
||||||
KBKeyCaseVariantLower = 1,
|
|
||||||
KBKeyCaseVariantUpper = 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
@interface KBKey : NSObject
|
@interface KBKey : NSObject
|
||||||
|
|
||||||
@property (nonatomic, assign) KBKeyType type;
|
@property (nonatomic, assign) KBKeyType type;
|
||||||
@property (nonatomic, copy) NSString *title; // 显示标题
|
@property (nonatomic, copy) NSString *title; // 显示标题
|
||||||
@property (nonatomic, copy) NSString *output; // 字符键插入的文本
|
@property (nonatomic, copy) NSString *output; // 字符键插入的文本
|
||||||
/// 逻辑按键标识,用于皮肤映射(如 @"letter_q" @"space" @"backspace")
|
|
||||||
@property (nonatomic, copy, nullable) NSString *identifier;
|
|
||||||
/// 字母键的大小写变体(便于皮肤为大小写准备不同图)
|
|
||||||
@property (nonatomic, assign) KBKeyCaseVariant caseVariant;
|
|
||||||
|
|
||||||
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output;
|
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output;
|
||||||
+ (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type;
|
+ (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type;
|
||||||
/// 通用构造方法:用于指定 identifier,便于皮肤做精细控制
|
|
||||||
+ (instancetype)keyWithIdentifier:(nullable NSString *)identifier
|
|
||||||
title:(NSString *)title
|
|
||||||
output:(NSString *)output
|
|
||||||
type:(KBKeyType)type;
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
#import "KBKey.h"
|
#import "KBKey.h"
|
||||||
|
|
||||||
NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
|
|
||||||
|
|
||||||
@implementation KBKey
|
@implementation KBKey
|
||||||
|
|
||||||
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output {
|
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output {
|
||||||
@@ -14,7 +12,6 @@ NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
|
|||||||
k.type = KBKeyTypeCharacter;
|
k.type = KBKeyTypeCharacter;
|
||||||
k.title = title ?: @"";
|
k.title = title ?: @"";
|
||||||
k.output = output ?: title ?: @"";
|
k.output = output ?: title ?: @"";
|
||||||
k.caseVariant = KBKeyCaseVariantNone;
|
|
||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,21 +20,8 @@ NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
|
|||||||
k.type = type;
|
k.type = type;
|
||||||
k.title = title ?: @"";
|
k.title = title ?: @"";
|
||||||
k.output = @"";
|
k.output = @"";
|
||||||
k.caseVariant = KBKeyCaseVariantNone;
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)keyWithIdentifier:(NSString *)identifier
|
|
||||||
title:(NSString *)title
|
|
||||||
output:(NSString *)output
|
|
||||||
type:(KBKeyType)type {
|
|
||||||
KBKey *k = [[KBKey alloc] init];
|
|
||||||
k.type = type;
|
|
||||||
k.identifier = identifier;
|
|
||||||
k.title = title ?: @"";
|
|
||||||
k.output = output ?: @"";
|
|
||||||
k.caseVariant = KBKeyCaseVariantNone;
|
|
||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardLayoutConfig.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// 键盘布局配置模型(由 JSON 驱动)
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBKeyboardLayoutMetrics : NSObject
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *rowSpacing;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *topInset;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *bottomInset;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *keyHeight;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *edgeInset;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *gap;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *letterWidth;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *controlWidth;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *sendWidth;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *symbolsWideWidth;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *symbolsSideWidth;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardLayoutFonts : NSObject
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *letter;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *digit;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *symbol;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *mode;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *space;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *send;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardKeyDef : NSObject
|
|
||||||
@property (nonatomic, copy, nullable) NSString *type;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *title;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *selectedTitle;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *symbolName;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *selectedSymbolName;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *font;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *width;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *backgroundColor;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardRowItem : NSObject
|
|
||||||
@property (nonatomic, copy, nullable) NSString *itemId;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *width;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
|
||||||
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardRowSegments : NSObject
|
|
||||||
@property (nonatomic, strong, nullable) NSArray *left;
|
|
||||||
@property (nonatomic, strong, nullable) NSArray *center;
|
|
||||||
@property (nonatomic, strong, nullable) NSArray *right;
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)leftItems;
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)centerItems;
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)rightItems;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardRowConfig : NSObject
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *height;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *insetLeft;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *insetRight;
|
|
||||||
@property (nonatomic, strong, nullable) NSNumber *gap;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *align;
|
|
||||||
@property (nonatomic, strong, nullable) NSArray *items;
|
|
||||||
@property (nonatomic, strong, nullable) KBKeyboardRowSegments *segments;
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)resolvedItems;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardLayout : NSObject
|
|
||||||
@property (nonatomic, strong, nullable) NSArray<KBKeyboardRowConfig *> *rows;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBKeyboardLayoutConfig : NSObject
|
|
||||||
@property (nonatomic, assign) CGFloat designWidth;
|
|
||||||
@property (nonatomic, strong, nullable) KBKeyboardLayoutMetrics *metrics;
|
|
||||||
@property (nonatomic, strong, nullable) KBKeyboardLayoutFonts *fonts;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *defaultKeyBackground;
|
|
||||||
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardKeyDef *> *keyDefs;
|
|
||||||
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardLayout *> *layouts;
|
|
||||||
|
|
||||||
+ (nullable instancetype)sharedConfig;
|
|
||||||
+ (nullable instancetype)configFromJSONData:(NSData *)data;
|
|
||||||
- (CGFloat)scaledValue:(CGFloat)designValue;
|
|
||||||
- (CGFloat)keyboardAreaDesignHeight;
|
|
||||||
- (CGFloat)keyboardAreaScaledHeight;
|
|
||||||
- (nullable KBKeyboardLayout *)layoutForName:(NSString *)name;
|
|
||||||
- (nullable KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier;
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardLayoutConfig.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardLayoutConfig.h"
|
|
||||||
#import <MJExtension/MJExtension.h>
|
|
||||||
#import "KBConfig.h"
|
|
||||||
|
|
||||||
static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_config";
|
|
||||||
|
|
||||||
@implementation KBKeyboardLayoutMetrics
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardLayoutFonts
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardKeyDef
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardRowItem
|
|
||||||
|
|
||||||
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
|
|
||||||
return @{ @"itemId": @"id" };
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw {
|
|
||||||
if (![raw isKindOfClass:[NSArray class]] || raw.count == 0) {
|
|
||||||
return @[];
|
|
||||||
}
|
|
||||||
NSMutableArray<KBKeyboardRowItem *> *items = [NSMutableArray arrayWithCapacity:raw.count];
|
|
||||||
for (id obj in raw) {
|
|
||||||
if ([obj isKindOfClass:[NSString class]]) {
|
|
||||||
KBKeyboardRowItem *item = [KBKeyboardRowItem new];
|
|
||||||
item.itemId = (NSString *)obj;
|
|
||||||
[items addObject:item];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
KBKeyboardRowItem *item = [KBKeyboardRowItem mj_objectWithKeyValues:obj];
|
|
||||||
if (item.itemId.length == 0) {
|
|
||||||
NSString *fallback = ((NSDictionary *)obj)[@"id"];
|
|
||||||
if ([fallback isKindOfClass:[NSString class]]) {
|
|
||||||
item.itemId = fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.itemId.length > 0) {
|
|
||||||
[items addObject:item];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardRowSegments
|
|
||||||
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)leftItems {
|
|
||||||
return [KBKeyboardRowItem itemsFromRawArray:self.left ?: @[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)centerItems {
|
|
||||||
return [KBKeyboardRowItem itemsFromRawArray:self.center ?: @[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)rightItems {
|
|
||||||
return [KBKeyboardRowItem itemsFromRawArray:self.right ?: @[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardRowConfig
|
|
||||||
|
|
||||||
- (NSArray<KBKeyboardRowItem *> *)resolvedItems {
|
|
||||||
return [KBKeyboardRowItem itemsFromRawArray:self.items ?: @[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardLayout
|
|
||||||
|
|
||||||
+ (NSDictionary *)mj_objectClassInArray {
|
|
||||||
return @{ @"rows": [KBKeyboardRowConfig class] };
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardLayoutConfig
|
|
||||||
|
|
||||||
+ (instancetype)sharedConfig {
|
|
||||||
static KBKeyboardLayoutConfig *config = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutConfigFileName ofType:@"json"];
|
|
||||||
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
|
||||||
config = data ? [KBKeyboardLayoutConfig configFromJSONData:data] : nil;
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)configFromJSONData:(NSData *)data {
|
|
||||||
if (data.length == 0) { return nil; }
|
|
||||||
NSError *error = nil;
|
|
||||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
|
||||||
if (error || ![json isKindOfClass:[NSDictionary class]]) {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
NSDictionary *dict = (NSDictionary *)json;
|
|
||||||
KBKeyboardLayoutConfig *config = [KBKeyboardLayoutConfig mj_objectWithKeyValues:dict];
|
|
||||||
|
|
||||||
NSDictionary *keyDefsRaw = dict[@"keyDefs"];
|
|
||||||
if ([keyDefsRaw isKindOfClass:[NSDictionary class]]) {
|
|
||||||
NSMutableDictionary<NSString *, KBKeyboardKeyDef *> *defs = [NSMutableDictionary dictionaryWithCapacity:keyDefsRaw.count];
|
|
||||||
[keyDefsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
|
||||||
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KBKeyboardKeyDef *def = [KBKeyboardKeyDef mj_objectWithKeyValues:obj];
|
|
||||||
if (def) {
|
|
||||||
defs[key] = def;
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
config.keyDefs = defs.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSDictionary *layoutsRaw = dict[@"layouts"];
|
|
||||||
if ([layoutsRaw isKindOfClass:[NSDictionary class]]) {
|
|
||||||
NSMutableDictionary<NSString *, KBKeyboardLayout *> *layouts = [NSMutableDictionary dictionaryWithCapacity:layoutsRaw.count];
|
|
||||||
[layoutsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
|
||||||
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KBKeyboardLayout *layout = [KBKeyboardLayout mj_objectWithKeyValues:obj];
|
|
||||||
if (layout) {
|
|
||||||
layouts[key] = layout;
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
config.layouts = layouts.copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)scaledValue:(CGFloat)designValue {
|
|
||||||
CGFloat baseWidth = (self.designWidth > 0.0) ? self.designWidth : KB_DESIGN_WIDTH;
|
|
||||||
CGFloat scale = KBScreenWidth() / baseWidth;
|
|
||||||
return designValue * scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)keyboardAreaDesignHeight {
|
|
||||||
KBKeyboardLayout *layout = [self layoutForName:@"letters"] ?: self.layouts.allValues.firstObject;
|
|
||||||
NSUInteger rowCount = layout.rows.count;
|
|
||||||
if (rowCount == 0) { return 0.0; }
|
|
||||||
|
|
||||||
CGFloat rowSpacing = self.metrics.rowSpacing.doubleValue;
|
|
||||||
CGFloat topInset = self.metrics.topInset.doubleValue;
|
|
||||||
CGFloat bottomInset = self.metrics.bottomInset.doubleValue;
|
|
||||||
|
|
||||||
CGFloat total = topInset + bottomInset + rowSpacing * (rowCount - 1);
|
|
||||||
for (KBKeyboardRowConfig *row in layout.rows) {
|
|
||||||
CGFloat height = row.height.doubleValue;
|
|
||||||
if (height <= 0.0) {
|
|
||||||
height = self.metrics.keyHeight.doubleValue;
|
|
||||||
}
|
|
||||||
if (height <= 0.0) { height = 40.0; }
|
|
||||||
total += height;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)keyboardAreaScaledHeight {
|
|
||||||
CGFloat designHeight = [self keyboardAreaDesignHeight];
|
|
||||||
return designHeight > 0.0 ? [self scaledValue:designHeight] : 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBKeyboardLayout *)layoutForName:(NSString *)name {
|
|
||||||
if (name.length == 0) { return nil; }
|
|
||||||
return self.layouts[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier {
|
|
||||||
if (identifier.length == 0) { return nil; }
|
|
||||||
return self.keyDefs[identifier];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionProduct.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// 订阅商品模型(键盘扩展专用),用于展示与主 App 相同的订阅列表。
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBKeyboardSubscriptionProduct : NSObject
|
|
||||||
/// 主键 id
|
|
||||||
@property (nonatomic, assign) NSInteger identifier;
|
|
||||||
/// Apple 商品编号
|
|
||||||
@property (nonatomic, copy, nullable) NSString *productId;
|
|
||||||
/// 商品名称,如 Monthly
|
|
||||||
@property (nonatomic, copy, nullable) NSString *name;
|
|
||||||
/// 单位,如 Subscription
|
|
||||||
@property (nonatomic, copy, nullable) NSString *unit;
|
|
||||||
/// 商品描述
|
|
||||||
@property (nonatomic, copy, nullable) NSString *productDescription;
|
|
||||||
/// 货币符号
|
|
||||||
@property (nonatomic, copy, nullable) NSString *currency;
|
|
||||||
/// 现价
|
|
||||||
@property (nonatomic, assign) double price;
|
|
||||||
/// 原价(如接口未返回,则回退为 price 的 1.25 倍)
|
|
||||||
@property (nonatomic, assign) double originPrice;
|
|
||||||
/// 有效期数值
|
|
||||||
@property (nonatomic, assign) NSInteger durationValue;
|
|
||||||
/// 有效期单位
|
|
||||||
@property (nonatomic, copy, nullable) NSString *durationUnit;
|
|
||||||
|
|
||||||
/// 标题(描述 > name+unit > name > unit)
|
|
||||||
- (NSString *)displayTitle;
|
|
||||||
/// 当前价格文本
|
|
||||||
- (NSString *)priceDisplayText;
|
|
||||||
/// 划线价文本
|
|
||||||
- (nullable NSString *)strikePriceDisplayText;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionProduct.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardSubscriptionProduct.h"
|
|
||||||
#import <MJExtension/MJExtension.h>
|
|
||||||
#import "KBLocalizationManager.h"
|
|
||||||
|
|
||||||
@implementation KBKeyboardSubscriptionProduct
|
|
||||||
|
|
||||||
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
|
|
||||||
return @{
|
|
||||||
@"identifier": @"id",
|
|
||||||
@"productDescription": @"description",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)displayTitle {
|
|
||||||
if (self.productDescription.length > 0) {
|
|
||||||
return self.productDescription;
|
|
||||||
}
|
|
||||||
NSString *name = self.name ?: @"";
|
|
||||||
NSString *unit = self.unit ?: @"";
|
|
||||||
if (name.length && unit.length) {
|
|
||||||
return [NSString stringWithFormat:@"%@ %@", name, unit];
|
|
||||||
}
|
|
||||||
if (name.length) { return name; }
|
|
||||||
if (unit.length) { return unit; }
|
|
||||||
if (self.durationValue > 0 && self.durationUnit.length > 0) {
|
|
||||||
return [NSString stringWithFormat:@"%ld %@", (long)self.durationValue, self.durationUnit];
|
|
||||||
}
|
|
||||||
return KBLocalized(@"Subscription");
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)priceDisplayText {
|
|
||||||
double priceValue = self.price;
|
|
||||||
if (priceValue <= 0) {
|
|
||||||
return @"$0.00";
|
|
||||||
}
|
|
||||||
NSString *currency = self.currency.length ? self.currency : @"$";
|
|
||||||
return [NSString stringWithFormat:@"%@%.2f", currency, priceValue];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable NSString *)strikePriceDisplayText {
|
|
||||||
double rawValue = self.originPrice;
|
|
||||||
if (rawValue <= 0 && self.price > 0) {
|
|
||||||
rawValue = self.price * 1.25;
|
|
||||||
}
|
|
||||||
if (rawValue <= 0) { return nil; }
|
|
||||||
NSString *currency = self.currency.length ? self.currency : @"$";
|
|
||||||
return [NSString stringWithFormat:@"%@%.2f", currency, rawValue];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -19,15 +19,8 @@ typedef NS_ERROR_ENUM(KBNetworkErrorDomain, KBNetworkError) {
|
|||||||
KBNetworkErrorDecodeFailed = 4,
|
KBNetworkErrorDecodeFailed = 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// JSON 回调(扩展侧目前很少使用 JSON,可按需扩展)
|
/// 简单的 JSON 回调:json 为 NSDictionary/NSArray 或者在非 JSON 情况下返回 NSData
|
||||||
typedef void(^KBNetworkCompletion)(NSDictionary *_Nullable json,
|
typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error);
|
||||||
NSURLResponse * _Nullable response,
|
|
||||||
NSError * _Nullable error);
|
|
||||||
|
|
||||||
/// 二进制回调:用于下载 zip、图片等原始数据
|
|
||||||
typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
|
||||||
NSURLResponse *_Nullable response,
|
|
||||||
NSError *_Nullable error);
|
|
||||||
|
|
||||||
@interface KBNetworkManager : NSObject
|
@interface KBNetworkManager : NSObject
|
||||||
|
|
||||||
@@ -52,27 +45,13 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
|||||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||||
completion:(KBNetworkCompletion)completion;
|
completion:(KBNetworkCompletion)completion;
|
||||||
|
|
||||||
/// GET 原始二进制数据(不做 JSON 解析)
|
|
||||||
- (nullable NSURLSessionDataTask *)GETData:(NSString *)path
|
|
||||||
parameters:(nullable NSDictionary *)parameters
|
|
||||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
|
||||||
completion:(KBNetworkDataCompletion)completion;
|
|
||||||
|
|
||||||
/// POST JSON 请求,jsonBody 会以 application/json 发送
|
/// POST JSON 请求,jsonBody 会以 application/json 发送
|
||||||
- (nullable NSURLSessionDataTask *)POST:(NSString *)path
|
- (nullable NSURLSessionDataTask *)POST:(NSString *)path
|
||||||
jsonBody:(nullable id)jsonBody
|
jsonBody:(nullable id)jsonBody
|
||||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||||
completion:(KBNetworkCompletion)completion;
|
completion:(KBNetworkCompletion)completion;
|
||||||
|
|
||||||
/// POST multipart 上传文件(常用于语音/图片等文件)
|
|
||||||
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
|
|
||||||
fileURL:(NSURL *)fileURL
|
|
||||||
name:(NSString *)name
|
|
||||||
mimeType:(NSString *)mimeType
|
|
||||||
parameters:(nullable NSDictionary *)parameters
|
|
||||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
|
||||||
completion:(KBNetworkCompletion)completion;
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
#import "KBNetworkManager.h"
|
#import "KBNetworkManager.h"
|
||||||
#import "AFNetworking.h"
|
#import "AFNetworking.h"
|
||||||
#import "KBAuthManager.h"
|
#import "KBAuthManager.h"
|
||||||
//#import "KBUserSessionManager.h"
|
|
||||||
#import "KBSignUtils.h"
|
|
||||||
NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||||
|
|
||||||
@interface KBNetworkManager ()
|
@interface KBNetworkManager ()
|
||||||
@@ -27,87 +26,37 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
_enabled = NO; // 键盘扩展默认无网络能力,需外部显式开启
|
_enabled = NO; // 键盘扩展默认无网络能力,需外部显式开启
|
||||||
_timeout = 10.0;
|
_timeout = 10.0;
|
||||||
// 默认请求头:Accept 任意类型 + 使用项目多语言管理器设置 Accept-Language
|
_defaultHeaders = @{ @"Accept": @"application/json" };
|
||||||
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
|
||||||
// NSString *token = [KBUserSessionManager shared].accessToken ? [KBUserSessionManager shared].accessToken : @"";
|
|
||||||
_defaultHeaders = @{
|
|
||||||
@"Accept": @"*/*",
|
|
||||||
@"Accept-Language": lang
|
|
||||||
};
|
|
||||||
// 设置基础域名,路径可相对该地址拼接
|
// 设置基础域名,路径可相对该地址拼接
|
||||||
_baseURL = [NSURL URLWithString:KB_BASE_URL];
|
_baseURL = [NSURL URLWithString:KB_BASE_URL];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
|
||||||
|
|
||||||
NSString *appId = @"loveKeyboard";
|
|
||||||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
|
||||||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
|
||||||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
|
||||||
// 1. 组装参与签名的所有参数
|
|
||||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
|
||||||
signParams[@"appId"] = appId;
|
|
||||||
signParams[@"timestamp"] = timestamp;
|
|
||||||
signParams[@"nonce"] = nonce;
|
|
||||||
// 把 body 里的字段也加入签名参数
|
|
||||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
|
||||||
if ([obj isKindOfClass:[NSString class]]) {
|
|
||||||
signParams[key] = obj;
|
|
||||||
} else {
|
|
||||||
signParams[key] = [obj description];
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
|
||||||
|
|
||||||
// 将签名相关字段合并进默认请求头
|
|
||||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
|
||||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
|
||||||
|
|
||||||
if (sign.length > 0) {
|
|
||||||
headers[@"X-Sign"] = sign;
|
|
||||||
}
|
|
||||||
headers[@"X-App-Id"] = appId;
|
|
||||||
headers[@"X-Timestamp"] = timestamp;
|
|
||||||
headers[@"X-Nonce"] = nonce;
|
|
||||||
|
|
||||||
// 触发 copy 语义,确保对外仍是不可变字典
|
|
||||||
self.defaultHeaders = headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Public
|
#pragma mark - Public
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)GET:(NSString *)path
|
- (NSURLSessionDataTask *)GET:(NSString *)path
|
||||||
parameters:(NSDictionary *)parameters
|
parameters:(NSDictionary *)parameters
|
||||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||||
completion:(KBNetworkCompletion)completion {
|
completion:(KBNetworkCompletion)completion {
|
||||||
[self getSignWithParare:parameters];
|
|
||||||
if (![self ensureEnabled:completion]) return nil;
|
if (![self ensureEnabled:completion]) return nil;
|
||||||
NSString *urlString = [self buildURLStringWithPath:path];
|
NSString *urlString = [self buildURLStringWithPath:path];
|
||||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||||
// 使用 AFHTTPRequestSerializer 生成带参数与头的请求
|
// 使用 AFHTTPRequestSerializer 生成带参数与头的请求
|
||||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||||
serializer.timeoutInterval = self.timeout;
|
serializer.timeoutInterval = self.timeout;
|
||||||
NSError *serror = nil;
|
|
||||||
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
|
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
|
||||||
URLString:urlString
|
URLString:urlString
|
||||||
parameters:parameters
|
parameters:parameters
|
||||||
error:&serror];
|
error:NULL];
|
||||||
if (serror || !req) {
|
|
||||||
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
return [self startAFTaskWithRequest:req completion:completion];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)POST:(NSString *)path
|
- (NSURLSessionDataTask *)POST:(NSString *)path
|
||||||
jsonBody:(id)jsonBody
|
jsonBody:(id)jsonBody
|
||||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||||
completion:(KBNetworkCompletion)completion {
|
completion:(KBNetworkCompletion)completion {
|
||||||
[self getSignWithParare:jsonBody];
|
|
||||||
|
|
||||||
if (![self ensureEnabled:completion]) return nil;
|
if (![self ensureEnabled:completion]) return nil;
|
||||||
NSString *urlString = [self buildURLStringWithPath:path];
|
NSString *urlString = [self buildURLStringWithPath:path];
|
||||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||||
@@ -121,127 +70,14 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
error:&error];
|
error:&error];
|
||||||
if (error) { if (completion) completion(nil, nil, error); return nil; }
|
if (error) { if (completion) completion(nil, nil, error); return nil; }
|
||||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
return [self startAFTaskWithRequest:req completion:completion];
|
||||||
}
|
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
|
||||||
fileURL:(NSURL *)fileURL
|
|
||||||
name:(NSString *)name
|
|
||||||
mimeType:(NSString *)mimeType
|
|
||||||
parameters:(NSDictionary *)parameters
|
|
||||||
headers:(NSDictionary<NSString *, NSString *> *)headers
|
|
||||||
completion:(KBNetworkCompletion)completion {
|
|
||||||
[self getSignWithParare:parameters];
|
|
||||||
if (![self ensureEnabled:completion]) return nil;
|
|
||||||
NSString *urlString = [self buildURLStringWithPath:path];
|
|
||||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
|
||||||
if (!fileURL) {
|
|
||||||
if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
|
||||||
serializer.timeoutInterval = self.timeout;
|
|
||||||
NSError *error = nil;
|
|
||||||
NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
|
|
||||||
URLString:urlString
|
|
||||||
parameters:parameters
|
|
||||||
constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
|
|
||||||
NSString *safeName = (name.length > 0) ? name : @"file";
|
|
||||||
NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
|
|
||||||
NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
|
|
||||||
[formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
|
|
||||||
} error:&error];
|
|
||||||
if (error || !req) {
|
|
||||||
if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
|
||||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
|
||||||
NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
|
||||||
if (error) {
|
|
||||||
if (completion) completion(nil, response, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSData *data = (NSData *)responseObject;
|
|
||||||
if (![data isKindOfClass:[NSData class]]) {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSString *ct = nil;
|
|
||||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
|
||||||
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
|
||||||
}
|
|
||||||
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
|
||||||
if (!looksJSON) {
|
|
||||||
const unsigned char *bytes = data.bytes;
|
|
||||||
NSUInteger len = data.length;
|
|
||||||
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
|
||||||
unsigned char c = bytes[i];
|
|
||||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
|
||||||
looksJSON = (c == '{' || c == '[');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (looksJSON) {
|
|
||||||
NSError *jsonErr = nil;
|
|
||||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
|
||||||
if (jsonErr) {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (completion) completion((NSDictionary *)json, response, nil);
|
|
||||||
} else {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
[task resume];
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
|
||||||
parameters:(NSDictionary *)parameters
|
|
||||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
|
||||||
completion:(KBNetworkDataCompletion)completion {
|
|
||||||
[self getSignWithParare:parameters];
|
|
||||||
if (!self.isEnabled) {
|
|
||||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
|
||||||
code:KBNetworkErrorDisabled
|
|
||||||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
|
|
||||||
if (completion) completion(nil, nil, e);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
NSString *urlString = [self buildURLStringWithPath:path];
|
|
||||||
if (!urlString) {
|
|
||||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
|
||||||
code:KBNetworkErrorInvalidURL
|
|
||||||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}];
|
|
||||||
if (completion) completion(nil, nil, e);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
|
||||||
serializer.timeoutInterval = self.timeout;
|
|
||||||
NSError *serror = nil;
|
|
||||||
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
|
|
||||||
URLString:urlString
|
|
||||||
parameters:parameters
|
|
||||||
error:&serror];
|
|
||||||
if (serror || !req) {
|
|
||||||
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
|
||||||
return [self startAFDataTaskWithRequest:req completion:completion];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Core
|
#pragma mark - Core
|
||||||
|
|
||||||
- (BOOL)ensureEnabled:(KBNetworkCompletion)completion {
|
- (BOOL)ensureEnabled:(KBNetworkCompletion)completion {
|
||||||
if (!self.isEnabled) {
|
if (!self.isEnabled) {
|
||||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
|
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: @"网络未启用(可能未开启完全访问)"}];
|
||||||
if (completion) completion(nil, nil, e);
|
if (completion) completion(nil, nil, e);
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
@@ -254,12 +90,7 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
if (self.baseURL) {
|
if (self.baseURL) {
|
||||||
// 统一为目录型 base(以 / 结尾),并剥掉 path 的前导 /,避免覆盖 base 路径
|
return [NSURL URLWithString:path relativeToURL:self.baseURL].absoluteURL.absoluteString;
|
||||||
NSString *base = self.baseURL.absoluteString ?: @"";
|
|
||||||
if (![base hasSuffix:@"/"]) { base = [base stringByAppendingString:@"/"]; }
|
|
||||||
NSURL *dirBase = [NSURL URLWithString:base];
|
|
||||||
NSString *relative = ([path hasPrefix:@"/"]) ? [path substringFromIndex:1] : path;
|
|
||||||
return [NSURL URLWithString:relative relativeToURL:dirBase].absoluteURL.absoluteString;
|
|
||||||
}
|
}
|
||||||
return path; // 当无 baseURL 且 path 不是完整 URL 时,让 AFN 处理(可能失败)
|
return path; // 当无 baseURL 且 path 不是完整 URL 时,让 AFN 处理(可能失败)
|
||||||
}
|
}
|
||||||
@@ -267,12 +98,6 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
- (void)applyHeaders:(NSDictionary<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
|
- (void)applyHeaders:(NSDictionary<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
|
||||||
// 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。
|
// 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。
|
||||||
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
|
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
|
||||||
NSString *token = [KBAuthManager shared].current.accessToken;
|
|
||||||
if (token.length > 0) {
|
|
||||||
all[@"auth-token"] = token;
|
|
||||||
} else {
|
|
||||||
[all removeObjectForKey:@"auth-token"];
|
|
||||||
}
|
|
||||||
NSDictionary *auth = [[KBAuthManager shared] authorizationHeader];
|
NSDictionary *auth = [[KBAuthManager shared] authorizationHeader];
|
||||||
[auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }];
|
[auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }];
|
||||||
if (contentType) all[@"Content-Type"] = contentType;
|
if (contentType) all[@"Content-Type"] = contentType;
|
||||||
@@ -280,82 +105,42 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
[all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }];
|
[all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)startAFJSONTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
|
- (NSURLSessionDataTask *)startAFTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
|
||||||
// 响应先用原始数据返回,再按 Content-Type 解析 JSON(与原实现一致)
|
// 响应先用原始数据返回,再按 Content-Type 解析 JSON(与原实现一致)
|
||||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||||
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||||
// AFN 默认对非 2xx 的状态码返回 error;这里直接回调上层
|
if (error) { if (completion) completion(nil, response, error); return; }
|
||||||
if (error) {
|
|
||||||
if (completion) completion(nil, response, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSData *data = (NSData *)responseObject;
|
NSData *data = (NSData *)responseObject;
|
||||||
if (![data isKindOfClass:[NSData class]]) {
|
if (![data isKindOfClass:[NSData class]]) {
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:@"无数据"}]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
NSString *ct = nil;
|
NSString *ct = nil;
|
||||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||||
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
||||||
}
|
}
|
||||||
// 更宽松的 JSON 判定:Content-Type 里包含 json;或首字符是 { / [
|
BOOL looksJSON = (ct && [ct.lowercaseString containsString:@"application/json"]);
|
||||||
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
|
||||||
if (!looksJSON) {
|
|
||||||
// 内容嗅探(不依赖服务端声明)
|
|
||||||
const unsigned char *bytes = data.bytes;
|
|
||||||
NSUInteger len = data.length;
|
|
||||||
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
|
||||||
unsigned char c = bytes[i];
|
|
||||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
|
||||||
looksJSON = (c == '{' || c == '[');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (looksJSON) {
|
if (looksJSON) {
|
||||||
NSError *jsonErr = nil;
|
NSError *jsonErr = nil;
|
||||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
||||||
if (jsonErr) {
|
if (jsonErr) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:@"JSON解析失败"}]); return; }
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
|
if (completion) completion(json, response, nil);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (completion) completion((NSDictionary *)json, response, nil);
|
|
||||||
} else {
|
} else {
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
if (completion) completion(data, response, nil);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
[task resume];
|
[task resume];
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)startAFDataTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkDataCompletion)completion {
|
|
||||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
|
||||||
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
|
||||||
if (error) {
|
|
||||||
if (completion) completion(nil, response, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSData *data = (NSData *)responseObject;
|
|
||||||
if (![data isKindOfClass:[NSData class]]) {
|
|
||||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (completion) completion(data, response, nil);
|
|
||||||
}];
|
|
||||||
[task resume];
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - AFHTTPSessionManager
|
#pragma mark - AFHTTPSessionManager
|
||||||
|
|
||||||
- (AFHTTPSessionManager *)manager {
|
- (AFHTTPSessionManager *)manager {
|
||||||
if (!_manager) {
|
if (!_manager) {
|
||||||
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||||
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||||
// 不在会话级别设置超时,避免与 per-request 的 serializer.timeoutInterval 产生不一致
|
cfg.timeoutIntervalForRequest = self.timeout;
|
||||||
|
cfg.timeoutIntervalForResource = MAX(self.timeout, 30.0);
|
||||||
if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; }
|
if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; }
|
||||||
_manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg];
|
_manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg];
|
||||||
// 默认不使用 JSON 解析器,保持原生数据,再按需解析
|
// 默认不使用 JSON 解析器,保持原生数据,再按需解析
|
||||||
@@ -367,12 +152,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
#pragma mark - Private helpers
|
#pragma mark - Private helpers
|
||||||
|
|
||||||
- (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion {
|
- (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion {
|
||||||
NSString *msg = KBLocalized(@"Network error");
|
NSString *msg = @"网络错误";
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case KBNetworkErrorDisabled: msg = KBLocalized(@"Network disabled (Full Access may be off)"); break;
|
case KBNetworkErrorDisabled: msg = @"网络未启用(可能未开启完全访问)"; break;
|
||||||
case KBNetworkErrorInvalidURL: msg = KBLocalized(@"Invalid URL"); break;
|
case KBNetworkErrorInvalidURL: msg = @"无效的URL"; break;
|
||||||
case KBNetworkErrorInvalidResponse: msg = KBLocalized(@"Invalid response"); break;
|
case KBNetworkErrorInvalidResponse: msg = @"无效的响应"; break;
|
||||||
case KBNetworkErrorDecodeFailed: msg = KBLocalized(@"Parse failed"); break;
|
case KBNetworkErrorDecodeFailed: msg = @"解析失败"; break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
//
|
|
||||||
// KBStreamFetcher.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// 轻量网络流拉取器:支持纯文本分块与 SSE(text/event-stream) 两种形式的“边下边显”。
|
|
||||||
// - 增量解码:按 UTF-8 安全前缀逐步转成字符串,避免半个多字节字符导致阻塞/乱码
|
|
||||||
// - SSE 解析:按 \n\n 切事件,合并 data: 行,移除前缀,仅回传正文
|
|
||||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
|
||||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
|
||||||
//
|
|
||||||
// 暂未使用
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
typedef void (^KBStreamFetcherChunkHandler)(NSString *chunk);
|
|
||||||
typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
|
||||||
|
|
||||||
@interface KBStreamFetcher : NSObject <NSURLSessionDataDelegate>
|
|
||||||
|
|
||||||
// 便利构造
|
|
||||||
+ (instancetype)fetcherWithURL:(NSURL *)url;
|
|
||||||
|
|
||||||
// 必填:请求地址
|
|
||||||
@property (nonatomic, strong) NSURL *url;
|
|
||||||
|
|
||||||
/// HTTP Method,默认为 GET
|
|
||||||
@property (nonatomic, copy, nullable) NSString *httpMethod;
|
|
||||||
|
|
||||||
/// 自定义请求体(例如 POST 的 JSON body)
|
|
||||||
@property (nonatomic, strong, nullable) NSData *httpBody;
|
|
||||||
|
|
||||||
// 可选 Header
|
|
||||||
@property (nonatomic, copy, nullable) NSDictionary<NSString *, NSString *> *extraHeaders;
|
|
||||||
|
|
||||||
// 配置项(默认值见注释)
|
|
||||||
@property (nonatomic, assign) BOOL acceptEventStream; // 默认 NO;置 YES 时发送 Accept: text/event-stream
|
|
||||||
@property (nonatomic, assign) BOOL disableCompression; // 默认 YES;发送 Accept-Encoding: identity
|
|
||||||
@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES;将“/t”替换为“\t”
|
|
||||||
@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES;首次正文起始的“\t”删一个(忽略前导空白)
|
|
||||||
@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s
|
|
||||||
/// UI 刷新节奏:当一次回调内解析出多段(如多条 SSE 事件)时,按该间隔逐条回调(默认 0.10s)。
|
|
||||||
@property (nonatomic, assign) NSTimeInterval flushInterval;
|
|
||||||
/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。
|
|
||||||
@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace;
|
|
||||||
|
|
||||||
/// 调试日志:默认 YES。输出起止时刻、首包耗时、各分片内容(截断)等关键信息。
|
|
||||||
@property (nonatomic, assign) BOOL loggingEnabled;
|
|
||||||
|
|
||||||
// 回调(统一在主线程触发)
|
|
||||||
@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
|
|
||||||
@property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish;
|
|
||||||
|
|
||||||
// 控制
|
|
||||||
- (void)start;
|
|
||||||
- (void)cancel;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
//
|
|
||||||
// KBStreamFetcher.m
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBStreamFetcher.h"
|
|
||||||
#import "KBLocalizationManager.h"
|
|
||||||
|
|
||||||
@interface KBStreamFetcher () <NSURLSessionDataDelegate>
|
|
||||||
@property (nonatomic, strong) NSURLSession *session;
|
|
||||||
@property (nonatomic, strong) NSURLSessionDataTask *task;
|
|
||||||
@property (nonatomic, strong) NSMutableData *buffer; // 网络原始字节累加
|
|
||||||
@property (nonatomic, assign) NSStringEncoding textEncoding; // 推断得到的文本编码(默认 UTF-8)
|
|
||||||
@property (nonatomic, assign) BOOL isSSE; // 是否为 SSE 响应
|
|
||||||
@property (nonatomic, strong) NSMutableString *sseTextBuffer; // SSE 文本缓冲(已解码)
|
|
||||||
@property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE)
|
|
||||||
@property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量)
|
|
||||||
@property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”)
|
|
||||||
@property (nonatomic, assign) BOOL lastChunkEndedWithTab; // 上一个已输出分片是否以 "\t" 结尾(用于跨分片去除“\t 后空格”)
|
|
||||||
@property (nonatomic, strong) NSMutableArray<NSString *> *pendingQueue; // 待回调的分片(节流输出)
|
|
||||||
@property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调
|
|
||||||
@property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调)
|
|
||||||
@property (nonatomic, copy, nullable) NSString *pendingSplitTokenPrefix; // `<SPLIT>` 跨分片残留
|
|
||||||
|
|
||||||
// Metrics
|
|
||||||
@property (nonatomic, assign) CFAbsoluteTime tStart; // start() 被调用的时刻
|
|
||||||
@property (nonatomic, assign) CFAbsoluteTime tFirstByte; // 第一次拿到可解码内容
|
|
||||||
@property (nonatomic, assign) CFAbsoluteTime tFinish; // 完成/失败时刻
|
|
||||||
@property (nonatomic, assign) NSInteger emittedChunkCount; // 已输出分片数量
|
|
||||||
@end
|
|
||||||
|
|
||||||
// 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符
|
|
||||||
static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
|
||||||
const unsigned char *bytes = (const unsigned char *)data.bytes;
|
|
||||||
NSUInteger n = data.length;
|
|
||||||
if (n == 0) return 0;
|
|
||||||
NSInteger i = (NSInteger)n - 1;
|
|
||||||
while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { i--; } // 10xxxxxx 续字节
|
|
||||||
if (i < 0) return 0; // 全是续字节,等下次
|
|
||||||
unsigned char b = bytes[i];
|
|
||||||
NSUInteger expected = 1;
|
|
||||||
if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx
|
|
||||||
else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx
|
|
||||||
else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx
|
|
||||||
else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx
|
|
||||||
else return (NSUInteger)i; // 非法起始,截到 i 之前
|
|
||||||
NSUInteger remain = n - (NSUInteger)i;
|
|
||||||
return (remain >= expected) ? n : (NSUInteger)i;
|
|
||||||
}
|
|
||||||
|
|
||||||
static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
|
||||||
|
|
||||||
@implementation KBStreamFetcher
|
|
||||||
|
|
||||||
+ (instancetype)fetcherWithURL:(NSURL *)url {
|
|
||||||
KBStreamFetcher *f = [[self alloc] init];
|
|
||||||
f.url = url;
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_httpMethod = @"GET";
|
|
||||||
_acceptEventStream = NO;
|
|
||||||
_disableCompression = YES;
|
|
||||||
_treatSlashTAsTab = YES;
|
|
||||||
_trimLeadingTabOnce = YES;
|
|
||||||
_requestTimeout = 30.0;
|
|
||||||
_textEncoding = NSUTF8StringEncoding;
|
|
||||||
_buffer = [NSMutableData data];
|
|
||||||
_sseTextBuffer = [NSMutableString string];
|
|
||||||
_pendingQueue = [NSMutableArray array];
|
|
||||||
_flushInterval = 0.1;
|
|
||||||
_splitLargeDeltasOnWhitespace = YES;
|
|
||||||
_loggingEnabled = YES;
|
|
||||||
_pendingSplitTokenPrefix = nil;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)start {
|
|
||||||
if (!self.url) return;
|
|
||||||
[self cancel];
|
|
||||||
|
|
||||||
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration];
|
|
||||||
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
|
||||||
cfg.timeoutIntervalForRequest = self.requestTimeout;
|
|
||||||
cfg.timeoutIntervalForResource = MAX(self.requestTimeout, 60.0);
|
|
||||||
|
|
||||||
self.session = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]];
|
|
||||||
|
|
||||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:self.url];
|
|
||||||
NSString *method = self.httpMethod.length > 0 ? self.httpMethod : @"GET";
|
|
||||||
req.HTTPMethod = method;
|
|
||||||
if (self.httpBody.length > 0) {
|
|
||||||
req.HTTPBody = self.httpBody;
|
|
||||||
}
|
|
||||||
if (self.disableCompression) { [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; }
|
|
||||||
if (self.acceptEventStream) { [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; }
|
|
||||||
[req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
|
|
||||||
[req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
|
||||||
[self.extraHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *k, NSString *v, BOOL *stop){ [req setValue:v forHTTPHeaderField:k]; }];
|
|
||||||
|
|
||||||
// 状态复位
|
|
||||||
[self.buffer setLength:0];
|
|
||||||
[self.sseTextBuffer setString:@""];
|
|
||||||
self.isSSE = NO;
|
|
||||||
self.textEncoding = NSUTF8StringEncoding;
|
|
||||||
self.decodedPrefixBytes = 0;
|
|
||||||
self.deliveredCharCount = 0;
|
|
||||||
self.hasEmitted = NO;
|
|
||||||
self.lastChunkEndedWithTab = NO;
|
|
||||||
[self.pendingQueue removeAllObjects];
|
|
||||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
|
||||||
self.finishError = nil;
|
|
||||||
self.pendingSplitTokenPrefix = nil;
|
|
||||||
|
|
||||||
self.tStart = CFAbsoluteTimeGetCurrent();
|
|
||||||
self.tFirstByte = 0;
|
|
||||||
self.tFinish = 0;
|
|
||||||
self.emittedChunkCount = 0;
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
NSLog(@"[KBStream] start url=%@ acceptSSE=%@ disableCompression=%@ flush=%.0fms splitWords=%@",
|
|
||||||
self.url.absoluteString,
|
|
||||||
self.acceptEventStream?@"YES":@"NO",
|
|
||||||
self.disableCompression?@"YES":@"NO",
|
|
||||||
self.flushInterval*1000.0,
|
|
||||||
self.splitLargeDeltasOnWhitespace?@"YES":@"NO");
|
|
||||||
}
|
|
||||||
self.task = [self.session dataTaskWithRequest:req];
|
|
||||||
[self.task resume];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)cancel {
|
|
||||||
[self.task cancel];
|
|
||||||
self.task = nil;
|
|
||||||
[self.session invalidateAndCancel];
|
|
||||||
self.session = nil;
|
|
||||||
[self.buffer setLength:0];
|
|
||||||
[self.sseTextBuffer setString:@""];
|
|
||||||
self.decodedPrefixBytes = 0;
|
|
||||||
self.deliveredCharCount = 0;
|
|
||||||
self.hasEmitted = NO;
|
|
||||||
self.lastChunkEndedWithTab = NO;
|
|
||||||
[self.pendingQueue removeAllObjects];
|
|
||||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
|
||||||
self.finishError = nil;
|
|
||||||
self.pendingSplitTokenPrefix = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - NSURLSessionDataDelegate
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
|
|
||||||
self.isSSE = NO;
|
|
||||||
self.textEncoding = NSUTF8StringEncoding;
|
|
||||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
|
||||||
NSHTTPURLResponse *r = (NSHTTPURLResponse *)response;
|
|
||||||
NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"];
|
|
||||||
if ([ct isKindOfClass:[NSString class]]) {
|
|
||||||
NSString *lower = [ct lowercaseString];
|
|
||||||
if ([lower containsString:@"text/event-stream"]) self.isSSE = YES;
|
|
||||||
NSRange pos = [lower rangeOfString:@"charset="];
|
|
||||||
if (pos.location != NSNotFound) {
|
|
||||||
NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0];
|
|
||||||
if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) {
|
|
||||||
self.textEncoding = NSUTF8StringEncoding;
|
|
||||||
} else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) {
|
|
||||||
self.textEncoding = NSISOLatin1StringEncoding;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[self.sseTextBuffer setString:@""];
|
|
||||||
self.decodedPrefixBytes = 0;
|
|
||||||
if (completionHandler) completionHandler(NSURLSessionResponseAllow);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
|
|
||||||
if (data.length == 0) return;
|
|
||||||
[self.buffer appendData:data];
|
|
||||||
|
|
||||||
NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding)
|
|
||||||
? kb_validUTF8PrefixLen(self.buffer)
|
|
||||||
: self.buffer.length;
|
|
||||||
if (validLen > 0 && self.tFirstByte == 0) {
|
|
||||||
self.tFirstByte = CFAbsoluteTimeGetCurrent();
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
NSLog(@"[KBStream] first-bytes after %.0fms (encoding=%@, SSE=%@)",
|
|
||||||
(self.tFirstByte - self.tStart)*1000.0,
|
|
||||||
(self.textEncoding==NSUTF8StringEncoding?@"UTF-8":@"Other"),
|
|
||||||
self.isSSE?@"YES":@"NO");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (validLen == 0) return; // 末尾可能卡着半个字符
|
|
||||||
|
|
||||||
if (self.isSSE) {
|
|
||||||
if ((NSUInteger)self.decodedPrefixBytes < validLen) {
|
|
||||||
NSRange rng = NSMakeRange((NSUInteger)self.decodedPrefixBytes, validLen - (NSUInteger)self.decodedPrefixBytes);
|
|
||||||
NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.buffer.bytes + rng.location
|
|
||||||
length:rng.length
|
|
||||||
encoding:self.textEncoding];
|
|
||||||
if (piece.length > 0) {
|
|
||||||
[self.sseTextBuffer appendString:piece];
|
|
||||||
self.decodedPrefixBytes = (NSInteger)validLen;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 统一换行并按 SSE 事件 \n\n 切开
|
|
||||||
if (self.sseTextBuffer.length > 0) {
|
|
||||||
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
|
|
||||||
[self.sseTextBuffer setString:normalized];
|
|
||||||
while (1) {
|
|
||||||
NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; // 完整事件
|
|
||||||
if (sep.location == NSNotFound) break;
|
|
||||||
NSString *event = [self.sseTextBuffer substringToIndex:sep.location];
|
|
||||||
[self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)];
|
|
||||||
|
|
||||||
// 合并 data: 行为正文
|
|
||||||
NSArray<NSString *> *lines = [event componentsSeparatedByString:@"\n"];
|
|
||||||
NSMutableString *payload = [NSMutableString string];
|
|
||||||
for (NSString *ln in lines) {
|
|
||||||
if ([ln hasPrefix:@"data:"]) {
|
|
||||||
NSString *v = [ln substringFromIndex:5];
|
|
||||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
|
||||||
[payload appendString:v ?: @""];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.length > 0) {
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
|
||||||
}
|
|
||||||
NSString *llmText = nil;
|
|
||||||
if ([self processLLMChunkPayload:payload output:&llmText]) {
|
|
||||||
if (llmText.length > 0) { [self enqueueChunk:llmText]; }
|
|
||||||
} else {
|
|
||||||
[self enqueueChunk:payload];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非 SSE:直接对“可解码前缀”做增量输出
|
|
||||||
NSString *prefix = [[NSString alloc] initWithBytes:self.buffer.bytes length:validLen encoding:self.textEncoding];
|
|
||||||
if (!prefix) return;
|
|
||||||
if (self.deliveredCharCount < (NSInteger)prefix.length) {
|
|
||||||
NSString *delta = [prefix substringFromIndex:self.deliveredCharCount];
|
|
||||||
self.deliveredCharCount = prefix.length;
|
|
||||||
if (self.splitLargeDeltasOnWhitespace && delta.length > 16) {
|
|
||||||
// 按空格切词逐条回调(保留空格,使观感更自然)
|
|
||||||
NSArray<NSString *> *parts = [delta componentsSeparatedByString:@" "];
|
|
||||||
for (NSUInteger i = 0; i < parts.count; i++) {
|
|
||||||
NSString *w = parts[i];
|
|
||||||
if (w.length == 0) { [self enqueueChunk:@" "]; continue; }
|
|
||||||
if (i + 1 < parts.count) {
|
|
||||||
[self enqueueChunk:[w stringByAppendingString:@" "]];
|
|
||||||
} else {
|
|
||||||
[self enqueueChunk:w];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
[self enqueueChunk:delta];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
|
|
||||||
if (!error && self.isSSE && self.sseTextBuffer.length > 0) {
|
|
||||||
// 处理最后一条未以 \n\n 结束的事件
|
|
||||||
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
|
|
||||||
NSArray<NSString *> *lines = [normalized componentsSeparatedByString:@"\n"];
|
|
||||||
NSMutableString *payload = [NSMutableString string];
|
|
||||||
for (NSString *ln in lines) {
|
|
||||||
if ([ln hasPrefix:@"data:"]) {
|
|
||||||
NSString *v = [ln substringFromIndex:5];
|
|
||||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
|
||||||
[payload appendString:v ?: @""];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.length > 0) {
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
|
||||||
}
|
|
||||||
NSString *delta = nil;
|
|
||||||
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
|
||||||
delta = [payload substringFromIndex:self.deliveredCharCount];
|
|
||||||
} else {
|
|
||||||
delta = payload;
|
|
||||||
}
|
|
||||||
self.deliveredCharCount = payload.length;
|
|
||||||
if (delta.length > 0) {
|
|
||||||
NSString *llmText = nil;
|
|
||||||
if ([self processLLMChunkPayload:delta output:&llmText]) {
|
|
||||||
if (llmText.length > 0) { [self emitChunk:llmText]; }
|
|
||||||
} else {
|
|
||||||
[self emitChunk:delta];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (self.pendingSplitTokenPrefix.length > 0) {
|
|
||||||
NSString *carry = self.pendingSplitTokenPrefix;
|
|
||||||
self.pendingSplitTokenPrefix = nil;
|
|
||||||
if (carry.length > 0) { [self enqueueChunk:carry]; }
|
|
||||||
}
|
|
||||||
|
|
||||||
self.tFinish = CFAbsoluteTimeGetCurrent();
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
double t0 = (self.tFirstByte>0? (self.tFirstByte - self.tStart)*1000.0 : -1);
|
|
||||||
double t1 = (self.tFirstByte>0? (self.tFinish - self.tFirstByte)*1000.0 : -1);
|
|
||||||
double tt = (self.tFinish - self.tStart)*1000.0;
|
|
||||||
NSLog(@"[KBStream] finish chunks=%ld firstByte=%.0fms after start, tail=%.0fms, total=%.0fms error=%@",
|
|
||||||
(long)self.emittedChunkCount, t0, t1, tt, error);
|
|
||||||
}
|
|
||||||
// 若队列还有待输出内容,等队列清空再回调 finish
|
|
||||||
if (self.pendingQueue.count > 0) {
|
|
||||||
self.finishError = error;
|
|
||||||
[self startFlushTimerIfNeeded];
|
|
||||||
} else {
|
|
||||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); });
|
|
||||||
[self cancel];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Helpers
|
|
||||||
|
|
||||||
- (void)emitChunk:(NSString *)rawText {
|
|
||||||
if (rawText.length == 0) return;
|
|
||||||
// 调试:在任何处理之前打印后端“原始文本分片”,便于对照排查
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
// NSLog(@"[KBStream] RAW chunk#%ld len=%lu text=\"%@\"",
|
|
||||||
// (long)(self.emittedChunkCount + 1),
|
|
||||||
// (unsigned long)rawText.length,
|
|
||||||
// KBPrintableSnippet(rawText, 160));
|
|
||||||
}
|
|
||||||
NSString *text = rawText;
|
|
||||||
// 0) 规范化换行与段起始:去掉位于片段开头的 \r/\n;将 "\n\t"、"\r\n\t"、"\r\t" 归一为 "\t"
|
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"\r\n\t" withString:@"\t"];
|
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"\n\t" withString:@"\t"];
|
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"\r\t" withString:@"\t"];
|
|
||||||
while (text.length > 0) {
|
|
||||||
unichar c0 = [text characterAtIndex:0];
|
|
||||||
if (c0 == '\n' || c0 == '\r') { text = [text substringFromIndex:1]; continue; }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// 1) 统一处理 “/t” -> “\t”
|
|
||||||
if (self.treatSlashTAsTab) {
|
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"];
|
|
||||||
}
|
|
||||||
// 2) 仅在整体首段:去掉一个起始的 "\t",以及其后紧邻的一个空格(若存在)
|
|
||||||
if (!self.hasEmitted && self.trimLeadingTabOnce) {
|
|
||||||
if (text.length > 0 && [text characterAtIndex:0] == '\t') {
|
|
||||||
NSUInteger start = 1;
|
|
||||||
if (start < text.length && [text characterAtIndex:start] == ' ') start++;
|
|
||||||
text = [text substringFromIndex:start];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3) 从第二段开始:去掉每个段首的一个空格(即 “\t ” -> “\t”),跨分片也处理
|
|
||||||
if (text.length > 0) {
|
|
||||||
// 跨分片:若上个分片以 \t 结尾,本分片起始的一个或多个空格去掉一个
|
|
||||||
if (self.lastChunkEndedWithTab) {
|
|
||||||
NSUInteger j = 0;
|
|
||||||
while (j < text.length && [text characterAtIndex:j] == ' ') { j++; }
|
|
||||||
if (j > 0) {
|
|
||||||
text = [text substringFromIndex:1]; // 仅去一个空格
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 同一分片内:将 “\t ” 规范化为 “\t”(仅去一个空格)
|
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"];
|
|
||||||
}
|
|
||||||
if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; }
|
|
||||||
self.emittedChunkCount += 1;
|
|
||||||
if (self.loggingEnabled) {
|
|
||||||
NSLog(@"[KBStream] chunk#%ld len=%lu text=\"%@\"",
|
|
||||||
(long)self.emittedChunkCount, (unsigned long)text.length, KBPrintableSnippet(text, 160));
|
|
||||||
}
|
|
||||||
if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); });
|
|
||||||
self.hasEmitted = YES;
|
|
||||||
// 记录末尾是否为分段分隔符 \t(用于跨分片处理)
|
|
||||||
unichar lastc = [text characterAtIndex:text.length - 1];
|
|
||||||
self.lastChunkEndedWithTab = (lastc == '\t');
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)processLLMChunkPayload:(NSString *)payload output:(NSString * _Nullable __autoreleasing *)output {
|
|
||||||
if (output) { *output = nil; }
|
|
||||||
if (payload.length == 0) { return NO; }
|
|
||||||
NSData *jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding];
|
|
||||||
if (!jsonData) { return NO; }
|
|
||||||
NSError *jsonError = nil;
|
|
||||||
id obj = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&jsonError];
|
|
||||||
if (jsonError || ![obj isKindOfClass:[NSDictionary class]]) { return NO; }
|
|
||||||
NSString *type = ((NSDictionary *)obj)[@"type"];
|
|
||||||
if (![type isKindOfClass:[NSString class]]) { return NO; }
|
|
||||||
if ([type isEqualToString:@"llm_chunk"]) {
|
|
||||||
id dataValue = ((NSDictionary *)obj)[@"data"];
|
|
||||||
if (![dataValue isKindOfClass:[NSString class]]) {
|
|
||||||
if (output) { *output = @""; }
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
NSString *normalized = [self normalizedLLMDataString:(NSString *)dataValue];
|
|
||||||
if (output) { *output = normalized; }
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
if ([type isEqualToString:@"search_result"]) {
|
|
||||||
NSString *searchText = [self normalizedSearchResultString:((NSDictionary *)obj)[@"data"]];
|
|
||||||
if (output) { *output = searchText ?: @""; }
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
if ([type isEqualToString:@"done"]) {
|
|
||||||
if (output) { *output = @""; }
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)normalizedLLMDataString:(NSString *)dataString {
|
|
||||||
NSString *combined = dataString ?: @"";
|
|
||||||
if (self.pendingSplitTokenPrefix.length > 0) {
|
|
||||||
combined = [self.pendingSplitTokenPrefix stringByAppendingString:combined];
|
|
||||||
self.pendingSplitTokenPrefix = nil;
|
|
||||||
}
|
|
||||||
if (combined.length == 0) { return @""; }
|
|
||||||
NSString *result = [combined stringByReplacingOccurrencesOfString:kKBStreamSplitToken withString:@"\t"];
|
|
||||||
NSString *suffix = [self pendingSplitPrefixSuffixForString:result];
|
|
||||||
if (suffix.length > 0) {
|
|
||||||
self.pendingSplitTokenPrefix = suffix;
|
|
||||||
result = [result substringToIndex:result.length - suffix.length];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)normalizedSearchResultString:(id)dataValue {
|
|
||||||
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
|
|
||||||
NSArray *list = (NSArray *)dataValue;
|
|
||||||
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
|
||||||
for (NSUInteger i = 0; i < list.count; i++) {
|
|
||||||
id item = list[i];
|
|
||||||
NSString *payload = nil;
|
|
||||||
if ([item isKindOfClass:[NSDictionary class]]) {
|
|
||||||
id val = ((NSDictionary *)item)[@"payload"];
|
|
||||||
if ([val isKindOfClass:[NSString class]]) {
|
|
||||||
payload = (NSString *)val;
|
|
||||||
}
|
|
||||||
} else if ([item isKindOfClass:[NSString class]]) {
|
|
||||||
payload = (NSString *)item;
|
|
||||||
}
|
|
||||||
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
||||||
if (payload.length == 0) { continue; }
|
|
||||||
NSString *line = [NSString stringWithFormat:@"%lu. %@", (unsigned long)(segments.count + 1), payload];
|
|
||||||
[segments addObject:line];
|
|
||||||
}
|
|
||||||
if (segments.count == 0) { return @""; }
|
|
||||||
NSString *title = KBLocalized(@"Search result");
|
|
||||||
NSMutableString *text = [NSMutableString string];
|
|
||||||
[text appendString:@"\t"];
|
|
||||||
[text appendFormat:@"%@:", title.length > 0 ? title : @"Search result"];
|
|
||||||
for (NSString *line in segments) {
|
|
||||||
[text appendString:@"\t"];
|
|
||||||
[text appendString:line];
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)pendingSplitPrefixSuffixForString:(NSString *)text {
|
|
||||||
if (text.length == 0) { return @""; }
|
|
||||||
NSUInteger tokenLen = kKBStreamSplitToken.length;
|
|
||||||
if (tokenLen <= 1) { return @""; }
|
|
||||||
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
|
|
||||||
for (NSUInteger len = maxLen; len > 0; len--) {
|
|
||||||
NSString *suffix = [text substringFromIndex:text.length - len];
|
|
||||||
NSString *prefix = [kKBStreamSplitToken substringToIndex:len];
|
|
||||||
if ([suffix isEqualToString:prefix]) {
|
|
||||||
return suffix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Queue/Flush
|
|
||||||
|
|
||||||
- (void)enqueueChunk:(NSString *)s {
|
|
||||||
if (s.length == 0) return;
|
|
||||||
[self.pendingQueue addObject:s];
|
|
||||||
[self startFlushTimerIfNeeded];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)startFlushTimerIfNeeded {
|
|
||||||
if (self.flushTimer) return;
|
|
||||||
__weak typeof(self) weakSelf = self;
|
|
||||||
self.flushTimer = [NSTimer scheduledTimerWithTimeInterval:MAX(0.01, self.flushInterval)
|
|
||||||
repeats:YES
|
|
||||||
block:^(NSTimer * _Nonnull t) {
|
|
||||||
__strong typeof(weakSelf) self = weakSelf; if (!self) { [t invalidate]; return; }
|
|
||||||
if (self.pendingQueue.count == 0) {
|
|
||||||
[t invalidate]; self.flushTimer = nil;
|
|
||||||
if (self.finishError || self.finishError == nil) {
|
|
||||||
NSError *err = self.finishError; self.finishError = nil;
|
|
||||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(err); });
|
|
||||||
[self cancel];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSString *first = self.pendingQueue.firstObject;
|
|
||||||
[self.pendingQueue removeObjectAtIndex:0];
|
|
||||||
[self emitChunk:first];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Logging helpers
|
|
||||||
|
|
||||||
static NSString *KBPrintableSnippet(NSString *s, NSUInteger maxLen) {
|
|
||||||
if (!s) return @"";
|
|
||||||
NSString *x = [s stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
|
|
||||||
if (x.length > maxLen) {
|
|
||||||
x = [[x substringToIndex:maxLen] stringByAppendingString:@"…"];
|
|
||||||
}
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//
|
|
||||||
// NetworkStreamHandler.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// Created by Mac on 2025/11/12.
|
|
||||||
//
|
|
||||||
// 暂未使用
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSUInteger, NetworkStreamState) {
|
|
||||||
NetworkStreamStateIdle,
|
|
||||||
NetworkStreamStateConnecting,
|
|
||||||
NetworkStreamStateReceiving,
|
|
||||||
NetworkStreamStateCompleted,
|
|
||||||
NetworkStreamStateError
|
|
||||||
};
|
|
||||||
|
|
||||||
@class NetworkStreamHandler;
|
|
||||||
|
|
||||||
@protocol NetworkStreamDelegate <NSObject>
|
|
||||||
|
|
||||||
@optional
|
|
||||||
// 接收到数据块
|
|
||||||
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveData:(NSData *)data;
|
|
||||||
// 接收到文本数据(如果是文本内容)
|
|
||||||
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveText:(NSString *)text;
|
|
||||||
// 进度更新
|
|
||||||
- (void)networkStream:(NetworkStreamHandler *)stream downloadProgress:(float)progress;
|
|
||||||
// 状态改变
|
|
||||||
- (void)networkStream:(NetworkStreamHandler *)stream stateChanged:(NetworkStreamState)state;
|
|
||||||
// 请求完成
|
|
||||||
- (void)networkStream:(NetworkStreamHandler *)stream didCompleteWithError:(NSError * _Nullable)error;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
typedef void (^NetworkStreamProgressBlock)(float progress);
|
|
||||||
typedef void (^NetworkStreamDataBlock)(NSData *data);
|
|
||||||
typedef void (^NetworkStreamTextBlock)(NSString *text);
|
|
||||||
typedef void (^NetworkStreamCompletionBlock)(NSError * _Nullable error);
|
|
||||||
|
|
||||||
@interface NetworkStreamHandler : NSObject <NSURLSessionDataDelegate>
|
|
||||||
|
|
||||||
@property (nonatomic, weak) id<NetworkStreamDelegate> delegate;
|
|
||||||
@property (nonatomic, assign, readonly) NetworkStreamState state;
|
|
||||||
@property (nonatomic, strong, readonly) NSURLResponse *response;
|
|
||||||
@property (nonatomic, assign, readonly) long long totalBytesReceived;
|
|
||||||
|
|
||||||
// 初始化方法
|
|
||||||
- (instancetype)initWithURL:(NSURL *)url;
|
|
||||||
- (instancetype)initWithRequest:(NSURLRequest *)request;
|
|
||||||
|
|
||||||
// 开始请求(使用代理回调)
|
|
||||||
- (void)startRequest;
|
|
||||||
|
|
||||||
// 开始请求(使用 Block 回调)
|
|
||||||
- (void)startRequestWithProgress:(NetworkStreamProgressBlock _Nullable)progress
|
|
||||||
onData:(NetworkStreamDataBlock _Nullable)dataBlock
|
|
||||||
onText:(NetworkStreamTextBlock _Nullable)textBlock
|
|
||||||
completion:(NetworkStreamCompletionBlock _Nullable)completion;
|
|
||||||
|
|
||||||
// 取消请求
|
|
||||||
- (void)cancelRequest;
|
|
||||||
|
|
||||||
// 构建默认请求(包含常见的请求头)
|
|
||||||
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
//
|
|
||||||
// NetworkStreamHandler.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// Created by Mac on 2025/11/12.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "NetworkStreamHandler.h"
|
|
||||||
|
|
||||||
@interface NetworkStreamHandler ()
|
|
||||||
|
|
||||||
@property (nonatomic, strong) NSURLSession *session;
|
|
||||||
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
|
|
||||||
@property (nonatomic, strong) NSURLRequest *request;
|
|
||||||
@property (nonatomic, strong) NSMutableData *receivedData;
|
|
||||||
@property (nonatomic, assign) long long expectedContentLength;
|
|
||||||
@property (nonatomic, assign) NetworkStreamState state;
|
|
||||||
@property (nonatomic, strong) NSURLResponse *response;
|
|
||||||
|
|
||||||
// Block 回调
|
|
||||||
@property (nonatomic, copy) NetworkStreamProgressBlock progressBlock;
|
|
||||||
@property (nonatomic, copy) NetworkStreamDataBlock dataBlock;
|
|
||||||
@property (nonatomic, copy) NetworkStreamTextBlock textBlock;
|
|
||||||
@property (nonatomic, copy) NetworkStreamCompletionBlock completionBlock;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation NetworkStreamHandler
|
|
||||||
|
|
||||||
- (instancetype)initWithURL:(NSURL *)url {
|
|
||||||
NSURLRequest *request = [NetworkStreamHandler createDefaultRequestWithURL:url method:@"GET"];
|
|
||||||
return [self initWithRequest:request];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)initWithRequest:(NSURLRequest *)request {
|
|
||||||
self = [super init];
|
|
||||||
if (self) {
|
|
||||||
_request = request;
|
|
||||||
_receivedData = [NSMutableData data];
|
|
||||||
_state = NetworkStreamStateIdle;
|
|
||||||
_totalBytesReceived = 0;
|
|
||||||
|
|
||||||
// 创建 URLSession 配置
|
|
||||||
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
|
|
||||||
config.timeoutIntervalForRequest = 30.0;
|
|
||||||
config.timeoutIntervalForResource = 300.0;
|
|
||||||
config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
|
||||||
|
|
||||||
// 创建 URLSession
|
|
||||||
_session = [NSURLSession sessionWithConfiguration:config
|
|
||||||
delegate:self
|
|
||||||
delegateQueue:[NSOperationQueue mainQueue]];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
[self cancelRequest];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Public Methods
|
|
||||||
|
|
||||||
- (void)startRequest {
|
|
||||||
if (self.state != NetworkStreamStateIdle) {
|
|
||||||
NSLog(@"Request already in progress");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self updateState:NetworkStreamStateConnecting];
|
|
||||||
self.dataTask = [self.session dataTaskWithRequest:self.request];
|
|
||||||
[self.dataTask resume];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)startRequestWithProgress:(NetworkStreamProgressBlock)progress
|
|
||||||
onData:(NetworkStreamDataBlock)dataBlock
|
|
||||||
onText:(NetworkStreamTextBlock)textBlock
|
|
||||||
completion:(NetworkStreamCompletionBlock)completion {
|
|
||||||
|
|
||||||
self.progressBlock = progress;
|
|
||||||
self.dataBlock = dataBlock;
|
|
||||||
self.textBlock = textBlock;
|
|
||||||
self.completionBlock = completion;
|
|
||||||
|
|
||||||
[self startRequest];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)cancelRequest {
|
|
||||||
if (self.dataTask) {
|
|
||||||
[self.dataTask cancel];
|
|
||||||
self.dataTask = nil;
|
|
||||||
}
|
|
||||||
[self updateState:NetworkStreamStateIdle];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method {
|
|
||||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
|
||||||
request.HTTPMethod = method;
|
|
||||||
request.timeoutInterval = 30.0;
|
|
||||||
|
|
||||||
// 设置常见的请求头(根据您的截图)
|
|
||||||
[request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"];
|
|
||||||
[request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
|
|
||||||
[request setValue:@"zh-CN, zh; q=0.9, ko; q=0.8, ja; q=0.7" forHTTPHeaderField:@"Accept-Language"];
|
|
||||||
[request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
|
||||||
[request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
|
|
||||||
|
|
||||||
// 用户代理(可选)
|
|
||||||
NSString *userAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1";
|
|
||||||
[request setValue:userAgent forHTTPHeaderField:@"User-Agent"];
|
|
||||||
|
|
||||||
return [request copy];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Private Methods
|
|
||||||
|
|
||||||
- (void)updateState:(NetworkStreamState)newState {
|
|
||||||
if (_state != newState) {
|
|
||||||
_state = newState;
|
|
||||||
|
|
||||||
// 通知代理状态改变
|
|
||||||
if ([self.delegate respondsToSelector:@selector(networkStream:stateChanged:)]) {
|
|
||||||
[self.delegate networkStream:self stateChanged:newState];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)notifyProgress:(float)progress {
|
|
||||||
if (self.progressBlock) {
|
|
||||||
self.progressBlock(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector(networkStream:downloadProgress:)]) {
|
|
||||||
[self.delegate networkStream:self downloadProgress:progress];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)notifyReceivedData:(NSData *)data {
|
|
||||||
if (self.dataBlock) {
|
|
||||||
self.dataBlock(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveData:)]) {
|
|
||||||
[self.delegate networkStream:self didReceiveData:data];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是文本数据,尝试转换为字符串
|
|
||||||
if (self.textBlock || [self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
|
|
||||||
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
||||||
if (text) {
|
|
||||||
if (self.textBlock) {
|
|
||||||
self.textBlock(text);
|
|
||||||
}
|
|
||||||
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
|
|
||||||
[self.delegate networkStream:self didReceiveText:text];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)notifyCompletionWithError:(NSError * _Nullable)error {
|
|
||||||
if (self.completionBlock) {
|
|
||||||
self.completionBlock(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector(networkStream:didCompleteWithError:)]) {
|
|
||||||
[self.delegate networkStream:self didCompleteWithError:error];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - NSURLSessionDataDelegate
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
dataTask:(NSURLSessionDataTask *)dataTask
|
|
||||||
didReceiveResponse:(NSURLResponse *)response
|
|
||||||
completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
|
|
||||||
|
|
||||||
self.response = response;
|
|
||||||
self.expectedContentLength = response.expectedContentLength;
|
|
||||||
_totalBytesReceived = 0;
|
|
||||||
[self.receivedData setLength:0];
|
|
||||||
|
|
||||||
[self updateState:NetworkStreamStateReceiving];
|
|
||||||
|
|
||||||
// 检查响应头,处理 CORS 等
|
|
||||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
|
||||||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
|
|
||||||
NSLog(@"Response headers: %@", httpResponse.allHeaderFields);
|
|
||||||
|
|
||||||
// 可以在这里检查 CORS 头等信息
|
|
||||||
NSString *allowOrigin = httpResponse.allHeaderFields[@"Access-Control-Allow-Origin"];
|
|
||||||
if (allowOrigin) {
|
|
||||||
NSLog(@"CORS Allow Origin: %@", allowOrigin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler(NSURLSessionResponseAllow);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
dataTask:(NSURLSessionDataTask *)dataTask
|
|
||||||
didReceiveData:(NSData *)data {
|
|
||||||
|
|
||||||
_totalBytesReceived += data.length;
|
|
||||||
[self.receivedData appendData:data];
|
|
||||||
|
|
||||||
// 通知接收到数据块
|
|
||||||
[self notifyReceivedData:data];
|
|
||||||
|
|
||||||
// 计算并通知进度
|
|
||||||
if (self.expectedContentLength != NSURLResponseUnknownLength) {
|
|
||||||
float progress = (float)self.totalBytesReceived / (float)self.expectedContentLength;
|
|
||||||
[self notifyProgress:progress];
|
|
||||||
} else {
|
|
||||||
// 对于 chunked 传输,可能没有确切的内容长度
|
|
||||||
[self notifyProgress:-1]; // 使用 -1 表示未知进度
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
task:(NSURLSessionTask *)task
|
|
||||||
didCompleteWithError:(NSError *)error {
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
[self updateState:NetworkStreamStateError];
|
|
||||||
NSLog(@"Request failed with error: %@", error);
|
|
||||||
} else {
|
|
||||||
[self updateState:NetworkStreamStateCompleted];
|
|
||||||
NSLog(@"Request completed successfully. Total bytes: %lld", self.totalBytesReceived);
|
|
||||||
}
|
|
||||||
|
|
||||||
[self notifyCompletionWithError:error];
|
|
||||||
|
|
||||||
// 清理
|
|
||||||
[self.session finishTasksAndInvalidate];
|
|
||||||
self.dataTask = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - URL Session Delegate (处理 SSL/认证)
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
|
|
||||||
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
|
|
||||||
|
|
||||||
// 处理 SSL 认证挑战
|
|
||||||
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
|
||||||
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
|
||||||
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
|
|
||||||
} else {
|
|
||||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
//
|
|
||||||
// WJXEventSource.h
|
|
||||||
// WJXEventSource
|
|
||||||
//
|
|
||||||
// Created by JiuxingWang on 2025/2/9.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#define WJX_EXTERN extern "C" __attribute__((visibility ("default")))
|
|
||||||
#else
|
|
||||||
#define WJX_EXTERN extern __attribute__((visibility ("default")))
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// 消息事件
|
|
||||||
typedef NSString *WJXEventName NS_TYPED_EXTENSIBLE_ENUM;
|
|
||||||
|
|
||||||
/// 消息事件
|
|
||||||
WJX_EXTERN WJXEventName const WJXEventNameMessage;
|
|
||||||
|
|
||||||
/// readyState 变化事件
|
|
||||||
WJX_EXTERN WJXEventName const WJXEventNameReadyState;
|
|
||||||
|
|
||||||
/// open 事件
|
|
||||||
WJX_EXTERN WJXEventName const WJXEventNameOpen;
|
|
||||||
|
|
||||||
/// error 事件
|
|
||||||
WJX_EXTERN WJXEventName const WJXEventNameError;
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSUInteger, WJXEventState) {
|
|
||||||
WJXEventStateConnecting = 0,
|
|
||||||
WJXEventStateOpen,
|
|
||||||
WJXEventStateClosed,
|
|
||||||
};
|
|
||||||
|
|
||||||
@interface WJXEvent : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, strong, nullable) id eventId;
|
|
||||||
|
|
||||||
@property (nonatomic, copy, nullable) NSString *event;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *data;
|
|
||||||
|
|
||||||
@property (nonatomic, assign) WJXEventState readyState;
|
|
||||||
@property (nonatomic, strong, nullable) NSError *error;
|
|
||||||
|
|
||||||
- (instancetype)initWithReadyState:(WJXEventState)readyState;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
typedef void(^WJXEventSourceEventHandler)(WJXEvent *event);
|
|
||||||
|
|
||||||
@interface WJXEventSource : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, assign) BOOL ignoreRetryAction;
|
|
||||||
|
|
||||||
- (instancetype)initWithRquest:(NSURLRequest *)request;
|
|
||||||
|
|
||||||
- (void)addListener:(WJXEventSourceEventHandler)listener
|
|
||||||
forEvent:(WJXEventName)eventName
|
|
||||||
queue:(nullable NSOperationQueue *)queue;
|
|
||||||
|
|
||||||
- (void)open;
|
|
||||||
- (void)close;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
//
|
|
||||||
// WJXEventSource.m
|
|
||||||
// WJXEventSource
|
|
||||||
//
|
|
||||||
// Created by JiuxingWang on 2025/2/9.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "WJXEventSource.h"
|
|
||||||
|
|
||||||
/// 消息事件
|
|
||||||
WJXEventName const WJXEventNameMessage = @"message";
|
|
||||||
|
|
||||||
/// readyState 变化事件
|
|
||||||
WJXEventName const WJXEventNameReadyState = @"readyState";
|
|
||||||
|
|
||||||
/// open 事件
|
|
||||||
WJXEventName const WJXEventNameOpen = @"open";
|
|
||||||
|
|
||||||
/// error 事件
|
|
||||||
WJXEventName const WJXEventNameError = @"error";
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark WJXEvent
|
|
||||||
|
|
||||||
@implementation WJXEvent
|
|
||||||
|
|
||||||
- (instancetype)initWithReadyState:(WJXEventState)readyState;
|
|
||||||
{
|
|
||||||
if (self = [super init]) {
|
|
||||||
self.readyState = readyState;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)description
|
|
||||||
{
|
|
||||||
NSString *state = nil;
|
|
||||||
switch (_readyState) {
|
|
||||||
case WJXEventStateConnecting: {
|
|
||||||
state = @"CONNECTING";
|
|
||||||
} break;
|
|
||||||
|
|
||||||
case WJXEventStateOpen: {
|
|
||||||
state = @"OPEN";
|
|
||||||
} break;
|
|
||||||
|
|
||||||
case WJXEventStateClosed: {
|
|
||||||
state = @"CLOSED";
|
|
||||||
} break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [NSString stringWithFormat:@"<%@: readyState: %@, id: %@; event: %@; data: %@>", [self class], state, _eventId, _event, _data];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark WJXEventHandler
|
|
||||||
|
|
||||||
@interface WJXEventHandler : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, copy, nonnull) WJXEventSourceEventHandler handler;
|
|
||||||
@property (nonatomic, strong, nullable) NSOperationQueue *queue;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation WJXEventHandler
|
|
||||||
|
|
||||||
- (instancetype)initWithHandler:(WJXEventSourceEventHandler)handler queue:(NSOperationQueue *)queue
|
|
||||||
{
|
|
||||||
if (self = [super init]) {
|
|
||||||
self.handler = handler;
|
|
||||||
self.queue = queue;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark WJXEventSource
|
|
||||||
|
|
||||||
@interface WJXEventSource () <NSURLSessionDataDelegate>
|
|
||||||
|
|
||||||
@property (nonatomic, strong) NSMutableURLRequest *request;
|
|
||||||
@property (nonatomic, strong) NSMutableDictionary<WJXEventName, NSMutableArray<WJXEventHandler *> *> *listeners;
|
|
||||||
|
|
||||||
@property (nonatomic, strong) NSURLSession *session;
|
|
||||||
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
|
|
||||||
@property (nonatomic, copy) NSString *lastEventId;
|
|
||||||
@property (nonatomic, assign) NSTimeInterval retryInterval;
|
|
||||||
|
|
||||||
@property (nonatomic, assign) BOOL closedByUser;
|
|
||||||
@property (nonatomic, strong) NSMutableData *buffer;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation WJXEventSource
|
|
||||||
|
|
||||||
- (instancetype)initWithRquest:(NSURLRequest *)request;
|
|
||||||
{
|
|
||||||
if (self = [super init]) {
|
|
||||||
self.request = [request mutableCopy];
|
|
||||||
self.listeners = [NSMutableDictionary dictionary];
|
|
||||||
self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration] delegate:self delegateQueue:NSOperationQueue.mainQueue];
|
|
||||||
self.buffer = [NSMutableData data];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc
|
|
||||||
{
|
|
||||||
[_session finishTasksAndInvalidate];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)addListener:(WJXEventSourceEventHandler)listener
|
|
||||||
forEvent:(WJXEventName)eventName
|
|
||||||
queue:(nullable NSOperationQueue *)queue;
|
|
||||||
{
|
|
||||||
if (nil == listener) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableArray *listeners = self.listeners[eventName];
|
|
||||||
if (nil == listeners) {
|
|
||||||
self.listeners[eventName] = listeners = [NSMutableArray array];
|
|
||||||
}
|
|
||||||
[listeners addObject:[[WJXEventHandler alloc] initWithHandler:listener queue:queue]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)open;
|
|
||||||
{
|
|
||||||
if (_lastEventId.length) {
|
|
||||||
[_request setValue:_lastEventId forHTTPHeaderField:@"Last-Event-ID"];
|
|
||||||
}
|
|
||||||
|
|
||||||
self.dataTask = [_session dataTaskWithRequest:_request];
|
|
||||||
[_dataTask resume];
|
|
||||||
|
|
||||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateConnecting];
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)close;
|
|
||||||
{
|
|
||||||
self.closedByUser = YES;
|
|
||||||
[_dataTask cancel];
|
|
||||||
[_session finishTasksAndInvalidate];
|
|
||||||
_buffer = [NSMutableData data];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark NSURLSessionDataDelegate
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
dataTask:(NSURLSessionDataTask *)dataTask
|
|
||||||
didReceiveResponse:(NSURLResponse *)response
|
|
||||||
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
|
|
||||||
{
|
|
||||||
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
|
|
||||||
if (200 == HTTPResponse.statusCode) {
|
|
||||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameOpen];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nil != completionHandler) {
|
|
||||||
completionHandler(NSURLSessionResponseAllow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
dataTask:(NSURLSessionDataTask *)dataTask
|
|
||||||
didReceiveData:(NSData *)data;
|
|
||||||
{
|
|
||||||
[_buffer appendData:data];
|
|
||||||
[self _processBuffer];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)URLSession:(NSURLSession *)session
|
|
||||||
task:(NSURLSessionTask *)task
|
|
||||||
didCompleteWithError:(nullable NSError *)error;
|
|
||||||
{
|
|
||||||
if (_closedByUser) {
|
|
||||||
_buffer = [NSMutableData data];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self _dispatchPlainBufferIfNeeded];
|
|
||||||
|
|
||||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateClosed];
|
|
||||||
if (nil == (event.error = error)) {
|
|
||||||
event.error = [NSError errorWithDomain:@"WJXEventSource" code:event.readyState userInfo:@{
|
|
||||||
NSLocalizedDescriptionKey: @"Connection with the event source was closed without error",
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
|
||||||
|
|
||||||
if (nil != error) {
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameError];
|
|
||||||
if (!_ignoreRetryAction) {
|
|
||||||
[self performSelector:@selector(open) withObject:nil afterDelay:_retryInterval];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark Private
|
|
||||||
|
|
||||||
- (void)_processBuffer
|
|
||||||
{
|
|
||||||
NSData *separatorLFLFData = [NSData dataWithBytes:"\n\n" length:2];
|
|
||||||
|
|
||||||
NSRange range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
|
|
||||||
.length = _buffer.length
|
|
||||||
}];
|
|
||||||
|
|
||||||
while (NSNotFound != range.location) {
|
|
||||||
// Extract event data
|
|
||||||
NSData *eventData = [_buffer subdataWithRange:(NSRange) {
|
|
||||||
.length = range.location
|
|
||||||
}];
|
|
||||||
[_buffer replaceBytesInRange:(NSRange) {
|
|
||||||
.length = range.location + 2
|
|
||||||
} withBytes:NULL length:0];
|
|
||||||
|
|
||||||
[self _parseEventData:eventData];
|
|
||||||
|
|
||||||
// Look for next event
|
|
||||||
range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
|
|
||||||
.length = _buffer.length
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)_parseEventData:(NSData *)data
|
|
||||||
{
|
|
||||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
|
|
||||||
|
|
||||||
NSString *eventString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
||||||
if (eventString.length == 0) { return; }
|
|
||||||
NSArray *lines = [eventString componentsSeparatedByCharactersInSet:NSCharacterSet.newlineCharacterSet];
|
|
||||||
BOOL hasDataLine = NO;
|
|
||||||
for (NSString *line in lines) {
|
|
||||||
if ([line hasPrefix:@"id:"]) {
|
|
||||||
event.eventId = [[line substringFromIndex:3] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
|
||||||
} else if ([line hasPrefix:@"event:"]) {
|
|
||||||
event.event = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
|
||||||
} else if ([line hasPrefix:@"data:"]) {
|
|
||||||
hasDataLine = YES;
|
|
||||||
NSString *data = [[line substringFromIndex:5] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
|
||||||
event.data = event.data ? [event.data stringByAppendingFormat:@"\n%@", data] : data;
|
|
||||||
} else if ([line hasPrefix:@"retry:"]) {
|
|
||||||
NSString *retryString = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
|
||||||
self.retryInterval = [retryString doubleValue] / 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDataLine) {
|
|
||||||
NSString *trimmed = [eventString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
||||||
if (trimmed.length > 0) {
|
|
||||||
event.data = trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.eventId) {
|
|
||||||
self.lastEventId = event.eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self _dispatchEvent:event forName:WJXEventNameMessage];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)_dispatchEvent:(WJXEvent *)event forName:(WJXEventName)name
|
|
||||||
{
|
|
||||||
NSMutableArray<WJXEventHandler *> *listeners = self.listeners[name];
|
|
||||||
[listeners enumerateObjectsUsingBlock:^(WJXEventHandler * _Nonnull handler, NSUInteger idx, BOOL * _Nonnull stop) {
|
|
||||||
NSOperationQueue *queue = handler.queue ?: NSOperationQueue.mainQueue;
|
|
||||||
[queue addOperationWithBlock:^{
|
|
||||||
handler.handler(event);
|
|
||||||
}];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)_dispatchPlainBufferIfNeeded
|
|
||||||
{
|
|
||||||
if (_buffer.length == 0) { return; }
|
|
||||||
NSData *data = [_buffer copy];
|
|
||||||
[_buffer setLength:0];
|
|
||||||
if (data.length == 0) { return; }
|
|
||||||
[self _parseEventData:data];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
#pragma mark Setters
|
|
||||||
|
|
||||||
- (void)setDataTask:(NSURLSessionDataTask *)dataTask
|
|
||||||
{
|
|
||||||
self.closedByUser = YES; {
|
|
||||||
[_dataTask cancel];
|
|
||||||
_dataTask = dataTask;
|
|
||||||
} self.closedByUser = NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -17,25 +17,15 @@
|
|||||||
|
|
||||||
// 公共配置
|
// 公共配置
|
||||||
#import "KBConfig.h"
|
#import "KBConfig.h"
|
||||||
#import "KBAPI.h" // 接口路径宏(统一管理)
|
|
||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||||
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
||||||
#import "KBMaiPointReporter.h"
|
|
||||||
//#import "KBLog.h"
|
|
||||||
|
|
||||||
|
|
||||||
// 通用链接(Universal Links)统一配置
|
// 通用链接(Universal Links)统一配置
|
||||||
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
||||||
#define KB_UL_BASE @"https://app.tknb.net/ul" // 与 Associated Domains 一致
|
#define KB_UL_BASE @"https://your.domain/ul" // 替换为你的真实域名与前缀路径
|
||||||
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
||||||
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
|
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
|
||||||
|
|
||||||
// 在扩展内,启用 URL Bridge(仅在显式的用户点击动作中使用)
|
|
||||||
// 这样即便宿主 App(如备忘录)拒绝 extensionContext 的 openURL,仍可通过响应链兜底拉起容器 App。
|
|
||||||
#ifndef KB_URL_BRIDGE_ENABLE
|
|
||||||
#define KB_URL_BRIDGE_ENABLE 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
#endif /* PrefixHeader_pch */
|
#endif /* PrefixHeader_pch */
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
/* 字母 q(小写) */
|
|
||||||
"letter_q_lower" = "key_q";
|
|
||||||
/* 字母 Q(大写) */
|
|
||||||
"letter_q_upper" = "key_q_up";
|
|
||||||
|
|
||||||
/* 字母 w(小写) */
|
|
||||||
"letter_w_lower" = "key_w";
|
|
||||||
/* 字母 W(大写) */
|
|
||||||
"letter_w_upper" = "key_w_up";
|
|
||||||
|
|
||||||
/* 字母 e(小写) */
|
|
||||||
"letter_e_lower" = "key_e";
|
|
||||||
/* 字母 E(大写) */
|
|
||||||
"letter_e_upper" = "key_e_up";
|
|
||||||
|
|
||||||
/* 字母 r(小写) */
|
|
||||||
"letter_r_lower" = "key_r";
|
|
||||||
/* 字母 R(大写) */
|
|
||||||
"letter_r_upper" = "key_r_up";
|
|
||||||
|
|
||||||
/* 字母 t(小写) */
|
|
||||||
"letter_t_lower" = "key_t";
|
|
||||||
/* 字母 T(大写) */
|
|
||||||
"letter_t_upper" = "key_t_up";
|
|
||||||
|
|
||||||
/* 字母 y(小写) */
|
|
||||||
"letter_y_lower" = "key_y";
|
|
||||||
/* 字母 Y(大写) */
|
|
||||||
"letter_y_upper" = "key_y_up";
|
|
||||||
|
|
||||||
/* 字母 u(小写) */
|
|
||||||
"letter_u_lower" = "key_u";
|
|
||||||
/* 字母 U(大写) */
|
|
||||||
"letter_u_upper" = "key_u_up";
|
|
||||||
|
|
||||||
/* 字母 i(小写) */
|
|
||||||
"letter_i_lower" = "key_i";
|
|
||||||
/* 字母 I(大写) */
|
|
||||||
"letter_i_upper" = "key_i_up";
|
|
||||||
|
|
||||||
/* 字母 o(小写) */
|
|
||||||
"letter_o_lower" = "key_o";
|
|
||||||
/* 字母 O(大写) */
|
|
||||||
"letter_o_upper" = "key_o_up";
|
|
||||||
|
|
||||||
/* 字母 p(小写) */
|
|
||||||
"letter_p_lower" = "key_p";
|
|
||||||
/* 字母 P(大写) */
|
|
||||||
"letter_p_upper" = "key_p_up";
|
|
||||||
|
|
||||||
/* 字母 a(小写) */
|
|
||||||
"letter_a_lower" = "key_a";
|
|
||||||
/* 字母 A(大写) */
|
|
||||||
"letter_a_upper" = "key_a_up";
|
|
||||||
|
|
||||||
/* 字母 s(小写) */
|
|
||||||
"letter_s_lower" = "key_s";
|
|
||||||
/* 字母 S(大写) */
|
|
||||||
"letter_s_upper" = "key_s_up";
|
|
||||||
|
|
||||||
/* 字母 d(小写) */
|
|
||||||
"letter_d_lower" = "key_d";
|
|
||||||
/* 字母 D(大写) */
|
|
||||||
"letter_d_upper" = "key_d_up";
|
|
||||||
|
|
||||||
/* 字母 f(小写) */
|
|
||||||
"letter_f_lower" = "key_f";
|
|
||||||
/* 字母 F(大写) */
|
|
||||||
"letter_f_upper" = "key_f_up";
|
|
||||||
|
|
||||||
/* 字母 g(小写) */
|
|
||||||
"letter_g_lower" = "key_g";
|
|
||||||
/* 字母 G(大写) */
|
|
||||||
"letter_g_upper" = "key_g_up";
|
|
||||||
|
|
||||||
/* 字母 h(小写) */
|
|
||||||
"letter_h_lower" = "key_h";
|
|
||||||
/* 字母 H(大写) */
|
|
||||||
"letter_h_upper" = "key_h_up";
|
|
||||||
|
|
||||||
/* 字母 j(小写) */
|
|
||||||
"letter_j_lower" = "key_j";
|
|
||||||
/* 字母 J(大写) */
|
|
||||||
"letter_j_upper" = "key_j_up";
|
|
||||||
|
|
||||||
/* 字母 k(小写) */
|
|
||||||
"letter_k_lower" = "key_k";
|
|
||||||
/* 字母 K(大写) */
|
|
||||||
"letter_k_upper" = "key_k_up";
|
|
||||||
|
|
||||||
/* 字母 l(小写) */
|
|
||||||
"letter_l_lower" = "key_l";
|
|
||||||
/* 字母 L(大写) */
|
|
||||||
"letter_l_upper" = "key_l_up";
|
|
||||||
|
|
||||||
/* 字母 z(小写) */
|
|
||||||
"letter_z_lower" = "key_z";
|
|
||||||
/* 字母 Z(大写) */
|
|
||||||
"letter_z_upper" = "key_z_up";
|
|
||||||
|
|
||||||
/* 字母 x(小写) */
|
|
||||||
"letter_x_lower" = "key_x";
|
|
||||||
/* 字母 X(大写) */
|
|
||||||
"letter_x_upper" = "key_x_up";
|
|
||||||
|
|
||||||
/* 字母 c(小写) */
|
|
||||||
"letter_c_lower" = "key_c";
|
|
||||||
/* 字母 C(大写) */
|
|
||||||
"letter_c_upper" = "key_c_up";
|
|
||||||
|
|
||||||
/* 字母 v(小写) */
|
|
||||||
"letter_v_lower" = "key_v";
|
|
||||||
/* 字母 V(大写) */
|
|
||||||
"letter_v_upper" = "key_v_up";
|
|
||||||
|
|
||||||
/* 字母 b(小写) */
|
|
||||||
"letter_b_lower" = "key_b";
|
|
||||||
/* 字母 B(大写) */
|
|
||||||
"letter_b_upper" = "key_b_up";
|
|
||||||
|
|
||||||
/* 字母 n(小写) */
|
|
||||||
"letter_n_lower" = "key_n";
|
|
||||||
/* 字母 N(大写) */
|
|
||||||
"letter_n_upper" = "key_n_up";
|
|
||||||
|
|
||||||
/* 字母 m(小写) */
|
|
||||||
"letter_m_lower" = "key_m";
|
|
||||||
/* 字母 M(大写) */
|
|
||||||
"letter_m_upper" = "key_m_up";
|
|
||||||
|
|
||||||
/* 数字 1 */
|
|
||||||
"digit_1" = "key_1";
|
|
||||||
/* 数字 2 */
|
|
||||||
"digit_2" = "key_2";
|
|
||||||
/* 数字 3 */
|
|
||||||
"digit_3" = "key_3";
|
|
||||||
/* 数字 4 */
|
|
||||||
"digit_4" = "key_4";
|
|
||||||
/* 数字 5 */
|
|
||||||
"digit_5" = "key_5";
|
|
||||||
/* 数字 6 */
|
|
||||||
"digit_6" = "key_6";
|
|
||||||
/* 数字 7 */
|
|
||||||
"digit_7" = "key_7";
|
|
||||||
/* 数字 8 */
|
|
||||||
"digit_8" = "key_8";
|
|
||||||
/* 数字 9 */
|
|
||||||
"digit_9" = "key_9";
|
|
||||||
/* 数字 0 */
|
|
||||||
"digit_0" = "key_0";
|
|
||||||
|
|
||||||
/* '-' */
|
|
||||||
"sym_minus" = "key_minus";
|
|
||||||
/* '/' */
|
|
||||||
"sym_slash" = "key_slash";
|
|
||||||
/* ':' */
|
|
||||||
"sym_colon" = "key_colon";
|
|
||||||
/* ';' */
|
|
||||||
"sym_semicolon" = "key_semicolon";
|
|
||||||
/* '(' */
|
|
||||||
"sym_paren_l" = "key_paren_l";
|
|
||||||
/* ')' */
|
|
||||||
"sym_paren_r" = "key_paren_r";
|
|
||||||
/* '$' */
|
|
||||||
"sym_dollar" = "key_dollar";
|
|
||||||
/* '&' */
|
|
||||||
"sym_amp" = "key_amp";
|
|
||||||
/* '@' */
|
|
||||||
"sym_at" = "key_at";
|
|
||||||
/* 双引号 " */
|
|
||||||
"sym_quote_double" = "key_quote_d";
|
|
||||||
|
|
||||||
/* ',' */
|
|
||||||
"sym_comma" = "key_comma";
|
|
||||||
/* '.' */
|
|
||||||
"sym_dot" = "key_dot";
|
|
||||||
/* '?' */
|
|
||||||
"sym_question" = "key_question";
|
|
||||||
/* '!' */
|
|
||||||
"sym_exclam" = "key_exclam";
|
|
||||||
/* 单引号 ' */
|
|
||||||
"sym_quote_single" = "key_quote";
|
|
||||||
|
|
||||||
/* '[' */
|
|
||||||
"sym_bracket_l" = "key_bracket_l";
|
|
||||||
/* ']' */
|
|
||||||
"sym_bracket_r" = "key_bracket_r";
|
|
||||||
/* '{' */
|
|
||||||
"sym_brace_l" = "key_brace_l";
|
|
||||||
/* '}' */
|
|
||||||
"sym_brace_r" = "key_brace_r";
|
|
||||||
/* '#' */
|
|
||||||
"sym_hash" = "key_hash";
|
|
||||||
/* '%' */
|
|
||||||
"sym_percent" = "key_percent";
|
|
||||||
/* '^' */
|
|
||||||
"sym_caret" = "key_caret";
|
|
||||||
/* '*' */
|
|
||||||
"sym_asterisk" = "key_asterisk";
|
|
||||||
/* '+' */
|
|
||||||
"sym_plus" = "key_plus";
|
|
||||||
/* '=' */
|
|
||||||
"sym_equal" = "key_equal";
|
|
||||||
|
|
||||||
/* '_' */
|
|
||||||
"sym_underscore" = "key_underscore";
|
|
||||||
/* '\' */
|
|
||||||
"sym_backslash" = "key_backslash";
|
|
||||||
/* '|' */
|
|
||||||
"sym_pipe" = "key_pipe";
|
|
||||||
/* '~' */
|
|
||||||
"sym_tilde" = "key_tilde";
|
|
||||||
/* '<' */
|
|
||||||
"sym_lt" = "key_lt";
|
|
||||||
/* '>' */
|
|
||||||
"sym_gt" = "key_gt";
|
|
||||||
/* '¥' */
|
|
||||||
"sym_money" = "key_money";
|
|
||||||
/* '€' */
|
|
||||||
"sym_euro" = "key_euro";
|
|
||||||
/* '£' */
|
|
||||||
"sym_pound" = "key_pound";
|
|
||||||
/* '•' */
|
|
||||||
"sym_bullet" = "key_bullet";
|
|
||||||
|
|
||||||
/* 空格键 */
|
|
||||||
"space" = "key_space";
|
|
||||||
/* 删除键(⌫) */
|
|
||||||
"backspace" = "key_del";
|
|
||||||
/* Shift(⇧) */
|
|
||||||
"shift" = "key_up";
|
|
||||||
/* Shift(⇧)大写 */
|
|
||||||
"shift_upper" = "key_up_upper";
|
|
||||||
/* 字母面板左下角 "123" */
|
|
||||||
"mode_123" = "key_123";
|
|
||||||
/* 数字面板左下角 "abc" */
|
|
||||||
"mode_abc" = "key_abc";
|
|
||||||
/* 数字面板内 "123 -> #+=" */
|
|
||||||
"symbols_toggle_more" = "key_symbols_more";
|
|
||||||
/* 数字面板内 "#+= -> 123" */
|
|
||||||
"symbols_toggle_123" = "key_symbols_123";
|
|
||||||
/* 自定义 AI 功能键 */
|
|
||||||
"ai" = "key_ai";
|
|
||||||
/* Emoji功能键 */
|
|
||||||
//"emoji" = "key_emoji";
|
|
||||||
"emoji_panel" = "key_emoji";
|
|
||||||
/* 发送/换行键 */
|
|
||||||
"return" = "key_send";
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
{
|
|
||||||
"__comment": "键盘布局配置:所有尺寸为设计稿值(会按 designWidth 等比缩放)",
|
|
||||||
"designWidth": 375,
|
|
||||||
"__comment_designWidth": "设计稿宽度(如 375),用于计算缩放比例",
|
|
||||||
"defaultKeyBackground": "#FFFFFF",
|
|
||||||
"__comment_defaultKeyBackground": "无皮肤时按键默认背景色",
|
|
||||||
"metrics": {
|
|
||||||
"__comment": "全局尺寸参数(单位:pt,按 designWidth 缩放)",
|
|
||||||
"rowSpacing": 8,
|
|
||||||
"__comment_rowSpacing": "行间距(垂直)",
|
|
||||||
"topInset": 8,
|
|
||||||
"__comment_topInset": "键盘顶部内边距",
|
|
||||||
"bottomInset": 6,
|
|
||||||
"__comment_bottomInset": "键盘底部内边距",
|
|
||||||
"keyHeight": 41,
|
|
||||||
"__comment_keyHeight": "默认按键高度",
|
|
||||||
"edgeInset": 4,
|
|
||||||
"__comment_edgeInset": "行左右内边距(默认)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "按键之间水平间距",
|
|
||||||
"letterWidth": 32,
|
|
||||||
"__comment_letterWidth": "字母键默认宽度",
|
|
||||||
"controlWidth": 41,
|
|
||||||
"__comment_controlWidth": "控制键宽度(如 shift/backspace/123)",
|
|
||||||
"sendWidth": 88,
|
|
||||||
"__comment_sendWidth": "send 键宽度",
|
|
||||||
"symbolsWideWidth": 47,
|
|
||||||
"__comment_symbolsWideWidth": "符号第3行中间大键宽度",
|
|
||||||
"symbolsSideWidth": 41,
|
|
||||||
"__comment_symbolsSideWidth": "符号第3行左右控制键宽度"
|
|
||||||
},
|
|
||||||
"fonts": {
|
|
||||||
"__comment": "字体大小(pt)",
|
|
||||||
"letter": 20,
|
|
||||||
"__comment_letter": "字母键字体大小",
|
|
||||||
"digit": 20,
|
|
||||||
"__comment_digit": "数字键字体大小",
|
|
||||||
"symbol": 18,
|
|
||||||
"__comment_symbol": "符号键字体大小",
|
|
||||||
"mode": 14,
|
|
||||||
"__comment_mode": "模式切换键字体大小(ABC/#+=/123)",
|
|
||||||
"space": 18,
|
|
||||||
"__comment_space": "空格键字体大小",
|
|
||||||
"send": 18,
|
|
||||||
"__comment_send": "发送键字体大小"
|
|
||||||
},
|
|
||||||
"keyDefs": {
|
|
||||||
"__comment": "特殊功能键配置(id 对应布局中的 item)",
|
|
||||||
"shift": {
|
|
||||||
"__comment": "大小写切换键",
|
|
||||||
"type": "shift",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "⇧",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"symbolName": "shift",
|
|
||||||
"__comment_symbolName": "无皮肤时使用 SF Symbol 名称",
|
|
||||||
"selectedSymbolName": "shift.fill",
|
|
||||||
"__comment_selectedSymbolName": "选中态 SF Symbol 名称",
|
|
||||||
"font": "symbol",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "controlWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"backspace": {
|
|
||||||
"__comment": "删除键",
|
|
||||||
"type": "backspace",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "⌫",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "symbol",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "controlWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"mode_123": {
|
|
||||||
"__comment": "字母面板左下角 123",
|
|
||||||
"type": "mode",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "123",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "mode",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "controlWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"mode_abc": {
|
|
||||||
"__comment": "数字面板左下角 ABC",
|
|
||||||
"type": "mode",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "ABC",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "mode",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "controlWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"symbols_toggle_more": {
|
|
||||||
"__comment": "数字面板内 123 -> #+=",
|
|
||||||
"type": "symbolsToggle",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "#+=",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "mode",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "symbolsSideWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"symbols_toggle_123": {
|
|
||||||
"__comment": "数字面板内 #+= -> 123",
|
|
||||||
"type": "symbolsToggle",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "123",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "mode",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "symbolsSideWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"emoji": {
|
|
||||||
"__comment": "emoji 功能键",
|
|
||||||
"type": "custom",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "😁",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "symbol",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "controlWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
},
|
|
||||||
"space": {
|
|
||||||
"__comment": "空格键",
|
|
||||||
"type": "space",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "space",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "space",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "flex",
|
|
||||||
"__comment_width": "flex 表示自动占满剩余空间"
|
|
||||||
},
|
|
||||||
"send": {
|
|
||||||
"__comment": "发送键",
|
|
||||||
"type": "return",
|
|
||||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
|
||||||
"title": "send",
|
|
||||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
|
||||||
"font": "send",
|
|
||||||
"__comment_font": "使用 fonts 中哪一类字号",
|
|
||||||
"width": "sendWidth",
|
|
||||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
|
||||||
"backgroundColor": "#B7BBC4",
|
|
||||||
"__comment_backgroundColor": "按键背景色"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"layouts": {
|
|
||||||
"__comment": "布局集合:letters/numbers/symbolsMore",
|
|
||||||
"letters": {
|
|
||||||
"__comment": "字母布局(小写/大写共用)",
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"__comment": "字母第一行 qwertyuiop",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
|
||||||
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "字母第二行 asdfghjkl",
|
|
||||||
"align": "center",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 0,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 0,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
|
||||||
"letter:h", "letter:j", "letter:k", "letter:l"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"segments": {
|
|
||||||
"__comment": "分段布局:left/center/right",
|
|
||||||
"left": [
|
|
||||||
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
|
||||||
],
|
|
||||||
"__comment_left": "左侧固定按钮",
|
|
||||||
"center": [
|
|
||||||
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
|
||||||
],
|
|
||||||
"__comment_center": "中间字母键集合,整体居中",
|
|
||||||
"right": [
|
|
||||||
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
|
||||||
],
|
|
||||||
"__comment_right": "右侧固定按钮"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "字母第四行:123/emoji/space/send",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"mode_123", "emoji", "space", "send"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"numbers": {
|
|
||||||
"__comment": "数字面板布局(123 页)",
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"__comment": "数字第一行 1234567890",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"digit:1", "digit:2", "digit:3", "digit:4", "digit:5",
|
|
||||||
"digit:6", "digit:7", "digit:8", "digit:9", "digit:0"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "数字第二行 - / : ; ( ) ¥ & @ “",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"sym:-", "sym:/", "sym::", "sym:;", "sym:(",
|
|
||||||
"sym:)", "sym:¥", "sym:&", "sym:@", "sym:“"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "数字第三行:#+= / 中间符号 / 删除",
|
|
||||||
"align": "center",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"segments": {
|
|
||||||
"__comment": "分段布局:left/center/right",
|
|
||||||
"left": [
|
|
||||||
{ "id": "symbols_toggle_more", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_left": "左侧切换按钮",
|
|
||||||
"center": [
|
|
||||||
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_center": "中间符号键集合,整体居中",
|
|
||||||
"right": [
|
|
||||||
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_right": "右侧删除键"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "数字第四行:ABC/emoji/space/send",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"mode_abc", "emoji", "space", "send"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symbolsMore": {
|
|
||||||
"__comment": "符号面板布局(#+= 页)",
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"__comment": "符号第一行 [ ] { } # % ^ * + =",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"sym:[", "sym:]", "sym:{", "sym:}", "sym:#",
|
|
||||||
"sym:%", "sym:^", "sym:*", "sym:+", "sym:="
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "符号第二行 _ \\ | ~ < > € ¥ $ ·",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"sym:_", "sym:\\", "sym:|", "sym:~", "sym:<",
|
|
||||||
"sym:>", "sym:€", "sym:¥", "sym:$", "sym:·"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "符号第三行:123 / 中间符号 / 删除",
|
|
||||||
"align": "center",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"segments": {
|
|
||||||
"__comment": "分段布局:left/center/right",
|
|
||||||
"left": [
|
|
||||||
{ "id": "symbols_toggle_123", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_left": "左侧切换按钮",
|
|
||||||
"center": [
|
|
||||||
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
|
||||||
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_center": "中间符号键集合,整体居中",
|
|
||||||
"right": [
|
|
||||||
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
|
||||||
],
|
|
||||||
"__comment_right": "右侧删除键"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"__comment": "符号第四行:ABC/emoji/space/send",
|
|
||||||
"align": "left",
|
|
||||||
"__comment_align": "对齐方式:left/center",
|
|
||||||
"insetLeft": 4,
|
|
||||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
|
||||||
"insetRight": 4,
|
|
||||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
|
||||||
"gap": 5,
|
|
||||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
|
||||||
"items": [
|
|
||||||
"mode_abc", "emoji", "space", "send"
|
|
||||||
],
|
|
||||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// KBBackspaceLongPressHandler.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBBackspaceLongPressHandler : NSObject
|
|
||||||
|
|
||||||
- (instancetype)initWithContainerView:(UIView *)containerView;
|
|
||||||
|
|
||||||
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
|
||||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
|
||||||
|
|
||||||
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
|
||||||
- (void)performClearAction;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,662 +0,0 @@
|
|||||||
//
|
|
||||||
// KBBackspaceLongPressHandler.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBBackspaceLongPressHandler.h"
|
|
||||||
#import "KBResponderUtils.h"
|
|
||||||
#import "KBSkinManager.h"
|
|
||||||
#import "KBBackspaceUndoManager.h"
|
|
||||||
#import "KBInputBufferManager.h"
|
|
||||||
|
|
||||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
|
||||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
|
||||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.6;
|
|
||||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
|
||||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
|
||||||
static const NSInteger kKBBackspaceChunkSize = 8;
|
|
||||||
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
|
||||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
|
||||||
static const CGFloat kKBBackspaceClearLabelHeight = 34;
|
|
||||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
|
||||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
|
||||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
|
||||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
|
||||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
|
||||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
|
||||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
|
||||||
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|
||||||
KBBackspaceChunkClassUnknown = 0,
|
|
||||||
KBBackspaceChunkClassWhitespace,
|
|
||||||
KBBackspaceChunkClassASCIIWord,
|
|
||||||
KBBackspaceChunkClassPunctuation,
|
|
||||||
KBBackspaceChunkClassOther
|
|
||||||
};
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
|
||||||
KBClearPhaseSkipWhitespace = 0,
|
|
||||||
KBClearPhaseSkipTrailingBoundary,
|
|
||||||
KBClearPhaseDeleteUntilBoundary
|
|
||||||
};
|
|
||||||
|
|
||||||
@interface KBBackspaceLongPressHandler ()
|
|
||||||
@property (nonatomic, weak) UIView *containerView;
|
|
||||||
@property (nonatomic, weak) UIView *backspaceButton;
|
|
||||||
@property (nonatomic, strong) UILongPressGestureRecognizer *longPress;
|
|
||||||
@property (nonatomic, assign) BOOL showClearLabelEnabled;
|
|
||||||
@property (nonatomic, assign) BOOL backspaceHoldActive;
|
|
||||||
@property (nonatomic, assign) NSTimeInterval backspaceHoldStartTime;
|
|
||||||
@property (nonatomic, assign) BOOL backspaceChunkModeActive;
|
|
||||||
@property (nonatomic, assign) BOOL backspaceClearHighlighted;
|
|
||||||
@property (nonatomic, assign) NSUInteger backspaceHoldToken;
|
|
||||||
@property (nonatomic, assign) BOOL backspaceHasLastTouchPoint;
|
|
||||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
|
||||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
|
||||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
|
||||||
@property (nonatomic, copy) NSString *pendingClearBefore;
|
|
||||||
@property (nonatomic, copy) NSString *pendingClearAfter;
|
|
||||||
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBBackspaceLongPressHandler
|
|
||||||
|
|
||||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_containerView = containerView;
|
|
||||||
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)bindDeleteButton:(UIView *)button showClearLabel:(BOOL)showClearLabel {
|
|
||||||
if (self.backspaceButton == button) { return; }
|
|
||||||
|
|
||||||
if (self.longPress && self.backspaceButton) {
|
|
||||||
[self.backspaceButton removeGestureRecognizer:self.longPress];
|
|
||||||
}
|
|
||||||
self.backspaceButton = button;
|
|
||||||
self.showClearLabelEnabled = showClearLabel;
|
|
||||||
self.backspaceHoldActive = NO;
|
|
||||||
self.backspaceChunkModeActive = NO;
|
|
||||||
self.backspaceClearHighlighted = NO;
|
|
||||||
self.backspaceHasLastTouchPoint = NO;
|
|
||||||
self.backspaceHoldToken += 1;
|
|
||||||
[self kb_hideBackspaceClearLabel];
|
|
||||||
self.pendingClearBefore = nil;
|
|
||||||
self.pendingClearAfter = nil;
|
|
||||||
|
|
||||||
if (!button) { return; }
|
|
||||||
|
|
||||||
self.longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
|
||||||
action:@selector(onBackspaceLongPress:)];
|
|
||||||
self.longPress.minimumPressDuration = kKBBackspaceLongPressMinDuration;
|
|
||||||
self.longPress.allowableMovement = CGFLOAT_MAX;
|
|
||||||
self.longPress.cancelsTouchesInView = YES;
|
|
||||||
[button addGestureRecognizer:self.longPress];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)performClearAction {
|
|
||||||
[self kb_clearAllInput];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Long Press
|
|
||||||
|
|
||||||
- (void)onBackspaceLongPress:(UILongPressGestureRecognizer *)gr {
|
|
||||||
UIView *hostView = [self kb_hostView];
|
|
||||||
if (!hostView) { return; }
|
|
||||||
if (gr) {
|
|
||||||
self.backspaceLastTouchPointInSelf = [gr locationInView:hostView];
|
|
||||||
self.backspaceHasLastTouchPoint = YES;
|
|
||||||
}
|
|
||||||
switch (gr.state) {
|
|
||||||
case UIGestureRecognizerStateBegan: {
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (ivc) {
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
|
||||||
[[KBInputBufferManager shared] prepareSnapshotForDeleteWithContextBefore:proxy.documentContextBeforeInput
|
|
||||||
after:proxy.documentContextAfterInput];
|
|
||||||
}
|
|
||||||
if (self.showClearLabelEnabled) {
|
|
||||||
[self kb_capturePendingClearSnapshotIfNeeded];
|
|
||||||
[[KBInputBufferManager shared] beginPendingClearSnapshot];
|
|
||||||
}
|
|
||||||
self.backspaceHoldToken += 1;
|
|
||||||
NSUInteger token = self.backspaceHoldToken;
|
|
||||||
self.backspaceHoldActive = YES;
|
|
||||||
self.backspaceHoldStartTime = [NSDate date].timeIntervalSinceReferenceDate;
|
|
||||||
self.backspaceChunkModeActive = NO;
|
|
||||||
[self kb_setBackspaceClearHighlighted:NO];
|
|
||||||
[self kb_hideBackspaceClearLabel];
|
|
||||||
if (self.showClearLabelEnabled) {
|
|
||||||
[self kb_showBackspaceClearLabelIfNeeded];
|
|
||||||
}
|
|
||||||
[self kb_backspaceStepForToken:token];
|
|
||||||
} break;
|
|
||||||
case UIGestureRecognizerStateChanged: {
|
|
||||||
[self kb_handleBackspaceLongPressChanged:gr];
|
|
||||||
} break;
|
|
||||||
case UIGestureRecognizerStateEnded:
|
|
||||||
case UIGestureRecognizerStateCancelled:
|
|
||||||
case UIGestureRecognizerStateFailed: {
|
|
||||||
[self kb_handleBackspaceLongPressEnded:gr];
|
|
||||||
} break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Delete Steps
|
|
||||||
|
|
||||||
- (void)kb_backspaceStepForToken:(NSUInteger)token {
|
|
||||||
if (!self.backspaceHoldActive) { return; }
|
|
||||||
if (token != self.backspaceHoldToken) { return; }
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
|
||||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
|
||||||
NSInteger deleteCount = 1;
|
|
||||||
if (before.length > 0) {
|
|
||||||
deleteCount = [self kb_backspaceDeleteCountForContext:before elapsed:elapsed];
|
|
||||||
}
|
|
||||||
if (!self.backspaceChunkModeActive && elapsed >= kKBBackspaceChunkStartDelay) {
|
|
||||||
self.backspaceChunkModeActive = YES;
|
|
||||||
if (self.showClearLabelEnabled) {
|
|
||||||
[self kb_showBackspaceClearLabelIfNeeded];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
|
||||||
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
|
||||||
|
|
||||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
|
||||||
__weak typeof(self) weakSelf = self;
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
||||||
(int64_t)(interval * NSEC_PER_SEC)),
|
|
||||||
dispatch_get_main_queue(), ^{
|
|
||||||
__strong typeof(weakSelf) selfStrong = weakSelf;
|
|
||||||
[selfStrong kb_backspaceStepForToken:token];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSTimeInterval)kb_backspaceRepeatIntervalForElapsed:(NSTimeInterval)elapsed {
|
|
||||||
if (elapsed >= kKBBackspaceChunkStartDelay) {
|
|
||||||
return kKBBackspaceChunkRepeatInterval;
|
|
||||||
}
|
|
||||||
return kKBBackspaceRepeatInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSInteger)kb_backspaceDeleteCountForContext:(NSString *)context elapsed:(NSTimeInterval)elapsed {
|
|
||||||
if (elapsed < kKBBackspaceChunkStartDelay) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
NSInteger maxCount = (elapsed >= kKBBackspaceChunkFastDelay)
|
|
||||||
? kKBBackspaceChunkSizeFast : kKBBackspaceChunkSize;
|
|
||||||
return [self kb_backspaceChunkDeleteCountForContext:context maxCount:maxCount];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSInteger)kb_backspaceChunkDeleteCountForContext:(NSString *)context maxCount:(NSInteger)maxCount {
|
|
||||||
if (context.length == 0) { return 1; }
|
|
||||||
|
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
|
||||||
static NSCharacterSet *asciiWordSet = nil;
|
|
||||||
static NSCharacterSet *punctuationSet = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
|
||||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
|
||||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
|
||||||
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
|
||||||
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
|
||||||
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
|
||||||
punctuationSet = [punct copy];
|
|
||||||
});
|
|
||||||
|
|
||||||
__block NSInteger deleteCount = 0;
|
|
||||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkPhase) {
|
|
||||||
KBBackspaceChunkPhaseWhitespace = 0,
|
|
||||||
KBBackspaceChunkPhasePunctuation,
|
|
||||||
KBBackspaceChunkPhaseCore
|
|
||||||
};
|
|
||||||
__block KBBackspaceChunkPhase phase = KBBackspaceChunkPhaseWhitespace;
|
|
||||||
__block KBBackspaceChunkClass coreClass = KBBackspaceChunkClassUnknown;
|
|
||||||
|
|
||||||
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
|
||||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
|
||||||
if (substring.length == 0) { return; }
|
|
||||||
if (deleteCount >= maxCount) {
|
|
||||||
*stop = YES;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
|
||||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassWhitespace;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassPunctuation;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL consumed = NO;
|
|
||||||
while (!consumed) {
|
|
||||||
if (phase == KBBackspaceChunkPhaseWhitespace) {
|
|
||||||
if (currentClass == KBBackspaceChunkClassWhitespace) {
|
|
||||||
deleteCount += 1;
|
|
||||||
consumed = YES;
|
|
||||||
} else {
|
|
||||||
phase = KBBackspaceChunkPhasePunctuation;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase == KBBackspaceChunkPhasePunctuation) {
|
|
||||||
if (currentClass == KBBackspaceChunkClassPunctuation) {
|
|
||||||
deleteCount += 1;
|
|
||||||
consumed = YES;
|
|
||||||
} else {
|
|
||||||
phase = KBBackspaceChunkPhaseCore;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase == Core:连续删同一类(ASCII 单词 / 其它),让效果更像微信“几个字一组”
|
|
||||||
if (coreClass == KBBackspaceChunkClassUnknown) {
|
|
||||||
coreClass = currentClass;
|
|
||||||
}
|
|
||||||
if (currentClass != coreClass) {
|
|
||||||
*stop = YES;
|
|
||||||
consumed = YES;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
deleteCount += 1;
|
|
||||||
consumed = YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteCount >= maxCount) {
|
|
||||||
*stop = YES;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
|
|
||||||
return MAX(deleteCount, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
|
||||||
hitBoundary:(BOOL *)hitBoundary {
|
|
||||||
if (context.length == 0) {
|
|
||||||
if (hitBoundary) { *hitBoundary = NO; }
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
|
||||||
});
|
|
||||||
|
|
||||||
NSInteger length = context.length;
|
|
||||||
NSInteger end = length;
|
|
||||||
while (end > 0) {
|
|
||||||
unichar ch = [context characterAtIndex:end - 1];
|
|
||||||
if ([whitespaceSet characterIsMember:ch]) {
|
|
||||||
end -= 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NSInteger searchEnd = end;
|
|
||||||
while (searchEnd > 0) {
|
|
||||||
unichar ch = [context characterAtIndex:searchEnd - 1];
|
|
||||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
|
||||||
searchEnd -= 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger boundaryIndex = NSNotFound;
|
|
||||||
for (NSInteger i = searchEnd - 1; i >= 0; i--) {
|
|
||||||
unichar ch = [context characterAtIndex:i];
|
|
||||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
|
||||||
boundaryIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL boundaryFound = (boundaryIndex != NSNotFound);
|
|
||||||
NSInteger deleteCount = length;
|
|
||||||
if (boundaryIndex != NSNotFound) {
|
|
||||||
deleteCount = length - (boundaryIndex + 1);
|
|
||||||
}
|
|
||||||
deleteCount = MAX(deleteCount, 1);
|
|
||||||
if (hitBoundary) {
|
|
||||||
*hitBoundary = boundaryFound;
|
|
||||||
}
|
|
||||||
return MIN(deleteCount, kKBBackspaceClearMaxStep);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Long Press State
|
|
||||||
|
|
||||||
- (void)kb_handleBackspaceLongPressChanged:(UILongPressGestureRecognizer *)gr {
|
|
||||||
if (!self.backspaceHoldActive) { return; }
|
|
||||||
if (!self.showClearLabelEnabled) { return; }
|
|
||||||
[self kb_showBackspaceClearLabelIfNeeded];
|
|
||||||
UIView *hostView = [self kb_hostView];
|
|
||||||
if (!hostView) { return; }
|
|
||||||
CGPoint point = [gr locationInView:hostView];
|
|
||||||
self.backspaceLastTouchPointInSelf = point;
|
|
||||||
self.backspaceHasLastTouchPoint = YES;
|
|
||||||
BOOL inside = [self kb_isPointInsideBackspaceClearLabel:point];
|
|
||||||
[self kb_setBackspaceClearHighlighted:inside];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_handleBackspaceLongPressEnded:(UILongPressGestureRecognizer *)gr {
|
|
||||||
BOOL shouldClear = NO;
|
|
||||||
if (self.showClearLabelEnabled) {
|
|
||||||
shouldClear = self.backspaceClearHighlighted;
|
|
||||||
if (!shouldClear) {
|
|
||||||
UIView *hostView = [self kb_hostView];
|
|
||||||
CGPoint point = CGPointZero;
|
|
||||||
if (gr && hostView) {
|
|
||||||
point = [gr locationInView:hostView];
|
|
||||||
} else if (self.backspaceHasLastTouchPoint) {
|
|
||||||
point = self.backspaceLastTouchPointInSelf;
|
|
||||||
}
|
|
||||||
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
NSLog(@"[kb_handleBackspaceLongPressEnded] shouldClear=%@ highlighted=%@ labelHidden=%@",
|
|
||||||
shouldClear ? @"YES" : @"NO",
|
|
||||||
self.backspaceClearHighlighted ? @"YES" : @"NO",
|
|
||||||
self.backspaceClearLabel.hidden ? @"YES" : @"NO");
|
|
||||||
#endif
|
|
||||||
self.backspaceHoldActive = NO;
|
|
||||||
self.backspaceChunkModeActive = NO;
|
|
||||||
self.backspaceHoldToken += 1;
|
|
||||||
self.backspaceHasLastTouchPoint = NO;
|
|
||||||
[self kb_hideBackspaceClearLabel];
|
|
||||||
if (shouldClear) {
|
|
||||||
[self kb_clearAllInput];
|
|
||||||
} else {
|
|
||||||
self.pendingClearBefore = nil;
|
|
||||||
self.pendingClearAfter = nil;
|
|
||||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
|
||||||
[[KBInputBufferManager shared] commitLiveToManual];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Clear Label
|
|
||||||
|
|
||||||
- (void)kb_showBackspaceClearLabelIfNeeded {
|
|
||||||
UIView *hostView = [self kb_hostView];
|
|
||||||
if (!hostView || !self.backspaceButton) { return; }
|
|
||||||
UILabel *label = self.backspaceClearLabel;
|
|
||||||
[self kb_refreshBackspaceClearLabelColors];
|
|
||||||
if (!label.superview) {
|
|
||||||
[hostView addSubview:label];
|
|
||||||
}
|
|
||||||
[self kb_updateBackspaceClearLabelFrame];
|
|
||||||
[hostView bringSubviewToFront:label];
|
|
||||||
if (label.hidden) {
|
|
||||||
label.alpha = 0.0;
|
|
||||||
label.hidden = NO;
|
|
||||||
[self kb_playLightHaptic];
|
|
||||||
[UIView animateWithDuration:0.12 animations:^{
|
|
||||||
label.alpha = 1.0;
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_hideBackspaceClearLabel {
|
|
||||||
if (!_backspaceClearLabel || _backspaceClearLabel.hidden) { return; }
|
|
||||||
_backspaceClearLabel.hidden = YES;
|
|
||||||
_backspaceClearLabel.alpha = 1.0;
|
|
||||||
[self kb_setBackspaceClearHighlighted:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_updateBackspaceClearLabelFrame {
|
|
||||||
UIView *hostView = [self kb_hostView];
|
|
||||||
if (!hostView || !self.backspaceButton || !self.backspaceClearLabel) { return; }
|
|
||||||
CGRect btnFrame = [self.backspaceButton convertRect:self.backspaceButton.bounds toView:hostView];
|
|
||||||
UILabel *label = self.backspaceClearLabel;
|
|
||||||
CGSize textSize = [label sizeThatFits:CGSizeMake(CGFLOAT_MAX, kKBBackspaceClearLabelHeight)];
|
|
||||||
CGFloat width = MAX(textSize.width + kKBBackspaceClearLabelPaddingX * 2.0, 60.0);
|
|
||||||
CGFloat height = kKBBackspaceClearLabelHeight;
|
|
||||||
CGFloat x = CGRectGetMidX(btnFrame) - width * 0.5;
|
|
||||||
CGFloat y = CGRectGetMinY(btnFrame) - height - kKBBackspaceClearLabelTopGap;
|
|
||||||
if (x < kKBBackspaceClearLabelHorizontalInset) { x = kKBBackspaceClearLabelHorizontalInset; }
|
|
||||||
CGFloat maxX = CGRectGetWidth(hostView.bounds) - kKBBackspaceClearLabelHorizontalInset - width;
|
|
||||||
if (x > maxX) { x = maxX; }
|
|
||||||
if (y < 0) { y = 0; }
|
|
||||||
label.frame = CGRectIntegral(CGRectMake(x, y, width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)kb_isPointInsideBackspaceClearLabel:(CGPoint)point {
|
|
||||||
if (!self.backspaceClearLabel || self.backspaceClearLabel.hidden) { return NO; }
|
|
||||||
[self kb_updateBackspaceClearLabelFrame];
|
|
||||||
CGRect hitFrame = CGRectInset(self.backspaceClearLabel.frame, -12.0, -10.0);
|
|
||||||
return CGRectContainsPoint(hitFrame, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_setBackspaceClearHighlighted:(BOOL)highlighted {
|
|
||||||
if (self.backspaceClearHighlighted == highlighted) { return; }
|
|
||||||
self.backspaceClearHighlighted = highlighted;
|
|
||||||
[self kb_refreshBackspaceClearLabelColors];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_refreshBackspaceClearLabelColors {
|
|
||||||
UILabel *label = self.backspaceClearLabel;
|
|
||||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
|
||||||
label.backgroundColor = self.backspaceClearHighlighted
|
|
||||||
? [self kb_backspaceClearLabelHighlightedColor]
|
|
||||||
: [self kb_backspaceClearLabelNormalColor];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIColor *)kb_backspaceClearLabelNormalColor {
|
|
||||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
|
||||||
return t.keyHighlightBackground ?: [UIColor colorWithWhite:0.9 alpha:1.0];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIColor *)kb_backspaceClearLabelHighlightedColor {
|
|
||||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
|
||||||
return t.accentColor ?: t.keyHighlightBackground ?: [UIColor colorWithWhite:0.8 alpha:1.0];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_playLightHaptic {
|
|
||||||
if (@available(iOS 10.0, *)) {
|
|
||||||
UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
|
||||||
[gen prepare];
|
|
||||||
[gen impactOccurred];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)backspaceClearLabel {
|
|
||||||
if (!_backspaceClearLabel) {
|
|
||||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
|
||||||
label.text = KBLocalized(@"Clear");
|
|
||||||
label.textAlignment = NSTextAlignmentCenter;
|
|
||||||
label.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
|
||||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
|
||||||
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
|
||||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
|
||||||
label.layer.masksToBounds = YES;
|
|
||||||
label.hidden = YES;
|
|
||||||
label.userInteractionEnabled = NO;
|
|
||||||
_backspaceClearLabel = label;
|
|
||||||
}
|
|
||||||
return _backspaceClearLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Clear
|
|
||||||
|
|
||||||
- (void)kb_clearAllInput {
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (ivc) {
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
|
||||||
}
|
|
||||||
self.pendingClearBefore = nil;
|
|
||||||
self.pendingClearAfter = nil;
|
|
||||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
|
||||||
self.backspaceClearToken += 1;
|
|
||||||
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
|
||||||
NSUInteger token = self.backspaceClearToken;
|
|
||||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_clearAllInputStepForToken:(NSUInteger)token
|
|
||||||
guard:(NSInteger)guard
|
|
||||||
emptyRounds:(NSInteger)emptyRounds {
|
|
||||||
if (token != self.backspaceClearToken) { return; }
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (!ivc) { return; }
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
NSInteger nextEmptyRounds = emptyRounds;
|
|
||||||
static NSCharacterSet *stopBoundarySet = nil;
|
|
||||||
static NSCharacterSet *trailingBoundarySet = nil;
|
|
||||||
static NSCharacterSet *trailingWhitespaceSet = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
// stopBoundary: 遇到这些符号就停(不删除它)
|
|
||||||
// - 句末符号:. ! ? 。!?
|
|
||||||
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
|
||||||
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
|
||||||
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
|
||||||
|
|
||||||
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
|
||||||
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
|
||||||
trailingBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
|
||||||
|
|
||||||
// trailingWhitespace: 只跳过空格/Tab(不包含换行,换行由 stopBoundarySet 处理)
|
|
||||||
trailingWhitespaceSet = [NSCharacterSet whitespaceCharacterSet];
|
|
||||||
});
|
|
||||||
KBClearPhase phase = self.backspaceClearPhase;
|
|
||||||
|
|
||||||
NSInteger deletedThisTick = 0;
|
|
||||||
BOOL shouldStop = NO;
|
|
||||||
NSString *lastBefore = nil;
|
|
||||||
for (NSInteger i = 0; i < kKBBackspaceClearDeletesPerTick; i++) {
|
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
if (before.length == 0) {
|
|
||||||
nextEmptyRounds += 1;
|
|
||||||
// 宿主(微信/QQ 等)可能在长文本场景下返回空 context,即使还有很多内容。
|
|
||||||
// 为了避免一次“清空”误删全文:一旦拿不到 before,就立刻停止本次清空。
|
|
||||||
shouldStop = YES;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
nextEmptyRounds = 0;
|
|
||||||
|
|
||||||
if (lastBefore && [before isEqualToString:lastBefore] && deletedThisTick > 0) {
|
|
||||||
// 宿主未及时刷新 context,留到下一 tick 再继续,避免越界/重复记录
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lastBefore = before;
|
|
||||||
|
|
||||||
// 取最后一个组合字符
|
|
||||||
__block NSString *lastChar = @"";
|
|
||||||
[before enumerateSubstringsInRange:NSMakeRange(0, before.length)
|
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
|
||||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
|
||||||
lastChar = substring ?: @"";
|
|
||||||
*stop = YES;
|
|
||||||
}];
|
|
||||||
if (lastChar.length == 0) { break; }
|
|
||||||
|
|
||||||
BOOL isWhitespace = ([lastChar rangeOfCharacterFromSet:trailingWhitespaceSet].location != NSNotFound);
|
|
||||||
BOOL isStopBoundary = ([lastChar rangeOfCharacterFromSet:stopBoundarySet].location != NSNotFound);
|
|
||||||
BOOL isTrailingBoundary = ([lastChar rangeOfCharacterFromSet:trailingBoundarySet].location != NSNotFound);
|
|
||||||
|
|
||||||
if (phase == KBClearPhaseSkipWhitespace) {
|
|
||||||
if (isWhitespace) {
|
|
||||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
|
||||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
|
||||||
deletedThisTick += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
phase = KBClearPhaseSkipTrailingBoundary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase == KBClearPhaseSkipTrailingBoundary) {
|
|
||||||
if (isTrailingBoundary) {
|
|
||||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
|
||||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
|
||||||
deletedThisTick += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
phase = KBClearPhaseDeleteUntilBoundary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase == DeleteUntilBoundary
|
|
||||||
if (isStopBoundary) {
|
|
||||||
shouldStop = YES; // 保留该句末符号
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
|
||||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
|
||||||
deletedThisTick += 1;
|
|
||||||
if (guard + deletedThisTick >= kKBBackspaceClearMaxDeletes) { break; }
|
|
||||||
if (deletedThisTick >= kKBBackspaceClearMaxStep) { break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
self.backspaceClearPhase = phase;
|
|
||||||
NSInteger nextGuard = guard + deletedThisTick;
|
|
||||||
if (nextGuard >= kKBBackspaceClearMaxDeletes ||
|
|
||||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds ||
|
|
||||||
shouldStop) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
__weak typeof(self) weakSelf = self;
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
||||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
|
||||||
dispatch_get_main_queue(), ^{
|
|
||||||
__strong typeof(weakSelf) selfStrong = weakSelf;
|
|
||||||
[selfStrong kb_clearAllInputStepForToken:token
|
|
||||||
guard:nextGuard
|
|
||||||
emptyRounds:nextEmptyRounds];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Helpers
|
|
||||||
|
|
||||||
- (UIView *)kb_hostView {
|
|
||||||
if (self.containerView) { return self.containerView; }
|
|
||||||
return self.backspaceButton.superview;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_captureDeletionSnapshotIfNeeded {
|
|
||||||
if ([KBBackspaceUndoManager shared].hasUndo) { return; }
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (!ivc) { return; }
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
[[KBBackspaceUndoManager shared] recordDeletionSnapshotBefore:proxy.documentContextBeforeInput
|
|
||||||
after:proxy.documentContextAfterInput];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_capturePendingClearSnapshotIfNeeded {
|
|
||||||
if (self.pendingClearBefore.length > 0 || self.pendingClearAfter.length > 0) { return; }
|
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
|
||||||
if (!ivc) { return; }
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
self.pendingClearBefore = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
self.pendingClearAfter = proxy.documentContextAfterInput ?: @"";
|
|
||||||
#if DEBUG
|
|
||||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/before] len=%lu text=%@", (unsigned long)self.pendingClearBefore.length, self.pendingClearBefore);
|
|
||||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/after] len=%lu text=%@", (unsigned long)self.pendingClearAfter.length, self.pendingClearAfter);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// KBBackspaceUndoManager.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
|
||||||
|
|
||||||
@interface KBBackspaceUndoManager : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL hasUndo;
|
|
||||||
|
|
||||||
+ (instancetype)shared;
|
|
||||||
|
|
||||||
/// 记录一次删除前的快照(不改变撤销按钮显示)。
|
|
||||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after;
|
|
||||||
|
|
||||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput/AfterInput)。
|
|
||||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after;
|
|
||||||
|
|
||||||
/// 记录本次将被 deleteBackward 的内容,并执行 deleteBackward(支持多次累计,撤销时一次性插回)。
|
|
||||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count;
|
|
||||||
|
|
||||||
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
|
||||||
|
|
||||||
/// 非删除行为触发时,清理撤销状态
|
|
||||||
- (void)registerNonClearAction;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
//
|
|
||||||
// KBBackspaceUndoManager.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBBackspaceUndoManager.h"
|
|
||||||
#import "KBResponderUtils.h"
|
|
||||||
#import "KBInputBufferManager.h"
|
|
||||||
|
|
||||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
static NSString *KBLogString(NSString *tag, NSString *text) {
|
|
||||||
NSString *safeTag = tag ?: @"";
|
|
||||||
NSString *safeText = text ?: @"";
|
|
||||||
if (safeText.length <= 2000) {
|
|
||||||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
|
||||||
}
|
|
||||||
NSString *head = [safeText substringToIndex:800];
|
|
||||||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
|
||||||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
|
||||||
}
|
|
||||||
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
|
||||||
#else
|
|
||||||
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
|
||||||
KBUndoSnapshotSourceNone = 0,
|
|
||||||
KBUndoSnapshotSourceDeletionSnapshot,
|
|
||||||
KBUndoSnapshotSourceClear
|
|
||||||
};
|
|
||||||
|
|
||||||
@interface KBBackspaceUndoManager ()
|
|
||||||
@property (nonatomic, copy) NSString *undoText;
|
|
||||||
@property (nonatomic, assign) NSInteger undoAfterLength;
|
|
||||||
@property (nonatomic, assign) BOOL hasUndo;
|
|
||||||
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
|
||||||
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBBackspaceUndoManager
|
|
||||||
|
|
||||||
+ (instancetype)shared {
|
|
||||||
static KBBackspaceUndoManager *mgr = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
mgr = [[KBBackspaceUndoManager alloc] init];
|
|
||||||
});
|
|
||||||
return mgr;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_undoText = @"";
|
|
||||||
_undoAfterLength = 0;
|
|
||||||
_snapshotSource = KBUndoSnapshotSourceNone;
|
|
||||||
_undoDeletedPieces = [NSMutableArray array];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
|
||||||
if (!proxy || count == 0) { return; }
|
|
||||||
|
|
||||||
NSString *selected = proxy.selectedText ?: @"";
|
|
||||||
NSString *ctxBefore = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
NSString *ctxAfter = proxy.documentContextAfterInput ?: @"";
|
|
||||||
NSUInteger ctxLen = ctxBefore.length + ctxAfter.length;
|
|
||||||
BOOL isSelectAllLike = (selected.length > 0 &&
|
|
||||||
(ctxLen == 0 || selected.length >= MAX((NSUInteger)40, ctxLen * 2)));
|
|
||||||
if (isSelectAllLike) {
|
|
||||||
// “全选删除”在微信/QQ中通常拿不到可靠的全文,因此禁用撤销,避免插回错误/不完整内容。
|
|
||||||
if (self.hasUndo) {
|
|
||||||
[self registerNonClearAction];
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
KB_UNDO_LOG(@"captureAndDelete/selectAllDisableUndo", selected);
|
|
||||||
#endif
|
|
||||||
[proxy deleteBackward];
|
|
||||||
[[KBInputBufferManager shared] resetWithText:@""];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self.hasUndo) {
|
|
||||||
[self.undoDeletedPieces removeAllObjects];
|
|
||||||
self.undoText = @"";
|
|
||||||
self.undoAfterLength = 0;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL didAppend = NO;
|
|
||||||
NSString *lastObservedBefore = nil;
|
|
||||||
for (NSUInteger i = 0; i < count; i++) {
|
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
if (before.length > 0) {
|
|
||||||
// 若宿主在同一 runloop 内不更新 context,则跳过记录,避免把同一个字符重复记录成“多句”。
|
|
||||||
if (lastObservedBefore && [before isEqualToString:lastObservedBefore]) {
|
|
||||||
// still delete, but don't record
|
|
||||||
} else {
|
|
||||||
NSString *piece = [self kb_lastComposedCharacterFromString:before];
|
|
||||||
if (piece.length > 0) {
|
|
||||||
[self.undoDeletedPieces addObject:piece];
|
|
||||||
didAppend = YES;
|
|
||||||
}
|
|
||||||
lastObservedBefore = before;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[proxy deleteBackward];
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
if (didAppend) {
|
|
||||||
NSUInteger piecesCount = self.undoDeletedPieces.count;
|
|
||||||
if (piecesCount <= 20) {
|
|
||||||
KB_UNDO_LOG(@"captureAndDelete/undoInsertTextNow", [self kb_buildUndoInsertTextFromPieces]);
|
|
||||||
} else if (piecesCount % 50 == 0) {
|
|
||||||
NSString *lastPiece = self.undoDeletedPieces.lastObject ?: @"";
|
|
||||||
NSLog(@"[captureAndDelete/undoPieces] pieces=%lu lastPiece=%@",
|
|
||||||
(unsigned long)piecesCount,
|
|
||||||
lastPiece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
|
||||||
if (self.hasUndo) { return; }
|
|
||||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
|
||||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
|
||||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
|
||||||
if (fallbackText.length > 0) {
|
|
||||||
self.undoText = fallbackText;
|
|
||||||
self.undoAfterLength = 0;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
||||||
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSString *safeBefore = before ?: @"";
|
|
||||||
NSString *safeAfter = after ?: @"";
|
|
||||||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
|
||||||
if (full.length == 0) { return; }
|
|
||||||
self.undoText = full;
|
|
||||||
self.undoAfterLength = (NSInteger)safeAfter.length;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
||||||
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
|
||||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
|
||||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
|
||||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
|
||||||
|
|
||||||
NSString *safeBefore = before ?: @"";
|
|
||||||
NSString *safeAfter = after ?: @"";
|
|
||||||
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
|
||||||
|
|
||||||
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
|
||||||
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
|
||||||
|
|
||||||
if (candidate.length == 0) { return; }
|
|
||||||
|
|
||||||
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
|
||||||
|
|
||||||
if (self.undoText.length > 0) {
|
|
||||||
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
|
||||||
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
|
||||||
if (candidate.length > self.undoText.length) {
|
|
||||||
self.undoText = candidate;
|
|
||||||
self.undoAfterLength = candidateAfterLen;
|
|
||||||
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
|
||||||
} else {
|
|
||||||
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
|
||||||
}
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.undoText = candidate;
|
|
||||||
self.undoAfterLength = candidateAfterLen;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
|
||||||
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
|
||||||
[self kb_updateHasUndo:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
|
||||||
if (!self.hasUndo) { return; }
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
|
||||||
if (!ivc) { return; }
|
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
||||||
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
|
||||||
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
|
||||||
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
|
||||||
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
|
||||||
if (insertText.length > 0) {
|
|
||||||
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
|
||||||
[proxy insertText:insertText];
|
|
||||||
[[KBInputBufferManager shared] appendText:insertText];
|
|
||||||
} else if (self.undoText.length > 0) {
|
|
||||||
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
|
||||||
[self kb_clearAllTextForProxy:proxy];
|
|
||||||
[proxy insertText:self.undoText];
|
|
||||||
if (self.undoAfterLength > 0 &&
|
|
||||||
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
|
||||||
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
|
||||||
}
|
|
||||||
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.undoText = @"";
|
|
||||||
self.undoAfterLength = 0;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
|
||||||
[self.undoDeletedPieces removeAllObjects];
|
|
||||||
[self kb_updateHasUndo:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)registerNonClearAction {
|
|
||||||
if (!self.hasUndo) { return; }
|
|
||||||
if (self.undoText.length > 0) {
|
|
||||||
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
|
||||||
}
|
|
||||||
if (self.undoDeletedPieces.count > 0) {
|
|
||||||
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
|
||||||
}
|
|
||||||
self.undoText = @"";
|
|
||||||
self.undoAfterLength = 0;
|
|
||||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
|
||||||
[self.undoDeletedPieces removeAllObjects];
|
|
||||||
[self kb_updateHasUndo:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Helpers
|
|
||||||
|
|
||||||
- (void)kb_updateHasUndo:(BOOL)hasUndo {
|
|
||||||
if (self.hasUndo == hasUndo) { return; }
|
|
||||||
self.hasUndo = hasUndo;
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
|
||||||
if (text.length == 0) { return @""; }
|
|
||||||
__block NSString *last = @"";
|
|
||||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
|
||||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
|
||||||
last = substring ?: @"";
|
|
||||||
*stop = YES;
|
|
||||||
}];
|
|
||||||
return last ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
|
||||||
if (self.undoDeletedPieces.count == 0) { return @""; }
|
|
||||||
NSMutableString *result = [NSMutableString string];
|
|
||||||
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
|
||||||
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
|
||||||
if (piece.length == 0) { continue; }
|
|
||||||
[result appendString:piece];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const NSInteger kKBUndoClearMaxRounds = 200;
|
|
||||||
|
|
||||||
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
|
||||||
if (!proxy) { return; }
|
|
||||||
|
|
||||||
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
|
||||||
NSInteger guard = 0;
|
|
||||||
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
|
||||||
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
|
||||||
NSInteger offset = (NSInteger)contextAfter.length;
|
|
||||||
[proxy adjustTextPositionByCharacterOffset:offset];
|
|
||||||
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
|
||||||
[proxy deleteBackward];
|
|
||||||
}
|
|
||||||
guard += 1;
|
|
||||||
contextAfter = proxy.documentContextAfterInput ?: @"";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger guard = 0;
|
|
||||||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
|
||||||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
|
||||||
[proxy deleteBackward];
|
|
||||||
}
|
|
||||||
guard += 1;
|
|
||||||
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
//
|
|
||||||
// KBExtensionAppLauncher.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link + 响应链兜底)。
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBExtensionAppLauncher : NSObject
|
|
||||||
|
|
||||||
/// 通用入口:优先尝试 primaryURL,失败后尝试 fallbackURL,
|
|
||||||
/// 两者都失败时再通过响应链(openURL:)做兜底。
|
|
||||||
/// - Parameters:
|
|
||||||
/// - primaryURL: 第一优先尝试的 URL(可为 Scheme 或 UL)
|
|
||||||
/// - fallbackURL: 失败时的备用 URL(可为 nil)
|
|
||||||
/// - ivc: 当前的 UIInputViewController(用于 extensionContext openURL)
|
|
||||||
/// - source: 兜底时用作起点的 responder(通常传 self 或 self.view)
|
|
||||||
/// - completion: 最终是否“看起来已成功发起”打开动作(不保证一定跳转到 App)
|
|
||||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
|
||||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
|
||||||
usingInputController:(UIInputViewController *)ivc
|
|
||||||
source:(UIResponder *)source
|
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
|
||||||
|
|
||||||
/// 简化版:只针对单一 Scheme 做尝试 + 响应链兜底。
|
|
||||||
+ (void)openScheme:(NSURL *)scheme
|
|
||||||
usingInputController:(UIInputViewController *)ivc
|
|
||||||
source:(UIResponder *)source
|
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//
|
|
||||||
// KBExtensionAppLauncher.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBExtensionAppLauncher.h"
|
|
||||||
#import <objc/message.h>
|
|
||||||
|
|
||||||
@implementation KBExtensionAppLauncher
|
|
||||||
|
|
||||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
|
||||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
|
||||||
usingInputController:(UIInputViewController *)ivc
|
|
||||||
source:(UIResponder *)source
|
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
|
||||||
if (!ivc || (!primaryURL && !fallbackURL)) {
|
|
||||||
if (completion) { completion(NO); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保证在主线程回调,避免调用方再做一次 dispatch。
|
|
||||||
void (^finish)(BOOL) = ^(BOOL ok){
|
|
||||||
if (!completion) return;
|
|
||||||
if ([NSThread isMainThread]) {
|
|
||||||
completion(ok);
|
|
||||||
} else {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(ok); });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
NSURL *first = primaryURL ?: fallbackURL;
|
|
||||||
NSURL *second = (first == primaryURL) ? fallbackURL : nil;
|
|
||||||
|
|
||||||
if (!first) {
|
|
||||||
finish(NO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[ivc.extensionContext openURL:first completionHandler:^(BOOL ok) {
|
|
||||||
if (ok) {
|
|
||||||
finish(YES);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (second) {
|
|
||||||
[ivc.extensionContext openURL:second completionHandler:^(BOOL ok2) {
|
|
||||||
if (ok2) {
|
|
||||||
finish(YES);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BOOL bridged = [self p_bridgeFirst:first second:second from:source];
|
|
||||||
finish(bridged);
|
|
||||||
}];
|
|
||||||
} else {
|
|
||||||
BOOL bridged = [self p_bridgeFirst:first second:nil from:source];
|
|
||||||
finish(bridged);
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (void)openScheme:(NSURL *)scheme
|
|
||||||
usingInputController:(UIInputViewController *)ivc
|
|
||||||
source:(UIResponder *)source
|
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
|
||||||
[self openPrimaryURL:scheme
|
|
||||||
fallbackURL:nil
|
|
||||||
usingInputController:ivc
|
|
||||||
source:source
|
|
||||||
completion:completion];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Private
|
|
||||||
|
|
||||||
// 通过响应链尝试调用 openURL:(等价于原 KBURLOpenBridge 实现)
|
|
||||||
+ (BOOL)p_openURLViaResponder:(NSURL *)url from:(UIResponder *)start {
|
|
||||||
#if KB_URL_BRIDGE_ENABLE
|
|
||||||
if (!url || !start) return NO;
|
|
||||||
SEL sel = NSSelectorFromString(@"openURL:");
|
|
||||||
UIResponder *responder = start;
|
|
||||||
while (responder) {
|
|
||||||
@try {
|
|
||||||
if ([responder respondsToSelector:sel]) {
|
|
||||||
BOOL handled = NO;
|
|
||||||
BOOL (*funcBool)(id, SEL, NSURL *) = (BOOL (*)(id, SEL, NSURL *))objc_msgSend;
|
|
||||||
if (funcBool) {
|
|
||||||
handled = funcBool(responder, sel, url);
|
|
||||||
} else {
|
|
||||||
#pragma clang diagnostic push
|
|
||||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
|
||||||
[responder performSelector:sel withObject:url];
|
|
||||||
handled = YES;
|
|
||||||
#pragma clang diagnostic pop
|
|
||||||
}
|
|
||||||
return handled;
|
|
||||||
}
|
|
||||||
} @catch (__unused NSException *e) {
|
|
||||||
// ignore and continue
|
|
||||||
}
|
|
||||||
responder = responder.nextResponder;
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
#else
|
|
||||||
(void)url; (void)start;
|
|
||||||
return NO;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (BOOL)p_bridgeFirst:(NSURL * _Nullable)first
|
|
||||||
second:(NSURL * _Nullable)second
|
|
||||||
from:(UIResponder *)source {
|
|
||||||
BOOL bridged = NO;
|
|
||||||
if (first) {
|
|
||||||
bridged = [self p_openURLViaResponder:first from:source];
|
|
||||||
}
|
|
||||||
if (!bridged && second) {
|
|
||||||
bridged = [self p_openURLViaResponder:second from:source];
|
|
||||||
}
|
|
||||||
return bridged;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@protocol UITextDocumentProxy;
|
|
||||||
|
|
||||||
@interface KBInputBufferManager : NSObject
|
|
||||||
|
|
||||||
+ (instancetype)shared;
|
|
||||||
|
|
||||||
@property (nonatomic, copy, readonly) NSString *liveText;
|
|
||||||
@property (nonatomic, copy, readonly) NSString *manualSnapshot;
|
|
||||||
@property (nonatomic, copy, readonly) NSString *pendingClearSnapshot;
|
|
||||||
|
|
||||||
- (void)seedIfEmptyWithContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
|
||||||
- (void)updateFromExternalContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
|
||||||
- (void)refreshFromProxyIfPossible:(nullable id<UITextDocumentProxy>)proxy;
|
|
||||||
- (void)prepareSnapshotForDeleteWithContextBefore:(nullable NSString *)before
|
|
||||||
after:(nullable NSString *)after;
|
|
||||||
- (void)beginPendingClearSnapshot;
|
|
||||||
- (void)clearPendingClearSnapshot;
|
|
||||||
- (void)resetWithText:(NSString *)text;
|
|
||||||
- (void)appendText:(NSString *)text;
|
|
||||||
- (void)deleteBackwardByCount:(NSUInteger)count;
|
|
||||||
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count;
|
|
||||||
- (void)applyHoldDeleteCount:(NSUInteger)count;
|
|
||||||
- (void)applyClearDeleteCount:(NSUInteger)count;
|
|
||||||
- (void)clearAllLiveText;
|
|
||||||
- (void)commitLiveToManual;
|
|
||||||
- (void)restoreManualSnapshot;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
#import "KBInputBufferManager.h"
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
static NSString *KBLogString2(NSString *tag, NSString *text) {
|
|
||||||
NSString *safeTag = tag ?: @"";
|
|
||||||
NSString *safeText = text ?: @"";
|
|
||||||
if (safeText.length <= 2000) {
|
|
||||||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
|
||||||
}
|
|
||||||
NSString *head = [safeText substringToIndex:800];
|
|
||||||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
|
||||||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
|
||||||
}
|
|
||||||
#define KB_BUF_LOG(tag, text) NSLog(@"❤️=%@", KBLogString2((tag), (text)))
|
|
||||||
#else
|
|
||||||
#define KB_BUF_LOG(tag, text) do {} while(0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@interface KBInputBufferManager ()
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *liveText;
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *manualSnapshot;
|
|
||||||
@property (nonatomic, copy, readwrite) NSString *pendingClearSnapshot;
|
|
||||||
@property (nonatomic, assign) BOOL manualSnapshotDirty;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBInputBufferManager
|
|
||||||
|
|
||||||
+ (instancetype)shared {
|
|
||||||
static KBInputBufferManager *mgr = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
mgr = [[KBInputBufferManager alloc] init];
|
|
||||||
});
|
|
||||||
return mgr;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_liveText = @"";
|
|
||||||
_manualSnapshot = @"";
|
|
||||||
_pendingClearSnapshot = @"";
|
|
||||||
_manualSnapshotDirty = NO;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)seedIfEmptyWithContextBefore:(NSString *)before after:(NSString *)after {
|
|
||||||
if (self.liveText.length > 0 || self.manualSnapshot.length > 0) { return; }
|
|
||||||
NSString *safeBefore = before ?: @"";
|
|
||||||
NSString *safeAfter = after ?: @"";
|
|
||||||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
|
||||||
if (full.length == 0) { return; }
|
|
||||||
self.liveText = full;
|
|
||||||
self.manualSnapshot = full;
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"seedIfEmpty", full);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updateFromExternalContextBefore:(NSString *)before after:(NSString *)after {
|
|
||||||
NSString *safeBefore = before ?: @"";
|
|
||||||
NSString *safeAfter = after ?: @"";
|
|
||||||
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
|
||||||
if (context.length == 0) { return; }
|
|
||||||
|
|
||||||
// 微信/QQ 等宿主通常只提供光标附近“截断窗口”,不应当作为全文快照。
|
|
||||||
// 这里只更新 liveText,给删除/清空逻辑做参考;manualSnapshot 仅由键盘自身输入/撤销来维护。
|
|
||||||
self.liveText = context;
|
|
||||||
self.manualSnapshotDirty = YES;
|
|
||||||
#if DEBUG
|
|
||||||
static NSUInteger sExternalLogCounter = 0;
|
|
||||||
sExternalLogCounter += 1;
|
|
||||||
if (sExternalLogCounter % 12 == 0) {
|
|
||||||
KB_BUF_LOG(@"updateFromExternalContext/liveOnly", context);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)refreshFromProxyIfPossible:(id<UITextDocumentProxy>)proxy {
|
|
||||||
NSString *harvested = [self kb_harvestFullTextFromProxy:proxy];
|
|
||||||
if (harvested.length == 0) {
|
|
||||||
KB_BUF_LOG(@"refreshFromProxy/failedOrUnsupported", @"");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL manualEmpty = (self.manualSnapshot.length == 0);
|
|
||||||
BOOL longerThanManual = (harvested.length > self.manualSnapshot.length);
|
|
||||||
if (!(manualEmpty || longerThanManual)) {
|
|
||||||
KB_BUF_LOG(@"refreshFromProxy/ignoredShorter", harvested);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.liveText = harvested;
|
|
||||||
self.manualSnapshot = harvested;
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"refreshFromProxy/accepted", harvested);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)prepareSnapshotForDeleteWithContextBefore:(NSString *)before
|
|
||||||
after:(NSString *)after {
|
|
||||||
NSString *safeBefore = before ?: @"";
|
|
||||||
NSString *safeAfter = after ?: @"";
|
|
||||||
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
|
||||||
|
|
||||||
BOOL manualValid = (self.manualSnapshot.length > 0 &&
|
|
||||||
(context.length == 0 ||
|
|
||||||
(self.manualSnapshot.length >= context.length &&
|
|
||||||
[self.manualSnapshot rangeOfString:context].location != NSNotFound)));
|
|
||||||
if (manualValid) { return; }
|
|
||||||
|
|
||||||
if (self.liveText.length > 0) {
|
|
||||||
self.manualSnapshot = self.liveText;
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"prepareSnapshotForDelete/fromLiveText", self.manualSnapshot);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (context.length > 0) {
|
|
||||||
self.manualSnapshot = context;
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"prepareSnapshotForDelete/fromContext", self.manualSnapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)beginPendingClearSnapshot {
|
|
||||||
if (self.pendingClearSnapshot.length > 0) { return; }
|
|
||||||
if (self.manualSnapshot.length > 0) {
|
|
||||||
self.pendingClearSnapshot = self.manualSnapshot;
|
|
||||||
KB_BUF_LOG(@"beginPendingClearSnapshot/fromManual", self.pendingClearSnapshot);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (self.liveText.length > 0) {
|
|
||||||
self.pendingClearSnapshot = self.liveText;
|
|
||||||
KB_BUF_LOG(@"beginPendingClearSnapshot/fromLive", self.pendingClearSnapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)clearPendingClearSnapshot {
|
|
||||||
self.pendingClearSnapshot = @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)resetWithText:(NSString *)text {
|
|
||||||
NSString *safe = text ?: @"";
|
|
||||||
self.liveText = safe;
|
|
||||||
self.manualSnapshot = safe;
|
|
||||||
self.pendingClearSnapshot = @"";
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"resetWithText", safe);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)appendText:(NSString *)text {
|
|
||||||
if (text.length == 0) { return; }
|
|
||||||
[self kb_syncManualSnapshotIfNeeded];
|
|
||||||
self.liveText = [self.liveText stringByAppendingString:text];
|
|
||||||
self.manualSnapshot = [self.manualSnapshot stringByAppendingString:text];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)deleteBackwardByCount:(NSUInteger)count {
|
|
||||||
if (count == 0) { return; }
|
|
||||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
|
||||||
self.manualSnapshot = [self kb_stringByDeletingComposedCharacters:count from:self.manualSnapshot];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count {
|
|
||||||
[self kb_syncManualSnapshotIfNeeded];
|
|
||||||
[self deleteBackwardByCount:count];
|
|
||||||
[self appendText:text];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)applyHoldDeleteCount:(NSUInteger)count {
|
|
||||||
if (count == 0) { return; }
|
|
||||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
|
||||||
self.manualSnapshotDirty = YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)applyClearDeleteCount:(NSUInteger)count {
|
|
||||||
if (count == 0) { return; }
|
|
||||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
|
||||||
self.manualSnapshotDirty = YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)clearAllLiveText {
|
|
||||||
self.liveText = @"";
|
|
||||||
self.pendingClearSnapshot = @"";
|
|
||||||
self.manualSnapshotDirty = YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)commitLiveToManual {
|
|
||||||
self.manualSnapshot = self.liveText ?: @"";
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
KB_BUF_LOG(@"commitLiveToManual", self.manualSnapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)restoreManualSnapshot {
|
|
||||||
self.liveText = self.manualSnapshot ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Helpers
|
|
||||||
|
|
||||||
- (void)kb_syncManualSnapshotIfNeeded {
|
|
||||||
if (!self.manualSnapshotDirty) { return; }
|
|
||||||
self.manualSnapshot = self.liveText ?: @"";
|
|
||||||
self.manualSnapshotDirty = NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_stringByDeletingComposedCharacters:(NSUInteger)count
|
|
||||||
from:(NSString *)text {
|
|
||||||
if (count == 0) { return text ?: @""; }
|
|
||||||
NSString *source = text ?: @"";
|
|
||||||
if (source.length == 0) { return @""; }
|
|
||||||
|
|
||||||
__block NSUInteger removed = 0;
|
|
||||||
__block NSUInteger endIndex = source.length;
|
|
||||||
[source enumerateSubstringsInRange:NSMakeRange(0, source.length)
|
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
|
||||||
usingBlock:^(__unused NSString *substring, NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
|
||||||
removed += 1;
|
|
||||||
endIndex = substringRange.location;
|
|
||||||
if (removed >= count) {
|
|
||||||
*stop = YES;
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
if (removed < count) { return @""; }
|
|
||||||
return [source substringToIndex:endIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_harvestFullTextFromProxy:(id<UITextDocumentProxy>)proxy {
|
|
||||||
if (!proxy) { return @""; }
|
|
||||||
if (![proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) { return @""; }
|
|
||||||
|
|
||||||
static const NSInteger kKBHarvestMaxRounds = 160;
|
|
||||||
static const NSInteger kKBHarvestMaxChars = 50000;
|
|
||||||
|
|
||||||
NSInteger movedToEnd = 0;
|
|
||||||
NSInteger movedLeft = 0;
|
|
||||||
NSMutableArray<NSString *> *chunks = [NSMutableArray array];
|
|
||||||
NSInteger totalChars = 0;
|
|
||||||
|
|
||||||
@try {
|
|
||||||
NSInteger guard = 0;
|
|
||||||
NSString *after = proxy.documentContextAfterInput ?: @"";
|
|
||||||
while (after.length > 0 && guard < kKBHarvestMaxRounds) {
|
|
||||||
NSInteger step = (NSInteger)after.length;
|
|
||||||
[(id)proxy adjustTextPositionByCharacterOffset:step];
|
|
||||||
movedToEnd += step;
|
|
||||||
guard += 1;
|
|
||||||
after = proxy.documentContextAfterInput ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
guard = 0;
|
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
while (before.length > 0 && guard < kKBHarvestMaxRounds && totalChars < kKBHarvestMaxChars) {
|
|
||||||
[chunks addObject:before];
|
|
||||||
totalChars += (NSInteger)before.length;
|
|
||||||
NSInteger step = (NSInteger)before.length;
|
|
||||||
[(id)proxy adjustTextPositionByCharacterOffset:-step];
|
|
||||||
movedLeft += step;
|
|
||||||
guard += 1;
|
|
||||||
before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
}
|
|
||||||
} @finally {
|
|
||||||
if (movedLeft != 0) {
|
|
||||||
[(id)proxy adjustTextPositionByCharacterOffset:movedLeft];
|
|
||||||
}
|
|
||||||
if (movedToEnd != 0) {
|
|
||||||
[(id)proxy adjustTextPositionByCharacterOffset:-movedToEnd];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunks.count == 0) { return @""; }
|
|
||||||
NSMutableString *result = [NSMutableString stringWithCapacity:(NSUInteger)totalChars];
|
|
||||||
for (NSInteger i = (NSInteger)chunks.count - 1; i >= 0; i--) {
|
|
||||||
NSString *part = chunks[(NSUInteger)i] ?: @"";
|
|
||||||
if (part.length == 0) { continue; }
|
|
||||||
[result appendString:part];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// KBVM.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// 键盘扩展的 ViewModel,封装网络请求逻辑
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
/// 聊天响应模型
|
|
||||||
@interface KBChatResponse : NSObject
|
|
||||||
@property (nonatomic, copy, nullable) NSString *text;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
|
||||||
@property (nonatomic, assign) BOOL success;
|
|
||||||
@end
|
|
||||||
|
|
||||||
/// 音频响应模型
|
|
||||||
@interface KBAudioResponse : NSObject
|
|
||||||
@property (nonatomic, copy, nullable) NSString *audioURL;
|
|
||||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
|
||||||
@property (nonatomic, assign) NSTimeInterval duration;
|
|
||||||
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
|
||||||
@property (nonatomic, assign) BOOL success;
|
|
||||||
@end
|
|
||||||
|
|
||||||
/// 聊天请求回调
|
|
||||||
typedef void(^KBChatCompletion)(KBChatResponse *response);
|
|
||||||
/// 音频 URL 回调
|
|
||||||
typedef void(^KBAudioURLCompletion)(KBAudioResponse *response);
|
|
||||||
/// 音频数据回调
|
|
||||||
typedef void(^KBAudioDataCompletion)(KBAudioResponse *response);
|
|
||||||
/// 头像回调
|
|
||||||
typedef void(^KBAvatarCompletion)(UIImage * _Nullable image, NSError * _Nullable error);
|
|
||||||
|
|
||||||
@interface KBVM : NSObject
|
|
||||||
|
|
||||||
+ (instancetype)shared;
|
|
||||||
|
|
||||||
#pragma mark - Chat API
|
|
||||||
|
|
||||||
/// 发送聊天消息
|
|
||||||
/// @param content 消息内容
|
|
||||||
/// @param companionId 人设 ID
|
|
||||||
/// @param completion 回调
|
|
||||||
- (void)sendChatMessageWithContent:(NSString *)content
|
|
||||||
companionId:(NSInteger)companionId
|
|
||||||
completion:(KBChatCompletion)completion;
|
|
||||||
|
|
||||||
#pragma mark - Audio API
|
|
||||||
|
|
||||||
/// 获取音频 URL(单次请求)
|
|
||||||
/// @param audioId 音频 ID
|
|
||||||
/// @param completion 回调
|
|
||||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
|
||||||
completion:(KBAudioURLCompletion)completion;
|
|
||||||
|
|
||||||
/// 轮询获取音频 URL(自动重试)
|
|
||||||
/// @param audioId 音频 ID
|
|
||||||
/// @param maxRetries 最大重试次数
|
|
||||||
/// @param interval 重试间隔(秒)
|
|
||||||
/// @param completion 回调
|
|
||||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
|
||||||
maxRetries:(NSInteger)maxRetries
|
|
||||||
interval:(NSTimeInterval)interval
|
|
||||||
completion:(KBAudioURLCompletion)completion;
|
|
||||||
|
|
||||||
/// 下载音频数据
|
|
||||||
/// @param urlString 音频 URL
|
|
||||||
/// @param completion 回调
|
|
||||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
|
||||||
completion:(KBAudioDataCompletion)completion;
|
|
||||||
|
|
||||||
#pragma mark - Avatar API
|
|
||||||
|
|
||||||
/// 下载头像图片
|
|
||||||
/// @param urlString 头像 URL
|
|
||||||
/// @param completion 回调
|
|
||||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
|
||||||
completion:(KBAvatarCompletion)completion;
|
|
||||||
|
|
||||||
#pragma mark - Helper
|
|
||||||
|
|
||||||
/// 从 AppGroup 获取选中的 persona companionId
|
|
||||||
- (NSInteger)selectedCompanionIdFromAppGroup;
|
|
||||||
|
|
||||||
/// 从 AppGroup 获取选中的 persona 信息
|
|
||||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
//
|
|
||||||
// KBVM.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBVM.h"
|
|
||||||
#import "KBNetworkManager.h"
|
|
||||||
#import "KBConfig.h"
|
|
||||||
#import <AVFoundation/AVFoundation.h>
|
|
||||||
|
|
||||||
@implementation KBChatResponse
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBAudioResponse
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface KBVM ()
|
|
||||||
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *avatarCache;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBVM
|
|
||||||
|
|
||||||
+ (instancetype)shared {
|
|
||||||
static KBVM *instance = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
instance = [[KBVM alloc] init];
|
|
||||||
});
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init {
|
|
||||||
if (self = [super init]) {
|
|
||||||
_avatarCache = [[NSCache alloc] init];
|
|
||||||
_avatarCache.countLimit = 20;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Chat API
|
|
||||||
|
|
||||||
- (void)sendChatMessageWithContent:(NSString *)content
|
|
||||||
companionId:(NSInteger)companionId
|
|
||||||
completion:(KBChatCompletion)completion {
|
|
||||||
if (content.length == 0) {
|
|
||||||
if (completion) {
|
|
||||||
KBChatResponse *response = [[KBChatResponse alloc] init];
|
|
||||||
response.success = NO;
|
|
||||||
response.errorMessage = @"内容为空";
|
|
||||||
completion(response);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
[[KBNetworkManager shared] POST:path
|
|
||||||
jsonBody:params
|
|
||||||
headers:nil
|
|
||||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
KBChatResponse *chatResponse = [[KBChatResponse alloc] init];
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
chatResponse.success = NO;
|
|
||||||
chatResponse.errorMessage = error.localizedDescription ?: @"请求失败";
|
|
||||||
if (completion) completion(chatResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析文本
|
|
||||||
chatResponse.text = [self p_parseTextFromJSON:json];
|
|
||||||
// 解析 audioId
|
|
||||||
chatResponse.audioId = [self p_parseAudioIdFromJSON:json];
|
|
||||||
|
|
||||||
chatResponse.success = (chatResponse.text.length > 0);
|
|
||||||
if (!chatResponse.success) {
|
|
||||||
chatResponse.errorMessage = @"未获取到回复内容";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completion) completion(chatResponse);
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Audio API
|
|
||||||
|
|
||||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
|
||||||
completion:(KBAudioURLCompletion)completion {
|
|
||||||
if (audioId.length == 0) {
|
|
||||||
if (completion) {
|
|
||||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
|
||||||
response.success = NO;
|
|
||||||
response.errorMessage = @"audioId 为空";
|
|
||||||
completion(response);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
|
||||||
|
|
||||||
[[KBNetworkManager shared] GET:path
|
|
||||||
parameters:nil
|
|
||||||
headers:nil
|
|
||||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
audioResponse.success = NO;
|
|
||||||
audioResponse.errorMessage = error.localizedDescription;
|
|
||||||
if (completion) completion(audioResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 audioURL
|
|
||||||
NSString *audioURL = [self p_parseAudioURLFromJSON:json];
|
|
||||||
audioResponse.audioURL = audioURL;
|
|
||||||
audioResponse.success = (audioURL.length > 0);
|
|
||||||
|
|
||||||
if (completion) completion(audioResponse);
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
|
||||||
maxRetries:(NSInteger)maxRetries
|
|
||||||
interval:(NSTimeInterval)interval
|
|
||||||
completion:(KBAudioURLCompletion)completion {
|
|
||||||
[self p_pollAudioURLWithAudioId:audioId
|
|
||||||
retryCount:0
|
|
||||||
maxRetries:maxRetries
|
|
||||||
interval:interval
|
|
||||||
completion:completion];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)p_pollAudioURLWithAudioId:(NSString *)audioId
|
|
||||||
retryCount:(NSInteger)retryCount
|
|
||||||
maxRetries:(NSInteger)maxRetries
|
|
||||||
interval:(NSTimeInterval)interval
|
|
||||||
completion:(KBAudioURLCompletion)completion {
|
|
||||||
|
|
||||||
[self fetchAudioURLWithAudioId:audioId completion:^(KBAudioResponse *response) {
|
|
||||||
if (response.success && response.audioURL.length > 0) {
|
|
||||||
// 成功获取到 URL
|
|
||||||
if (completion) completion(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果还没达到最大重试次数,继续轮询
|
|
||||||
if (retryCount < maxRetries - 1) {
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)),
|
|
||||||
dispatch_get_main_queue(), ^{
|
|
||||||
[self p_pollAudioURLWithAudioId:audioId
|
|
||||||
retryCount:retryCount + 1
|
|
||||||
maxRetries:maxRetries
|
|
||||||
interval:interval
|
|
||||||
completion:completion];
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 达到最大重试次数
|
|
||||||
KBAudioResponse *failResponse = [[KBAudioResponse alloc] init];
|
|
||||||
failResponse.success = NO;
|
|
||||||
failResponse.errorMessage = [NSString stringWithFormat:@"轮询失败,已重试 %ld 次", (long)maxRetries];
|
|
||||||
if (completion) completion(failResponse);
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
|
||||||
completion:(KBAudioDataCompletion)completion {
|
|
||||||
if (urlString.length == 0) {
|
|
||||||
if (completion) {
|
|
||||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
|
||||||
response.success = NO;
|
|
||||||
response.errorMessage = @"URL 为空";
|
|
||||||
completion(response);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[[KBNetworkManager shared] GETData:urlString
|
|
||||||
parameters:nil
|
|
||||||
headers:nil
|
|
||||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
|
||||||
|
|
||||||
if (error || !data || data.length == 0) {
|
|
||||||
audioResponse.success = NO;
|
|
||||||
audioResponse.errorMessage = error.localizedDescription ?: @"下载失败";
|
|
||||||
if (completion) completion(audioResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioResponse.audioData = data;
|
|
||||||
|
|
||||||
// 计算音频时长
|
|
||||||
NSError *playerError = nil;
|
|
||||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
|
||||||
if (!playerError && player) {
|
|
||||||
audioResponse.duration = player.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioResponse.success = YES;
|
|
||||||
if (completion) completion(audioResponse);
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Avatar API
|
|
||||||
|
|
||||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
|
||||||
completion:(KBAvatarCompletion)completion {
|
|
||||||
if (urlString.length == 0) {
|
|
||||||
if (completion) completion(nil, nil);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
UIImage *cached = [self.avatarCache objectForKey:urlString];
|
|
||||||
if (cached) {
|
|
||||||
if (completion) completion(cached, nil);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[[KBNetworkManager shared] GETData:urlString
|
|
||||||
parameters:nil
|
|
||||||
headers:nil
|
|
||||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
if (error || data.length == 0) {
|
|
||||||
if (completion) completion(nil, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UIImage *image = [UIImage imageWithData:data];
|
|
||||||
if (image) {
|
|
||||||
[self.avatarCache setObject:image forKey:urlString];
|
|
||||||
}
|
|
||||||
if (completion) completion(image, nil);
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Helper
|
|
||||||
|
|
||||||
- (NSInteger)selectedCompanionIdFromAppGroup {
|
|
||||||
NSDictionary *persona = [self selectedPersonaFromAppGroup];
|
|
||||||
if (persona) {
|
|
||||||
id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"];
|
|
||||||
if ([companionIdObj respondsToSelector:@selector(integerValue)]) {
|
|
||||||
return [companionIdObj integerValue];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup {
|
|
||||||
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
|
||||||
return [shared objectForKey:@"AppGroup_SelectedPersona"];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Private Parse Methods
|
|
||||||
|
|
||||||
/// 解析聊天文本
|
|
||||||
- (NSString *)p_parseTextFromJSON:(NSDictionary *)json {
|
|
||||||
if (![json isKindOfClass:[NSDictionary class]]) return @"";
|
|
||||||
|
|
||||||
id dataObj = json[@"data"];
|
|
||||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
NSDictionary *data = (NSDictionary *)dataObj;
|
|
||||||
// 优先读取 aiResponse 字段
|
|
||||||
NSArray *keys = @[@"aiResponse", @"content", @"text", @"message"];
|
|
||||||
for (NSString *key in keys) {
|
|
||||||
id value = data[key];
|
|
||||||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
|
||||||
return (NSString *)value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
|
||||||
return (NSString *)dataObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
return @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 audioId
|
|
||||||
- (NSString *)p_parseAudioIdFromJSON:(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 audioURL
|
|
||||||
- (NSString *)p_parseAudioURLFromJSON:(NSDictionary *)json {
|
|
||||||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
|
||||||
|
|
||||||
id dataObj = json[@"data"];
|
|
||||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
NSDictionary *data = (NSDictionary *)dataObj;
|
|
||||||
id audioUrlObj = data[@"audioUrl"] ?: data[@"url"];
|
|
||||||
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
|
||||||
return (NSString *)audioUrlObj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionFeatureItemView.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
/// 顶部滚动的功能点 Item(左图右文)
|
|
||||||
@interface KBKeyboardSubscriptionFeatureItemView : UIView
|
|
||||||
|
|
||||||
- (void)configureWithImage:(UIImage *)image title:(NSString *)title;
|
|
||||||
|
|
||||||
/// 根据 title 计算推荐宽度:textWidth + 50(图片 35 + 间距 5 + 左右内边距各 5)
|
|
||||||
+ (CGFloat)preferredWidthForTitle:(NSString *)title;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionFeatureItemView.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardSubscriptionFeatureItemView.h"
|
|
||||||
#import "Masonry.h"
|
|
||||||
|
|
||||||
@interface KBKeyboardSubscriptionFeatureItemView ()
|
|
||||||
@property (nonatomic, strong) UIImageView *iconView;
|
|
||||||
@property (nonatomic, strong) UILabel *titleLabel;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardSubscriptionFeatureItemView
|
|
||||||
|
|
||||||
static const CGFloat kKBFeatureItemPadding = 5.0;
|
|
||||||
static const CGFloat kKBFeatureItemIconSize = 35.0;
|
|
||||||
static const CGFloat kKBFeatureItemGap = 5.0;
|
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
|
||||||
if (self = [super initWithFrame:frame]) {
|
|
||||||
// self.layer.cornerRadius = 24;
|
|
||||||
// self.layer.masksToBounds = YES;
|
|
||||||
// self.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.85];
|
|
||||||
|
|
||||||
[self addSubview:self.iconView];
|
|
||||||
[self addSubview:self.titleLabel];
|
|
||||||
|
|
||||||
[self.iconView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.mas_left).offset(kKBFeatureItemPadding);
|
|
||||||
make.centerY.equalTo(self.mas_centerY);
|
|
||||||
make.width.height.mas_equalTo(kKBFeatureItemIconSize);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.iconView.mas_right).offset(kKBFeatureItemGap);
|
|
||||||
make.centerY.equalTo(self.mas_centerY);
|
|
||||||
make.right.equalTo(self.mas_right).offset(-kKBFeatureItemPadding);
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)configureWithImage:(UIImage *)image title:(NSString *)title {
|
|
||||||
self.iconView.image = image;
|
|
||||||
self.titleLabel.text = title ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (CGFloat)preferredWidthForTitle:(NSString *)title {
|
|
||||||
UIFont *font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
|
||||||
NSString *text = title ?: @"";
|
|
||||||
NSArray<NSString *> *lines = [text componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
|
||||||
CGFloat maxLineWidth = 0;
|
|
||||||
for (NSString *line in lines) {
|
|
||||||
NSString *trimLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
|
||||||
if (trimLine.length == 0) { continue; }
|
|
||||||
CGSize size = [trimLine sizeWithAttributes:@{NSFontAttributeName: font}];
|
|
||||||
maxLineWidth = MAX(maxLineWidth, ceil(size.width));
|
|
||||||
}
|
|
||||||
if (maxLineWidth <= 0) { maxLineWidth = 80; }
|
|
||||||
|
|
||||||
CGFloat width = maxLineWidth + 50.0; // 5 + 35 + 5 + 5
|
|
||||||
width = MIN(MAX(width, 120.0), 240.0);
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIImageView *)iconView {
|
|
||||||
if (!_iconView) {
|
|
||||||
_iconView = [[UIImageView alloc] init];
|
|
||||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
|
||||||
}
|
|
||||||
return _iconView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)titleLabel {
|
|
||||||
if (!_titleLabel) {
|
|
||||||
_titleLabel = [[UILabel alloc] init];
|
|
||||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
|
||||||
_titleLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
|
||||||
_titleLabel.numberOfLines = 0;
|
|
||||||
_titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
|
||||||
}
|
|
||||||
return _titleLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionFeatureMarqueeView.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
/// 顶部功能点横向自动滚动视图
|
|
||||||
@interface KBKeyboardSubscriptionFeatureMarqueeView : UIView
|
|
||||||
|
|
||||||
/// titles/images 数量不一致时,以较小的 count 为准
|
|
||||||
- (void)configureWithTitles:(NSArray<NSString *> *)titles
|
|
||||||
images:(NSArray<UIImage *> *)images;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionFeatureMarqueeView.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardSubscriptionFeatureMarqueeView.h"
|
|
||||||
#import "KBKeyboardSubscriptionFeatureItemView.h"
|
|
||||||
#import "Masonry.h"
|
|
||||||
|
|
||||||
@interface KBKeyboardSubscriptionFeatureMarqueeView ()
|
|
||||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
|
||||||
@property (nonatomic, strong) UIView *contentView;
|
|
||||||
@property (nonatomic, strong) CADisplayLink *displayLink;
|
|
||||||
@property (nonatomic, assign) CGFloat loopWidth;
|
|
||||||
@property (nonatomic, copy) NSArray<NSDictionary *> *items;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardSubscriptionFeatureMarqueeView
|
|
||||||
|
|
||||||
static const CGFloat kKBFeatureMarqueeItemSpacing = 12.0;
|
|
||||||
static const CGFloat kKBFeatureMarqueeSpeedPerFrame = 0.35f;
|
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
|
||||||
if (self = [super initWithFrame:frame]) {
|
|
||||||
self.backgroundColor = [UIColor clearColor];
|
|
||||||
[self addSubview:self.scrollView];
|
|
||||||
[self.scrollView addSubview:self.contentView];
|
|
||||||
|
|
||||||
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.edges.equalTo(self);
|
|
||||||
}];
|
|
||||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.top.bottom.equalTo(self.scrollView);
|
|
||||||
make.left.equalTo(self.scrollView);
|
|
||||||
make.height.equalTo(self.scrollView);
|
|
||||||
make.right.equalTo(self.scrollView);
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
[self stopTicker];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)didMoveToWindow {
|
|
||||||
[super didMoveToWindow];
|
|
||||||
if (self.window) {
|
|
||||||
[self startTickerIfNeeded];
|
|
||||||
} else {
|
|
||||||
[self stopTicker];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setHidden:(BOOL)hidden {
|
|
||||||
BOOL oldHidden = self.isHidden;
|
|
||||||
[super setHidden:hidden];
|
|
||||||
if (oldHidden == hidden) { return; }
|
|
||||||
if (hidden) {
|
|
||||||
[self stopTicker];
|
|
||||||
} else if (self.window) {
|
|
||||||
[self startTickerIfNeeded];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)layoutSubviews {
|
|
||||||
[super layoutSubviews];
|
|
||||||
// 宽度变化时重新评估是否需要滚动
|
|
||||||
[self rebuildIfNeeded];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Public
|
|
||||||
|
|
||||||
- (void)configureWithTitles:(NSArray<NSString *> *)titles images:(NSArray<UIImage *> *)images {
|
|
||||||
NSInteger count = MIN(titles.count, images.count);
|
|
||||||
if (count <= 0) {
|
|
||||||
self.items = @[];
|
|
||||||
[self rebuildIfNeeded];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSMutableArray *arr = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
|
||||||
for (NSInteger i = 0; i < count; i++) {
|
|
||||||
NSString *t = titles[(NSUInteger)i] ?: @"";
|
|
||||||
UIImage *img = images[(NSUInteger)i] ?: [UIImage new];
|
|
||||||
[arr addObject:@{@"title": t, @"image": img}];
|
|
||||||
}
|
|
||||||
self.items = [arr copy];
|
|
||||||
[self rebuildIfNeeded];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Build
|
|
||||||
|
|
||||||
- (void)rebuildIfNeeded {
|
|
||||||
[self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
|
||||||
if (self.items.count == 0) {
|
|
||||||
self.loopWidth = 0;
|
|
||||||
self.scrollView.contentSize = CGSizeZero;
|
|
||||||
self.scrollView.contentOffset = CGPointZero;
|
|
||||||
[self stopTicker];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL shouldLoop = (self.items.count > 1);
|
|
||||||
NSInteger baseCount = self.items.count;
|
|
||||||
|
|
||||||
NSMutableArray<NSNumber *> *baseWidths = [NSMutableArray arrayWithCapacity:(NSUInteger)baseCount];
|
|
||||||
CGFloat baseTotalWidth = 0;
|
|
||||||
for (NSInteger i = 0; i < baseCount; i++) {
|
|
||||||
NSDictionary *info = self.items[(NSUInteger)i];
|
|
||||||
NSString *title = [info[@"title"] isKindOfClass:NSString.class] ? info[@"title"] : @"";
|
|
||||||
CGFloat w = [KBKeyboardSubscriptionFeatureItemView preferredWidthForTitle:title];
|
|
||||||
[baseWidths addObject:@(w)];
|
|
||||||
baseTotalWidth += w;
|
|
||||||
if (i > 0) { baseTotalWidth += kKBFeatureMarqueeItemSpacing; }
|
|
||||||
}
|
|
||||||
|
|
||||||
NSArray *loopData = shouldLoop ? [self.items arrayByAddingObjectsFromArray:self.items] : self.items;
|
|
||||||
CGFloat totalWidth = shouldLoop ? (baseTotalWidth * 2 + kKBFeatureMarqueeItemSpacing) : baseTotalWidth;
|
|
||||||
|
|
||||||
UIView *previous = nil;
|
|
||||||
for (NSInteger idx = 0; idx < loopData.count; idx++) {
|
|
||||||
NSDictionary *info = loopData[(NSUInteger)idx];
|
|
||||||
UIImage *img = [info[@"image"] isKindOfClass:UIImage.class] ? info[@"image"] : nil;
|
|
||||||
NSString *title = [info[@"title"] isKindOfClass:NSString.class] ? info[@"title"] : @"";
|
|
||||||
CGFloat width = baseWidths[(NSUInteger)(idx % baseCount)].doubleValue;
|
|
||||||
|
|
||||||
KBKeyboardSubscriptionFeatureItemView *itemView = [[KBKeyboardSubscriptionFeatureItemView alloc] init];
|
|
||||||
[itemView configureWithImage:(img ?: [UIImage new]) title:title];
|
|
||||||
[self.contentView addSubview:itemView];
|
|
||||||
|
|
||||||
[itemView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.top.bottom.equalTo(self.contentView);
|
|
||||||
make.width.mas_equalTo(width);
|
|
||||||
if (previous) {
|
|
||||||
make.left.equalTo(previous.mas_right).offset(kKBFeatureMarqueeItemSpacing);
|
|
||||||
} else {
|
|
||||||
make.left.equalTo(self.contentView.mas_left);
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
previous = itemView;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self.contentView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.top.bottom.equalTo(self.scrollView);
|
|
||||||
make.left.equalTo(self.scrollView);
|
|
||||||
make.height.equalTo(self.scrollView);
|
|
||||||
if (previous) {
|
|
||||||
make.right.equalTo(previous.mas_right);
|
|
||||||
} else {
|
|
||||||
make.right.equalTo(self.scrollView);
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
|
|
||||||
CGFloat minWidth = CGRectGetWidth(self.bounds);
|
|
||||||
if (minWidth <= 0) { minWidth = 1; }
|
|
||||||
CGFloat height = CGRectGetHeight(self.bounds);
|
|
||||||
if (height <= 0) { height = 48; }
|
|
||||||
|
|
||||||
CGFloat contentWidth = totalWidth;
|
|
||||||
if (contentWidth <= minWidth) {
|
|
||||||
contentWidth = minWidth;
|
|
||||||
self.loopWidth = 0;
|
|
||||||
[self stopTicker];
|
|
||||||
self.scrollView.contentOffset = CGPointZero;
|
|
||||||
} else {
|
|
||||||
self.loopWidth = shouldLoop ? (baseTotalWidth + kKBFeatureMarqueeItemSpacing) : 0;
|
|
||||||
[self startTickerIfNeeded];
|
|
||||||
}
|
|
||||||
self.scrollView.contentSize = CGSizeMake(contentWidth, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Ticker
|
|
||||||
|
|
||||||
- (void)startTickerIfNeeded {
|
|
||||||
if (self.displayLink || self.loopWidth <= 0) { return; }
|
|
||||||
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleTick)];
|
|
||||||
self.displayLink.preferredFramesPerSecond = 60;
|
|
||||||
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)stopTicker {
|
|
||||||
[self.displayLink invalidate];
|
|
||||||
self.displayLink = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)handleTick {
|
|
||||||
if (self.loopWidth <= 0) { return; }
|
|
||||||
CGFloat nextX = self.scrollView.contentOffset.x + kKBFeatureMarqueeSpeedPerFrame;
|
|
||||||
if (nextX >= self.loopWidth) {
|
|
||||||
nextX -= self.loopWidth;
|
|
||||||
}
|
|
||||||
self.scrollView.contentOffset = CGPointMake(nextX, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Lazy
|
|
||||||
|
|
||||||
- (UIScrollView *)scrollView {
|
|
||||||
if (!_scrollView) {
|
|
||||||
_scrollView = [[UIScrollView alloc] init];
|
|
||||||
_scrollView.showsHorizontalScrollIndicator = NO;
|
|
||||||
_scrollView.scrollEnabled = NO;
|
|
||||||
_scrollView.clipsToBounds = YES;
|
|
||||||
}
|
|
||||||
return _scrollView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIView *)contentView {
|
|
||||||
if (!_contentView) {
|
|
||||||
_contentView = [[UIView alloc] init];
|
|
||||||
}
|
|
||||||
return _contentView;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionOptionCell.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// Created by Mac on 2025/12/17.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
#import "KBKeyboardSubscriptionProduct.h"
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface KBKeyboardSubscriptionOptionCell : UICollectionViewCell
|
|
||||||
- (void)configureWithProduct:(KBKeyboardSubscriptionProduct *)product;
|
|
||||||
- (void)applySelected:(BOOL)selected animated:(BOOL)animated;
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionOptionCell.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
// Created by Mac on 2025/12/17.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardSubscriptionOptionCell.h"
|
|
||||||
@interface KBKeyboardSubscriptionOptionCell()
|
|
||||||
@property (nonatomic, strong) UIView *cardView;
|
|
||||||
@property (nonatomic, strong) UILabel *titleLabel;
|
|
||||||
@property (nonatomic, strong) UILabel *priceLabel;
|
|
||||||
@property (nonatomic, strong) UILabel *strikeLabel;
|
|
||||||
@property (nonatomic, strong) UIImageView *selectedImageView;
|
|
||||||
@end
|
|
||||||
@implementation KBKeyboardSubscriptionOptionCell
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
|
||||||
if (self = [super initWithFrame:frame]) {
|
|
||||||
self.contentView.backgroundColor = [UIColor clearColor];
|
|
||||||
[self.contentView addSubview:self.cardView];
|
|
||||||
[self.cardView addSubview:self.titleLabel];
|
|
||||||
[self.cardView addSubview:self.priceLabel];
|
|
||||||
[self.cardView addSubview:self.strikeLabel];
|
|
||||||
[self.cardView addSubview:self.selectedImageView];
|
|
||||||
|
|
||||||
[self.cardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
// make.edges.equalTo(self.contentView);
|
|
||||||
make.left.right.top.equalTo(self.contentView);
|
|
||||||
make.bottom.equalTo(self.contentView).offset(-10);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.top.equalTo(self.cardView.mas_top).offset(8);
|
|
||||||
make.left.equalTo(self.cardView.mas_left).offset(10);
|
|
||||||
make.right.equalTo(self.cardView.mas_right).offset(-10);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.priceLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.titleLabel.mas_left);
|
|
||||||
make.top.equalTo(self.titleLabel.mas_bottom).offset(8);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.strikeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.priceLabel.mas_right).offset(5);
|
|
||||||
make.centerY.equalTo(self.priceLabel);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.selectedImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
|
||||||
make.bottom.equalTo(self.cardView.mas_bottom).offset(10);
|
|
||||||
make.width.mas_equalTo(16);
|
|
||||||
make.height.mas_equalTo(17);
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)prepareForReuse {
|
|
||||||
[super prepareForReuse];
|
|
||||||
self.titleLabel.text = @"";
|
|
||||||
self.priceLabel.text = @"";
|
|
||||||
self.strikeLabel.attributedText = nil;
|
|
||||||
self.strikeLabel.hidden = YES;
|
|
||||||
[self applySelected:NO animated:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)configureWithProduct:(KBKeyboardSubscriptionProduct *)product {
|
|
||||||
if (!product) { return; }
|
|
||||||
self.titleLabel.text = [product displayTitle];
|
|
||||||
self.priceLabel.text = [product priceDisplayText];
|
|
||||||
NSString *strike = [product strikePriceDisplayText];
|
|
||||||
if (strike.length > 0) {
|
|
||||||
NSDictionary *attr = @{
|
|
||||||
NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle),
|
|
||||||
NSForegroundColorAttributeName: [UIColor colorWithHex:0xCCCCCC],
|
|
||||||
NSFontAttributeName: [UIFont systemFontOfSize:14]
|
|
||||||
};
|
|
||||||
self.strikeLabel.attributedText = [[NSAttributedString alloc] initWithString:strike attributes:attr];
|
|
||||||
self.strikeLabel.hidden = NO;
|
|
||||||
} else {
|
|
||||||
self.strikeLabel.attributedText = nil;
|
|
||||||
self.strikeLabel.hidden = YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)applySelected:(BOOL)selected animated:(BOOL)animated {
|
|
||||||
void (^changes)(void) = ^{
|
|
||||||
self.cardView.layer.borderColor = selected ? [UIColor colorWithHex:0x02BEAC].CGColor : [[UIColor blackColor] colorWithAlphaComponent:0.12].CGColor;
|
|
||||||
self.cardView.layer.borderWidth = selected ? 2.0 : 1.0;
|
|
||||||
self.selectedImageView.alpha = selected ? 1.0 : 0.0;
|
|
||||||
};
|
|
||||||
if (animated) {
|
|
||||||
self.selectedImageView.hidden = NO;
|
|
||||||
[UIView animateWithDuration:0.18 animations:changes completion:^(BOOL finished) {
|
|
||||||
self.selectedImageView.hidden = !selected;
|
|
||||||
}];
|
|
||||||
} else {
|
|
||||||
changes();
|
|
||||||
self.selectedImageView.hidden = !selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIView *)cardView {
|
|
||||||
if (!_cardView) {
|
|
||||||
_cardView = [[UIView alloc] init];
|
|
||||||
_cardView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.96];
|
|
||||||
_cardView.layer.cornerRadius = 20;
|
|
||||||
_cardView.layer.borderWidth = 1.0;
|
|
||||||
_cardView.layer.borderColor = [[UIColor blackColor] colorWithAlphaComponent:0.12].CGColor;
|
|
||||||
}
|
|
||||||
return _cardView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)titleLabel {
|
|
||||||
if (!_titleLabel) {
|
|
||||||
_titleLabel = [[UILabel alloc] init];
|
|
||||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
|
||||||
_titleLabel.numberOfLines = 2;
|
|
||||||
_titleLabel.textColor = [UIColor colorWithHex:0x1B1F1A];
|
|
||||||
}
|
|
||||||
return _titleLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)priceLabel {
|
|
||||||
if (!_priceLabel) {
|
|
||||||
_priceLabel = [[UILabel alloc] init];
|
|
||||||
_priceLabel.font = [UIFont systemFontOfSize:20 weight:UIFontWeightBold];
|
|
||||||
_priceLabel.textColor = [UIColor colorWithHex:0x1B1F1A];
|
|
||||||
}
|
|
||||||
return _priceLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)strikeLabel {
|
|
||||||
if (!_strikeLabel) {
|
|
||||||
_strikeLabel = [[UILabel alloc] init];
|
|
||||||
_strikeLabel.textColor = [UIColor colorWithHex:0xCCCCCC];
|
|
||||||
_strikeLabel.hidden = YES;
|
|
||||||
}
|
|
||||||
return _strikeLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIImageView *)selectedImageView {
|
|
||||||
if (!_selectedImageView) {
|
|
||||||
_selectedImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"buy_sel_icon"]];
|
|
||||||
_selectedImageView.contentMode = UIViewContentModeScaleAspectFit;
|
|
||||||
_selectedImageView.hidden = YES;
|
|
||||||
_selectedImageView.alpha = 0.0;
|
|
||||||
}
|
|
||||||
return _selectedImageView;
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionView.h
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@class KBKeyboardSubscriptionProduct;
|
|
||||||
@class KBKeyboardSubscriptionView;
|
|
||||||
|
|
||||||
@protocol KBKeyboardSubscriptionViewDelegate <NSObject>
|
|
||||||
@optional
|
|
||||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view;
|
|
||||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product;
|
|
||||||
@end
|
|
||||||
|
|
||||||
/// 键盘内的订阅弹层
|
|
||||||
@interface KBKeyboardSubscriptionView : UIView
|
|
||||||
|
|
||||||
@property (nonatomic, weak) id<KBKeyboardSubscriptionViewDelegate> delegate;
|
|
||||||
|
|
||||||
/// 首次展示时调用,内部会自动请求订阅商品
|
|
||||||
- (void)refreshProductsIfNeeded;
|
|
||||||
/// 外部强制刷新
|
|
||||||
- (void)reloadProducts;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
//
|
|
||||||
// KBKeyboardSubscriptionView.m
|
|
||||||
// CustomKeyboard
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "KBKeyboardSubscriptionView.h"
|
|
||||||
#import "KBKeyboardSubscriptionProduct.h"
|
|
||||||
#import "KBNetworkManager.h"
|
|
||||||
#import "KBFullAccessManager.h"
|
|
||||||
#import "KBKeyboardSubscriptionFeatureMarqueeView.h"
|
|
||||||
#import "KBKeyboardSubscriptionOptionCell.h"
|
|
||||||
#import "KBConfig.h"
|
|
||||||
|
|
||||||
#import <MJExtension/MJExtension.h>
|
|
||||||
|
|
||||||
static NSString * const kKBKeyboardSubscriptionCellId = @"kKBKeyboardSubscriptionCellId";
|
|
||||||
|
|
||||||
static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
|
||||||
if (!obj || obj == (id)kCFNull) { return nil; }
|
|
||||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
|
||||||
NSDictionary *dict = (NSDictionary *)obj;
|
|
||||||
NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:dict.count];
|
|
||||||
[dict enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
|
|
||||||
(void)stop;
|
|
||||||
if (![key isKindOfClass:[NSString class]]) { return; }
|
|
||||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(value);
|
|
||||||
if (!sanitized) { return; }
|
|
||||||
result[key] = sanitized;
|
|
||||||
}];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if ([obj isKindOfClass:[NSArray class]]) {
|
|
||||||
NSArray *arr = (NSArray *)obj;
|
|
||||||
NSMutableArray *result = [NSMutableArray arrayWithCapacity:arr.count];
|
|
||||||
for (id item in arr) {
|
|
||||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(item);
|
|
||||||
if (!sanitized) { continue; }
|
|
||||||
[result addObject:sanitized];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
@interface KBKeyboardSubscriptionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
|
|
||||||
@property (nonatomic, strong) UIImageView *cardView;
|
|
||||||
@property (nonatomic, strong) UIButton *closeButton;
|
|
||||||
@property (nonatomic, strong) KBKeyboardSubscriptionFeatureMarqueeView *featureMarqueeView;
|
|
||||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
|
||||||
@property (nonatomic, strong) UIButton *purchaseButton;
|
|
||||||
@property (nonatomic, strong) UILabel *agreementLabel;
|
|
||||||
@property (nonatomic, strong) UIButton *agreementButton;
|
|
||||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
|
||||||
@property (nonatomic, strong) UILabel *emptyLabel;
|
|
||||||
@property (nonatomic, copy) NSArray<KBKeyboardSubscriptionProduct *> *products;
|
|
||||||
@property (nonatomic, copy, nullable) NSArray *productsRawJSON;
|
|
||||||
@property (nonatomic, assign) NSInteger selectedIndex;
|
|
||||||
@property (nonatomic, assign) BOOL didLoadOnce;
|
|
||||||
@property (nonatomic, assign, getter=isLoading) BOOL loading;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KBKeyboardSubscriptionView
|
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
|
||||||
if (self = [super initWithFrame:frame]) {
|
|
||||||
self.backgroundColor = [UIColor clearColor];
|
|
||||||
_selectedIndex = NSNotFound;
|
|
||||||
[self setupCardView];
|
|
||||||
[self setupFeatureItems];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Public
|
|
||||||
|
|
||||||
- (void)refreshProductsIfNeeded {
|
|
||||||
if (!self.didLoadOnce) {
|
|
||||||
[self fetchProducts];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)reloadProducts {
|
|
||||||
[self fetchProducts];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - UI
|
|
||||||
|
|
||||||
- (void)setupCardView {
|
|
||||||
[self addSubview:self.cardView];
|
|
||||||
[self.cardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.mas_left).offset(0);
|
|
||||||
make.right.equalTo(self.mas_right).offset(0);
|
|
||||||
make.top.equalTo(self.mas_top).offset(0);
|
|
||||||
make.bottom.equalTo(self.mas_bottom).offset(0);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.cardView addSubview:self.closeButton];
|
|
||||||
[self.cardView addSubview:self.featureMarqueeView];
|
|
||||||
[self.cardView addSubview:self.collectionView];
|
|
||||||
[self.cardView addSubview:self.purchaseButton];
|
|
||||||
[self.cardView addSubview:self.agreementLabel];
|
|
||||||
[self.cardView addSubview:self.agreementButton];
|
|
||||||
[self.cardView addSubview:self.loadingIndicator];
|
|
||||||
[self.cardView addSubview:self.emptyLabel];
|
|
||||||
|
|
||||||
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.cardView.mas_left).offset(12);
|
|
||||||
make.top.equalTo(self.cardView.mas_top).offset(25);
|
|
||||||
make.width.height.mas_equalTo(28);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.featureMarqueeView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.closeButton.mas_right).offset(5);
|
|
||||||
make.centerY.equalTo(self.closeButton);
|
|
||||||
make.right.equalTo(self.cardView.mas_right).offset(-12);
|
|
||||||
make.height.mas_equalTo(48);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.right.equalTo(self).inset(16);
|
|
||||||
make.top.equalTo(self.featureMarqueeView.mas_bottom).offset(0);
|
|
||||||
make.height.mas_equalTo(76);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.purchaseButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.equalTo(self.cardView.mas_left).offset(16);
|
|
||||||
make.right.equalTo(self.cardView.mas_right).offset(-16);
|
|
||||||
make.top.equalTo(self.collectionView.mas_bottom).offset(20);
|
|
||||||
// make.bottom.equalTo(self.agreementLabel.mas_top).offset(-16);
|
|
||||||
make.height.mas_greaterThanOrEqualTo(@45);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.agreementLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
|
||||||
make.top.equalTo(self.purchaseButton.mas_bottom).offset(8);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.agreementButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
|
||||||
make.top.equalTo(self.agreementLabel.mas_bottom).offset(4);
|
|
||||||
}];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.center.equalTo(self.collectionView);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.emptyLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.center.equalTo(self.collectionView);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self updatePurchaseButtonState];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setupFeatureItems {
|
|
||||||
NSArray *titles = @[
|
|
||||||
KBLocalized(@"Wireless Sub-ai\nDialogue"),
|
|
||||||
KBLocalized(@"Personalized\nKeyboard"),
|
|
||||||
KBLocalized(@"Chat\nPersona"),
|
|
||||||
KBLocalized(@"Emotional\nCounseling")
|
|
||||||
];
|
|
||||||
NSArray *images = @[
|
|
||||||
[UIImage imageNamed:@"home_ai_icon"] ?: [UIImage new],
|
|
||||||
[UIImage imageNamed:@"home_keyboard_icon"] ?: [UIImage new],
|
|
||||||
[UIImage imageNamed:@"home_chat_icon"] ?: [UIImage new],
|
|
||||||
[UIImage imageNamed:@"home_emotion_icon"] ?: [UIImage new]
|
|
||||||
];
|
|
||||||
[self.featureMarqueeView configureWithTitles:titles images:images];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Actions
|
|
||||||
|
|
||||||
- (void)onTapClose {
|
|
||||||
if ([self.delegate respondsToSelector:@selector(subscriptionViewDidTapClose:)]) {
|
|
||||||
[self.delegate subscriptionViewDidTapClose:self];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)onTapPurchase {
|
|
||||||
if (self.selectedIndex == NSNotFound || self.selectedIndex >= self.products.count) {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Please select a product")];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KBKeyboardSubscriptionProduct *product = self.products[self.selectedIndex];
|
|
||||||
[self kb_persistPrefillPayloadForProduct:product];
|
|
||||||
if ([self.delegate respondsToSelector:@selector(subscriptionView:didTapPurchaseForProduct:)]) {
|
|
||||||
[self.delegate subscriptionView:self didTapPurchaseForProduct:product];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)onTapAgreement {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Agreement coming soon")];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Data
|
|
||||||
|
|
||||||
- (void)fetchProducts {
|
|
||||||
if (self.isLoading) { return; }
|
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Enable Full Access to continue")];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.loading = YES;
|
|
||||||
self.emptyLabel.hidden = YES;
|
|
||||||
[self.loadingIndicator startAnimating];
|
|
||||||
NSDictionary *params = @{@"type": @"subscription"};
|
|
||||||
__weak typeof(self) weakSelf = self;
|
|
||||||
[[KBNetworkManager shared] GET:API_SUBSCRIPTION_PRODUCT_LIST
|
|
||||||
parameters:params
|
|
||||||
headers:nil
|
|
||||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
__strong typeof(weakSelf) self = weakSelf;
|
|
||||||
if (!self) { return; }
|
|
||||||
self.loading = NO;
|
|
||||||
[self.loadingIndicator stopAnimating];
|
|
||||||
if (error) {
|
|
||||||
NSString *tip = error.localizedDescription ?: KBLocalized(@"Network error");
|
|
||||||
[KBHUD showInfo:tip];
|
|
||||||
self.products = @[];
|
|
||||||
self.productsRawJSON = nil;
|
|
||||||
self.selectedIndex = NSNotFound;
|
|
||||||
[self.collectionView reloadData];
|
|
||||||
self.emptyLabel.hidden = NO;
|
|
||||||
[self updatePurchaseButtonState];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
id dataObj = json[@"data"];
|
|
||||||
if (![dataObj isKindOfClass:[NSArray class]]) {
|
|
||||||
dataObj = json[@"list"];
|
|
||||||
}
|
|
||||||
if (![dataObj isKindOfClass:[NSArray class]]) {
|
|
||||||
self.products = @[];
|
|
||||||
self.productsRawJSON = nil;
|
|
||||||
self.selectedIndex = NSNotFound;
|
|
||||||
[self.collectionView reloadData];
|
|
||||||
self.emptyLabel.hidden = NO;
|
|
||||||
[self updatePurchaseButtonState];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(dataObj);
|
|
||||||
self.productsRawJSON = [sanitized isKindOfClass:NSArray.class] ? (NSArray *)sanitized : nil;
|
|
||||||
NSArray *models = [KBKeyboardSubscriptionProduct mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
|
||||||
self.products = models ?: @[];
|
|
||||||
self.selectedIndex = self.products.count > 0 ? 0 : NSNotFound;
|
|
||||||
self.emptyLabel.hidden = self.products.count > 0;
|
|
||||||
[self.collectionView reloadData];
|
|
||||||
[self selectCurrentProductAnimated:NO];
|
|
||||||
[self updatePurchaseButtonState];
|
|
||||||
self.didLoadOnce = YES;
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)kb_persistPrefillPayloadForProduct:(KBKeyboardSubscriptionProduct *)product {
|
|
||||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class]) { return; }
|
|
||||||
if (![self.productsRawJSON isKindOfClass:NSArray.class] || self.productsRawJSON.count == 0) { return; }
|
|
||||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
|
||||||
if (!ud) { return; }
|
|
||||||
NSMutableDictionary *payload = [NSMutableDictionary dictionary];
|
|
||||||
payload[@"ts"] = @((long long)floor([NSDate date].timeIntervalSince1970));
|
|
||||||
payload[@"src"] = @"keyboard";
|
|
||||||
if (product.productId.length) {
|
|
||||||
payload[@"productId"] = product.productId;
|
|
||||||
}
|
|
||||||
if (self.selectedIndex != NSNotFound) {
|
|
||||||
payload[@"selectedIndex"] = @(self.selectedIndex);
|
|
||||||
}
|
|
||||||
payload[@"products"] = self.productsRawJSON;
|
|
||||||
[ud setObject:payload forKey:AppGroup_SubscriptionPrefillPayload];
|
|
||||||
[ud synchronize];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)selectCurrentProductAnimated:(BOOL)animated {
|
|
||||||
if (self.selectedIndex == NSNotFound || self.selectedIndex >= self.products.count) { return; }
|
|
||||||
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.selectedIndex inSection:0];
|
|
||||||
[self.collectionView selectItemAtIndexPath:indexPath animated:animated scrollPosition:UICollectionViewScrollPositionCenteredHorizontally];
|
|
||||||
KBKeyboardSubscriptionOptionCell *cell = (KBKeyboardSubscriptionOptionCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
|
||||||
if ([cell isKindOfClass:KBKeyboardSubscriptionOptionCell.class]) {
|
|
||||||
[cell applySelected:YES animated:animated];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updatePurchaseButtonState {
|
|
||||||
BOOL enabled = (self.products.count > 0);
|
|
||||||
self.purchaseButton.enabled = enabled;
|
|
||||||
self.purchaseButton.alpha = enabled ? 1.0 : 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - UICollectionView DataSource
|
|
||||||
|
|
||||||
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
|
|
||||||
return self.products.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
|
||||||
KBKeyboardSubscriptionOptionCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBKeyboardSubscriptionCellId forIndexPath:indexPath];
|
|
||||||
if (indexPath.item < self.products.count) {
|
|
||||||
KBKeyboardSubscriptionProduct *product = self.products[indexPath.item];
|
|
||||||
[cell configureWithProduct:product];
|
|
||||||
BOOL selected = (indexPath.item == self.selectedIndex);
|
|
||||||
[cell applySelected:selected animated:NO];
|
|
||||||
} else {
|
|
||||||
[cell configureWithProduct:nil];
|
|
||||||
[cell applySelected:NO animated:NO];
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - UICollectionView Delegate
|
|
||||||
|
|
||||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
|
||||||
if (indexPath.item >= self.products.count) { return; }
|
|
||||||
NSInteger previous = self.selectedIndex;
|
|
||||||
self.selectedIndex = indexPath.item;
|
|
||||||
if (previous != NSNotFound && previous != indexPath.item) {
|
|
||||||
NSIndexPath *prev = [NSIndexPath indexPathForItem:previous inSection:0];
|
|
||||||
KBKeyboardSubscriptionOptionCell *prevCell = (KBKeyboardSubscriptionOptionCell *)[collectionView cellForItemAtIndexPath:prev];
|
|
||||||
[prevCell applySelected:NO animated:YES];
|
|
||||||
}
|
|
||||||
KBKeyboardSubscriptionOptionCell *cell = (KBKeyboardSubscriptionOptionCell *)[collectionView cellForItemAtIndexPath:indexPath];
|
|
||||||
[cell applySelected:YES animated:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Layout
|
|
||||||
|
|
||||||
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
|
|
||||||
// CGFloat width = MIN(MAX(collectionView.bounds.size.width * 0.56, 150), 220);
|
|
||||||
return CGSizeMake(160, collectionView.bounds.size.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
|
|
||||||
return 12.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
|
|
||||||
return UIEdgeInsetsMake(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Lazy
|
|
||||||
|
|
||||||
- (UIImageView *)cardView {
|
|
||||||
if (!_cardView) {
|
|
||||||
_cardView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"keybord_bg_icon"]];
|
|
||||||
// _cardView.layer.cornerRadius = 20;
|
|
||||||
// _cardView.layer.masksToBounds = YES;
|
|
||||||
_cardView.userInteractionEnabled = YES;
|
|
||||||
_cardView.contentMode = UIViewContentModeScaleAspectFill;
|
|
||||||
}
|
|
||||||
return _cardView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIButton *)closeButton {
|
|
||||||
if (!_closeButton) {
|
|
||||||
_closeButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
|
||||||
_closeButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.7];
|
|
||||||
_closeButton.layer.cornerRadius = 14;
|
|
||||||
_closeButton.layer.masksToBounds = YES;
|
|
||||||
[_closeButton setTitle:@"✕" forState:UIControlStateNormal];
|
|
||||||
[_closeButton setTitleColor:[UIColor colorWithHex:0x666666] forState:UIControlStateNormal];
|
|
||||||
_closeButton.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
|
||||||
[_closeButton addTarget:self action:@selector(onTapClose) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
}
|
|
||||||
return _closeButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBKeyboardSubscriptionFeatureMarqueeView *)featureMarqueeView {
|
|
||||||
if (!_featureMarqueeView) {
|
|
||||||
_featureMarqueeView = [[KBKeyboardSubscriptionFeatureMarqueeView alloc] init];
|
|
||||||
}
|
|
||||||
return _featureMarqueeView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UICollectionView *)collectionView {
|
|
||||||
if (!_collectionView) {
|
|
||||||
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
|
|
||||||
layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
|
|
||||||
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
|
|
||||||
_collectionView.backgroundColor = [UIColor clearColor];
|
|
||||||
_collectionView.showsHorizontalScrollIndicator = NO;
|
|
||||||
_collectionView.dataSource = self;
|
|
||||||
_collectionView.delegate = self;
|
|
||||||
[_collectionView registerClass:KBKeyboardSubscriptionOptionCell.class forCellWithReuseIdentifier:kKBKeyboardSubscriptionCellId];
|
|
||||||
}
|
|
||||||
return _collectionView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIButton *)purchaseButton {
|
|
||||||
if (!_purchaseButton) {
|
|
||||||
_purchaseButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
|
||||||
_purchaseButton.layer.cornerRadius = 26;
|
|
||||||
_purchaseButton.layer.masksToBounds = YES;
|
|
||||||
[_purchaseButton setTitle:KBLocalized(@"Recharge Now") forState:UIControlStateNormal];
|
|
||||||
_purchaseButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
|
||||||
[_purchaseButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
|
||||||
[_purchaseButton setBackgroundImage:[self imageWithColor:[UIColor colorWithHex:0x02BEAC]] forState:UIControlStateNormal];
|
|
||||||
[_purchaseButton addTarget:self action:@selector(onTapPurchase) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
}
|
|
||||||
return _purchaseButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)agreementLabel {
|
|
||||||
if (!_agreementLabel) {
|
|
||||||
_agreementLabel = [[UILabel alloc] init];
|
|
||||||
_agreementLabel.text = KBLocalized(@"By clicking \"pay\", you agree to the");
|
|
||||||
_agreementLabel.font = [UIFont systemFontOfSize:11];
|
|
||||||
_agreementLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
|
||||||
}
|
|
||||||
return _agreementLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIButton *)agreementButton {
|
|
||||||
if (!_agreementButton) {
|
|
||||||
_agreementButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
|
||||||
[_agreementButton setTitle:KBLocalized(@"Membership Agreement") forState:UIControlStateNormal];
|
|
||||||
_agreementButton.titleLabel.font = [UIFont systemFontOfSize:11 weight:UIFontWeightSemibold];
|
|
||||||
[_agreementButton setTitleColor:[UIColor colorWithHex:0x02BEAC] forState:UIControlStateNormal];
|
|
||||||
[_agreementButton addTarget:self action:@selector(onTapAgreement) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
}
|
|
||||||
return _agreementButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIActivityIndicatorView *)loadingIndicator {
|
|
||||||
if (!_loadingIndicator) {
|
|
||||||
_loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
|
||||||
_loadingIndicator.hidesWhenStopped = YES;
|
|
||||||
}
|
|
||||||
return _loadingIndicator;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UILabel *)emptyLabel {
|
|
||||||
if (!_emptyLabel) {
|
|
||||||
_emptyLabel = [[UILabel alloc] init];
|
|
||||||
_emptyLabel.text = KBLocalized(@"No products available");
|
|
||||||
_emptyLabel.font = [UIFont systemFontOfSize:13];
|
|
||||||
_emptyLabel.textColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
|
||||||
_emptyLabel.textAlignment = NSTextAlignmentCenter;
|
|
||||||
_emptyLabel.hidden = YES;
|
|
||||||
}
|
|
||||||
return _emptyLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIImage *)imageWithColor:(UIColor *)color {
|
|
||||||
CGSize size = CGSizeMake(1, 1);
|
|
||||||
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
|
|
||||||
[color setFill];
|
|
||||||
UIRectFill(CGRectMake(0, 0, size.width, size.height));
|
|
||||||
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
|
||||||
UIGraphicsEndImageContext();
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||