Compare commits
131 Commits
main
...
fa9af5ff1b
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9af5ff1b | |||
| 08628bcd1d | |||
| 19cb29616f | |||
| 6e50cdcd2a | |||
| f1b52151be | |||
| 993ec623af | |||
| 0416a64235 | |||
| 2b75ad90fb | |||
| 0ac9030f80 | |||
| ea9c40f64f | |||
| 48c90fa0be | |||
| fe59a0cb45 | |||
| 81bc50ce17 | |||
| 6ae504823b | |||
| d2f582b7f8 | |||
| cc82396195 | |||
| 2ff8a7a4af | |||
| 3c0b7e754c | |||
| 3705db4aab | |||
| 36774a8a2c | |||
| 36135313d8 | |||
| 23c0d14128 | |||
| d0c5cada35 | |||
| b556e6841d | |||
| 26096abbcc | |||
| 766c62f3c0 | |||
| 07a77149fc | |||
| 32ebc6fb65 | |||
| 25fbe9b64e | |||
| 4392296616 | |||
| ef52cd4872 | |||
| 70a8466d9f | |||
| 66d85f78a0 | |||
| 93a20cd92a | |||
| 9a54a2ae6c | |||
| 1b9ce1622d | |||
| b4db79eba8 | |||
| 22f77d56ea | |||
| d8d5bdc3ae | |||
| 7d583ceb1d | |||
| 51b744ecd7 | |||
| 3fd7d2af2e | |||
| db869552e4 | |||
| b34de116a3 | |||
| e67bc37571 | |||
| 2b749cd2b0 | |||
| ce889e1ed0 | |||
| e8b4b2c58a | |||
| 3a5a6395af | |||
| a22599feda | |||
| 6a177ceebc | |||
| f9d7579536 | |||
| 0fa31418f6 | |||
| 77fd46aa34 | |||
| 6ad9783bcb | |||
| edc25c159d | |||
| 06a572c08a | |||
| 36c0b0b210 | |||
| d1d47336c2 | |||
| 063ceae10f | |||
| 552387293c | |||
| 93489b09d9 | |||
| 663cb8493b | |||
| ac0d9584d8 | |||
| 7fa124d45f | |||
| 3dfb8f31e2 | |||
| 619c02f236 | |||
| 28852a8d4b | |||
| b021fd308f | |||
| 169a1929d7 | |||
| b5da9f35a5 | |||
| 8f4deaac4e | |||
| d479d1903b | |||
| 32c4138ae0 | |||
| da62d4f411 | |||
| 85dcd72a5d | |||
| 21fcbe3665 | |||
| 1b6724f043 | |||
| ef332ecaa1 | |||
| 3d6d673c0b | |||
| 674f09d5b6 | |||
| 11d8f78b1b | |||
| bbacef4ff7 | |||
| 8e692647d3 | |||
| 6f80f969a4 | |||
| bdf2a9af80 | |||
| e858d35722 | |||
| f2d5210313 | |||
| 1b0af3e2d6 | |||
| 0965cd3c7e | |||
| c3909d63da | |||
| 1096f24c57 | |||
| 7ed84fd445 | |||
| 4e2d7d2908 | |||
| 34089ddeea | |||
| 6ec98468de | |||
| 2d5919016f | |||
| c0fa51bb2e | |||
| 6713f36387 | |||
| f24750458a | |||
| 510a2f4d66 | |||
| ae37730da6 | |||
| 203f104ece | |||
| 8e934dd83a | |||
| 1676916a5c | |||
| 1af5a0e849 | |||
| 5b6e0a8fbf | |||
| 9968883bab | |||
| af5f637d31 | |||
| 0a725e845e | |||
| 6a539dc3c5 | |||
| 73d6ec933a | |||
| 000d603241 | |||
| fbf9fe9f2a | |||
| 8e4d7e1ee8 | |||
| 262eb57b36 | |||
| 2e1c261775 | |||
| 6ad2079351 | |||
| a477592f5d | |||
| 6f336e8368 | |||
| 17e038beb1 | |||
| 4e6fd90668 | |||
| 5cfc76e6c5 | |||
| 9e33c93763 | |||
| 1c9ae7bc06 | |||
| 472e9ad341 | |||
| 19c69f4f6f | |||
| 8788cbb105 | |||
| ea77e9a5f8 | |||
| eaaf0e1ed6 | |||
| 8a344b293d |
@@ -6,6 +6,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>kbkeyboardAppExtension</string>
|
<string>kbkeyboardAppExtension</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>需要使用麦克风进行语音输入</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "切图 270@2x.png",
|
"filename" : "切图 271@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "切图 270@3x.png",
|
"filename" : "切图 271@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_123@2x.png",
|
"filename" : "close_icon@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_123@3x.png",
|
"filename" : "close_icon@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@@ -4,15 +4,79 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"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",
|
"filename" : "kb_del_icon@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"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",
|
"filename" : "kb_del_icon@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"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" : {
|
"info" : {
|
||||||
|
|||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@2x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@3x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_ai@2x.png",
|
"filename" : "key_revoke@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_ai@3x.png",
|
"filename" : "key_revoke@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
23
CustomKeyboard/Manager/KBSuggestionEngine.h
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
167
CustomKeyboard/Manager/KBSuggestionEngine.m
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
49
CustomKeyboard/Model/KBChatMessage.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
55
CustomKeyboard/Model/KBChatMessage.m
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
96
CustomKeyboard/Model/KBKeyboardLayoutConfig.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
187
CustomKeyboard/Model/KBKeyboardLayoutConfig.m
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
|||||||
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
|
||||||
|
|||||||
@@ -124,6 +124,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
return [self startAFJSONTaskWithRequest: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
|
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
||||||
parameters:(NSDictionary *)parameters
|
parameters:(NSDictionary *)parameters
|
||||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||||
//
|
//
|
||||||
|
// 暂未使用
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Created by Mac on 2025/11/12.
|
// Created by Mac on 2025/11/12.
|
||||||
//
|
//
|
||||||
|
// 暂未使用
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
#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。
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
/* 字母 g(小写) */
|
/* 字母 g(小写) */
|
||||||
"letter_g_lower" = "key_g";
|
"letter_g_lower" = "key_g";
|
||||||
/* 字母 G(大写) */
|
/* 字母 G(大写) */
|
||||||
"letter_g_upper" = "key_f_up";
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
/* 字母 h(小写) */
|
/* 字母 h(小写) */
|
||||||
"letter_h_lower" = "key_h";
|
"letter_h_lower" = "key_h";
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
/* 自定义 AI 功能键 */
|
/* 自定义 AI 功能键 */
|
||||||
"ai" = "key_ai";
|
"ai" = "key_ai";
|
||||||
/* Emoji功能键 */
|
/* Emoji功能键 */
|
||||||
"emoji" = "key_emoji";
|
//"emoji" = "key_emoji";
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
/* 发送/换行键 */
|
/* 发送/换行键 */
|
||||||
"return" = "key_send";
|
"return" = "key_send";
|
||||||
|
|
||||||
|
|||||||
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
414
CustomKeyboard/Resource/kb_keyboard_layout_config.json
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
{
|
||||||
|
"__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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234454
CustomKeyboard/Resource/kb_words.txt
Normal file
BIN
CustomKeyboard/Resource/normal_hei_them.zip
Normal file
BIN
CustomKeyboard/Resource/normal_them.zip
Normal file
@@ -11,10 +11,10 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
- (instancetype)initWithContainerView:(UIView *)containerView;
|
- (instancetype)initWithContainerView:(UIView *)containerView;
|
||||||
|
|
||||||
/// 配置删除按钮(包含长按删除;可选是否显示“立刻清空”提示)
|
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
||||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
||||||
|
|
||||||
/// 触发“立刻清空”逻辑(可用于功能面板的清空按钮)
|
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
||||||
- (void)performClearAction;
|
- (void)performClearAction;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -7,24 +7,25 @@
|
|||||||
#import "KBResponderUtils.h"
|
#import "KBResponderUtils.h"
|
||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
#import "KBBackspaceUndoManager.h"
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
|
||||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
||||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 1.0;
|
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.6;
|
||||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
||||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4;
|
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
||||||
static const NSInteger kKBBackspaceChunkSize = 6;
|
static const NSInteger kKBBackspaceChunkSize = 8;
|
||||||
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
||||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelHeight = 26.0;
|
static const CGFloat kKBBackspaceClearLabelHeight = 34;
|
||||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||||
static const NSInteger kKBBackspaceClearBatchSize = 24;
|
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
||||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005;
|
|
||||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||||
|
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||||
KBBackspaceChunkClassUnknown = 0,
|
KBBackspaceChunkClassUnknown = 0,
|
||||||
@@ -34,6 +35,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
KBBackspaceChunkClassOther
|
KBBackspaceChunkClassOther
|
||||||
};
|
};
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||||
|
KBClearPhaseSkipWhitespace = 0,
|
||||||
|
KBClearPhaseSkipTrailingBoundary,
|
||||||
|
KBClearPhaseDeleteUntilBoundary
|
||||||
|
};
|
||||||
|
|
||||||
@interface KBBackspaceLongPressHandler ()
|
@interface KBBackspaceLongPressHandler ()
|
||||||
@property (nonatomic, weak) UIView *containerView;
|
@property (nonatomic, weak) UIView *containerView;
|
||||||
@property (nonatomic, weak) UIView *backspaceButton;
|
@property (nonatomic, weak) UIView *backspaceButton;
|
||||||
@@ -48,6 +55,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
||||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
||||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
||||||
|
@property (nonatomic, copy) NSString *pendingClearBefore;
|
||||||
|
@property (nonatomic, copy) NSString *pendingClearAfter;
|
||||||
|
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBBackspaceLongPressHandler
|
@implementation KBBackspaceLongPressHandler
|
||||||
@@ -55,6 +65,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
_containerView = containerView;
|
_containerView = containerView;
|
||||||
|
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
@@ -73,6 +84,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
self.backspaceHasLastTouchPoint = NO;
|
self.backspaceHasLastTouchPoint = NO;
|
||||||
self.backspaceHoldToken += 1;
|
self.backspaceHoldToken += 1;
|
||||||
[self kb_hideBackspaceClearLabel];
|
[self kb_hideBackspaceClearLabel];
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
|
||||||
if (!button) { return; }
|
if (!button) { return; }
|
||||||
|
|
||||||
@@ -99,7 +112,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
}
|
}
|
||||||
switch (gr.state) {
|
switch (gr.state) {
|
||||||
case UIGestureRecognizerStateBegan: {
|
case UIGestureRecognizerStateBegan: {
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
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;
|
self.backspaceHoldToken += 1;
|
||||||
NSUInteger token = self.backspaceHoldToken;
|
NSUInteger token = self.backspaceHoldToken;
|
||||||
self.backspaceHoldActive = YES;
|
self.backspaceHoldActive = YES;
|
||||||
@@ -134,6 +158,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
||||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
||||||
NSInteger deleteCount = 1;
|
NSInteger deleteCount = 1;
|
||||||
if (before.length > 0) {
|
if (before.length > 0) {
|
||||||
@@ -145,9 +170,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
[self kb_showBackspaceClearLabelIfNeeded];
|
[self kb_showBackspaceClearLabelIfNeeded];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (NSInteger i = 0; i < deleteCount; i++) {
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
||||||
[proxy deleteBackward];
|
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
||||||
}
|
|
||||||
|
|
||||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
@@ -186,34 +210,77 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
||||||
punctuationSet = [NSCharacterSet punctuationCharacterSet];
|
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||||
|
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
||||||
|
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
||||||
|
punctuationSet = [punct copy];
|
||||||
});
|
});
|
||||||
|
|
||||||
__block NSInteger deleteCount = 0;
|
__block NSInteger deleteCount = 0;
|
||||||
__block KBBackspaceChunkClass chunkClass = KBBackspaceChunkClassUnknown;
|
typedef NS_ENUM(NSInteger, KBBackspaceChunkPhase) {
|
||||||
|
KBBackspaceChunkPhaseWhitespace = 0,
|
||||||
|
KBBackspaceChunkPhasePunctuation,
|
||||||
|
KBBackspaceChunkPhaseCore
|
||||||
|
};
|
||||||
|
__block KBBackspaceChunkPhase phase = KBBackspaceChunkPhaseWhitespace;
|
||||||
|
__block KBBackspaceChunkClass coreClass = KBBackspaceChunkClassUnknown;
|
||||||
|
|
||||||
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
if (substring.length == 0) { return; }
|
if (substring.length == 0) { return; }
|
||||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
if (deleteCount >= maxCount) {
|
||||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassWhitespace;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassPunctuation;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunkClass == KBBackspaceChunkClassUnknown) {
|
|
||||||
chunkClass = currentClass;
|
|
||||||
} else if (chunkClass != currentClass) {
|
|
||||||
*stop = YES;
|
*stop = YES;
|
||||||
return;
|
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;
|
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) {
|
if (deleteCount >= maxCount) {
|
||||||
*stop = YES;
|
*stop = YES;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -222,13 +289,16 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
|
|
||||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||||
hitBoundary:(BOOL *)hitBoundary {
|
hitBoundary:(BOOL *)hitBoundary {
|
||||||
if (context.length == 0) { return kKBBackspaceClearBatchSize; }
|
if (context.length == 0) {
|
||||||
|
if (hitBoundary) { *hitBoundary = NO; }
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
static NSCharacterSet *whitespaceSet = nil;
|
||||||
static dispatch_once_t onceToken;
|
static dispatch_once_t onceToken;
|
||||||
dispatch_once(&onceToken, ^{
|
dispatch_once(&onceToken, ^{
|
||||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,6 +373,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
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.backspaceHoldActive = NO;
|
||||||
self.backspaceChunkModeActive = NO;
|
self.backspaceChunkModeActive = NO;
|
||||||
self.backspaceHoldToken += 1;
|
self.backspaceHoldToken += 1;
|
||||||
@@ -310,6 +386,11 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
[self kb_hideBackspaceClearLabel];
|
[self kb_hideBackspaceClearLabel];
|
||||||
if (shouldClear) {
|
if (shouldClear) {
|
||||||
[self kb_clearAllInput];
|
[self kb_clearAllInput];
|
||||||
|
} else {
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||||
|
[[KBInputBufferManager shared] commitLiveToManual];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,9 +482,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
- (UILabel *)backspaceClearLabel {
|
- (UILabel *)backspaceClearLabel {
|
||||||
if (!_backspaceClearLabel) {
|
if (!_backspaceClearLabel) {
|
||||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||||
label.text = @"立刻清空";
|
label.text = KBLocalized(@"Clear");
|
||||||
label.textAlignment = NSTextAlignmentCenter;
|
label.textAlignment = NSTextAlignmentCenter;
|
||||||
label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold];
|
label.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
||||||
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
||||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
||||||
@@ -421,10 +502,14 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
if (ivc) {
|
if (ivc) {
|
||||||
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
|
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||||
}
|
}
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||||
self.backspaceClearToken += 1;
|
self.backspaceClearToken += 1;
|
||||||
|
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||||
NSUInteger token = self.backspaceClearToken;
|
NSUInteger token = self.backspaceClearToken;
|
||||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
||||||
}
|
}
|
||||||
@@ -437,40 +522,101 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
if (!ivc) { return; }
|
if (!ivc) { return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
NSInteger count = before.length;
|
|
||||||
NSInteger batch = 0;
|
|
||||||
NSInteger nextEmptyRounds = emptyRounds;
|
NSInteger nextEmptyRounds = emptyRounds;
|
||||||
BOOL hitBoundary = NO;
|
static NSCharacterSet *stopBoundarySet = nil;
|
||||||
if (count > 0) {
|
static NSCharacterSet *trailingBoundarySet = nil;
|
||||||
batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary];
|
static NSCharacterSet *trailingWhitespaceSet = nil;
|
||||||
nextEmptyRounds = 0;
|
static dispatch_once_t onceToken;
|
||||||
} else {
|
dispatch_once(&onceToken, ^{
|
||||||
batch = kKBBackspaceClearBatchSize;
|
// stopBoundary: 遇到这些符号就停(不删除它)
|
||||||
nextEmptyRounds = emptyRounds + 1;
|
// - 句末符号:. ! ? 。!?
|
||||||
}
|
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
||||||
if (batch <= 0) { batch = 1; }
|
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
||||||
|
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
||||||
|
|
||||||
if (guard >= kKBBackspaceClearMaxDeletes ||
|
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
||||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) {
|
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (NSInteger i = 0; i < batch; i++) {
|
|
||||||
[proxy deleteBackward];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger nextGuard = guard + batch;
|
|
||||||
BOOL shouldContinue = NO;
|
|
||||||
if (count > 0 && !hitBoundary) {
|
|
||||||
if (count > batch) {
|
|
||||||
shouldContinue = YES;
|
|
||||||
} else if ([proxy hasText]) {
|
|
||||||
shouldContinue = YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldContinue) { return; }
|
|
||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
||||||
@@ -489,4 +635,28 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
return self.backspaceButton.superview;
|
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
|
@end
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
|||||||
|
|
||||||
+ (instancetype)shared;
|
+ (instancetype)shared;
|
||||||
|
|
||||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput)
|
/// 记录一次删除前的快照(不改变撤销按钮显示)。
|
||||||
- (void)recordClearWithContext:(NSString *)context;
|
- (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 处执行撤销(向光标处插回删除的内容)
|
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||||
|
|
||||||
/// 非清空行为触发时,清理撤销状态
|
/// 非删除行为触发时,清理撤销状态
|
||||||
- (void)registerNonClearAction;
|
- (void)registerNonClearAction;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -5,13 +5,38 @@
|
|||||||
|
|
||||||
#import "KBBackspaceUndoManager.h"
|
#import "KBBackspaceUndoManager.h"
|
||||||
#import "KBResponderUtils.h"
|
#import "KBResponderUtils.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
|
||||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
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 ()
|
@interface KBBackspaceUndoManager ()
|
||||||
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
|
@property (nonatomic, copy) NSString *undoText;
|
||||||
@property (nonatomic, assign) BOOL lastActionWasClear;
|
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||||||
@property (nonatomic, assign) BOOL hasUndo;
|
@property (nonatomic, assign) BOOL hasUndo;
|
||||||
|
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBBackspaceUndoManager
|
@implementation KBBackspaceUndoManager
|
||||||
@@ -27,42 +52,191 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
|||||||
|
|
||||||
- (instancetype)init {
|
- (instancetype)init {
|
||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
_segments = [NSMutableArray array];
|
_undoText = @"";
|
||||||
|
_undoAfterLength = 0;
|
||||||
|
_snapshotSource = KBUndoSnapshotSourceNone;
|
||||||
|
_undoDeletedPieces = [NSMutableArray array];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)recordClearWithContext:(NSString *)context {
|
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
||||||
if (context.length == 0) { return; }
|
if (!proxy || count == 0) { return; }
|
||||||
NSString *segment = [self kb_segmentForClearFromContext:context];
|
|
||||||
if (segment.length == 0) { return; }
|
|
||||||
|
|
||||||
if (!self.lastActionWasClear) {
|
NSString *selected = proxy.selectedText ?: @"";
|
||||||
[self.segments removeAllObjects];
|
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];
|
||||||
}
|
}
|
||||||
[self.segments addObject:segment];
|
#if DEBUG
|
||||||
self.lastActionWasClear = YES;
|
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];
|
[self kb_updateHasUndo:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||||
if (self.segments.count == 0) { return; }
|
if (!self.hasUndo) { return; }
|
||||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||||
if (!ivc) { return; }
|
if (!ivc) { return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *text = [self kb_buildUndoText];
|
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
if (text.length == 0) { return; }
|
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
[proxy insertText:text];
|
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||||||
|
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||||||
[self.segments removeAllObjects];
|
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||||||
self.lastActionWasClear = NO;
|
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];
|
[self kb_updateHasUndo:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)registerNonClearAction {
|
- (void)registerNonClearAction {
|
||||||
self.lastActionWasClear = NO;
|
if (!self.hasUndo) { return; }
|
||||||
if (self.segments.count == 0) { return; }
|
if (self.undoText.length > 0) {
|
||||||
[self.segments removeAllObjects];
|
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];
|
[self kb_updateHasUndo:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,97 +248,57 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
|||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSString *)kb_segmentForClearFromContext:(NSString *)context {
|
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
||||||
NSInteger length = context.length;
|
if (text.length == 0) { return @""; }
|
||||||
if (length == 0) { return @""; }
|
__block NSString *last = @"";
|
||||||
|
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
static dispatch_once_t onceToken;
|
last = substring ?: @"";
|
||||||
dispatch_once(&onceToken, ^{
|
*stop = YES;
|
||||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
}];
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
return last ?: @"";
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
||||||
for (NSInteger i = searchEnd - 1; i >= 0; i--) {
|
if (self.undoDeletedPieces.count == 0) { return @""; }
|
||||||
unichar ch = [context characterAtIndex:i];
|
|
||||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
|
||||||
boundaryIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger start = (boundaryIndex == NSNotFound) ? 0 : (boundaryIndex + 1);
|
|
||||||
if (start >= length) { return @""; }
|
|
||||||
return [context substringFromIndex:start];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_buildUndoText {
|
|
||||||
if (self.segments.count == 0) { return @""; }
|
|
||||||
NSArray<NSString *> *ordered = [[self.segments reverseObjectEnumerator] allObjects];
|
|
||||||
NSMutableString *result = [NSMutableString string];
|
NSMutableString *result = [NSMutableString string];
|
||||||
for (NSInteger i = 0; i < ordered.count; i++) {
|
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
||||||
NSString *segment = ordered[i] ?: @"";
|
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
||||||
if (segment.length == 0) { continue; }
|
if (piece.length == 0) { continue; }
|
||||||
if (i < ordered.count - 1) {
|
[result appendString:piece];
|
||||||
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
|
|
||||||
}
|
|
||||||
[result appendString:segment];
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
|
static const NSInteger kKBUndoClearMaxRounds = 200;
|
||||||
if (segment.length == 0) { return segment; }
|
|
||||||
|
|
||||||
static NSCharacterSet *boundarySet = nil;
|
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
||||||
static NSCharacterSet *englishBoundarySet = nil;
|
if (!proxy) { return; }
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
|
||||||
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
|
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
|
||||||
});
|
|
||||||
|
|
||||||
NSInteger idx = segment.length - 1;
|
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||||
while (idx >= 0) {
|
NSInteger guard = 0;
|
||||||
unichar ch = [segment characterAtIndex:idx];
|
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
if ([whitespaceSet characterIsMember:ch]) {
|
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||||
idx -= 1;
|
NSInteger offset = (NSInteger)contextAfter.length;
|
||||||
continue;
|
[proxy adjustTextPositionByCharacterOffset:offset];
|
||||||
|
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
||||||
|
[proxy deleteBackward];
|
||||||
}
|
}
|
||||||
if (![boundarySet characterIsMember:ch]) {
|
guard += 1;
|
||||||
return segment;
|
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
}
|
}
|
||||||
NSString *comma = [englishBoundarySet characterIsMember:ch] ? @"," : @",";
|
|
||||||
NSMutableString *mutable = [segment mutableCopy];
|
|
||||||
NSRange r = NSMakeRange(idx, 1);
|
|
||||||
[mutable replaceCharactersInRange:r withString:comma];
|
|
||||||
return mutable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segment;
|
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
|
@end
|
||||||
|
|||||||
34
CustomKeyboard/Utils/KBInputBufferManager.h
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#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
|
||||||
279
CustomKeyboard/Utils/KBInputBufferManager.m
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#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
|
||||||
95
CustomKeyboard/VM/KBVM.h
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
334
CustomKeyboard/VM/KBVM.m
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
40
CustomKeyboard/View/Chat/KBChatAssistantCell.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatAssistantCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatAssistantCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatAssistantCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatAssistantCellDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
346
CustomKeyboard/View/Chat/KBChatAssistantCell.m
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatAssistantCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatAssistantCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIButton *voiceButton;
|
||||||
|
@property (nonatomic, strong) UILabel *durationLabel;
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||||
|
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||||
|
|
||||||
|
/// 打字机效果
|
||||||
|
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||||
|
@property (nonatomic, copy) NSString *fullText;
|
||||||
|
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatAssistantCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
[self.contentView addSubview:self.voiceButton];
|
||||||
|
[self.contentView addSubview:self.durationLabel];
|
||||||
|
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
|
||||||
|
// 语音按钮
|
||||||
|
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.top.equalTo(self.contentView).offset(6);
|
||||||
|
make.width.height.mas_equalTo(20);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 语音时长
|
||||||
|
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||||
|
make.centerY.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 语音加载指示器
|
||||||
|
[self.voiceLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 消息加载指示器
|
||||||
|
[self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.top.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 气泡
|
||||||
|
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView).offset(-4);
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 消息文本
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(8);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
make.height.greaterThanOrEqualTo(@18);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||||
|
NSLog(@"[KBChatAssistantCell] ========== configureWithMessage ==========");
|
||||||
|
NSLog(@"[KBChatAssistantCell] text: %@", message.text);
|
||||||
|
NSLog(@"[KBChatAssistantCell] outgoing: %d, isLoading: %d, isComplete: %d, needsTypewriter: %d",
|
||||||
|
message.outgoing, message.isLoading, message.isComplete, message.needsTypewriterEffect);
|
||||||
|
|
||||||
|
// 先停止之前的打字机效果
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
|
// 处理 loading 状态
|
||||||
|
if (message.isLoading) {
|
||||||
|
NSLog(@"[KBChatAssistantCell] 显示 loading 状态");
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.bubbleView.hidden = YES;
|
||||||
|
self.voiceButton.hidden = YES;
|
||||||
|
self.durationLabel.hidden = YES;
|
||||||
|
[self.messageLoadingIndicator startAnimating];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 loading 状态
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
self.bubbleView.hidden = NO;
|
||||||
|
|
||||||
|
// 语音按钮显示逻辑
|
||||||
|
BOOL hasAudio = (message.audioId.length > 0) || (message.audioData.length > 0);
|
||||||
|
self.voiceButton.hidden = !hasAudio;
|
||||||
|
self.durationLabel.hidden = !hasAudio;
|
||||||
|
NSLog(@"[KBChatAssistantCell] hasAudio: %d, audioId: %@", hasAudio, message.audioId);
|
||||||
|
|
||||||
|
// 语音时长
|
||||||
|
if (message.audioDuration > 0) {
|
||||||
|
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||||
|
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||||
|
} else {
|
||||||
|
self.durationLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||||
|
NSLog(@"[KBChatAssistantCell] ✅ 启动打字机效果");
|
||||||
|
[self startTypewriterEffectWithText:message.text];
|
||||||
|
} else {
|
||||||
|
NSLog(@"[KBChatAssistantCell] 直接显示文本(不使用打字机)");
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Typewriter Effect
|
||||||
|
|
||||||
|
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
self.fullText = text;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
|
||||||
|
// 先设置完整文本让布局计算正确高度
|
||||||
|
self.messageLabel.text = text;
|
||||||
|
[self.contentView setNeedsLayout];
|
||||||
|
[self.contentView layoutIfNeeded];
|
||||||
|
|
||||||
|
// 应用打字机效果
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||||
|
target:self
|
||||||
|
selector:@selector(typewriterTick)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:YES];
|
||||||
|
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||||
|
[self typewriterTick];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)typewriterTick {
|
||||||
|
NSString *text = self.fullText;
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
self.currentCharIndex++;
|
||||||
|
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = [UIColor whiteColor];
|
||||||
|
|
||||||
|
if (self.currentCharIndex > 0) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
} else {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
// 显示完整文本
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor whiteColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
if (self.currentMessage) {
|
||||||
|
self.currentMessage.isComplete = YES;
|
||||||
|
self.currentMessage.needsTypewriterEffect = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)stopTypewriterEffect {
|
||||||
|
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||||
|
[self.typewriterTimer invalidate];
|
||||||
|
}
|
||||||
|
self.typewriterTimer = nil;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
self.fullText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Voice Button
|
||||||
|
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)showVoiceLoadingAnimation {
|
||||||
|
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.voiceLoadingIndicator startAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)hideVoiceLoadingAnimation {
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceButtonTapped {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(assistantCell:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate assistantCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Reuse
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIButton *)voiceButton {
|
||||||
|
if (!_voiceButton) {
|
||||||
|
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_voiceButton.tintColor = [UIColor whiteColor];
|
||||||
|
[_voiceButton addTarget:self action:@selector(voiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _voiceButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)durationLabel {
|
||||||
|
if (!_durationLabel) {
|
||||||
|
_durationLabel = [[UILabel alloc] init];
|
||||||
|
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_durationLabel.textColor = [UIColor whiteColor];
|
||||||
|
}
|
||||||
|
return _durationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||||
|
if (!_voiceLoadingIndicator) {
|
||||||
|
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _voiceLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||||
|
if (!_messageLoadingIndicator) {
|
||||||
|
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _messageLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
49
CustomKeyboard/View/Chat/KBChatPanelView.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// KBChatPanelView.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatPanelView, KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatPanelViewDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||||
|
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view;
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatPanelView : UIView
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatPanelViewDelegate> delegate;
|
||||||
|
|
||||||
|
@property (nonatomic, strong, readonly) UITableView *tableView;
|
||||||
|
|
||||||
|
//- (void)kb_setBackgroundImage:(nullable UIImage *)image;
|
||||||
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
||||||
|
|
||||||
|
/// 添加用户消息
|
||||||
|
- (void)kb_addUserMessage:(NSString *)text;
|
||||||
|
|
||||||
|
/// 添加 loading 状态的 AI 消息
|
||||||
|
- (void)kb_addLoadingAssistantMessage;
|
||||||
|
|
||||||
|
/// 移除 loading 状态的 AI 消息
|
||||||
|
- (void)kb_removeLoadingAssistantMessage;
|
||||||
|
|
||||||
|
/// 添加 AI 消息(带打字机效果)
|
||||||
|
- (void)kb_addAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 更新最后一条 AI 消息的音频数据
|
||||||
|
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration;
|
||||||
|
|
||||||
|
/// 滚动到底部
|
||||||
|
- (void)kb_scrollToBottom;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
347
CustomKeyboard/View/Chat/KBChatPanelView.m
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
//
|
||||||
|
// KBChatPanelView.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatPanelView.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "KBChatUserCell.h"
|
||||||
|
#import "KBChatAssistantCell.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
static NSString * const kUserCellIdentifier = @"KBChatUserCell";
|
||||||
|
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell";
|
||||||
|
static const NSUInteger kKBChatMessageLimit = 10;
|
||||||
|
|
||||||
|
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantCellDelegate>
|
||||||
|
@property (nonatomic, strong) UIView *headerView;
|
||||||
|
@property (nonatomic, strong) UILabel *titleLabel;
|
||||||
|
@property (nonatomic, strong) UIButton *closeButton;
|
||||||
|
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatPanelView
|
||||||
|
|
||||||
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
|
if (self = [super initWithFrame:frame]) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.messages = [NSMutableArray array];
|
||||||
|
|
||||||
|
[self addSubview:self.headerView];
|
||||||
|
[self addSubview:self.tableViewInternal];
|
||||||
|
|
||||||
|
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self);
|
||||||
|
make.top.equalTo(self.mas_top);
|
||||||
|
make.height.mas_equalTo(KBFit(36.0f));
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self);
|
||||||
|
make.top.equalTo(self.headerView.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.mas_bottom).offset(-8);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Public
|
||||||
|
|
||||||
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
|
||||||
|
NSLog(@"[Panel] ⚠️ kb_reloadWithMessages 被调用,传入 %lu 条消息", (unsigned long)messages.count);
|
||||||
|
|
||||||
|
[self.messages removeAllObjects];
|
||||||
|
if (messages.count > 0) {
|
||||||
|
[self.messages addObjectsFromArray:messages];
|
||||||
|
}
|
||||||
|
[self.tableViewInternal reloadData];
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addUserMessage:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加用户消息: %@,当前消息数: %lu", text, (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
KBChatMessage *msg = [KBChatMessage userMessageWithText:text];
|
||||||
|
[self kb_appendMessage:msg];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addLoadingAssistantMessage {
|
||||||
|
NSLog(@"[Panel] 添加 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
KBChatMessage *msg = [KBChatMessage loadingAssistantMessage];
|
||||||
|
[self kb_appendMessage:msg];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_removeLoadingAssistantMessage {
|
||||||
|
NSLog(@"[Panel] 移除 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
// 只移除 AI 消息(outgoing == NO)且是 loading 状态的
|
||||||
|
if (!msg.outgoing && msg.isLoading) {
|
||||||
|
NSLog(@"[Panel] ✅ 找到 loading 消息,移除索引: %ld", (long)i);
|
||||||
|
[self.messages removeObjectAtIndex:i];
|
||||||
|
|
||||||
|
// 使用 beginUpdates/endUpdates 包裹删除操作
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
[self.tableViewInternal deleteRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 移除后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addAssistantMessage:(NSString *)text audioId:(NSString *)audioId {
|
||||||
|
NSLog(@"[Panel] ========== kb_addAssistantMessage ==========");
|
||||||
|
NSLog(@"[Panel] 当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
// 查找 loading 消息的索引
|
||||||
|
NSInteger loadingIndex = -1;
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
if (!msg.outgoing && msg.isLoading) {
|
||||||
|
loadingIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 AI 消息
|
||||||
|
KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId];
|
||||||
|
msg.displayName = KBLocalized(@"AI助手");
|
||||||
|
NSLog(@"[Panel] 创建 AI 消息,needsTypewriter: %d", msg.needsTypewriterEffect);
|
||||||
|
|
||||||
|
// 使用批量更新,避免界面跳动
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
|
||||||
|
if (loadingIndex >= 0) {
|
||||||
|
// 移除 loading 消息
|
||||||
|
NSLog(@"[Panel] 移除 loading 索引: %ld", (long)loadingIndex);
|
||||||
|
[self.messages removeObjectAtIndex:loadingIndex];
|
||||||
|
NSIndexPath *deleteIndexPath = [NSIndexPath indexPathForRow:loadingIndex inSection:0];
|
||||||
|
[self.tableViewInternal deleteRowsAtIndexPaths:@[deleteIndexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 消息
|
||||||
|
NSInteger insertIndex = self.messages.count;
|
||||||
|
[self.messages addObject:msg];
|
||||||
|
NSLog(@"[Panel] 插入 AI 消息索引: %ld", (long)insertIndex);
|
||||||
|
NSIndexPath *insertIndexPath = [NSIndexPath indexPathForRow:insertIndex inSection:0];
|
||||||
|
[self.tableViewInternal insertRowsAtIndexPaths:@[insertIndexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration {
|
||||||
|
NSLog(@"[Panel] 更新音频数据,duration: %.2f", duration);
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
// 只更新 AI 消息(outgoing == NO)且非 loading 状态的
|
||||||
|
if (!msg.outgoing && !msg.isLoading) {
|
||||||
|
msg.audioData = audioData;
|
||||||
|
msg.audioDuration = duration;
|
||||||
|
|
||||||
|
// 不刷新 Cell,避免打断打字机效果
|
||||||
|
if (duration > 0) {
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
msg.isComplete = YES;
|
||||||
|
}
|
||||||
|
NSLog(@"[Panel] ✅ 音频数据已更新");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_scrollToBottom {
|
||||||
|
if (self.messages.count == 0) return;
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 滚动到底部,消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
[self.tableViewInternal layoutIfNeeded];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
||||||
|
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
|
||||||
|
atScrollPosition:UITableViewScrollPositionBottom
|
||||||
|
animated:NO]; // 改为 NO,避免动画导致跳动
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Private
|
||||||
|
|
||||||
|
- (void)kb_appendMessage:(KBChatMessage *)message {
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
[self.messages addObject:message];
|
||||||
|
NSLog(@"[Panel] kb_appendMessage: oldCount=%ld, newCount=%lu", (long)oldCount, (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
// 限制消息数量
|
||||||
|
if (self.messages.count > kKBChatMessageLimit) {
|
||||||
|
NSUInteger overflow = self.messages.count - kKBChatMessageLimit;
|
||||||
|
[self.messages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||||
|
NSLog(@"[Panel] 消息超限,reloadData");
|
||||||
|
[self.tableViewInternal reloadData];
|
||||||
|
} else {
|
||||||
|
NSLog(@"[Panel] 插入新行: %ld", (long)oldCount);
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:oldCount inSection:0];
|
||||||
|
[self.tableViewInternal insertRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接滚动,不用 dispatch_async
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Actions
|
||||||
|
|
||||||
|
- (void)kb_onTapClose {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelViewDidTapClose:)]) {
|
||||||
|
[self.delegate chatPanelViewDidTapClose:self];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDataSource
|
||||||
|
|
||||||
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||||
|
return self.messages.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
if (indexPath.row >= self.messages.count) {
|
||||||
|
NSLog(@"[Panel] ❌ cellForRow 索引越界: %ld >= %lu", (long)indexPath.row, (unsigned long)self.messages.count);
|
||||||
|
return [[UITableViewCell alloc] init];
|
||||||
|
}
|
||||||
|
|
||||||
|
KBChatMessage *msg = self.messages[indexPath.row];
|
||||||
|
NSLog(@"[Panel] cellForRow[%ld]: outgoing=%d, isLoading=%d", (long)indexPath.row, msg.outgoing, msg.isLoading);
|
||||||
|
|
||||||
|
if (msg.outgoing) {
|
||||||
|
// 用户消息(右侧)
|
||||||
|
KBChatUserCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath];
|
||||||
|
[cell configureWithMessage:msg];
|
||||||
|
return cell;
|
||||||
|
} else {
|
||||||
|
// AI 消息(左侧)
|
||||||
|
KBChatAssistantCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath];
|
||||||
|
cell.delegate = self;
|
||||||
|
[cell configureWithMessage:msg];
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDelegate
|
||||||
|
|
||||||
|
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
return UITableViewAutomaticDimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
return 60.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
if (indexPath.row >= self.messages.count) { return; }
|
||||||
|
KBChatMessage *msg = self.messages[indexPath.row];
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
|
||||||
|
[self.delegate chatPanelView:self didTapMessage:msg];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBChatAssistantCellDelegate
|
||||||
|
|
||||||
|
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate chatPanelView:self didTapVoiceButtonForMessage:message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UITableView *)tableViewInternal {
|
||||||
|
if (!_tableViewInternal) {
|
||||||
|
_tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||||
|
_tableViewInternal.backgroundColor = [UIColor clearColor];
|
||||||
|
_tableViewInternal.backgroundView = nil;
|
||||||
|
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||||
|
_tableViewInternal.dataSource = self;
|
||||||
|
_tableViewInternal.delegate = self;
|
||||||
|
_tableViewInternal.estimatedRowHeight = 60.0;
|
||||||
|
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
|
||||||
|
// 注册两种 Cell
|
||||||
|
[_tableViewInternal registerClass:KBChatUserCell.class forCellReuseIdentifier:kUserCellIdentifier];
|
||||||
|
[_tableViewInternal registerClass:KBChatAssistantCell.class forCellReuseIdentifier:kAssistantCellIdentifier];
|
||||||
|
if (@available(iOS 11.0, *)) {
|
||||||
|
_tableViewInternal.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _tableViewInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)headerView {
|
||||||
|
if (!_headerView) {
|
||||||
|
_headerView = [[UIView alloc] init];
|
||||||
|
_headerView.backgroundColor = [UIColor clearColor];
|
||||||
|
[_headerView addSubview:self.titleLabel];
|
||||||
|
[_headerView addSubview:self.closeButton];
|
||||||
|
|
||||||
|
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(_headerView.mas_left).offset(12);
|
||||||
|
make.centerY.equalTo(_headerView);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.right.equalTo(_headerView.mas_right).offset(-12);
|
||||||
|
make.centerY.equalTo(_headerView);
|
||||||
|
make.width.height.mas_equalTo(KBFit(24.0f));
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return _headerView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)titleLabel {
|
||||||
|
if (!_titleLabel) {
|
||||||
|
_titleLabel = [[UILabel alloc] init];
|
||||||
|
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
|
||||||
|
_titleLabel.textColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||||
|
darkColor:[UIColor whiteColor]];
|
||||||
|
_titleLabel.text = KBLocalized(@"AI对话");
|
||||||
|
}
|
||||||
|
return _titleLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIButton *)closeButton {
|
||||||
|
if (!_closeButton) {
|
||||||
|
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = [UIImage imageNamed:@"close_icon"];
|
||||||
|
[_closeButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_closeButton.backgroundColor = [UIColor clearColor];
|
||||||
|
[_closeButton addTarget:self
|
||||||
|
action:@selector(kb_onTapClose)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _closeButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Expose
|
||||||
|
|
||||||
|
- (UITableView *)tableView { return self.tableViewInternal; }
|
||||||
|
|
||||||
|
@end
|
||||||
19
CustomKeyboard/View/Chat/KBChatUserCell.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 用户消息 Cell(右侧显示)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBChatUserCell : UITableViewCell
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
85
CustomKeyboard/View/Chat/KBChatUserCell.m
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 用户消息 Cell(右侧显示)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatUserCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatUserCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatUserCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
|
||||||
|
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.contentView).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView).offset(-4);
|
||||||
|
make.right.equalTo(self.contentView).offset(-12);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||||
|
make.height.greaterThanOrEqualTo(@36);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(8);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.backgroundColor = [UIColor colorWithHex:0x02BEAC];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#import "KBFunctionTagListView.h"
|
#import "KBFunctionTagListView.h"
|
||||||
#import "KBFunctionTagCell.h"
|
#import "KBFunctionTagCell.h"
|
||||||
|
#import "KBMaiPointReporter.h"
|
||||||
|
|
||||||
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||||
static CGFloat const kKBItemSpace = 4;
|
static CGFloat const kKBItemSpace = 4;
|
||||||
@@ -66,8 +67,23 @@ static CGFloat const kKBItemSpace = 4;
|
|||||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||||
|
|
||||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
|
||||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||||
|
NSInteger personaId = 0;
|
||||||
|
if ([model isKindOfClass:KBTagItemModel.class]) {
|
||||||
|
personaId = model.characterId > 0 ? model.characterId : model.tagId;
|
||||||
|
}
|
||||||
|
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||||
|
extra[@"index"] = @(indexPath.item);
|
||||||
|
extra[@"id"] = @(personaId);
|
||||||
|
if ([model.characterName isKindOfClass:NSString.class] && model.characterName.length > 0) {
|
||||||
|
extra[@"name"] = model.characterName;
|
||||||
|
}
|
||||||
|
[[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_function_tag_item"
|
||||||
|
pageId:@"keyboard_function_panel"
|
||||||
|
elementId:@"renshe_item"
|
||||||
|
extra:extra.copy
|
||||||
|
completion:nil];
|
||||||
|
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
||||||
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
CustomKeyboard/View/KBChatMessageCell.h
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessageCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatMessageCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatMessageCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatMessageCell:(KBChatMessageCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatMessageCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatMessageCellDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)kb_showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)kb_hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)kb_stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
495
CustomKeyboard/View/KBChatMessageCell.m
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessageCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatMessageCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatMessageCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIImageView *avatarView;
|
||||||
|
@property (nonatomic, strong) UILabel *nameLabel;
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
@property (nonatomic, strong) UIImageView *audioIconView;
|
||||||
|
@property (nonatomic, strong) UILabel *audioLabel;
|
||||||
|
|
||||||
|
/// 语音播放按钮
|
||||||
|
@property (nonatomic, strong) UIButton *voiceButton;
|
||||||
|
/// 语音时长标签
|
||||||
|
@property (nonatomic, strong) UILabel *durationLabel;
|
||||||
|
/// 语音加载指示器
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||||
|
/// 消息加载指示器(AI 回复 loading)
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||||
|
|
||||||
|
/// 当前消息
|
||||||
|
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||||
|
|
||||||
|
/// 打字机效果
|
||||||
|
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||||
|
@property (nonatomic, copy) NSString *fullText;
|
||||||
|
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatMessageCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
|
||||||
|
[self.contentView addSubview:self.avatarView];
|
||||||
|
[self.contentView addSubview:self.nameLabel];
|
||||||
|
[self.contentView addSubview:self.voiceButton];
|
||||||
|
[self.contentView addSubview:self.durationLabel];
|
||||||
|
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
[self.bubbleView addSubview:self.audioIconView];
|
||||||
|
[self.bubbleView addSubview:self.audioLabel];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
||||||
|
// 先停止之前的打字机效果
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
|
BOOL outgoing = message.outgoing;
|
||||||
|
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
||||||
|
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
||||||
|
UIColor *incomingTextColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||||
|
darkColor:[UIColor whiteColor]];
|
||||||
|
UIColor *textColor = outgoing ? [UIColor whiteColor] : incomingTextColor;
|
||||||
|
UIColor *nameColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x6B6F7A]
|
||||||
|
darkColor:[UIColor colorWithHex:0xC7CBD4]];
|
||||||
|
|
||||||
|
self.bubbleView.backgroundColor = bubbleColor;
|
||||||
|
self.messageLabel.textColor = textColor;
|
||||||
|
self.audioLabel.textColor = textColor;
|
||||||
|
self.audioIconView.tintColor = textColor;
|
||||||
|
self.audioLabel.text =
|
||||||
|
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||||
|
self.messageLabel.hidden = audioMessage;
|
||||||
|
self.audioIconView.hidden = !audioMessage;
|
||||||
|
self.audioLabel.hidden = !audioMessage;
|
||||||
|
|
||||||
|
UIImage *avatarImage = message.avatarImage;
|
||||||
|
if (!avatarImage) {
|
||||||
|
avatarImage = [self kb_defaultAvatarImage];
|
||||||
|
}
|
||||||
|
self.avatarView.image = avatarImage;
|
||||||
|
self.avatarView.backgroundColor =
|
||||||
|
avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||||
|
self.nameLabel.hidden = outgoing;
|
||||||
|
self.nameLabel.textColor = nameColor;
|
||||||
|
self.nameLabel.text =
|
||||||
|
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
|
||||||
|
|
||||||
|
// 处理 loading 状态
|
||||||
|
if (message.isLoading && !outgoing) {
|
||||||
|
self.bubbleView.hidden = YES;
|
||||||
|
self.voiceButton.hidden = YES;
|
||||||
|
self.durationLabel.hidden = YES;
|
||||||
|
[self.messageLoadingIndicator startAnimating];
|
||||||
|
[self kb_layoutForOutgoing:outgoing audioMessage:NO];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 loading 状态
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
self.bubbleView.hidden = NO;
|
||||||
|
|
||||||
|
// 语音按钮显示逻辑(仅 AI 消息且有 audioId 或 audioData)
|
||||||
|
BOOL hasAudio = (!outgoing) && (message.audioId.length > 0 || message.audioData.length > 0);
|
||||||
|
self.voiceButton.hidden = !hasAudio;
|
||||||
|
self.durationLabel.hidden = !hasAudio;
|
||||||
|
if (hasAudio && message.audioDuration > 0) {
|
||||||
|
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||||
|
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||||
|
} else {
|
||||||
|
self.durationLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
if (!outgoing && message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||||
|
[self kb_startTypewriterEffectWithText:message.text];
|
||||||
|
} else {
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
[self kb_layoutForOutgoing:outgoing audioMessage:audioMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_layoutForOutgoing:(BOOL)outgoing audioMessage:(BOOL)audioMessage {
|
||||||
|
CGFloat avatarSize = 28.0;
|
||||||
|
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(avatarSize);
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||||
|
if (outgoing) {
|
||||||
|
make.right.equalTo(self.contentView.mas_right).offset(-8);
|
||||||
|
} else {
|
||||||
|
make.left.equalTo(self.contentView.mas_left).offset(8);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (outgoing) {
|
||||||
|
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(0);
|
||||||
|
make.left.equalTo(self.contentView.mas_left);
|
||||||
|
}];
|
||||||
|
// 用户消息不显示语音按钮
|
||||||
|
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.top.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.top.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(2);
|
||||||
|
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||||
|
}];
|
||||||
|
// AI 消息语音按钮
|
||||||
|
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.top.equalTo(self.nameLabel.mas_bottom).offset(4);
|
||||||
|
make.width.height.mas_equalTo(20);
|
||||||
|
}];
|
||||||
|
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||||
|
make.centerY.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
[self.voiceLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息加载指示器
|
||||||
|
[self.messageLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
if (outgoing) {
|
||||||
|
make.right.equalTo(self.avatarView.mas_left).offset(-10);
|
||||||
|
} else {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(10);
|
||||||
|
}
|
||||||
|
make.top.equalTo(self.nameLabel.mas_bottom).offset(8);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
||||||
|
if (outgoing) {
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||||
|
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
|
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||||
|
} else {
|
||||||
|
// AI 消息:气泡在语音按钮下方
|
||||||
|
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (audioMessage) {
|
||||||
|
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left).offset(10);
|
||||||
|
make.centerY.equalTo(self.bubbleView);
|
||||||
|
make.width.height.mas_equalTo(16);
|
||||||
|
}];
|
||||||
|
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.audioIconView.mas_right).offset(6);
|
||||||
|
make.centerY.equalTo(self.bubbleView);
|
||||||
|
make.right.equalTo(self.bubbleView.mas_right).offset(-10);
|
||||||
|
make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8);
|
||||||
|
make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8);
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.audioIconView.mas_right);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Typewriter Effect
|
||||||
|
|
||||||
|
- (void)kb_startTypewriterEffectWithText:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
self.fullText = text;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
|
||||||
|
// 先设置完整文本让布局计算正确高度
|
||||||
|
self.messageLabel.text = text;
|
||||||
|
[self.contentView setNeedsLayout];
|
||||||
|
[self.contentView layoutIfNeeded];
|
||||||
|
|
||||||
|
// 应用打字机效果
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||||
|
target:self
|
||||||
|
selector:@selector(kb_typewriterTick)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:YES];
|
||||||
|
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||||
|
[self kb_typewriterTick];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_typewriterTick {
|
||||||
|
NSString *text = self.fullText;
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
self.currentCharIndex++;
|
||||||
|
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||||
|
|
||||||
|
if (self.currentCharIndex > 0) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
} else {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
|
||||||
|
// 显示完整文本
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
if (self.currentMessage) {
|
||||||
|
self.currentMessage.isComplete = YES;
|
||||||
|
self.currentMessage.needsTypewriterEffect = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_stopTypewriterEffect {
|
||||||
|
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||||
|
[self.typewriterTimer invalidate];
|
||||||
|
}
|
||||||
|
self.typewriterTimer = nil;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
self.fullText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Voice Button
|
||||||
|
|
||||||
|
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying {
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_showVoiceLoadingAnimation {
|
||||||
|
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.voiceLoadingIndicator startAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_hideVoiceLoadingAnimation {
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_onVoiceButtonTapped {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatMessageCell:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate chatMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Reuse
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIImageView *)avatarView {
|
||||||
|
if (!_avatarView) {
|
||||||
|
_avatarView = [[UIImageView alloc] init];
|
||||||
|
_avatarView.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_avatarView.layer.cornerRadius = 14;
|
||||||
|
_avatarView.layer.masksToBounds = YES;
|
||||||
|
_avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||||
|
_avatarView.tintColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0xB9BDC8]
|
||||||
|
darkColor:[UIColor colorWithHex:0x6B6F7A]];
|
||||||
|
}
|
||||||
|
return _avatarView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)nameLabel {
|
||||||
|
if (!_nameLabel) {
|
||||||
|
_nameLabel = [[UILabel alloc] init];
|
||||||
|
_nameLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A];
|
||||||
|
_nameLabel.numberOfLines = 1;
|
||||||
|
}
|
||||||
|
return _nameLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImageView *)audioIconView {
|
||||||
|
if (!_audioIconView) {
|
||||||
|
_audioIconView = [[UIImageView alloc] init];
|
||||||
|
_audioIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
|
_audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"waveform"];
|
||||||
|
}
|
||||||
|
_audioIconView.image = icon;
|
||||||
|
}
|
||||||
|
return _audioIconView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)audioLabel {
|
||||||
|
if (!_audioLabel) {
|
||||||
|
_audioLabel = [[UILabel alloc] init];
|
||||||
|
_audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||||
|
_audioLabel.numberOfLines = 1;
|
||||||
|
}
|
||||||
|
return _audioLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIButton *)voiceButton {
|
||||||
|
if (!_voiceButton) {
|
||||||
|
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_voiceButton.tintColor = [UIColor whiteColor];
|
||||||
|
[_voiceButton addTarget:self action:@selector(kb_onVoiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _voiceButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)durationLabel {
|
||||||
|
if (!_durationLabel) {
|
||||||
|
_durationLabel = [[UILabel alloc] init];
|
||||||
|
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_durationLabel.textColor = [UIColor whiteColor];
|
||||||
|
}
|
||||||
|
return _durationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||||
|
if (!_voiceLoadingIndicator) {
|
||||||
|
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _voiceLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||||
|
if (!_messageLoadingIndicator) {
|
||||||
|
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _messageLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImage *)kb_defaultAvatarImage {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIImage systemImageNamed:@"person.circle.fill"];
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBFunctionBarView
|
@implementation KBFunctionBarView
|
||||||
|
static const CGFloat kKBBackButtonWidth = 40;
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame{
|
- (instancetype)initWithFrame:(CGRect)frame{
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
@@ -83,14 +84,14 @@
|
|||||||
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
appButton.tag = 100; // 左侧 index = 0
|
appButton.tag = 100; // 左侧 index = 0
|
||||||
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
||||||
[appButton setImage:appImage forState:UIControlStateNormal];
|
[appButton setBackgroundImage:appImage forState:UIControlStateNormal];
|
||||||
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
appButton.adjustsImageWhenHighlighted = YES;
|
appButton.adjustsImageWhenHighlighted = YES;
|
||||||
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||||
[self.leftContainer addSubview:appButton];
|
[self.leftContainer addSubview:appButton];
|
||||||
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.center.equalTo(self.leftContainer);
|
make.center.equalTo(self.leftContainer);
|
||||||
make.width.height.mas_equalTo(34); // 设计图尺寸
|
make.width.height.mas_equalTo(kKBBackButtonWidth); // 设计图尺寸
|
||||||
}];
|
}];
|
||||||
self.leftButtonsInternal = @[appButton];
|
self.leftButtonsInternal = @[appButton];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "KBFunctionTagCell.h"
|
#import "KBFunctionTagCell.h"
|
||||||
|
#import "KBFunctionView.h"
|
||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
|
|
||||||
@interface KBFunctionTagCell ()
|
@interface KBFunctionTagCell ()
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
self.contentView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
self.contentView.backgroundColor = [KBFunctionView kb_cellBackgroundColor];
|
||||||
self.contentView.layer.cornerRadius = 12;
|
self.contentView.layer.cornerRadius = 12;
|
||||||
self.contentView.layer.masksToBounds = YES;
|
self.contentView.layer.masksToBounds = YES;
|
||||||
|
|
||||||
@@ -73,7 +74,6 @@
|
|||||||
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
||||||
_emojiLabel.font = [KBFont medium:20];
|
_emojiLabel.font = [KBFont medium:20];
|
||||||
_emojiLabel.adjustsFontSizeToFitWidth = YES;
|
_emojiLabel.adjustsFontSizeToFitWidth = YES;
|
||||||
|
|
||||||
}
|
}
|
||||||
return _emojiLabel;
|
return _emojiLabel;
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
if (!_titleLabelInternal) {
|
if (!_titleLabelInternal) {
|
||||||
_titleLabelInternal = [[UILabel alloc] init];
|
_titleLabelInternal = [[UILabel alloc] init];
|
||||||
_titleLabelInternal.font = [KBFont medium:10];
|
_titleLabelInternal.font = [KBFont medium:10];
|
||||||
_titleLabelInternal.textColor = [UIColor colorWithHex:0x1B1F1A];
|
_titleLabelInternal.textColor = [KBFunctionView kb_cellTextColor];
|
||||||
// 最多两行,文本过长时末尾截断
|
// 最多两行,文本过长时末尾截断
|
||||||
_titleLabelInternal.numberOfLines = 2;
|
_titleLabelInternal.numberOfLines = 2;
|
||||||
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
|
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||||
@@ -91,14 +91,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
|
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
|
||||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleMedium; }
|
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) {
|
||||||
|
return UIActivityIndicatorViewStyleMedium;
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleGray; }
|
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) {
|
||||||
|
return UIActivityIndicatorViewStyleGray;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
- (UIActivityIndicatorView *)loadingView {
|
- (UIActivityIndicatorView *)loadingView {
|
||||||
if (!_loadingView) {
|
if (!_loadingView) {
|
||||||
_loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
_loadingView = [[UIActivityIndicatorView alloc]
|
||||||
|
initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
||||||
_loadingView.hidesWhenStopped = YES;
|
_loadingView.hidesWhenStopped = YES;
|
||||||
_loadingView.color = [UIColor grayColor];
|
_loadingView.color = [UIColor grayColor];
|
||||||
_loadingView.hidden = YES;
|
_loadingView.hidden = YES;
|
||||||
@@ -108,7 +113,9 @@ static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndi
|
|||||||
|
|
||||||
#pragma mark - Expose
|
#pragma mark - Expose
|
||||||
|
|
||||||
- (UILabel *)titleLabel { return self.titleLabelInternal; }
|
- (UILabel *)titleLabel {
|
||||||
|
return self.titleLabelInternal;
|
||||||
|
}
|
||||||
|
|
||||||
- (void)setLoading:(BOOL)loading {
|
- (void)setLoading:(BOOL)loading {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
|
|
||||||
@protocol KBFunctionViewDelegate <NSObject>
|
@protocol KBFunctionViewDelegate <NSObject>
|
||||||
@optional
|
@optional
|
||||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didTapToolActionAtIndex:(NSInteger)index;
|
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index;
|
didTapToolActionAtIndex:(NSInteger)index;
|
||||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *_Nullable)functionView;
|
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||||
|
didRightTapToolActionAtIndex:(NSInteger)index;
|
||||||
|
- (void)functionViewDidRequestSubscription:
|
||||||
|
(KBFunctionView *_Nullable)functionView;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -23,9 +26,10 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
@property(nonatomic, weak) id<KBFunctionViewDelegate> delegate;
|
@property(nonatomic, weak) id<KBFunctionViewDelegate> delegate;
|
||||||
|
|
||||||
|
@property(nonatomic, strong, readonly)
|
||||||
@property (nonatomic, strong, readonly) UICollectionView *collectionView; // 话术分类/标签列表
|
UICollectionView *collectionView; // 话术分类/标签列表
|
||||||
@property (nonatomic, strong, readonly) NSArray<NSString *> *items; // 简单数据源(演示用)
|
@property(nonatomic, strong, readonly)
|
||||||
|
NSArray<NSString *> *items; // 简单数据源(演示用)
|
||||||
|
|
||||||
// 子视图暴露,便于外部接入事件
|
// 子视图暴露,便于外部接入事件
|
||||||
@property(nonatomic, strong, readonly) KBFunctionBarView *barView;
|
@property(nonatomic, strong, readonly) KBFunctionBarView *barView;
|
||||||
@@ -39,6 +43,14 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/// 应用当前皮肤(更新背景/强调色)
|
/// 应用当前皮肤(更新背景/强调色)
|
||||||
- (void)kb_applyTheme;
|
- (void)kb_applyTheme;
|
||||||
|
|
||||||
|
#pragma mark - Theme Colors (用于 Cell 获取暗黑模式颜色)
|
||||||
|
|
||||||
|
/// Cell 背景色:暗黑 #707070,浅色 白色90%透明度
|
||||||
|
+ (UIColor *)kb_cellBackgroundColor;
|
||||||
|
|
||||||
|
/// Cell 文字颜色:暗黑 #FFFFFF,浅色 #1B1F1A
|
||||||
|
+ (UIColor *)kb_cellTextColor;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -6,29 +6,32 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "KBFunctionView.h"
|
#import "KBFunctionView.h"
|
||||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
#import "KBAuthManager.h" // 登录态判断(共享钥匙串)
|
||||||
|
#import "KBBackspaceLongPressHandler.h"
|
||||||
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBBizCode.h"
|
||||||
|
#import "KBFullAccessGuideView.h"
|
||||||
|
#import "KBFullAccessManager.h"
|
||||||
#import "KBFunctionBarView.h"
|
#import "KBFunctionBarView.h"
|
||||||
#import "KBFunctionPasteView.h"
|
#import "KBFunctionPasteView.h"
|
||||||
#import "KBFunctionTagCell.h"
|
#import "KBFunctionTagCell.h"
|
||||||
#import "Masonry.h"
|
|
||||||
#import <MBProgressHUD.h>
|
|
||||||
#import "KBFullAccessGuideView.h"
|
|
||||||
#import "KBFullAccessManager.h"
|
|
||||||
#import "KBSkinManager.h"
|
|
||||||
#import "KBAuthManager.h" // 登录态判断(共享钥匙串)
|
|
||||||
#import "KBULBridgeNotification.h" // Darwin 通知常量(UL 已处理)
|
|
||||||
#import "KBHostAppLauncher.h"
|
|
||||||
#import "KBStreamTextView.h" // 流式文本视图
|
|
||||||
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
|
||||||
#import "KBFunctionTagListView.h"
|
#import "KBFunctionTagListView.h"
|
||||||
#import "WJXEventSource.h"
|
#import "KBHostAppLauncher.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||||
|
#import "KBSkinManager.h"
|
||||||
|
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
||||||
|
#import "KBStreamTextView.h" // 流式文本视图
|
||||||
#import "KBTagItemModel.h"
|
#import "KBTagItemModel.h"
|
||||||
|
#import "KBULBridgeNotification.h" // Darwin 通知常量(UL 已处理)
|
||||||
|
#import "Masonry.h"
|
||||||
|
#import "WJXEventSource.h"
|
||||||
|
#import <MBProgressHUD.h>
|
||||||
#import <MJExtension/MJExtension.h>
|
#import <MJExtension/MJExtension.h>
|
||||||
#import "KBBizCode.h"
|
|
||||||
#import "KBBackspaceLongPressHandler.h"
|
|
||||||
#import "KBBackspaceUndoManager.h"
|
|
||||||
|
|
||||||
@interface KBFunctionView () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
|
@interface KBFunctionView () <KBFunctionBarViewDelegate,
|
||||||
|
KBStreamOverlayViewDelegate,
|
||||||
|
KBFunctionTagListViewDelegate>
|
||||||
// UI
|
// UI
|
||||||
@property(nonatomic, strong) KBFunctionBarView *barViewInternal;
|
@property(nonatomic, strong) KBFunctionBarView *barViewInternal;
|
||||||
@property(nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
|
@property(nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
|
||||||
@@ -44,8 +47,10 @@
|
|||||||
|
|
||||||
// 网络流式(封装)
|
// 网络流式(封装)
|
||||||
@property(nonatomic, strong, nullable) WJXEventSource *eventSource;
|
@property(nonatomic, strong, nullable) WJXEventSource *eventSource;
|
||||||
@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
|
@property(nonatomic, assign)
|
||||||
@property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index
|
BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
|
||||||
|
@property(nonatomic, strong, nullable)
|
||||||
|
NSNumber *loadingTagIndex; // 当前显示loading的标签index
|
||||||
@property(nonatomic, copy, nullable) NSString *loadingTagTitle;
|
@property(nonatomic, copy, nullable) NSString *loadingTagTitle;
|
||||||
@property(nonatomic, assign) BOOL eventSourceDidReceiveDone;
|
@property(nonatomic, assign) BOOL eventSourceDidReceiveDone;
|
||||||
@property(nonatomic, copy, nullable) NSString *eventSourceSplitPrefix;
|
@property(nonatomic, copy, nullable) NSString *eventSourceSplitPrefix;
|
||||||
@@ -55,8 +60,10 @@
|
|||||||
@property(nonatomic, strong) NSMutableArray<KBTagItemModel *> *modelArray;
|
@property(nonatomic, strong) NSMutableArray<KBTagItemModel *> *modelArray;
|
||||||
|
|
||||||
// 剪贴板自动检测
|
// 剪贴板自动检测
|
||||||
@property (nonatomic, strong) NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程)
|
@property(nonatomic, strong)
|
||||||
@property (nonatomic, assign) NSInteger lastHandledPBCount; // 上次处理过的 changeCount,避免重复弹窗
|
NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程)
|
||||||
|
@property(nonatomic, assign)
|
||||||
|
NSInteger lastHandledPBCount; // 上次处理过的 changeCount,避免重复弹窗
|
||||||
|
|
||||||
// UL 双路兜底
|
// UL 双路兜底
|
||||||
@property(nonatomic, assign) NSUInteger kb_ulSeq; // 当前 UL 发起序号
|
@property(nonatomic, assign) NSUInteger kb_ulSeq; // 当前 UL 发起序号
|
||||||
@@ -70,25 +77,29 @@
|
|||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
// 背景使用当前主题强调色
|
// 背景使用当前主题强调色
|
||||||
[self kb_applyTheme];
|
[self kb_applyTheme];
|
||||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
self.backspaceHandler =
|
||||||
|
[[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||||
|
|
||||||
[self setupUI];
|
[self setupUI];
|
||||||
// [self reloadDemoData];
|
// [self reloadDemoData];
|
||||||
[self kb_reloadTagsFromSharedDefaults];
|
[self kb_reloadTagsFromSharedDefaults];
|
||||||
|
|
||||||
|
|
||||||
// 初始化剪贴板监控状态
|
// 初始化剪贴板监控状态
|
||||||
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
|
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
|
||||||
|
|
||||||
// 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发 TCC/XPC 错误日志
|
// 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil];
|
// TCC/XPC 错误日志
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
addObserver:self
|
||||||
|
selector:@selector(kb_fullAccessChanged)
|
||||||
|
name:KBFullAccessChangedNotification
|
||||||
|
object:nil];
|
||||||
|
|
||||||
// 监听主 App 的 Darwin 确认(UL 已处理)
|
// 监听主 App 的 Darwin 确认(UL 已处理)
|
||||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
CFNotificationCenterAddObserver(
|
||||||
(__bridge const void *)(self),
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
KBULDarwinCallback,
|
(__bridge const void *)(self), KBULDarwinCallback,
|
||||||
(__bridge CFStringRef)KBDarwinULHandled,
|
(__bridge CFStringRef)KBDarwinULHandled, NULL,
|
||||||
NULL,
|
|
||||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
@@ -98,11 +109,13 @@
|
|||||||
|
|
||||||
/// 从 App Group 的 NSUserDefaults 中读取真实 JSON,解析为 model + 标签文案
|
/// 从 App Group 的 NSUserDefaults 中读取真实 JSON,解析为 model + 标签文案
|
||||||
- (void)kb_reloadTagsFromSharedDefaults {
|
- (void)kb_reloadTagsFromSharedDefaults {
|
||||||
NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
NSUserDefaults *sharedDefaults =
|
||||||
|
[[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
NSDictionary *jsonDict = [sharedDefaults objectForKey:AppGroup_MyKbJson];
|
NSDictionary *jsonDict = [sharedDefaults objectForKey:AppGroup_MyKbJson];
|
||||||
if (jsonDict != nil) {
|
if (jsonDict != nil) {
|
||||||
id dataObj = jsonDict[@"data"];
|
id dataObj = jsonDict[@"data"];
|
||||||
NSArray<KBTagItemModel *> *modelList = [KBTagItemModel mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
NSArray<KBTagItemModel *> *modelList =
|
||||||
|
[KBTagItemModel mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
||||||
if (modelList.count > 0) {
|
if (modelList.count > 0) {
|
||||||
self.modelArray = [NSMutableArray array];
|
self.modelArray = [NSMutableArray array];
|
||||||
[self.modelArray addObjectsFromArray:modelList];
|
[self.modelArray addObjectsFromArray:modelList];
|
||||||
@@ -114,21 +127,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Theme
|
#pragma mark - Theme
|
||||||
|
|
||||||
|
/// 判断当前是否为暗黑模式
|
||||||
|
- (BOOL)kb_isDarkMode {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Theme Colors
|
||||||
|
|
||||||
|
/// 整体背景色:暗黑 #323232,浅色 #D0D3DA
|
||||||
|
+ (UIColor *)kb_backgroundColor {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||||
|
return [UIColor colorWithHex:0x2B2B2B];
|
||||||
|
} else {
|
||||||
|
return [UIColor colorWithHex:0xD0D3DA];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [UIColor colorWithHex:0xD0D3DA];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cell 背景色:暗黑 #707070,浅色 白色90%透明度
|
||||||
|
+ (UIColor *)kb_cellBackgroundColor {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||||
|
return [UIColor colorWithHex:0x707070];
|
||||||
|
} else {
|
||||||
|
return [UIColor colorWithWhite:1 alpha:0.9];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [UIColor colorWithWhite:1 alpha:0.9];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cell 文字颜色:暗黑 #FFFFFF,浅色 #1B1F1A
|
||||||
|
+ (UIColor *)kb_cellTextColor {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||||
|
return [UIColor whiteColor];
|
||||||
|
} else {
|
||||||
|
return [UIColor colorWithHex:0x1B1F1A];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [UIColor colorWithHex:0x1B1F1A];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear 按钮文字颜色:暗黑白色,浅色黑色
|
||||||
|
+ (UIColor *)kb_clearButtonTextColor {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||||
|
return [UIColor whiteColor];
|
||||||
|
} else {
|
||||||
|
return [UIColor blackColor];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [UIColor blackColor];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除按钮背景色:暗黑 #707070,浅色 #B9BDC8
|
||||||
|
+ (UIColor *)kb_deleteButtonBackgroundColor {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||||
|
return [UIColor colorWithHex:0x707070];
|
||||||
|
} else {
|
||||||
|
return [UIColor colorWithHex:0xB9BDC8];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [UIColor colorWithHex:0xB9BDC8];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)kb_applyTheme {
|
- (void)kb_applyTheme {
|
||||||
// KBSkinManager *mgr = [KBSkinManager shared];
|
// 使用动态颜色设置背景
|
||||||
// UIColor *accent = mgr.current.accentColor ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0];
|
self.backgroundColor = [KBFunctionView kb_backgroundColor];
|
||||||
// BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
|
||||||
self.backgroundColor = [UIColor colorWithHex:0xD0D3DA];
|
// 更新按钮颜色
|
||||||
|
self.clearButtonInternal.backgroundColor =
|
||||||
|
[KBFunctionView kb_deleteButtonBackgroundColor];
|
||||||
|
[self.clearButtonInternal
|
||||||
|
setTitleColor:[KBFunctionView kb_clearButtonTextColor]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
self.deleteButtonInternal.backgroundColor =
|
||||||
|
[KBFunctionView kb_deleteButtonBackgroundColor];
|
||||||
|
|
||||||
|
// 刷新 TagListView 以更新 cell 颜色
|
||||||
|
[self.tagListView.collectionView reloadData];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dealloc {
|
- (void)dealloc {
|
||||||
[self stopPasteboardMonitor];
|
[self stopPasteboardMonitor];
|
||||||
[self kb_stopNetworkStreaming];
|
[self kb_stopNetworkStreaming];
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled, NULL);
|
CFNotificationCenterRemoveObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
(__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled,
|
||||||
|
NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - UI
|
#pragma mark - UI
|
||||||
@@ -195,7 +305,9 @@
|
|||||||
make.height.mas_equalTo(smallH);
|
make.height.mas_equalTo(smallH);
|
||||||
}];
|
}];
|
||||||
// 点击整个粘贴卡片按钮,行为与右侧「Paste」按钮保持一致
|
// 点击整个粘贴卡片按钮,行为与右侧「Paste」按钮保持一致
|
||||||
[self.pasteViewInternal.pasBtn addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
[self.pasteViewInternal.pasBtn addTarget:self
|
||||||
|
action:@selector(onTapPaste)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
|
||||||
// 3. Tag List View
|
// 3. Tag List View
|
||||||
[self addSubview:self.tagListView];
|
[self addSubview:self.tagListView];
|
||||||
@@ -228,7 +340,9 @@
|
|||||||
|
|
||||||
- (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title {
|
- (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title {
|
||||||
// 已有则不重复创建
|
// 已有则不重复创建
|
||||||
if (self.streamOverlay.superview) { return; }
|
if (self.streamOverlay.superview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 隐藏标签列表,使用同一区域展示流式文本
|
// 隐藏标签列表,使用同一区域展示流式文本
|
||||||
self.tagListView.hidden = YES;
|
self.tagListView.hidden = YES;
|
||||||
@@ -276,16 +390,20 @@
|
|||||||
[self kb_onTapStreamDelete];
|
[self kb_onTapStreamDelete];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Network Streaming (WJXEventSource)
|
#pragma mark - Network Streaming (WJXEventSource)
|
||||||
|
|
||||||
- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle {
|
- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle {
|
||||||
[self kb_stopNetworkStreaming];
|
[self kb_stopNetworkStreaming];
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) { return; }
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
NSString *apiUrl = [NSString stringWithFormat:@"%@%@", KB_BASE_URL, API_AI_TALK];
|
NSString *apiUrl =
|
||||||
|
[NSString stringWithFormat:@"%@%@", KB_BASE_URL, API_AI_TALK];
|
||||||
NSURL *url = [NSURL URLWithString:apiUrl];
|
NSURL *url = [NSURL URLWithString:apiUrl];
|
||||||
if (!url) { return; }
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
NSInteger characterId = 0;
|
NSInteger characterId = 0;
|
||||||
if (self.loadingTagIndex != nil) {
|
if (self.loadingTagIndex != nil) {
|
||||||
@@ -296,21 +414,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
NSInteger resolvedCharacterId = (characterId > 0) ? characterId : 75;
|
NSInteger resolvedCharacterId = (characterId > 0) ? characterId : 75;
|
||||||
NSString *message = seedTitle.length > 0 ? seedTitle : @"aliqua non cupidatat";
|
NSString *message =
|
||||||
// message = [NSString stringWithFormat:@"%@%d",message,arc4random() % 10000];
|
seedTitle.length > 0 ? seedTitle : @"aliqua non cupidatat";
|
||||||
NSDictionary *payload = @{
|
// message = [NSString stringWithFormat:@"%@%d",message,arc4random() %
|
||||||
@"characterId": @(resolvedCharacterId),
|
// 10000];
|
||||||
@"message": message
|
NSDictionary *payload =
|
||||||
};
|
@{@"characterId" : @(resolvedCharacterId), @"message" : message};
|
||||||
NSLog(@"[KBFunction] request payload: %@", payload);
|
NSLog(@"[KBFunction] request payload: %@", payload);
|
||||||
NSError *bodyError = nil;
|
NSError *bodyError = nil;
|
||||||
NSData *bodyData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&bodyError];
|
NSData *bodyData = [NSJSONSerialization dataWithJSONObject:payload
|
||||||
|
options:0
|
||||||
|
error:&bodyError];
|
||||||
if (bodyError || bodyData.length == 0) {
|
if (bodyError || bodyData.length == 0) {
|
||||||
NSLog(@"[KBFunction] build body failed: %@", bodyError);
|
NSLog(@"[KBFunction] build body failed: %@", bodyError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60];
|
NSMutableURLRequest *request =
|
||||||
|
[NSMutableURLRequest requestWithURL:url
|
||||||
|
cachePolicy:NSURLRequestReloadIgnoringCacheData
|
||||||
|
timeoutInterval:60];
|
||||||
request.HTTPMethod = @"POST";
|
request.HTTPMethod = @"POST";
|
||||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||||
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
||||||
@@ -326,14 +449,24 @@
|
|||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
WJXEventSource *source = [[WJXEventSource alloc] initWithRquest:request];
|
WJXEventSource *source = [[WJXEventSource alloc] initWithRquest:request];
|
||||||
source.ignoreRetryAction = YES;
|
source.ignoreRetryAction = YES;
|
||||||
[source addListener:^(WJXEvent * _Nonnull event) {
|
[source
|
||||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
addListener:^(WJXEvent *_Nonnull event) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
[self kb_handleEventSourceMessage:event];
|
[self kb_handleEventSourceMessage:event];
|
||||||
} forEvent:WJXEventNameMessage queue:NSOperationQueue.mainQueue];
|
}
|
||||||
[source addListener:^(WJXEvent * _Nonnull event) {
|
forEvent:WJXEventNameMessage
|
||||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
queue:NSOperationQueue.mainQueue];
|
||||||
|
[source
|
||||||
|
addListener:^(WJXEvent *_Nonnull event) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
[self kb_handleEventSourceError:event.error];
|
[self kb_handleEventSourceError:event.error];
|
||||||
} forEvent:WJXEventNameError queue:NSOperationQueue.mainQueue];
|
}
|
||||||
|
forEvent:WJXEventNameError
|
||||||
|
queue:NSOperationQueue.mainQueue];
|
||||||
self.eventSource = source;
|
self.eventSource = source;
|
||||||
[self.eventSource open];
|
[self.eventSource open];
|
||||||
}
|
}
|
||||||
@@ -347,16 +480,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_handleEventSourceMessage:(WJXEvent *)event {
|
- (void)kb_handleEventSourceMessage:(WJXEvent *)event {
|
||||||
if (event.data.length == 0) { return; }
|
if (event.data.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSLog(@"[KBStream] SSE raw payload: %@", event.data);
|
NSLog(@"[KBStream] SSE raw payload: %@", event.data);
|
||||||
NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding];
|
NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding];
|
||||||
if (!jsonData) { return; }
|
if (!jsonData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSError *error = nil;
|
NSError *error = nil;
|
||||||
NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
|
NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:jsonData
|
||||||
if (error || ![payload isKindOfClass:[NSDictionary class]]) { return; }
|
options:0
|
||||||
if ([self kb_handleBizErrorIfNeeded:payload]) { return; }
|
error:&error];
|
||||||
|
if (error || ![payload isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([self kb_handleBizErrorIfNeeded:payload]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSString *type = payload[@"type"];
|
NSString *type = payload[@"type"];
|
||||||
if (![type isKindOfClass:[NSString class]]) { return; }
|
if (![type isKindOfClass:[NSString class]]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ([type isEqualToString:@"llm_chunk"]) {
|
if ([type isEqualToString:@"llm_chunk"]) {
|
||||||
NSString *chunk = [self kb_normalizedLLMChunkString:payload[@"data"]];
|
NSString *chunk = [self kb_normalizedLLMChunkString:payload[@"data"]];
|
||||||
@@ -380,7 +525,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_handleEventSourceError:(NSError *_Nullable)error {
|
- (void)kb_handleEventSourceError:(NSError *_Nullable)error {
|
||||||
if (self.eventSourceDidReceiveDone) { return; }
|
if (self.eventSourceDidReceiveDone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
[self kb_finishEventSourceWithError:error];
|
[self kb_finishEventSourceWithError:error];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +560,8 @@
|
|||||||
if (msg.length == 0) {
|
if (msg.length == 0) {
|
||||||
msg = KBLocalized(@"拉取失败");
|
msg = KBLocalized(@"拉取失败");
|
||||||
}
|
}
|
||||||
NSError *bizError = [NSError errorWithDomain:@"KBStreamBizError"
|
NSError *bizError =
|
||||||
|
[NSError errorWithDomain:@"KBStreamBizError"
|
||||||
code:code
|
code:code
|
||||||
userInfo:@{NSLocalizedDescriptionKey : msg}];
|
userInfo:@{NSLocalizedDescriptionKey : msg}];
|
||||||
[self kb_finishEventSourceWithError:bizError];
|
[self kb_finishEventSourceWithError:bizError];
|
||||||
@@ -424,7 +572,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_requestSubscriptionGuide {
|
- (void)kb_requestSubscriptionGuide {
|
||||||
if ([self.delegate respondsToSelector:@selector(functionViewDidRequestSubscription:)]) {
|
if ([self.delegate
|
||||||
|
respondsToSelector:@selector(functionViewDidRequestSubscription:)]) {
|
||||||
[self.delegate functionViewDidRequestSubscription:self];
|
[self.delegate functionViewDidRequestSubscription:self];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,7 +581,9 @@
|
|||||||
#pragma mark - Event Parsing
|
#pragma mark - Event Parsing
|
||||||
|
|
||||||
- (NSString *)kb_normalizedLLMChunkString:(id)dataValue {
|
- (NSString *)kb_normalizedLLMChunkString:(id)dataValue {
|
||||||
if (![dataValue isKindOfClass:[NSString class]]) { return @""; }
|
if (![dataValue isKindOfClass:[NSString class]]) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
NSString *text = (NSString *)dataValue;
|
NSString *text = (NSString *)dataValue;
|
||||||
|
|
||||||
// 1. 处理上一个包遗留的 <SPLIT> 前缀(比如 "<SP" + "LIT>")
|
// 1. 处理上一个包遗留的 <SPLIT> 前缀(比如 "<SP" + "LIT>")
|
||||||
@@ -440,7 +591,9 @@
|
|||||||
text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""];
|
text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""];
|
||||||
self.eventSourceSplitPrefix = nil;
|
self.eventSourceSplitPrefix = nil;
|
||||||
}
|
}
|
||||||
if (text.length == 0) { return @""; }
|
if (text.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 去掉开头多余换行(避免一开始就空一大块)
|
// 2. 去掉开头多余换行(避免一开始就空一大块)
|
||||||
while (text.length > 0) {
|
while (text.length > 0) {
|
||||||
@@ -451,7 +604,9 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (text.length == 0) { return @""; }
|
if (text.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 处理结尾可能是不完整的 "<SPLIT" 之类,先截掉,放到下一个包里拼
|
// 3. 处理结尾可能是不完整的 "<SPLIT" 之类,先截掉,放到下一个包里拼
|
||||||
NSString *suffix = [self kb_pendingSplitSuffixForString:text];
|
NSString *suffix = [self kb_pendingSplitSuffixForString:text];
|
||||||
@@ -459,24 +614,29 @@
|
|||||||
self.eventSourceSplitPrefix = suffix;
|
self.eventSourceSplitPrefix = suffix;
|
||||||
text = [text substringToIndex:text.length - suffix.length];
|
text = [text substringToIndex:text.length - suffix.length];
|
||||||
}
|
}
|
||||||
if (text.length == 0) { return @""; }
|
if (text.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 处理完整的 <SPLIT>,变成段落分隔符 \t
|
// 4. 处理完整的 <SPLIT>,变成段落分隔符 \t
|
||||||
text = [text stringByReplacingOccurrencesOfString:@"<SPLIT>" withString:@"\t"];
|
text = [text stringByReplacingOccurrencesOfString:@"<SPLIT>"
|
||||||
|
withString:@"\t"];
|
||||||
|
|
||||||
// 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI
|
// 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
- (NSString *)kb_formattedSearchResultString:(id)dataValue {
|
- (NSString *)kb_formattedSearchResultString:(id)dataValue {
|
||||||
// data 不是数组就直接返回空串
|
// data 不是数组就直接返回空串
|
||||||
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
|
if (![dataValue isKindOfClass:[NSArray class]]) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
NSArray *list = (NSArray *)dataValue;
|
NSArray *list = (NSArray *)dataValue;
|
||||||
|
|
||||||
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
||||||
|
|
||||||
[list enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
[list enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx,
|
||||||
|
BOOL *_Nonnull stop) {
|
||||||
NSString *payload = nil;
|
NSString *payload = nil;
|
||||||
|
|
||||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||||||
@@ -489,19 +649,24 @@
|
|||||||
payload = (NSString *)obj;
|
payload = (NSString *)obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
payload = [payload
|
||||||
|
stringByTrimmingCharactersInSet:[NSCharacterSet
|
||||||
|
whitespaceAndNewlineCharacterSet]];
|
||||||
if (payload.length > 0) {
|
if (payload.length > 0) {
|
||||||
// 每一个 payload 就是一段
|
// 每一个 payload 就是一段
|
||||||
[segments addObject:payload];
|
[segments addObject:payload];
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
if (segments.count == 0) { return @""; }
|
if (segments.count == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
// 用 \t 拼起来,KBStreamTextView 会按 \t 拆成多个 label
|
// 用 \t 拼起来,KBStreamTextView 会按 \t 拆成多个 label
|
||||||
NSMutableString *result = [NSMutableString string];
|
NSMutableString *result = [NSMutableString string];
|
||||||
|
|
||||||
[segments enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
[segments enumerateObjectsUsingBlock:^(NSString *_Nonnull obj, NSUInteger idx,
|
||||||
|
BOOL *_Nonnull stop) {
|
||||||
// 每段前面加一个 \t,保证是新的一段
|
// 每段前面加一个 \t,保证是新的一段
|
||||||
[result appendFormat:@"\t%@", obj];
|
[result appendFormat:@"\t%@", obj];
|
||||||
}];
|
}];
|
||||||
@@ -509,12 +674,15 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
- (NSString *)kb_pendingSplitSuffixForString:(NSString *)text {
|
- (NSString *)kb_pendingSplitSuffixForString:(NSString *)text {
|
||||||
static NSString *const token = @"<SPLIT>";
|
static NSString *const token = @"<SPLIT>";
|
||||||
if (text.length == 0) { return @""; }
|
if (text.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
NSUInteger tokenLen = token.length;
|
NSUInteger tokenLen = token.length;
|
||||||
if (tokenLen <= 1) { return @""; }
|
if (tokenLen <= 1) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
|
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
|
||||||
for (NSUInteger len = maxLen; len > 0; len--) {
|
for (NSUInteger len = maxLen; len > 0; len--) {
|
||||||
NSString *suffix = [text substringFromIndex:text.length - len];
|
NSString *suffix = [text substringFromIndex:text.length - len];
|
||||||
@@ -532,16 +700,20 @@
|
|||||||
/// - 已将 `<SPLIT>` 转换为 `\t` 并去掉多余换行
|
/// - 已将 `<SPLIT>` 转换为 `\t` 并去掉多余换行
|
||||||
/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动
|
/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动
|
||||||
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
|
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
|
||||||
if (chunk.length == 0) return;
|
if (chunk.length == 0)
|
||||||
|
return;
|
||||||
// 第一次有数据才创建 overlay,并取消 cell 上的小菊花
|
// 第一次有数据才创建 overlay,并取消 cell 上的小菊花
|
||||||
if (!self.streamOverlay) {
|
if (!self.streamOverlay) {
|
||||||
[self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""];
|
[self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""];
|
||||||
if (self.loadingTagIndex) {
|
if (self.loadingTagIndex) {
|
||||||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
[self.tagListView setLoading:NO
|
||||||
self.loadingTagIndex = nil; self.loadingTagTitle = nil;
|
atIndex:self.loadingTagIndex.integerValue];
|
||||||
|
self.loadingTagIndex = nil;
|
||||||
|
self.loadingTagTitle = nil;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!self.streamOverlay) return;
|
if (!self.streamOverlay)
|
||||||
|
return;
|
||||||
[self.streamOverlay appendChunk:chunk];
|
[self.streamOverlay appendChunk:chunk];
|
||||||
self.streamHasOutput = YES;
|
self.streamHasOutput = YES;
|
||||||
}
|
}
|
||||||
@@ -553,20 +725,24 @@
|
|||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
NSString *displayText = text;
|
NSString *displayText = text;
|
||||||
if (displayText.length > 30) {
|
if (displayText.length > 30) {
|
||||||
displayText = [[displayText substringToIndex:30] stringByAppendingString:@"…"];
|
displayText =
|
||||||
|
[[displayText substringToIndex:30] stringByAppendingString:@"…"];
|
||||||
}
|
}
|
||||||
[self.pasteView.pasBtn setImage:nil forState:UIControlStateNormal];
|
[self.pasteView.pasBtn setImage:nil forState:UIControlStateNormal];
|
||||||
[self.pasteView.pasBtn setTitle:displayText forState:UIControlStateNormal];
|
[self.pasteView.pasBtn setTitle:displayText forState:UIControlStateNormal];
|
||||||
} else {
|
} else {
|
||||||
UIImage *img = [UIImage imageNamed:@"kb_zt_icon"];
|
UIImage *img = [UIImage imageNamed:@"kb_zt_icon"];
|
||||||
[self.pasteView.pasBtn setImage:img forState:UIControlStateNormal];
|
[self.pasteView.pasBtn setImage:img forState:UIControlStateNormal];
|
||||||
[self.pasteView.pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal];
|
[self.pasteView.pasBtn setTitle:KBLocalized(@" Paste Ta's Words")
|
||||||
|
forState:UIControlStateNormal];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - KBFunctionTagListViewDelegate
|
#pragma mark - KBFunctionTagListViewDelegate
|
||||||
|
|
||||||
- (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title {
|
- (void)tagListView:(KBFunctionTagListView *)view
|
||||||
|
didSelectIndex:(NSInteger)index
|
||||||
|
title:(NSString *)title {
|
||||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
// 未开启完全访问:保持原有引导路径
|
// 未开启完全访问:保持原有引导路径
|
||||||
@@ -575,38 +751,46 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App
|
||||||
|
// 负责完成登录
|
||||||
if (!KBAuthManager.shared.isLoggedIn) {
|
if (!KBAuthManager.shared.isLoggedIn) {
|
||||||
|
|
||||||
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||||
|
|
||||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
NSString *schemeStr =
|
||||||
|
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view];
|
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view];
|
||||||
return;
|
return;
|
||||||
// if (!ivc) return;
|
// if (!ivc) return;
|
||||||
// NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
// NSString *encodedTitle = [title
|
||||||
// NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)index, encodedTitle]];
|
// stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet
|
||||||
// if (!ul) return;
|
// URLQueryAllowedCharacterSet]] ?: @""; NSURL *ul = [NSURL
|
||||||
|
// URLWithString:[NSString
|
||||||
|
// stringWithFormat:@"%@?src=functionView&index=%ld&title=%@",
|
||||||
|
// KB_UL_LOGIN, (long)index, encodedTitle]]; if (!ul) return;
|
||||||
// // 发起 UL,不依赖 ok 结果
|
// // 发起 UL,不依赖 ok 结果
|
||||||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 *
|
||||||
// [ivc.extensionContext openURL:ul completionHandler:^(__unused BOOL ok) {}];
|
// NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
// [ivc.extensionContext openURL:ul completionHandler:^(__unused
|
||||||
|
// BOOL ok) {}];
|
||||||
// });
|
// });
|
||||||
// // 双路兜底:500ms 内未收到主 App 确认,则回退到自定义 Scheme(通过宿主 UIApplication 打开)
|
// // 双路兜底:500ms 内未收到主 App 确认,则回退到自定义
|
||||||
// self.kb_ulHandledFlag = NO;
|
// Scheme(通过宿主 UIApplication 打开) self.kb_ulHandledFlag = NO;
|
||||||
// NSUInteger token = ++self.kb_ulSeq;
|
// NSUInteger token = ++self.kb_ulSeq;
|
||||||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 *
|
||||||
|
// NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
// if (token != self.kb_ulSeq) return; // 已有新请求覆盖
|
// if (token != self.kb_ulSeq) return; // 已有新请求覆盖
|
||||||
// if (self.kb_ulHandledFlag) return; // 主 App 已确认处理
|
// if (self.kb_ulHandledFlag) return; // 主 App 已确认处理
|
||||||
// NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)index, encodedTitle]];
|
// NSURL *scheme = [NSURL URLWithString:[NSString
|
||||||
// if (!scheme) return;
|
// stringWithFormat:@"%@://login?src=functionView&index=%ld&title=%@",
|
||||||
// UIResponder *start = ivc.view ?: (UIResponder *)self;
|
// KB_APP_SCHEME, (long)index, encodedTitle]]; if (!scheme)
|
||||||
|
// return; UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||||
// // 让键盘失去焦点
|
// // 让键盘失去焦点
|
||||||
// [ivc dismissKeyboard];
|
// [ivc dismissKeyboard];
|
||||||
// BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
// BOOL ok = [KBHostAppLauncher openHostAppURL:scheme
|
||||||
// if (!ok) {
|
// fromResponder:start]; if (!ok) {
|
||||||
// [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")];
|
// [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")];
|
||||||
// }else{
|
// }else{
|
||||||
//
|
//
|
||||||
@@ -614,7 +798,8 @@
|
|||||||
// });
|
// });
|
||||||
// return;
|
// return;
|
||||||
}
|
}
|
||||||
BOOL hasPasteText = ![self.pasteView.pasBtn.currentTitle isEqualToString:KBLocalized(@" Paste Ta's Words")];
|
BOOL hasPasteText = ![self.pasteView.pasBtn.currentTitle
|
||||||
|
isEqualToString:KBLocalized(@" Paste Ta's Words")];
|
||||||
// BOOL hasPasteText = (self.pasteView.pasBtn.imageView.image == nil);
|
// BOOL hasPasteText = (self.pasteView.pasBtn.imageView.image == nil);
|
||||||
if (!hasPasteText) {
|
if (!hasPasteText) {
|
||||||
[KBHUD showInfo:KBLocalized(@"Please copy the text first")];
|
[KBHUD showInfo:KBLocalized(@"Please copy the text first")];
|
||||||
@@ -630,15 +815,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Darwin 回调:主 App 已处理 UL
|
// Darwin 回调:主 App 已处理 UL
|
||||||
static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
|
static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||||
|
CFStringRef name, const void *object,
|
||||||
|
CFDictionaryRef userInfo) {
|
||||||
KBFunctionView *self_ = (__bridge KBFunctionView *)observer;
|
KBFunctionView *self_ = (__bridge KBFunctionView *)observer;
|
||||||
if (!self_) return;
|
if (!self_)
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{ self_.kb_ulHandledFlag = YES; });
|
return;
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
self_.kb_ulHandledFlag = YES;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。
|
// 用户点击功能标签:优先 UL 拉起主App,失败再
|
||||||
// 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView,并在其右上角提供删除按钮关闭。
|
// Scheme;两次都失败则提示开启完全访问。 若已开启“完全访问”,则直接在键盘侧创建
|
||||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
// KBStreamTextView,并在其右上角提供删除按钮关闭。
|
||||||
|
- (void)collectionView:(UICollectionView *)collectionView
|
||||||
|
didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
// 点击上报已下沉到 KBFunctionTagListView(保证能拿到人设 id/name)
|
||||||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||||||
if ([[KBFullAccessManager shared] hasFullAccess]) {
|
if ([[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
KBTagItemModel *selModel = self.modelArray[indexPath.item];
|
KBTagItemModel *selModel = self.modelArray[indexPath.item];
|
||||||
@@ -649,43 +842,71 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||||
|
|
||||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||||
if (!ivc) return;
|
if (!ivc)
|
||||||
|
return;
|
||||||
|
|
||||||
NSString *title = self.modelArray[indexPath.item].characterName;
|
NSString *title = self.modelArray[indexPath.item].characterName;
|
||||||
NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
NSString *encodedTitle =
|
||||||
|
[title stringByAddingPercentEncodingWithAllowedCharacters:
|
||||||
|
[NSCharacterSet URLQueryAllowedCharacterSet]]
|
||||||
|
?: @"";
|
||||||
|
|
||||||
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]];
|
NSURL *ul = [NSURL
|
||||||
if (!ul) return;
|
URLWithString:
|
||||||
|
[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@",
|
||||||
|
KB_UL_LOGIN, (long)indexPath.item,
|
||||||
|
encodedTitle]];
|
||||||
|
if (!ul)
|
||||||
|
return;
|
||||||
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
dispatch_after(
|
||||||
|
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)),
|
||||||
|
dispatch_get_main_queue(), ^{
|
||||||
// 先尝试通过 extensionContext 打开 UL
|
// 先尝试通过 extensionContext 打开 UL
|
||||||
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
|
[ivc.extensionContext
|
||||||
|
openURL:ul
|
||||||
|
completionHandler:^(BOOL ok) {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底
|
// UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底
|
||||||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
|
NSURL *scheme = [NSURL
|
||||||
|
URLWithString:
|
||||||
|
[NSString
|
||||||
|
stringWithFormat:
|
||||||
|
@"%@@//login?src=functionView&index=%ld&title=%@",
|
||||||
|
KB_APP_SCHEME, (long)indexPath.item,
|
||||||
|
encodedTitle]];
|
||||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||||
BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme
|
||||||
|
fromResponder:start];
|
||||||
if (!ok2) {
|
if (!ok2) {
|
||||||
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。
|
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager
|
||||||
|
// 引导。
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
[[KBFullAccessManager shared]
|
||||||
|
ensureFullAccessOrGuideInView:self];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Button Actions
|
#pragma mark - Button Actions
|
||||||
|
|
||||||
- (void)onTapPaste {
|
- (void)onTapPaste {
|
||||||
|
// [[KBMaiPointReporter sharedReporter]
|
||||||
|
// reportClickWithEventName:@"click_keyboard_function_paste_btn"
|
||||||
|
// pageId:@"keyboard_function_panel"
|
||||||
|
// elementId:@"paste_btn"
|
||||||
|
// extra:nil
|
||||||
|
// completion:nil];
|
||||||
// 用户点击“粘贴”时才读取剪贴板:
|
// 用户点击“粘贴”时才读取剪贴板:
|
||||||
// - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗;
|
// - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗;
|
||||||
// - iOS15 及以下不会弹窗,直接返回内容;
|
// - iOS15 及以下不会弹窗,直接返回内容;
|
||||||
// 注意:不要在非用户触发的时机主动读取(如 viewDidLoad),否则会造成“立刻弹窗”的体验。
|
// 注意:不要在非用户触发的时机主动读取(如
|
||||||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
// viewDidLoad),否则会造成“立刻弹窗”的体验。 权限全部打开(键盘已启用 +
|
||||||
|
// 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
// 未开启完全访问:保持原有引导路径
|
// 未开启完全访问:保持原有引导路径
|
||||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||||||
@@ -721,21 +942,34 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
// - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。
|
// - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。
|
||||||
|
|
||||||
- (void)startPasteboardMonitor {
|
- (void)startPasteboardMonitor {
|
||||||
|
// 禁用自动读取剪贴板,避免触发系统“允许粘贴”弹窗
|
||||||
|
return;
|
||||||
// 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志
|
// 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) return;
|
if (![[KBFullAccessManager shared] hasFullAccess])
|
||||||
if (self.pasteboardTimer) return;
|
return;
|
||||||
KBWeakSelf
|
if (self.pasteboardTimer)
|
||||||
self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
|
return;
|
||||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
KBWeakSelf self.pasteboardTimer = [NSTimer
|
||||||
UIPasteboard *pb = [UIPasteboard generalPasteboard];
|
scheduledTimerWithTimeInterval:0.5
|
||||||
|
repeats:YES
|
||||||
|
block:^(NSTimer *_Nonnull timer) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
|
UIPasteboard *pb =
|
||||||
|
[UIPasteboard generalPasteboard];
|
||||||
NSInteger cc = pb.changeCount;
|
NSInteger cc = pb.changeCount;
|
||||||
if (cc <= self.lastHandledPBCount) return; // 没有新复制
|
if (cc <= self.lastHandledPBCount)
|
||||||
self.lastHandledPBCount = cc; // 标记已处理,避免重复
|
return; // 没有新复制
|
||||||
|
self.lastHandledPBCount =
|
||||||
|
cc; // 标记已处理,避免重复
|
||||||
|
|
||||||
// 实际读取触发系统弹窗(iOS16+)
|
// 实际读取触发系统弹窗(iOS16+)
|
||||||
NSString *text = pb.string;
|
NSString *text = pb.string;
|
||||||
// 有文字 -> 仅展示文字;无文字/非文本 -> 恢复图标 + 原占位文案
|
// 有文字 -> 仅展示文字;无文字/非文本 ->
|
||||||
[self kb_updatePasteButtonWithDisplayText:text];
|
// 恢复图标 + 原占位文案
|
||||||
|
[self
|
||||||
|
kb_updatePasteButtonWithDisplayText:text];
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,26 +1002,55 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_fullAccessChanged {
|
- (void)kb_fullAccessChanged {
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshPasteboardMonitor]; });
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[self kb_refreshPasteboardMonitor];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)onTapDelete {
|
- (void)onTapDelete {
|
||||||
NSLog(@"点击:删除");
|
NSLog(@"点击:删除");
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
// [[KBMaiPointReporter sharedReporter]
|
||||||
|
// reportClickWithEventName:@"click_keyboard_function_delete_btn"
|
||||||
|
// pageId:@"keyboard_function_panel"
|
||||||
|
// elementId:@"delete_btn"
|
||||||
|
// extra:nil
|
||||||
|
// completion:nil];
|
||||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
[proxy deleteBackward];
|
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
prepareSnapshotForDeleteWithContextBefore:proxy.documentContextBeforeInput
|
||||||
|
after:proxy
|
||||||
|
.documentContextAfterInput];
|
||||||
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy
|
||||||
|
count:1];
|
||||||
|
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
|
||||||
}
|
}
|
||||||
- (void)onTapClear {
|
- (void)onTapClear {
|
||||||
NSLog(@"点击:清空");
|
NSLog(@"点击:清空");
|
||||||
|
// [[KBMaiPointReporter sharedReporter]
|
||||||
|
// reportClickWithEventName:@"click_keyboard_function_clear_btn"
|
||||||
|
// pageId:@"keyboard_function_panel"
|
||||||
|
// elementId:@"clear_btn"
|
||||||
|
// extra:nil
|
||||||
|
// completion:nil];
|
||||||
[self.backspaceHandler performClearAction];
|
[self.backspaceHandler performClearAction];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)onTapSend {
|
- (void)onTapSend {
|
||||||
NSLog(@"点击:发送");
|
NSLog(@"点击:发送");
|
||||||
|
// [[KBMaiPointReporter sharedReporter]
|
||||||
|
// reportClickWithEventName:@"click_keyboard_function_send_btn"
|
||||||
|
// pageId:@"keyboard_function_panel"
|
||||||
|
// elementId:@"send_btn"
|
||||||
|
// extra:nil
|
||||||
|
// completion:nil];
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
|
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
|
||||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
[proxy insertText:@"\n"];
|
[proxy insertText:@"\n"];
|
||||||
|
[[KBInputBufferManager shared] appendText:@"\n"];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Lazy
|
#pragma mark - Lazy
|
||||||
@@ -802,16 +1065,20 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
|
|
||||||
#pragma mark - KBFunctionBarViewDelegate
|
#pragma mark - KBFunctionBarViewDelegate
|
||||||
|
|
||||||
- (void)functionBarView:(KBFunctionBarView *)bar didTapLeftAtIndex:(NSInteger)index {
|
- (void)functionBarView:(KBFunctionBarView *)bar
|
||||||
|
didTapLeftAtIndex:(NSInteger)index {
|
||||||
// 将事件继续透传给上层(如键盘控制器),用于切换界面或其它业务
|
// 将事件继续透传给上层(如键盘控制器),用于切换界面或其它业务
|
||||||
if ([self.delegate respondsToSelector:@selector(functionView:didTapToolActionAtIndex:)]) {
|
if ([self.delegate respondsToSelector:@selector(functionView:
|
||||||
|
didTapToolActionAtIndex:)]) {
|
||||||
[self.delegate functionView:self didTapToolActionAtIndex:index];
|
[self.delegate functionView:self didTapToolActionAtIndex:index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index {
|
- (void)functionBarView:(KBFunctionBarView *)bar
|
||||||
|
didTapRightAtIndex:(NSInteger)index {
|
||||||
// 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展)
|
// 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展)
|
||||||
if ([self.delegate respondsToSelector:@selector(functionView:didRightTapToolActionAtIndex:)]) {
|
if ([self.delegate respondsToSelector:@selector(functionView:
|
||||||
|
didRightTapToolActionAtIndex:)]) {
|
||||||
[self.delegate functionView:self didRightTapToolActionAtIndex:index];
|
[self.delegate functionView:self didRightTapToolActionAtIndex:index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,7 +1106,8 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
return _rightButtonContainer;
|
return _rightButtonContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color {
|
- (UIButton *)buildRightButtonWithTitle:(NSString *)title
|
||||||
|
color:(UIColor *)color {
|
||||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
btn.backgroundColor = color;
|
btn.backgroundColor = color;
|
||||||
btn.layer.cornerRadius = 8.0;
|
btn.layer.cornerRadius = 8.0;
|
||||||
@@ -852,8 +1120,15 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
|
|
||||||
- (UIButton *)pasteButtonInternal {
|
- (UIButton *)pasteButtonInternal {
|
||||||
if (!_pasteButtonInternal) {
|
if (!_pasteButtonInternal) {
|
||||||
_pasteButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Paste") color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
|
_pasteButtonInternal =
|
||||||
[_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
[self buildRightButtonWithTitle:KBLocalized(@"Paste")
|
||||||
|
color:[UIColor colorWithRed:0.13
|
||||||
|
green:0.73
|
||||||
|
blue:0.60
|
||||||
|
alpha:1.0]];
|
||||||
|
[_pasteButtonInternal addTarget:self
|
||||||
|
action:@selector(onTapPaste)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
}
|
}
|
||||||
return _pasteButtonInternal;
|
return _pasteButtonInternal;
|
||||||
}
|
}
|
||||||
@@ -864,10 +1139,14 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
_deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
_deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
||||||
_deleteButtonInternal.layer.cornerRadius = 8.0;
|
_deleteButtonInternal.layer.cornerRadius = 8.0;
|
||||||
_deleteButtonInternal.layer.masksToBounds = YES;
|
_deleteButtonInternal.layer.masksToBounds = YES;
|
||||||
[_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal];
|
[_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
|
||||||
[_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside];
|
[_deleteButtonInternal addTarget:self
|
||||||
[self.backspaceHandler bindDeleteButton:_deleteButtonInternal showClearLabel:NO];
|
action:@selector(onTapDelete)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[self.backspaceHandler bindDeleteButton:_deleteButtonInternal
|
||||||
|
showClearLabel:NO];
|
||||||
}
|
}
|
||||||
return _deleteButtonInternal;
|
return _deleteButtonInternal;
|
||||||
}
|
}
|
||||||
@@ -879,32 +1158,53 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
|
|||||||
_clearButtonInternal.layer.cornerRadius = 8.0;
|
_clearButtonInternal.layer.cornerRadius = 8.0;
|
||||||
_clearButtonInternal.layer.masksToBounds = YES;
|
_clearButtonInternal.layer.masksToBounds = YES;
|
||||||
_clearButtonInternal.titleLabel.font = [KBFont medium:13];
|
_clearButtonInternal.titleLabel.font = [KBFont medium:13];
|
||||||
[_clearButtonInternal setTitle:KBLocalized(@"Clear") forState:UIControlStateNormal];
|
[_clearButtonInternal setTitle:KBLocalized(@"Clear")
|
||||||
[_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
forState:UIControlStateNormal];
|
||||||
[_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside];
|
[_clearButtonInternal setTitleColor:[UIColor blackColor]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
[_clearButtonInternal addTarget:self
|
||||||
|
action:@selector(onTapClear)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
}
|
}
|
||||||
return _clearButtonInternal;
|
return _clearButtonInternal;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UIButton *)sendButtonInternal {
|
- (UIButton *)sendButtonInternal {
|
||||||
if (!_sendButtonInternal) {
|
if (!_sendButtonInternal) {
|
||||||
_sendButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Send") color:[UIColor colorWithHex:0x02BEAC]];
|
_sendButtonInternal =
|
||||||
[_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside];
|
[self buildRightButtonWithTitle:KBLocalized(@"Send")
|
||||||
|
color:[UIColor colorWithHex:0x02BEAC]];
|
||||||
|
[_sendButtonInternal addTarget:self
|
||||||
|
action:@selector(onTapSend)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
}
|
}
|
||||||
return _sendButtonInternal;
|
return _sendButtonInternal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Expose
|
#pragma mark - Expose
|
||||||
|
|
||||||
- (UICollectionView *)collectionView { return self.tagListView.collectionView; }
|
- (UICollectionView *)collectionView {
|
||||||
|
return self.tagListView.collectionView;
|
||||||
|
}
|
||||||
//- (NSArray<NSString *> *)items { return self.itemsInternal; }
|
//- (NSArray<NSString *> *)items { return self.itemsInternal; }
|
||||||
- (KBFunctionBarView *)barView { return self.barViewInternal; }
|
- (KBFunctionBarView *)barView {
|
||||||
- (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; }
|
return self.barViewInternal;
|
||||||
- (UIButton *)pasteButton { return self.pasteButtonInternal; }
|
}
|
||||||
- (UIButton *)deleteButton { return self.deleteButtonInternal; }
|
- (KBFunctionPasteView *)pasteView {
|
||||||
- (UIButton *)clearButton { return self.clearButtonInternal; }
|
return self.pasteViewInternal;
|
||||||
- (UIButton *)sendButton { return self.sendButtonInternal; }
|
}
|
||||||
|
- (UIButton *)pasteButton {
|
||||||
|
return self.pasteButtonInternal;
|
||||||
|
}
|
||||||
|
- (UIButton *)deleteButton {
|
||||||
|
return self.deleteButtonInternal;
|
||||||
|
}
|
||||||
|
- (UIButton *)clearButton {
|
||||||
|
return self.clearButtonInternal;
|
||||||
|
}
|
||||||
|
- (UIButton *)sendButton {
|
||||||
|
return self.sendButtonInternal;
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Find Owner Controller
|
#pragma mark - Find Owner Controller
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
/// emoji 面板点击搜索
|
/// emoji 面板点击搜索
|
||||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
|
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
|
||||||
|
|
||||||
|
/// 选择了联想词
|
||||||
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectSuggestion:(NSString *)suggestion;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@interface KBKeyBoardMainView : UIView
|
@interface KBKeyBoardMainView : UIView
|
||||||
@@ -39,6 +42,9 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/// 应用当前皮肤(会触发键区重载以应用按键颜色)
|
/// 应用当前皮肤(会触发键区重载以应用按键颜色)
|
||||||
- (void)kb_applyTheme;
|
- (void)kb_applyTheme;
|
||||||
|
|
||||||
|
/// 更新联想候选
|
||||||
|
- (void)kb_setSuggestions:(NSArray<NSString *> *)suggestions;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -11,29 +11,50 @@
|
|||||||
#import "KBFunctionView.h"
|
#import "KBFunctionView.h"
|
||||||
#import "KBKey.h"
|
#import "KBKey.h"
|
||||||
#import "KBEmojiPanelView.h"
|
#import "KBEmojiPanelView.h"
|
||||||
|
#import "KBSuggestionBarView.h"
|
||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBKeyboardLayoutConfig.h"
|
||||||
|
|
||||||
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate>
|
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate, KBSuggestionBarViewDelegate>
|
||||||
@property (nonatomic, strong) KBToolBar *topBar;
|
@property (nonatomic, strong) KBToolBar *topBar;
|
||||||
|
@property (nonatomic, strong) KBSuggestionBarView *suggestionBar;
|
||||||
@property (nonatomic, strong) KBKeyboardView *keyboardView;
|
@property (nonatomic, strong) KBKeyboardView *keyboardView;
|
||||||
@property (nonatomic, strong) KBEmojiPanelView *emojiView;
|
@property (nonatomic, strong) KBEmojiPanelView *emojiView;
|
||||||
@property (nonatomic, assign) BOOL emojiPanelVisible;
|
@property (nonatomic, assign) BOOL emojiPanelVisible;
|
||||||
|
@property (nonatomic, assign) BOOL suggestionBarHasItems;
|
||||||
// 注意:功能面板的展示/隐藏由外部控制器决定,此处不再直接管理显隐
|
// 注意:功能面板的展示/隐藏由外部控制器决定,此处不再直接管理显隐
|
||||||
@end
|
@end
|
||||||
@implementation KBKeyBoardMainView
|
@implementation KBKeyBoardMainView
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
// self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
||||||
|
self.backgroundColor = [UIColor colorWithHex:0xD1D3DB];
|
||||||
|
|
||||||
// 顶部栏
|
// 顶部栏
|
||||||
self.topBar = [[KBToolBar alloc] init];
|
self.topBar = [[KBToolBar alloc] init];
|
||||||
self.topBar.delegate = self;
|
self.topBar.delegate = self;
|
||||||
[self addSubview:self.topBar];
|
[self addSubview:self.topBar];
|
||||||
|
|
||||||
|
// 联想栏
|
||||||
|
self.suggestionBar = [[KBSuggestionBarView alloc] init];
|
||||||
|
self.suggestionBar.delegate = self;
|
||||||
|
self.suggestionBar.hidden = YES;
|
||||||
|
[self addSubview:self.suggestionBar];
|
||||||
|
|
||||||
// 键盘区域(高度按照设计值做等比缩放,避免不同机型上按键被压缩/拉伸)
|
// 键盘区域(高度按照设计值做等比缩放,避免不同机型上按键被压缩/拉伸)
|
||||||
CGFloat keyboardAreaHeight = KBFit(200.0f);
|
CGFloat keyboardAreaHeight = KBFit(200.0f);
|
||||||
|
KBKeyboardLayoutConfig *layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||||
|
if (layoutConfig) {
|
||||||
|
CGFloat configHeight = [layoutConfig keyboardAreaScaledHeight];
|
||||||
|
if (configHeight > 0.0) {
|
||||||
|
keyboardAreaHeight = configHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
CGFloat bottomInset = KBFit(4.0f);
|
CGFloat bottomInset = KBFit(4.0f);
|
||||||
|
// CGFloat topBarHeight = KBFit(40.0f);
|
||||||
CGFloat barSpacing = KBFit(6.0f);
|
CGFloat barSpacing = KBFit(6.0f);
|
||||||
|
|
||||||
self.keyboardView = [[KBKeyboardView alloc] init];
|
self.keyboardView = [[KBKeyboardView alloc] init];
|
||||||
@@ -54,16 +75,39 @@
|
|||||||
make.edges.equalTo(self);
|
make.edges.equalTo(self);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
// [self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
// make.left.right.equalTo(self);
|
||||||
|
// make.top.equalTo(self.mas_top).offset(0);
|
||||||
|
// make.height.mas_equalTo(topBarHeight);
|
||||||
|
// }];
|
||||||
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.top.equalTo(self.mas_top).offset(0);
|
make.top.equalTo(self.mas_top).offset(0);
|
||||||
make.bottom.equalTo(self.keyboardView.mas_top).offset(0);
|
make.bottom.equalTo(self.keyboardView.mas_top).offset(0);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
[self.suggestionBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self);
|
||||||
|
make.top.equalTo(self.topBar);
|
||||||
|
make.bottom.equalTo(self.topBar);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.topBar.mas_bottom).offset(barSpacing);
|
||||||
|
}];
|
||||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||||
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
|
selector:@selector(kb_undoStateChanged)
|
||||||
|
name:KBBackspaceUndoStateDidChangeNotification
|
||||||
|
object:nil];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)setEmojiPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
- (void)setEmojiPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||||
if (self.emojiPanelVisible == visible) return;
|
if (self.emojiPanelVisible == visible) return;
|
||||||
self.emojiPanelVisible = visible;
|
self.emojiPanelVisible = visible;
|
||||||
@@ -74,17 +118,24 @@
|
|||||||
} else {
|
} else {
|
||||||
self.keyboardView.hidden = NO;
|
self.keyboardView.hidden = NO;
|
||||||
self.topBar.hidden = NO;
|
self.topBar.hidden = NO;
|
||||||
|
self.suggestionBar.hidden = !self.suggestionBarHasItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (^changes)(void) = ^{
|
void (^changes)(void) = ^{
|
||||||
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
||||||
self.keyboardView.alpha = visible ? 0.0 : 1.0;
|
self.keyboardView.alpha = visible ? 0.0 : 1.0;
|
||||||
self.topBar.alpha = visible ? 0.0 : 1.0;
|
self.topBar.alpha = visible ? 0.0 : 1.0;
|
||||||
|
self.suggestionBar.alpha = visible ? 0.0 : ([self kb_shouldShowSuggestions] ? 1.0 : 0.0);
|
||||||
};
|
};
|
||||||
void (^completion)(BOOL) = ^(BOOL finished) {
|
void (^completion)(BOOL) = ^(BOOL finished) {
|
||||||
self.emojiView.hidden = !visible;
|
self.emojiView.hidden = !visible;
|
||||||
self.keyboardView.hidden = visible;
|
self.keyboardView.hidden = visible;
|
||||||
self.topBar.hidden = visible;
|
self.topBar.hidden = visible;
|
||||||
|
if (visible) {
|
||||||
|
self.suggestionBar.hidden = YES;
|
||||||
|
} else {
|
||||||
|
self.suggestionBar.hidden = ![self kb_shouldShowSuggestions];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (animated) {
|
if (animated) {
|
||||||
@@ -204,17 +255,50 @@
|
|||||||
|
|
||||||
- (void)kb_applyTheme {
|
- (void)kb_applyTheme {
|
||||||
KBSkinManager *mgr = [KBSkinManager shared];
|
KBSkinManager *mgr = [KBSkinManager shared];
|
||||||
BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
self.backgroundColor = [UIColor clearColor];
|
||||||
UIColor *bg = mgr.current.keyboardBackground;
|
self.keyboardView.backgroundColor = [UIColor clearColor];
|
||||||
self.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
|
||||||
self.keyboardView.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
|
||||||
if ([self.topBar respondsToSelector:@selector(kb_applyTheme)]) {
|
if ([self.topBar respondsToSelector:@selector(kb_applyTheme)]) {
|
||||||
[self.topBar kb_applyTheme];
|
[self.topBar kb_applyTheme];
|
||||||
}
|
}
|
||||||
|
[self.suggestionBar applyTheme:mgr.current];
|
||||||
[self.keyboardView reloadKeys];
|
[self.keyboardView reloadKeys];
|
||||||
if (self.emojiView) {
|
if (self.emojiView) {
|
||||||
[self.emojiView applyTheme:mgr.current];
|
[self.emojiView applyTheme:mgr.current];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Suggestions
|
||||||
|
|
||||||
|
- (void)kb_setSuggestions:(NSArray<NSString *> *)suggestions {
|
||||||
|
self.suggestionBarHasItems = (suggestions.count > 0);
|
||||||
|
[self.suggestionBar updateSuggestions:suggestions];
|
||||||
|
[self kb_applySuggestionVisibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBSuggestionBarViewDelegate
|
||||||
|
|
||||||
|
- (void)suggestionBarView:(KBSuggestionBarView *)view didSelectSuggestion:(NSString *)suggestion {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didSelectSuggestion:)]) {
|
||||||
|
[self.delegate keyBoardMainView:self didSelectSuggestion:suggestion];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_undoStateChanged {
|
||||||
|
[self kb_applySuggestionVisibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_shouldShowSuggestions {
|
||||||
|
if (self.emojiPanelVisible) { return NO; }
|
||||||
|
if (![KBBackspaceUndoManager shared].hasUndo && self.suggestionBarHasItems) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_applySuggestionVisibility {
|
||||||
|
BOOL shouldShow = [self kb_shouldShowSuggestions];
|
||||||
|
self.suggestionBar.hidden = !shouldShow;
|
||||||
|
self.suggestionBar.alpha = shouldShow ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
@property (nonatomic, strong) KBKey *key;
|
@property (nonatomic, strong) KBKey *key;
|
||||||
@property (nonatomic, strong) UIImageView *iconView;
|
@property (nonatomic, strong) UIImageView *iconView;
|
||||||
|
@property (nonatomic, strong, nullable) UIColor *customBackgroundColor;
|
||||||
|
|
||||||
/// 配置基础样式(背景、圆角等)。创建按钮时调用。
|
/// 配置基础样式(背景、圆角等)。创建按钮时调用。
|
||||||
- (void)applyDefaultStyle;
|
- (void)applyDefaultStyle;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
#import "KBKeyButton.h"
|
#import "KBKeyButton.h"
|
||||||
#import "KBKey.h"
|
#import "KBKey.h"
|
||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
|
||||||
@interface KBKeyButton ()
|
@interface KBKeyButton ()
|
||||||
// 内部缓存:便于从按钮查找到所属的 KBKeyboardView
|
// 内部缓存:便于从按钮查找到所属的 KBKeyboardView
|
||||||
@property (nonatomic, weak, readonly) UIView *kb_keyboardContainer;
|
@property (nonatomic, weak, readonly) UIView *kb_keyboardContainer;
|
||||||
@property (nonatomic, strong) UIImageView *normalImageView; /// 没有皮肤的时候展示
|
@property (nonatomic, strong) UIImageView *normalImageView; /// 没有皮肤的时候展示
|
||||||
@property (nonatomic, strong) UIColor *baseBackgroundColor; /// 无按下状态下,由皮肤/主题决定的底色(由 normalImageView 展示)
|
@property (nonatomic, strong) UIColor *baseBackgroundColor; /// 无按下状态下,由皮肤/主题决定的底色(由 normalImageView 展示)
|
||||||
|
@property (nonatomic, strong) CAGradientLayer *bottomShadowLayer;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -24,8 +26,8 @@
|
|||||||
[NSLayoutConstraint activateConstraints:@[
|
[NSLayoutConstraint activateConstraints:@[
|
||||||
[self.normalImageView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
[self.normalImageView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||||
[self.normalImageView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
[self.normalImageView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||||
[self.normalImageView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
[self.normalImageView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||||
[self.normalImageView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
[self.normalImageView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||||
]];
|
]];
|
||||||
[self applyDefaultStyle];
|
[self applyDefaultStyle];
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
|
|
||||||
// 初始状态下根据主题设置底色(给没有皮肤图的按键使用)
|
// 初始状态下根据主题设置底色(给没有皮肤图的按键使用)
|
||||||
[self refreshStateAppearance];
|
[self refreshStateAppearance];
|
||||||
|
[self kb_setupBottomShadowIfNeeded];
|
||||||
|
|
||||||
// 懒创建图标视图,用于后续皮肤按键小图标展示
|
// 懒创建图标视图,用于后续皮肤按键小图标展示
|
||||||
if (!self.iconView) {
|
if (!self.iconView) {
|
||||||
@@ -61,8 +64,8 @@
|
|||||||
[NSLayoutConstraint activateConstraints:@[
|
[NSLayoutConstraint activateConstraints:@[
|
||||||
[iv.topAnchor constraintEqualToAnchor:self.topAnchor],
|
[iv.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||||
[iv.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
[iv.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||||
[iv.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
[iv.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||||
[iv.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
[iv.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||||
]];
|
]];
|
||||||
self.iconView = iv;
|
self.iconView = iv;
|
||||||
|
|
||||||
@@ -72,6 +75,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)layoutSubviews {
|
||||||
|
[super layoutSubviews];
|
||||||
|
if (!self.bottomShadowLayer) { return; }
|
||||||
|
CGRect bounds = self.normalImageView.bounds;
|
||||||
|
CGFloat shadowHeight = 2;
|
||||||
|
if (CGRectGetHeight(bounds) <= 0 || CGRectGetWidth(bounds) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 有皮肤图时,强制隐藏文字,避免图标与文本叠加
|
||||||
|
if (self.iconView.image != nil) {
|
||||||
|
self.titleLabel.hidden = YES;
|
||||||
|
}
|
||||||
|
self.bottomShadowLayer.frame = CGRectMake(0,
|
||||||
|
CGRectGetHeight(bounds) - shadowHeight,
|
||||||
|
CGRectGetWidth(bounds),
|
||||||
|
shadowHeight);
|
||||||
|
}
|
||||||
|
|
||||||
- (void)setKey:(KBKey *)key {
|
- (void)setKey:(KBKey *)key {
|
||||||
_key = key;
|
_key = key;
|
||||||
}
|
}
|
||||||
@@ -121,14 +142,25 @@
|
|||||||
[self refreshStateAppearance];
|
[self refreshStateAppearance];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)setCustomBackgroundColor:(UIColor *)customBackgroundColor {
|
||||||
|
_customBackgroundColor = customBackgroundColor;
|
||||||
|
[self refreshStateAppearance];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)refreshStateAppearance {
|
- (void)refreshStateAppearance {
|
||||||
// 选中态用于 Shift/CapsLock 等特殊按键的高亮显示
|
// 选中态用于 Shift/CapsLock 等特殊按键的高亮显示
|
||||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||||
UIColor *base = nil;
|
UIColor *base = nil;
|
||||||
if (self.isSelected) {
|
if (self.isSelected) {
|
||||||
base = t.keyHighlightBackground ?: t.keyBackground;
|
base = t.keyHighlightBackground ?: t.keyBackground;
|
||||||
|
if (self.customBackgroundColor) {
|
||||||
|
base = t.keyHighlightBackground ?: self.customBackgroundColor;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
base = t.keyBackground;
|
base = self.customBackgroundColor ?: t.keyBackground;
|
||||||
|
}
|
||||||
|
if (self.customBackgroundColor && self.key.type == KBKeyTypeShift) {
|
||||||
|
base = self.customBackgroundColor;
|
||||||
}
|
}
|
||||||
if (!base) {
|
if (!base) {
|
||||||
base = [UIColor whiteColor];
|
base = [UIColor whiteColor];
|
||||||
@@ -138,6 +170,13 @@
|
|||||||
// 按键背景统一由 normalImageView 控制,按钮本身透明
|
// 按键背景统一由 normalImageView 控制,按钮本身透明
|
||||||
self.backgroundColor = [UIColor clearColor];
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
|
||||||
|
if (self.key.type == KBKeyTypeShift) {
|
||||||
|
UIColor *textColor = self.isSelected ? [UIColor blackColor] : (t.keyTextColor ?: [UIColor blackColor]);
|
||||||
|
[self setTitleColor:textColor forState:UIControlStateNormal];
|
||||||
|
[self setTitleColor:textColor forState:UIControlStateHighlighted];
|
||||||
|
[self setTitleColor:textColor forState:UIControlStateSelected];
|
||||||
|
}
|
||||||
|
|
||||||
// 有皮肤图时仅展示 icon,不再显示普通背景色
|
// 有皮肤图时仅展示 icon,不再显示普通背景色
|
||||||
if (self.iconView.image != nil || self.normalImageView.hidden) {
|
if (self.iconView.image != nil || self.normalImageView.hidden) {
|
||||||
return;
|
return;
|
||||||
@@ -169,6 +208,7 @@
|
|||||||
|
|
||||||
BOOL hasIcon = (iconImg != nil);
|
BOOL hasIcon = (iconImg != nil);
|
||||||
self.normalImageView.hidden = hasIcon;
|
self.normalImageView.hidden = hasIcon;
|
||||||
|
self.bottomShadowLayer.hidden = hasIcon;
|
||||||
if (hasIcon) {
|
if (hasIcon) {
|
||||||
// 有图标:仅显示图片,完全隐藏文字
|
// 有图标:仅显示图片,完全隐藏文字
|
||||||
[self setTitle:@"" forState:UIControlStateNormal];
|
[self setTitle:@"" forState:UIControlStateNormal];
|
||||||
@@ -184,6 +224,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)kb_setupBottomShadowIfNeeded {
|
||||||
|
if (self.bottomShadowLayer) { return; }
|
||||||
|
CAGradientLayer *layer = [CAGradientLayer layer];
|
||||||
|
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||||
|
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||||
|
layer.colors = @[
|
||||||
|
(id)[UIColor colorWithWhite:0 alpha:0.5].CGColor,
|
||||||
|
(id)[UIColor colorWithWhite:0 alpha:0.7].CGColor
|
||||||
|
];
|
||||||
|
[self.normalImageView.layer addSublayer:layer];
|
||||||
|
// self.bottomShadowLayer = layer;
|
||||||
|
}
|
||||||
|
|
||||||
- (UIImageView *)normalImageView{
|
- (UIImageView *)normalImageView{
|
||||||
if (!_normalImageView) {
|
if (!_normalImageView) {
|
||||||
_normalImageView = [[UIImageView alloc] init];
|
_normalImageView = [[UIImageView alloc] init];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
#import "KBKeyPreviewView.h"
|
#import "KBKeyPreviewView.h"
|
||||||
#import "KBBackspaceLongPressHandler.h"
|
#import "KBBackspaceLongPressHandler.h"
|
||||||
|
#import "KBKeyboardLayoutConfig.h"
|
||||||
|
|
||||||
// UI 常量统一管理,方便后续调试样式(以 375 宽设计稿为基准,通过 KBFit 做等比缩放)
|
// UI 常量统一管理,方便后续调试样式(以 375 宽设计稿为基准,通过 KBFit 做等比缩放)
|
||||||
#define kKBRowVerticalSpacing KBFit(8.0f)
|
#define kKBRowVerticalSpacing KBFit(8.0f)
|
||||||
@@ -33,17 +34,19 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
|||||||
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
|
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
|
||||||
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
||||||
@property (nonatomic, strong) KBKeyPreviewView *previewView;
|
@property (nonatomic, strong) KBKeyPreviewView *previewView;
|
||||||
|
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBKeyboardView
|
@implementation KBKeyboardView
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
self.backgroundColor = [UIColor clearColor];
|
||||||
_layoutStyle = KBKeyboardLayoutStyleLetters;
|
_layoutStyle = KBKeyboardLayoutStyleLetters;
|
||||||
// 默认小写:与需求一致,初始不开启 Shift
|
// 默认小写:与需求一致,初始不开启 Shift
|
||||||
_shiftOn = NO;
|
_shiftOn = NO;
|
||||||
_symbolsMoreOn = NO; // 数字面板默认第一页(123)
|
_symbolsMoreOn = NO; // 数字面板默认第一页(123)
|
||||||
|
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||||
[self buildBase];
|
[self buildBase];
|
||||||
[self reloadKeys];
|
[self reloadKeys];
|
||||||
@@ -67,26 +70,39 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
|||||||
[self addSubview:self.row3];
|
[self addSubview:self.row3];
|
||||||
[self addSubview:self.row4];
|
[self addSubview:self.row4];
|
||||||
|
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
KBKeyboardLayout *layout = [self kb_layoutForName:@"letters"];
|
||||||
|
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
|
||||||
|
|
||||||
|
CGFloat rowSpacing = [self kb_metricValue:config.metrics.rowSpacing fallback:nil defaultValue:8.0];
|
||||||
|
CGFloat topInset = [self kb_metricValue:config.metrics.topInset fallback:nil defaultValue:8.0];
|
||||||
|
CGFloat bottomInset = [self kb_metricValue:config.metrics.bottomInset fallback:nil defaultValue:6.0];
|
||||||
|
|
||||||
|
CGFloat row1Height = [self kb_rowHeightForRow:(rows.count > 0 ? rows[0] : nil)];
|
||||||
|
CGFloat row2Height = [self kb_rowHeightForRow:(rows.count > 1 ? rows[1] : nil)];
|
||||||
|
CGFloat row3Height = [self kb_rowHeightForRow:(rows.count > 2 ? rows[2] : nil)];
|
||||||
|
CGFloat row4Height = [self kb_rowHeightForRow:(rows.count > 3 ? rows[3] : nil)];
|
||||||
|
|
||||||
[self.row1 mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.row1 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.top.equalTo(self.mas_top).offset(kKBRowVerticalSpacing);
|
make.top.equalTo(self.mas_top).offset(topInset);
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.height.mas_equalTo(kKBRowHeight);
|
make.height.mas_equalTo(row1Height);
|
||||||
}];
|
}];
|
||||||
[self.row2 mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.row2 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.top.equalTo(self.row1.mas_bottom).offset(kKBRowVerticalSpacing);
|
make.top.equalTo(self.row1.mas_bottom).offset(rowSpacing);
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.height.equalTo(self.row1);
|
make.height.mas_equalTo(row2Height);
|
||||||
}];
|
}];
|
||||||
[self.row3 mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.row3 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.top.equalTo(self.row2.mas_bottom).offset(kKBRowVerticalSpacing);
|
make.top.equalTo(self.row2.mas_bottom).offset(rowSpacing);
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.height.equalTo(self.row1);
|
make.height.mas_equalTo(row3Height);
|
||||||
}];
|
}];
|
||||||
[self.row4 mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.row4 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.top.equalTo(self.row3.mas_bottom).offset(kKBRowVerticalSpacing);
|
make.top.equalTo(self.row3.mas_bottom).offset(rowSpacing);
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.height.equalTo(self.row1);
|
make.height.mas_equalTo(row4Height);
|
||||||
make.bottom.equalTo(self.mas_bottom).offset(-6);
|
make.bottom.equalTo(self.mas_bottom).offset(-bottomInset);
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +115,92 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
|||||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||||
}
|
}
|
||||||
|
|
||||||
self.keysForRows = [self buildKeysForCurrentLayout];
|
KBKeyboardLayout *layout = [self kb_currentLayout];
|
||||||
if (self.keysForRows.count < 4) return;
|
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
|
||||||
|
if (rows.count < 4) {
|
||||||
|
[self kb_buildLegacyLayout];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
[self buildRow:self.row1 withKeys:self.keysForRows[0]];
|
[self buildRow:self.row1 withRowConfig:rows[0]];
|
||||||
|
[self buildRow:self.row2 withRowConfig:rows[1]];
|
||||||
|
[self buildRow:self.row3 withRowConfig:rows[2]];
|
||||||
|
[self buildRow:self.row4 withRowConfig:rows[3]];
|
||||||
|
}
|
||||||
|
|
||||||
// 第二行:字母布局时通过左右等宽占位让整行居中
|
#pragma mark - Hit Test
|
||||||
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
|
||||||
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
|
||||||
[self buildRow:self.row2 withKeys:self.keysForRows[1] edgeSpacerMultiplier:row2Spacer];
|
|
||||||
|
|
||||||
[self buildRow:self.row3 withKeys:self.keysForRows[2]];
|
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
|
||||||
[self buildRow:self.row4 withKeys:self.keysForRows[3]];
|
UIView *hit = [super hitTest:point withEvent:event];
|
||||||
|
if ([hit isKindOfClass:[KBKeyButton class]]) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
if ([self kb_isHitInsideKeyRows:hit]) {
|
||||||
|
KBKeyButton *btn = [self kb_nearestKeyButtonForPoint:point];
|
||||||
|
if (btn) { return btn; }
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_isHitInsideKeyRows:(UIView *)hitView {
|
||||||
|
if (!hitView) { return NO; }
|
||||||
|
if (hitView == self) { return YES; }
|
||||||
|
if ([hitView isDescendantOfView:self.row1]) { return YES; }
|
||||||
|
if ([hitView isDescendantOfView:self.row2]) { return YES; }
|
||||||
|
if ([hitView isDescendantOfView:self.row3]) { return YES; }
|
||||||
|
if ([hitView isDescendantOfView:self.row4]) { return YES; }
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyButton *)kb_nearestKeyButtonForPoint:(CGPoint)point {
|
||||||
|
KBKeyButton *best = nil;
|
||||||
|
CGFloat bestDistance = CGFLOAT_MAX;
|
||||||
|
NSArray<UIView *> *rows = @[self.row1, self.row2, self.row3, self.row4];
|
||||||
|
|
||||||
|
UIView *targetRow = nil;
|
||||||
|
for (UIView *row in rows) {
|
||||||
|
CGRect rowFrame = [self convertRect:row.bounds fromView:row];
|
||||||
|
if (CGRectContainsPoint(rowFrame, point)) {
|
||||||
|
targetRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray<UIView *> *candidateRows = targetRow ? @[targetRow] : rows;
|
||||||
|
for (UIView *row in candidateRows) {
|
||||||
|
NSArray<KBKeyButton *> *buttons = [self kb_collectKeyButtonsInView:row];
|
||||||
|
for (KBKeyButton *btn in buttons) {
|
||||||
|
CGRect frame = [self convertRect:btn.frame fromView:btn.superview];
|
||||||
|
CGFloat dx = point.x - CGRectGetMidX(frame);
|
||||||
|
CGFloat dy = point.y - CGRectGetMidY(frame);
|
||||||
|
CGFloat dist = (dx * dx) + (dy * dy);
|
||||||
|
if (dist < bestDistance) {
|
||||||
|
bestDistance = dist;
|
||||||
|
best = btn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<KBKeyButton *> *)kb_collectKeyButtonsInView:(UIView *)view {
|
||||||
|
if (!view) { return @[]; }
|
||||||
|
NSMutableArray<KBKeyButton *> *buttons = [NSMutableArray array];
|
||||||
|
[self kb_collectKeyButtonsInView:view into:buttons];
|
||||||
|
return buttons.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_collectKeyButtonsInView:(UIView *)view
|
||||||
|
into:(NSMutableArray<KBKeyButton *> *)buttons {
|
||||||
|
for (UIView *sub in view.subviews) {
|
||||||
|
if ([sub isKindOfClass:[KBKeyButton class]]) {
|
||||||
|
[buttons addObject:(KBKeyButton *)sub];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sub.subviews.count > 0) {
|
||||||
|
[self kb_collectKeyButtonsInView:sub into:buttons];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Key Model Construction
|
#pragma mark - Key Model Construction
|
||||||
@@ -315,6 +405,152 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
|||||||
|
|
||||||
#pragma mark - Row Building
|
#pragma mark - Row Building
|
||||||
|
|
||||||
|
- (void)buildRow:(UIView *)row withRowConfig:(KBKeyboardRowConfig *)rowConfig {
|
||||||
|
if (!row || !rowConfig) { return; }
|
||||||
|
CGFloat gap = [self kb_gapForRow:rowConfig];
|
||||||
|
CGFloat insetLeft = [self kb_insetLeftForRow:rowConfig];
|
||||||
|
CGFloat insetRight = [self kb_insetRightForRow:rowConfig];
|
||||||
|
|
||||||
|
if (rowConfig.segments) {
|
||||||
|
KBKeyboardRowSegments *segments = rowConfig.segments;
|
||||||
|
NSArray<KBKeyboardRowItem *> *leftItems = [segments leftItems];
|
||||||
|
NSArray<KBKeyboardRowItem *> *centerItems = [segments centerItems];
|
||||||
|
NSArray<KBKeyboardRowItem *> *rightItems = [segments rightItems];
|
||||||
|
UIView *leftContainer = [UIView new];
|
||||||
|
UIView *centerContainer = [UIView new];
|
||||||
|
UIView *rightContainer = [UIView new];
|
||||||
|
[row addSubview:leftContainer];
|
||||||
|
[row addSubview:centerContainer];
|
||||||
|
[row addSubview:rightContainer];
|
||||||
|
|
||||||
|
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(row.mas_left).offset(insetLeft);
|
||||||
|
make.top.bottom.equalTo(row);
|
||||||
|
}];
|
||||||
|
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.right.equalTo(row.mas_right).offset(-insetRight);
|
||||||
|
make.top.bottom.equalTo(row);
|
||||||
|
}];
|
||||||
|
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.centerX.equalTo(row);
|
||||||
|
make.top.bottom.equalTo(row);
|
||||||
|
make.left.greaterThanOrEqualTo(leftContainer.mas_right).offset(gap);
|
||||||
|
make.right.lessThanOrEqualTo(rightContainer.mas_left).offset(-gap);
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (leftItems.count == 0) {
|
||||||
|
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.mas_equalTo(0);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (centerItems.count == 0) {
|
||||||
|
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.mas_equalTo(0);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (rightItems.count == 0) {
|
||||||
|
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.mas_equalTo(0);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self kb_buildButtonsInContainer:leftContainer
|
||||||
|
items:leftItems
|
||||||
|
gap:gap
|
||||||
|
insetLeft:0
|
||||||
|
insetRight:0
|
||||||
|
alignCenter:NO];
|
||||||
|
[self kb_buildButtonsInContainer:centerContainer
|
||||||
|
items:centerItems
|
||||||
|
gap:gap
|
||||||
|
insetLeft:0
|
||||||
|
insetRight:0
|
||||||
|
alignCenter:NO];
|
||||||
|
[self kb_buildButtonsInContainer:rightContainer
|
||||||
|
items:rightItems
|
||||||
|
gap:gap
|
||||||
|
insetLeft:0
|
||||||
|
insetRight:0
|
||||||
|
alignCenter:NO];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL alignCenter = [rowConfig.align.lowercaseString isEqualToString:@"center"];
|
||||||
|
[self kb_buildButtonsInContainer:row
|
||||||
|
items:[rowConfig resolvedItems]
|
||||||
|
gap:gap
|
||||||
|
insetLeft:insetLeft
|
||||||
|
insetRight:insetRight
|
||||||
|
alignCenter:alignCenter];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_buildButtonsInContainer:(UIView *)container
|
||||||
|
items:(NSArray<KBKeyboardRowItem *> *)items
|
||||||
|
gap:(CGFloat)gap
|
||||||
|
insetLeft:(CGFloat)insetLeft
|
||||||
|
insetRight:(CGFloat)insetRight
|
||||||
|
alignCenter:(BOOL)alignCenter {
|
||||||
|
if (items.count == 0) { return; }
|
||||||
|
|
||||||
|
UIView *leftSpacer = nil;
|
||||||
|
UIView *rightSpacer = nil;
|
||||||
|
if (alignCenter) {
|
||||||
|
leftSpacer = [UIView new];
|
||||||
|
rightSpacer = [UIView new];
|
||||||
|
[container addSubview:leftSpacer];
|
||||||
|
[container addSubview:rightSpacer];
|
||||||
|
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||||
|
make.top.bottom.equalTo(container);
|
||||||
|
}];
|
||||||
|
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||||
|
make.top.bottom.equalTo(container);
|
||||||
|
}];
|
||||||
|
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.equalTo(rightSpacer);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
KBKeyButton *previous = nil;
|
||||||
|
for (KBKeyboardRowItem *item in items) {
|
||||||
|
KBKeyButton *btn = [self kb_buttonForItem:item];
|
||||||
|
if (!btn) { continue; }
|
||||||
|
[container addSubview:btn];
|
||||||
|
|
||||||
|
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.bottom.equalTo(container);
|
||||||
|
if (previous) {
|
||||||
|
make.left.equalTo(previous.mas_right).offset(gap);
|
||||||
|
} else {
|
||||||
|
if (leftSpacer) {
|
||||||
|
make.left.equalTo(leftSpacer.mas_right).offset(gap);
|
||||||
|
} else {
|
||||||
|
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
CGFloat width = [self kb_widthForItem:item key:btn.key];
|
||||||
|
if (width > 0.0) {
|
||||||
|
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.mas_equalTo(width);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previous) { return; }
|
||||||
|
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
if (rightSpacer) {
|
||||||
|
make.right.equalTo(rightSpacer.mas_left).offset(-gap);
|
||||||
|
} else {
|
||||||
|
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)buildRow:(UIView *)row withKeys:(NSArray<KBKey *> *)keys {
|
- (void)buildRow:(UIView *)row withKeys:(NSArray<KBKey *> *)keys {
|
||||||
[self buildRow:row withKeys:keys edgeSpacerMultiplier:0.0];
|
[self buildRow:row withKeys:keys edgeSpacerMultiplier:0.0];
|
||||||
}
|
}
|
||||||
@@ -581,6 +817,386 @@ edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
|||||||
// Space 不设置宽度;通过此前已建立的左右约束自动占满剩余宽度。
|
// Space 不设置宽度;通过此前已建立的左右约束自动占满剩余宽度。
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Config Helpers
|
||||||
|
|
||||||
|
- (KBKeyboardLayoutConfig *)kb_layoutConfig {
|
||||||
|
if (!self.layoutConfig) {
|
||||||
|
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||||
|
}
|
||||||
|
return self.layoutConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyboardLayout *)kb_layoutForName:(NSString *)name {
|
||||||
|
return [[self kb_layoutConfig] layoutForName:name];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyboardLayout *)kb_currentLayout {
|
||||||
|
if (self.layoutStyle == KBKeyboardLayoutStyleNumbers) {
|
||||||
|
return [self kb_layoutForName:(self.symbolsMoreOn ? @"symbolsMore" : @"numbers")];
|
||||||
|
}
|
||||||
|
return [self kb_layoutForName:@"letters"];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_buildLegacyLayout {
|
||||||
|
self.keysForRows = [self buildKeysForCurrentLayout];
|
||||||
|
if (self.keysForRows.count < 4) { return; }
|
||||||
|
|
||||||
|
[self buildRow:self.row1 withKeys:self.keysForRows[0]];
|
||||||
|
|
||||||
|
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
||||||
|
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
||||||
|
[self buildRow:self.row2 withKeys:self.keysForRows[1] edgeSpacerMultiplier:row2Spacer];
|
||||||
|
|
||||||
|
[self buildRow:self.row3 withKeys:self.keysForRows[2]];
|
||||||
|
[self buildRow:self.row4 withKeys:self.keysForRows[3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_scaledValue:(CGFloat)designValue {
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
if (config) {
|
||||||
|
return [config scaledValue:designValue];
|
||||||
|
}
|
||||||
|
return KBFit(designValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_numberValue:(NSNumber *)value defaultValue:(CGFloat)defaultValue {
|
||||||
|
if ([value isKindOfClass:[NSNumber class]]) {
|
||||||
|
return value.doubleValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_metricValue:(NSNumber *)value fallback:(NSNumber *)fallback defaultValue:(CGFloat)defaultValue {
|
||||||
|
CGFloat v = [self kb_numberValue:value defaultValue:-1.0];
|
||||||
|
if (v < 0.0) {
|
||||||
|
v = [self kb_numberValue:fallback defaultValue:defaultValue];
|
||||||
|
}
|
||||||
|
if (v < 0.0) {
|
||||||
|
v = defaultValue;
|
||||||
|
}
|
||||||
|
return [self kb_scaledValue:v];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_rowHeightForRow:(KBKeyboardRowConfig *)row {
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
NSNumber *height = row.height ?: config.metrics.keyHeight;
|
||||||
|
CGFloat value = [self kb_numberValue:height defaultValue:40.0];
|
||||||
|
return [self kb_scaledValue:value];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_gapForRow:(KBKeyboardRowConfig *)row {
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
return [self kb_metricValue:row.gap fallback:config.metrics.gap defaultValue:5.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_insetLeftForRow:(KBKeyboardRowConfig *)row {
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
return [self kb_metricValue:row.insetLeft fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_insetRightForRow:(KBKeyboardRowConfig *)row {
|
||||||
|
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||||
|
return [self kb_metricValue:row.insetRight fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyButton *)kb_buttonForItem:(KBKeyboardRowItem *)item {
|
||||||
|
if (item.itemId.length == 0) { return nil; }
|
||||||
|
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:item.itemId];
|
||||||
|
KBKey *key = [self kb_keyForItemId:item.itemId];
|
||||||
|
if (!key) { return nil; }
|
||||||
|
|
||||||
|
KBKeyButton *btn = [[KBKeyButton alloc] init];
|
||||||
|
btn.key = key;
|
||||||
|
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||||
|
|
||||||
|
UIColor *bgColor = [self kb_backgroundColorForItem:item keyDef:def];
|
||||||
|
if (bgColor) {
|
||||||
|
btn.customBackgroundColor = bgColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
CGFloat fontSize = [self kb_fontSizeForItem:item key:key];
|
||||||
|
if (fontSize > 0.0) {
|
||||||
|
btn.titleLabel.font = [UIFont systemFontOfSize:fontSize weight:UIFontWeightSemibold];
|
||||||
|
}
|
||||||
|
|
||||||
|
[btn applyThemeForCurrentKey];
|
||||||
|
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
|
||||||
|
if (key.type == KBKeyTypeBackspace) {
|
||||||
|
[self.backspaceHandler bindDeleteButton:btn showClearLabel:YES];
|
||||||
|
}
|
||||||
|
if (key.type == KBKeyTypeShift) {
|
||||||
|
btn.selected = self.shiftOn;
|
||||||
|
}
|
||||||
|
[self kb_applySymbolIfNeededForButton:btn keyDef:def fontSize:fontSize];
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_applySymbolIfNeededForButton:(KBKeyButton *)button
|
||||||
|
keyDef:(KBKeyboardKeyDef *)def
|
||||||
|
fontSize:(CGFloat)fontSize {
|
||||||
|
if (!button || !def) { return; }
|
||||||
|
if (button.iconView.image != nil) { return; }
|
||||||
|
NSString *symbolName = button.isSelected ? def.selectedSymbolName : def.symbolName;
|
||||||
|
if (symbolName.length == 0) { return; }
|
||||||
|
|
||||||
|
UIImage *image = [UIImage systemImageNamed:symbolName];
|
||||||
|
if (!image) { return; }
|
||||||
|
|
||||||
|
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:fontSize weight:UIFontWeightSemibold];
|
||||||
|
image = [image imageWithConfiguration:config];
|
||||||
|
|
||||||
|
button.iconView.image = image;
|
||||||
|
button.iconView.hidden = NO;
|
||||||
|
button.iconView.contentMode = UIViewContentModeCenter;
|
||||||
|
button.titleLabel.hidden = YES;
|
||||||
|
|
||||||
|
UIColor *textColor = [KBSkinManager shared].current.keyTextColor ?: [UIColor blackColor];
|
||||||
|
button.iconView.tintColor = button.isSelected ? [UIColor blackColor] : textColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIColor *)kb_backgroundColorForItem:(KBKeyboardRowItem *)item keyDef:(KBKeyboardKeyDef *)def {
|
||||||
|
NSString *hex = def.backgroundColor;
|
||||||
|
if (hex.length == 0) {
|
||||||
|
hex = [self kb_layoutConfig].defaultKeyBackground;
|
||||||
|
}
|
||||||
|
if (hex.length == 0) { return nil; }
|
||||||
|
return [KBSkinManager colorFromHexString:hex defaultColor:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_metricWidthForKey:(NSString *)key {
|
||||||
|
KBKeyboardLayoutMetrics *m = [self kb_layoutConfig].metrics;
|
||||||
|
if ([key isEqualToString:@"letterWidth"]) { return m.letterWidth.doubleValue; }
|
||||||
|
if ([key isEqualToString:@"controlWidth"]) { return m.controlWidth.doubleValue; }
|
||||||
|
if ([key isEqualToString:@"sendWidth"]) { return m.sendWidth.doubleValue; }
|
||||||
|
if ([key isEqualToString:@"symbolsWideWidth"]) { return m.symbolsWideWidth.doubleValue; }
|
||||||
|
if ([key isEqualToString:@"symbolsSideWidth"]) { return m.symbolsSideWidth.doubleValue; }
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_widthForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||||
|
CGFloat width = 0.0;
|
||||||
|
if (item.widthValue.doubleValue > 0.0) {
|
||||||
|
width = item.widthValue.doubleValue;
|
||||||
|
} else if (item.width.length > 0) {
|
||||||
|
if ([item.width.lowercaseString isEqualToString:@"flex"]) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
width = [self kb_metricWidthForKey:item.width];
|
||||||
|
if (width <= 0.0) {
|
||||||
|
width = item.width.doubleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width <= 0.0) {
|
||||||
|
KBKeyboardLayoutMetrics *m = [self kb_layoutConfig].metrics;
|
||||||
|
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||||
|
[item.itemId hasPrefix:@"digit:"] ||
|
||||||
|
[item.itemId hasPrefix:@"sym:"]) {
|
||||||
|
width = m.letterWidth.doubleValue;
|
||||||
|
} else if (key.type == KBKeyTypeReturn) {
|
||||||
|
width = m.sendWidth.doubleValue;
|
||||||
|
} else if (key.type == KBKeyTypeSpace) {
|
||||||
|
return 0.0;
|
||||||
|
} else {
|
||||||
|
width = m.controlWidth.doubleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width <= 0.0) {
|
||||||
|
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||||
|
[item.itemId hasPrefix:@"digit:"] ||
|
||||||
|
[item.itemId hasPrefix:@"sym:"]) {
|
||||||
|
width = 32.0;
|
||||||
|
} else if (key.type == KBKeyTypeReturn) {
|
||||||
|
width = 88.0;
|
||||||
|
} else if (key.type == KBKeyTypeSpace) {
|
||||||
|
return 0.0;
|
||||||
|
} else {
|
||||||
|
width = 41.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return width > 0.0 ? [self kb_scaledValue:width] : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_fontSizeForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||||
|
NSString *fontKey = nil;
|
||||||
|
if ([item.itemId hasPrefix:@"letter:"]) {
|
||||||
|
fontKey = @"letter";
|
||||||
|
} else if ([item.itemId hasPrefix:@"digit:"]) {
|
||||||
|
fontKey = @"digit";
|
||||||
|
} else if ([item.itemId hasPrefix:@"sym:"]) {
|
||||||
|
fontKey = @"symbol";
|
||||||
|
} else {
|
||||||
|
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:item.itemId];
|
||||||
|
fontKey = def.font;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontKey.length == 0) {
|
||||||
|
switch (key.type) {
|
||||||
|
case KBKeyTypeModeChange:
|
||||||
|
case KBKeyTypeSymbolsToggle:
|
||||||
|
fontKey = @"mode";
|
||||||
|
break;
|
||||||
|
case KBKeyTypeSpace:
|
||||||
|
fontKey = @"space";
|
||||||
|
break;
|
||||||
|
case KBKeyTypeReturn:
|
||||||
|
fontKey = @"send";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fontKey = @"symbol";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [self kb_fontSizeForFontKey:fontKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_fontSizeForFontKey:(NSString *)fontKey {
|
||||||
|
KBKeyboardLayoutFonts *fonts = [self kb_layoutConfig].fonts;
|
||||||
|
CGFloat size = 0.0;
|
||||||
|
if ([fontKey isEqualToString:@"letter"]) { size = fonts.letter.doubleValue; }
|
||||||
|
else if ([fontKey isEqualToString:@"digit"]) { size = fonts.digit.doubleValue; }
|
||||||
|
else if ([fontKey isEqualToString:@"symbol"]) { size = fonts.symbol.doubleValue; }
|
||||||
|
else if ([fontKey isEqualToString:@"mode"]) { size = fonts.mode.doubleValue; }
|
||||||
|
else if ([fontKey isEqualToString:@"space"]) { size = fonts.space.doubleValue; }
|
||||||
|
else if ([fontKey isEqualToString:@"send"]) { size = fonts.send.doubleValue; }
|
||||||
|
if (size <= 0.0) { size = 18.0; }
|
||||||
|
return [self kb_scaledValue:size];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKey *)kb_keyForItemId:(NSString *)itemId {
|
||||||
|
if (itemId.length == 0) { return nil; }
|
||||||
|
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:itemId];
|
||||||
|
if (def) {
|
||||||
|
return [self kb_keyFromDef:def identifier:itemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSRange range = [itemId rangeOfString:@":"];
|
||||||
|
if (range.location != NSNotFound) {
|
||||||
|
NSString *prefix = [itemId substringToIndex:range.location];
|
||||||
|
NSString *value = [itemId substringFromIndex:range.location + 1];
|
||||||
|
if ([prefix isEqualToString:@"letter"]) {
|
||||||
|
if (value.length == 1) {
|
||||||
|
return [self kb_letterKeyWithChar:value];
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
if ([prefix isEqualToString:@"digit"]) {
|
||||||
|
NSString *identifier = [NSString stringWithFormat:@"digit_%@", value];
|
||||||
|
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||||
|
k.caseVariant = KBKeyCaseVariantNone;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
if ([prefix isEqualToString:@"sym"]) {
|
||||||
|
NSString *identifier = [self kb_identifierForSymbol:value];
|
||||||
|
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||||
|
k.caseVariant = KBKeyCaseVariantNone;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKey *)kb_keyFromDef:(KBKeyboardKeyDef *)def identifier:(NSString *)identifier {
|
||||||
|
KBKeyType type = [self kb_keyTypeForDef:def];
|
||||||
|
NSString *title = def.title ?: @"";
|
||||||
|
if (type == KBKeyTypeShift && self.shiftOn && def.selectedTitle.length > 0) {
|
||||||
|
title = def.selectedTitle;
|
||||||
|
}
|
||||||
|
NSString *output = @"";
|
||||||
|
switch (type) {
|
||||||
|
case KBKeyTypeSpace:
|
||||||
|
output = @" ";
|
||||||
|
break;
|
||||||
|
case KBKeyTypeReturn:
|
||||||
|
output = @"\n";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
output = @"";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *finalId = identifier;
|
||||||
|
if ([identifier isEqualToString:@"emoji"]) {
|
||||||
|
finalId = KBKeyIdentifierEmojiPanel;
|
||||||
|
} else if ([identifier isEqualToString:@"send"]) {
|
||||||
|
finalId = @"return";
|
||||||
|
}
|
||||||
|
|
||||||
|
KBKey *k = [KBKey keyWithIdentifier:finalId title:title output:output type:type];
|
||||||
|
if (type == KBKeyTypeShift) {
|
||||||
|
k.caseVariant = self.shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
||||||
|
} else {
|
||||||
|
k.caseVariant = KBKeyCaseVariantNone;
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyType)kb_keyTypeForDef:(KBKeyboardKeyDef *)def {
|
||||||
|
NSString *type = def.type.lowercaseString;
|
||||||
|
if ([type isEqualToString:@"shift"]) return KBKeyTypeShift;
|
||||||
|
if ([type isEqualToString:@"backspace"]) return KBKeyTypeBackspace;
|
||||||
|
if ([type isEqualToString:@"mode"]) return KBKeyTypeModeChange;
|
||||||
|
if ([type isEqualToString:@"symbolstoggle"]) return KBKeyTypeSymbolsToggle;
|
||||||
|
if ([type isEqualToString:@"space"]) return KBKeyTypeSpace;
|
||||||
|
if ([type isEqualToString:@"return"]) return KBKeyTypeReturn;
|
||||||
|
if ([type isEqualToString:@"globe"]) return KBKeyTypeGlobe;
|
||||||
|
return KBKeyTypeCustom;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_identifierForSymbol:(NSString *)symbol {
|
||||||
|
if (symbol.length == 0) { return nil; }
|
||||||
|
static NSDictionary<NSString *, NSString *> *map = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
map = @{
|
||||||
|
@"-": @"sym_minus",
|
||||||
|
@"/": @"sym_slash",
|
||||||
|
@":": @"sym_colon",
|
||||||
|
@";": @"sym_semicolon",
|
||||||
|
@"(": @"sym_paren_l",
|
||||||
|
@")": @"sym_paren_r",
|
||||||
|
@"¥": @"sym_money",
|
||||||
|
@"¥": @"sym_money",
|
||||||
|
@"&": @"sym_amp",
|
||||||
|
@"@": @"sym_at",
|
||||||
|
@"\"": @"sym_quote_double",
|
||||||
|
@"“": @"sym_quote_double",
|
||||||
|
@"”": @"sym_quote_double",
|
||||||
|
@".": @"sym_dot",
|
||||||
|
@",": @"sym_comma",
|
||||||
|
@"?": @"sym_question",
|
||||||
|
@"!": @"sym_exclam",
|
||||||
|
@"'": @"sym_quote_single",
|
||||||
|
@"‘": @"sym_quote_single",
|
||||||
|
@"’": @"sym_quote_single",
|
||||||
|
@"[": @"sym_bracket_l",
|
||||||
|
@"]": @"sym_bracket_r",
|
||||||
|
@"{": @"sym_brace_l",
|
||||||
|
@"}": @"sym_brace_r",
|
||||||
|
@"#": @"sym_hash",
|
||||||
|
@"%": @"sym_percent",
|
||||||
|
@"^": @"sym_caret",
|
||||||
|
@"*": @"sym_asterisk",
|
||||||
|
@"+": @"sym_plus",
|
||||||
|
@"=": @"sym_equal",
|
||||||
|
@"_": @"sym_underscore",
|
||||||
|
@"\\": @"sym_backslash",
|
||||||
|
@"|": @"sym_pipe",
|
||||||
|
@"~": @"sym_tilde",
|
||||||
|
@"<": @"sym_lt",
|
||||||
|
@">": @"sym_gt",
|
||||||
|
@"€": @"sym_euro",
|
||||||
|
@"$": @"sym_dollar",
|
||||||
|
@"·": @"sym_bullet"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return map[symbol];
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Actions
|
#pragma mark - Actions
|
||||||
|
|
||||||
- (void)onKeyTapped:(KBKeyButton *)sender {
|
- (void)onKeyTapped:(KBKeyButton *)sender {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#import "KBStreamTextView.h"
|
#import "KBStreamTextView.h"
|
||||||
#import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController,并将文本输出到宿主
|
#import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController,并将文本输出到宿主
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
|
||||||
@interface KBStreamTextView ()
|
@interface KBStreamTextView ()
|
||||||
|
|
||||||
@@ -359,7 +360,6 @@ static inline NSString *KBTrimRight(NSString *s) {
|
|||||||
contextAfter = proxy.documentContextAfterInput ?: @"";
|
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
while (contextBefore.length > 0) {
|
while (contextBefore.length > 0) {
|
||||||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||||||
@@ -371,6 +371,17 @@ static inline NSString *KBTrimRight(NSString *s) {
|
|||||||
if (rawText.length > 0) {
|
if (rawText.length > 0) {
|
||||||
[proxy insertText:rawText];
|
[proxy insertText:rawText];
|
||||||
}
|
}
|
||||||
|
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||||
|
extra[@"send_text"] = rawText ? rawText : @"";
|
||||||
|
extra[@"index"] = index ? rawText : 0;
|
||||||
|
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"send_stream_text_action"
|
||||||
|
pageId:@"keyboard_StreamTextView"
|
||||||
|
elementId:@"handle_label"
|
||||||
|
extra:extra.copy
|
||||||
|
completion:nil];
|
||||||
|
[[KBInputBufferManager shared] resetWithText:rawText ?: @""];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
CustomKeyboard/View/KBSuggestionBarView.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// KBSuggestionBarView.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@class KBSuggestionBarView;
|
||||||
|
@class KBSkinTheme;
|
||||||
|
|
||||||
|
@protocol KBSuggestionBarViewDelegate <NSObject>
|
||||||
|
- (void)suggestionBarView:(KBSuggestionBarView *)view didSelectSuggestion:(NSString *)suggestion;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBSuggestionBarView : UIView
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBSuggestionBarViewDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)updateSuggestions:(NSArray<NSString *> *)suggestions;
|
||||||
|
- (void)applyTheme:(KBSkinTheme *)theme;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
114
CustomKeyboard/View/KBSuggestionBarView.m
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// KBSuggestionBarView.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBSuggestionBarView.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
#import "KBSkinManager.h"
|
||||||
|
|
||||||
|
@interface KBSuggestionBarView ()
|
||||||
|
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||||
|
@property (nonatomic, strong) UIStackView *stackView;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *items;
|
||||||
|
@property (nonatomic, strong) UIColor *pillColor;
|
||||||
|
@property (nonatomic, strong) UIColor *textColor;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBSuggestionBarView
|
||||||
|
|
||||||
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
|
if (self = [super initWithFrame:frame]) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
[self addSubview:self.scrollView];
|
||||||
|
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.scrollView addSubview:self.stackView];
|
||||||
|
[self.stackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.scrollView).insets(UIEdgeInsetsMake(0, 8, 0, 8));
|
||||||
|
make.height.equalTo(self.scrollView);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self applyTheme:[KBSkinManager shared].current];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateSuggestions:(NSArray<NSString *> *)suggestions {
|
||||||
|
self.items = suggestions ?: @[];
|
||||||
|
|
||||||
|
for (UIView *view in self.stackView.arrangedSubviews) {
|
||||||
|
[self.stackView removeArrangedSubview:view];
|
||||||
|
[view removeFromSuperview];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *item in self.items) {
|
||||||
|
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||||
|
btn.layer.cornerRadius = 12.0;
|
||||||
|
btn.layer.masksToBounds = YES;
|
||||||
|
btn.backgroundColor = self.pillColor ?: [UIColor colorWithWhite:1 alpha:0.9];
|
||||||
|
btn.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||||
|
[btn setTitle:item forState:UIControlStateNormal];
|
||||||
|
[btn setTitleColor:self.textColor ?: [UIColor blackColor] forState:UIControlStateNormal];
|
||||||
|
btn.contentEdgeInsets = UIEdgeInsetsMake(4, 10, 4, 10);
|
||||||
|
[btn addTarget:self action:@selector(onTapSuggestion:) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[self.stackView addArrangedSubview:btn];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hidden = (self.items.count == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)applyTheme:(KBSkinTheme *)theme {
|
||||||
|
UIColor *bg = theme.keyBackground ?: [UIColor whiteColor];
|
||||||
|
UIColor *text = theme.keyTextColor ?: [UIColor blackColor];
|
||||||
|
UIColor *barBg = [UIColor colorWithHex:0xD1D3DB];
|
||||||
|
self.backgroundColor = barBg;
|
||||||
|
self.pillColor = bg;
|
||||||
|
self.textColor = text;
|
||||||
|
|
||||||
|
for (UIView *view in self.stackView.arrangedSubviews) {
|
||||||
|
if (![view isKindOfClass:[UIButton class]]) { continue; }
|
||||||
|
UIButton *btn = (UIButton *)view;
|
||||||
|
btn.backgroundColor = self.pillColor;
|
||||||
|
[btn setTitleColor:self.textColor forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Actions
|
||||||
|
|
||||||
|
- (void)onTapSuggestion:(UIButton *)sender {
|
||||||
|
NSString *title = sender.currentTitle ?: @"";
|
||||||
|
if (title.length == 0) { return; }
|
||||||
|
if ([self.delegate respondsToSelector:@selector(suggestionBarView:didSelectSuggestion:)]) {
|
||||||
|
[self.delegate suggestionBarView:self didSelectSuggestion:title];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIScrollView *)scrollView {
|
||||||
|
if (!_scrollView) {
|
||||||
|
_scrollView = [[UIScrollView alloc] init];
|
||||||
|
_scrollView.showsHorizontalScrollIndicator = NO;
|
||||||
|
_scrollView.alwaysBounceHorizontal = YES;
|
||||||
|
}
|
||||||
|
return _scrollView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIStackView *)stackView {
|
||||||
|
if (!_stackView) {
|
||||||
|
_stackView = [[UIStackView alloc] init];
|
||||||
|
_stackView.axis = UILayoutConstraintAxisHorizontal;
|
||||||
|
_stackView.alignment = UIStackViewAlignmentCenter;
|
||||||
|
_stackView.spacing = 8.0;
|
||||||
|
}
|
||||||
|
return _stackView;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
|
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
|
||||||
|
|
||||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
|
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
|
||||||
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
|
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
|
||||||
|
|
||||||
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
||||||
|
|||||||
@@ -23,13 +23,15 @@
|
|||||||
@implementation KBToolBar
|
@implementation KBToolBar
|
||||||
|
|
||||||
static NSString * const kKBAIKeyIdentifier = @"ai";
|
static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||||
|
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
|
||||||
static const CGFloat kKBAIButtonWidth = 40;
|
static const CGFloat kKBAIButtonWidth = 40;
|
||||||
static const CGFloat kKBAIButtonHeight = 40;
|
static const CGFloat kKBAIButtonHeight = 40;
|
||||||
|
static const NSInteger kKBVoiceButtonIndex = 1;
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame{
|
- (instancetype)initWithFrame:(CGRect)frame{
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
self.backgroundColor = [UIColor clearColor];
|
self.backgroundColor = [UIColor clearColor];
|
||||||
_leftButtonTitles = @[@"AI"]; // 默认标题
|
_leftButtonTitles = @[@"AI", KBLocalized(@"语音")]; // 默认标题
|
||||||
[self setupUI];
|
[self setupUI];
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
selector:@selector(kb_undoStateChanged)
|
selector:@selector(kb_undoStateChanged)
|
||||||
@@ -67,6 +69,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
[self kb_updateAIButtonAppearance];
|
[self kb_updateAIButtonAppearance];
|
||||||
|
[self kb_updateVoiceButtonAppearance];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - 视图搭建
|
#pragma mark - 视图搭建
|
||||||
@@ -96,6 +99,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
make.right.equalTo(self.mas_right).offset(-12);
|
make.right.equalTo(self.mas_right).offset(-12);
|
||||||
make.centerY.equalTo(self.mas_centerY);
|
make.centerY.equalTo(self.mas_centerY);
|
||||||
make.height.mas_equalTo(32);
|
make.height.mas_equalTo(32);
|
||||||
|
make.width.mas_equalTo(84);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
[self kb_updateLeftContainerConstraints];
|
[self kb_updateLeftContainerConstraints];
|
||||||
@@ -169,6 +173,8 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
|
|
||||||
- (void)kb_applyTheme {
|
- (void)kb_applyTheme {
|
||||||
[self kb_updateAIButtonAppearance];
|
[self kb_updateAIButtonAppearance];
|
||||||
|
[self kb_updateVoiceButtonAppearance];
|
||||||
|
[self kb_updateUndoButtonAppearance];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_updateAIButtonAppearance {
|
- (void)kb_updateAIButtonAppearance {
|
||||||
@@ -205,6 +211,36 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateVoiceButtonAppearance {
|
||||||
|
UIButton *voiceButton = [self kb_voiceButton];
|
||||||
|
if (!voiceButton) { return; }
|
||||||
|
|
||||||
|
voiceButton.backgroundColor = [UIColor colorWithHex:0xE53935];
|
||||||
|
[voiceButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||||
|
voiceButton.layer.cornerRadius = 16;
|
||||||
|
voiceButton.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateUndoButtonAppearance {
|
||||||
|
if (!self.undoButtonInternal) { return; }
|
||||||
|
|
||||||
|
KBSkinManager *skinManager = [KBSkinManager shared];
|
||||||
|
UIImage *icon = [skinManager iconImageForKeyIdentifier:kKBUndoKeyIdentifier caseVariant:0];
|
||||||
|
if (!icon) {
|
||||||
|
icon = [UIImage imageNamed:@"key_revoke"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
[self.undoButtonInternal setImage:icon forState:UIControlStateNormal];
|
||||||
|
[self.undoButtonInternal setImage:icon forState:UIControlStateHighlighted];
|
||||||
|
[self.undoButtonInternal setImage:icon forState:UIControlStateSelected];
|
||||||
|
} else {
|
||||||
|
[self.undoButtonInternal setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.undoButtonInternal setImage:nil forState:UIControlStateHighlighted];
|
||||||
|
[self.undoButtonInternal setImage:nil forState:UIControlStateSelected];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Actions
|
#pragma mark - Actions
|
||||||
|
|
||||||
- (void)onLeftAction:(UIButton *)sender {
|
- (void)onLeftAction:(UIButton *)sender {
|
||||||
@@ -262,14 +298,15 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
|
|
||||||
- (UIButton *)undoButtonInternal {
|
- (UIButton *)undoButtonInternal {
|
||||||
if (!_undoButtonInternal) {
|
if (!_undoButtonInternal) {
|
||||||
_undoButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
_undoButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
_undoButtonInternal.layer.cornerRadius = 16;
|
// _undoButtonInternal.layer.cornerRadius = 16;
|
||||||
_undoButtonInternal.layer.masksToBounds = YES;
|
// _undoButtonInternal.layer.masksToBounds = YES;
|
||||||
_undoButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
// _undoButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||||
_undoButtonInternal.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
// _undoButtonInternal.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||||
[_undoButtonInternal setTitle:@"撤销删除" forState:UIControlStateNormal];
|
// [_undoButtonInternal setTitle:@"撤销删除" forState:UIControlStateNormal];
|
||||||
[_undoButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
// [_undoButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||||
_undoButtonInternal.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
|
// _undoButtonInternal.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
|
||||||
|
[_undoButtonInternal setImage:[UIImage imageNamed:@"key_revoke"] forState:UIControlStateNormal];
|
||||||
_undoButtonInternal.hidden = YES;
|
_undoButtonInternal.hidden = YES;
|
||||||
_undoButtonInternal.alpha = 0.0;
|
_undoButtonInternal.alpha = 0.0;
|
||||||
[_undoButtonInternal addTarget:self action:@selector(onUndo) forControlEvents:UIControlEventTouchUpInside];
|
[_undoButtonInternal addTarget:self action:@selector(onUndo) forControlEvents:UIControlEventTouchUpInside];
|
||||||
@@ -282,6 +319,11 @@ static const CGFloat kKBAIButtonHeight = 40;
|
|||||||
return self.leftButtonsInternal[0];
|
return self.leftButtonsInternal[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (UIButton *)kb_voiceButton {
|
||||||
|
if (self.leftButtonsInternal.count <= kKBVoiceButtonIndex) { return nil; }
|
||||||
|
return self.leftButtonsInternal[kKBVoiceButtonIndex];
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Globe (Input Mode Switch)
|
#pragma mark - Globe (Input Mode Switch)
|
||||||
|
|
||||||
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
|
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
|
||||||
|
|||||||
148
KBMaiPointEventTable.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# KBMaiPoint 埋点事件表(统一口径:iOS / Android / 后端)
|
||||||
|
|
||||||
|
## 统一约定(全端一致)
|
||||||
|
|
||||||
|
### 1)事件类型(event_type)
|
||||||
|
- 页面曝光:`page_exposure`
|
||||||
|
- 点击事件:`click`
|
||||||
|
|
||||||
|
> iOS 侧可映射为:`KBMaiPointGenericReportTypePage / KBMaiPointGenericReportTypeClick`
|
||||||
|
|
||||||
|
### 2)事件名称(event_name)
|
||||||
|
- 统一使用 `lower_snake_case`,不绑定任何端的类名/资源名
|
||||||
|
- 页面曝光统一前缀:`enter_`
|
||||||
|
- 点击事件统一前缀:`click_`
|
||||||
|
|
||||||
|
### 3)事件参数(value / params)
|
||||||
|
- **所有事件都固定带**:`token`(`NSString`,有就传真实值;没有就传空字符串 `""`)
|
||||||
|
- 建议额外固定带:`page_id`(页面/区域统一ID)
|
||||||
|
- 点击类事件建议固定带:`element_id`(控件/入口统一ID)
|
||||||
|
- 列表/集合类点击建议带:`index`(`NSInteger`)与业务 `id`(如 `theme_id` / `product_id`)
|
||||||
|
|
||||||
|
参数示例(最小):
|
||||||
|
```json
|
||||||
|
{ "token": "", "page_id": "shop", "element_id": "search_btn" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. 主工程(keyBoard)
|
||||||
|
|
||||||
|
### A1)页面曝光(触发:VC 的 `viewDidAppear`)
|
||||||
|
|
||||||
|
| 注释 | 事件类型 | 事件名称 | page_id | iOS 对应页面 | Android 对应页面 | 触发时机 | 事件参数(示例) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 进入首页 | page_exposure | enter_home_main | home_main | HomeMainVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_main" }` |
|
||||||
|
| 进入首页Tab容器 | page_exposure | enter_home | home | HomeVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home" }` |
|
||||||
|
| 进入热门页 | page_exposure | enter_home_hot | home_hot | HomeHotVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_hot" }` |
|
||||||
|
| 进入排行榜页 | page_exposure | enter_home_rank | home_rank | HomeRankVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_rank" }` |
|
||||||
|
| 进入排行榜内容页 | page_exposure | enter_home_rank_content | home_rank_content | HomeRankContentVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_rank_content" }` |
|
||||||
|
| 进入首页底部弹层 | page_exposure | enter_home_sheet | home_sheet | HomeSheetVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_sheet" }` |
|
||||||
|
| 进入社区页 | page_exposure | enter_community | community | KBCommunityVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"community" }` |
|
||||||
|
| 进入搜索页 | page_exposure | enter_search | search | KBSearchVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"search" }` |
|
||||||
|
| 进入搜索结果页 | page_exposure | enter_search_result | search_result | KBSearchResultVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"search_result" }` |
|
||||||
|
| 进入商店页 | page_exposure | enter_shop | shop | KBShopVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"shop" }` |
|
||||||
|
| 进入商店分类列表页 | page_exposure | enter_shop_item_list | shop_item_list | KBShopItemVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"shop_item_list" }` |
|
||||||
|
| 进入皮肤详情页 | page_exposure | enter_skin_detail | skin_detail | KBSkinDetailVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"skin_detail", "theme_id":"" }` |
|
||||||
|
| 进入我的页 | page_exposure | enter_my | my | MyVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my" }` |
|
||||||
|
| 进入我的皮肤页 | page_exposure | enter_my_skin | my_skin | MySkinVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my_skin" }` |
|
||||||
|
| 进入我的键盘配置页 | page_exposure | enter_my_keyboard | my_keyboard | KBMyKeyBoardVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my_keyboard" }` |
|
||||||
|
| 进入个人信息页 | page_exposure | enter_person_info | person_info | KBPersonInfoVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"person_info" }` |
|
||||||
|
| 进入反馈页 | page_exposure | enter_feedback | feedback | KBFeedBackVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"feedback" }` |
|
||||||
|
| 进入公告页 | page_exposure | enter_notice | notice | KBNoticeVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"notice" }` |
|
||||||
|
| 进入消费记录页 | page_exposure | enter_consumption_record | consumption_record | KBConsumptionRecordVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"consumption_record" }` |
|
||||||
|
| 进入VIP购买页 | page_exposure | enter_vip_pay | vip_pay | KBVipPay | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"vip_pay" }` |
|
||||||
|
| 进入积分充值页 | page_exposure | enter_points_recharge | points_recharge | KBJfPay | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"points_recharge" }` |
|
||||||
|
| 进入登录页 | page_exposure | enter_login | login | KBLoginVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"login" }` |
|
||||||
|
| 进入邮箱登录页 | page_exposure | enter_login_email | login_email | KBEmailLoginVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"login_email" }` |
|
||||||
|
| 进入邮箱注册页 | page_exposure | enter_register_email | register_email | KBEmailRegistVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"register_email" }` |
|
||||||
|
| 进入注册验证码页 | page_exposure | enter_register_verify_email | register_verify_email | KBRegistVerEmailVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"register_verify_email" }` |
|
||||||
|
| 进入忘记密码页 | page_exposure | enter_forgot_password_email | forgot_password_email | KBForgetPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_email" }` |
|
||||||
|
| 进入忘记密码验证码页 | page_exposure | enter_forgot_password_verify | forgot_password_verify | KBForgetVerPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_verify" }` |
|
||||||
|
| 进入忘记密码新密码页 | page_exposure | enter_forgot_password_newpwd | forgot_password_newpwd | KBForgetPwdNewPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_newpwd" }` |
|
||||||
|
| 进入键盘权限引导页(App内) | page_exposure | enter_keyboard_permission_guide | keyboard_permission_guide | KBPermissionViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"keyboard_permission_guide" }` |
|
||||||
|
| 进入首次引导页 | page_exposure | enter_guide | guide | KBGuideVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"guide" }` |
|
||||||
|
| 进入性别选择页 | page_exposure | enter_sex_select | sex_select | KBSexSelVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"sex_select" }` |
|
||||||
|
| 进入WebView页 | page_exposure | enter_webview | webview | KBWebViewViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"webview", "url":"" }` |
|
||||||
|
|
||||||
|
> 测试/工具页(建议仅 DEBUG 或按需接入):`KBTestVC / KBLangTestVC / KBSkinCenterVC / ViewController / LoginViewController / KBLoginSheetViewController`。
|
||||||
|
|
||||||
|
### A2)点击事件(按钮/列表/入口)
|
||||||
|
|
||||||
|
| 注释 | 事件类型 | 事件名称 | page_id | element_id | iOS 对应控件/方法 | Android 对应控件 | 触发时机 | 事件参数(示例) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 首页点击“购买会员” | click | click_home_buy_vip_btn | home_main | buy_vip_btn | HomeHeadView `onTapBuyAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"home_main", "element_id":"buy_vip_btn" }` |
|
||||||
|
| 首页点击“权限悬浮按钮” | click | click_home_permission_float_btn | home_main | permission_float_btn | HomeMainVC `keyPermissButton.clickDragViewBlock` | Android 自定义 | 点击悬浮按钮 | `{ "token":"", "page_id":"home_main", "element_id":"permission_float_btn" }` |
|
||||||
|
| 权限引导页点击“去设置” | click | click_permission_open_settings_btn | keyboard_permission_guide | open_settings_btn | KBPermissionViewController `openSettings` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_permission_guide", "element_id":"open_settings_btn" }` |
|
||||||
|
| 权限引导页点击“关闭” | click | click_permission_close_btn | keyboard_permission_guide | close_btn | KBPermissionViewController `closeButtonAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_permission_guide", "element_id":"close_btn" }` |
|
||||||
|
| 商店页点击“搜索” | click | click_shop_search_btn | shop | search_btn | KBShopVC `searchBtnAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"shop", "element_id":"search_btn" }` |
|
||||||
|
| 商店页点击“我的皮肤” | click | click_shop_my_skin_btn | shop | my_skin_btn | KBShopVC `skinBtnAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"shop", "element_id":"my_skin_btn" }` |
|
||||||
|
| 商店列表点击皮肤卡片 | click | click_shop_theme_card | shop_item_list | theme_card | KBShopItemVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"shop_item_list", "element_id":"theme_card", "theme_id":"", "index":0 }` |
|
||||||
|
| 皮肤详情点击“下载/购买” | click | click_skin_download_btn | skin_detail | download_btn | KBSkinDetailVC `handleDownloadAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"skin_detail", "element_id":"download_btn", "theme_id":"", "purchased":0 }` |
|
||||||
|
| 皮肤详情点击“推荐皮肤” | click | click_skin_recommend_card | skin_detail | recommend_card | KBSkinDetailVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"skin_detail", "element_id":"recommend_card", "from_theme_id":"", "to_theme_id":"", "index":0 }` |
|
||||||
|
| 搜索栏点击搜索 | click | click_search_submit | search | search_submit | KBSearchBarView `onSearch` | Android 自定义 | 点击搜索 | `{ "token":"", "page_id":"search", "element_id":"search_submit", "keyword_len":0 }` |
|
||||||
|
| 搜索页点击历史词条 | click | click_search_history_item | search | history_item | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"history_item", "index":0 }` |
|
||||||
|
| 搜索页点击“展开更多历史” | click | click_search_history_more | search | history_more | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"history_more" }` |
|
||||||
|
| 搜索页点击“清空历史” | click | click_search_clear_history | search | clear_history | KBSearchVC `clearHistory`(header trash) | Android 自定义 | 点击垃圾桶 | `{ "token":"", "page_id":"search", "element_id":"clear_history" }` |
|
||||||
|
| 搜索页点击推荐皮肤 | click | click_search_recommend_theme | search | recommend_theme_card | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"recommend_theme_card", "theme_id":"", "index":0 }` |
|
||||||
|
| 搜索结果页点击皮肤 | click | click_search_result_theme | search_result | result_theme_card | KBSearchResultVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search_result", "element_id":"result_theme_card", "theme_id":"", "index":0 }` |
|
||||||
|
| 我的页点击菜单项 | click | click_my_menu_item | my | menu_item | MyVC `didSelectRowAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"my", "element_id":"menu_item", "item_id":"", "item_title":"" }` |
|
||||||
|
| 我的页点击“邀请”成功复制 | click | click_my_invite_copy | my | invite_copy | MyVC(邀请分支) | Android 自定义 | 复制时机 | `{ "token":"", "page_id":"my", "element_id":"invite_copy" }` |
|
||||||
|
| 反馈页点击提交 | click | click_feedback_commit_btn | feedback | commit_btn | KBFeedBackVC `onTapCommit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"feedback", "element_id":"commit_btn", "content_len":0 }` |
|
||||||
|
| 个人信息点击更换头像 | click | click_person_avatar_edit | person_info | avatar_edit | KBPersonInfoVC `onTapAvatarEdit` | Android 自定义 | tapGesture | `{ "token":"", "page_id":"person_info", "element_id":"avatar_edit" }` |
|
||||||
|
| 个人信息点击退出登录 | click | click_person_logout_btn | person_info | logout_btn | KBPersonInfoVC `onTapLogout` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"person_info", "element_id":"logout_btn" }` |
|
||||||
|
| 我的键盘页点击保存 | click | click_my_keyboard_save_btn | my_keyboard | save_btn | KBMyKeyBoardVC `onSave` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_keyboard", "element_id":"save_btn" }` |
|
||||||
|
| 我的皮肤页点击编辑/取消 | click | click_my_skin_toggle_edit | my_skin | toggle_edit | MySkinVC `onToggleEdit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_skin", "element_id":"toggle_edit", "editing":0 }` |
|
||||||
|
| 我的皮肤页点击删除 | click | click_my_skin_delete_btn | my_skin | delete_btn | MySkinVC `onDelete` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_skin", "element_id":"delete_btn", "selected_count":0 }` |
|
||||||
|
| 我的皮肤页点击皮肤(进入详情) | click | click_my_skin_theme_card | my_skin | theme_card | MySkinVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"my_skin", "element_id":"theme_card", "theme_id":"", "index":0 }` |
|
||||||
|
| 登录页点击 Apple 登录 | click | click_login_apple_btn | login | apple_btn | KBLoginVC `onTapAppleLogin` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"apple_btn" }` |
|
||||||
|
| 登录页点击邮箱登录 | click | click_login_email_btn | login | email_btn | KBLoginVC `onTapEmailLogin` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"email_btn" }` |
|
||||||
|
| 登录页点击注册 | click | click_login_signup_btn | login | signup_btn | KBLoginVC `onTapSignUp` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"signup_btn" }` |
|
||||||
|
| 登录页点击忘记密码 | click | click_login_forgot_btn | login | forgot_btn | KBLoginVC `onTapForgotPassword` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"forgot_btn" }` |
|
||||||
|
| 邮箱登录页点击提交 | click | click_login_email_submit_btn | login_email | submit_btn | KBEmailLoginVC `onTapSubmit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login_email", "element_id":"submit_btn" }` |
|
||||||
|
| 邮箱注册页点击提交 | click | click_register_email_submit_btn | register_email | submit_btn | KBEmailRegistVC `onTapSubmit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"register_email", "element_id":"submit_btn" }` |
|
||||||
|
| 注册验证码页点击确认 | click | click_register_verify_confirm_btn | register_verify_email | confirm_btn | KBRegistVerEmailVC `onTapConfirm` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"register_verify_email", "element_id":"confirm_btn" }` |
|
||||||
|
| 忘记密码(邮箱)点击下一步 | click | click_forgot_email_next_btn | forgot_password_email | next_btn | KBForgetPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_email", "element_id":"next_btn" }` |
|
||||||
|
| 忘记密码(验证码)点击下一步 | click | click_forgot_verify_next_btn | forgot_password_verify | next_btn | KBForgetVerPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_verify", "element_id":"next_btn" }` |
|
||||||
|
| 忘记密码(新密码)点击下一步 | click | click_forgot_newpwd_next_btn | forgot_password_newpwd | next_btn | KBForgetPwdNewPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_newpwd", "element_id":"next_btn" }` |
|
||||||
|
| VIP页选择套餐 | click | click_vip_select_plan | vip_pay | plan_item | KBVipPay `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"vip_pay", "element_id":"plan_item", "product_id":"", "index":0 }` |
|
||||||
|
| VIP页点击支付 | click | click_vip_pay_btn | vip_pay | pay_btn | KBVipPay `onTapPayButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"pay_btn", "product_id":"" }` |
|
||||||
|
| VIP页点击恢复购买 | click | click_vip_restore_btn | vip_pay | restore_btn | KBVipPay `onTapRestoreButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"restore_btn" }` |
|
||||||
|
| VIP页点击关闭 | click | click_vip_close_btn | vip_pay | close_btn | KBVipPay `onTapClose` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"close_btn" }` |
|
||||||
|
| 积分充值页选择商品 | click | click_points_select_product | points_recharge | product_item | KBJfPay `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"points_recharge", "element_id":"product_item", "product_id":"", "index":0 }` |
|
||||||
|
| 积分充值页点击充值 | click | click_points_pay_btn | points_recharge | pay_btn | KBJfPay `onTapPayButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"points_recharge", "element_id":"pay_btn", "product_id":"" }` |
|
||||||
|
| 引导页点击复制示例1 | click | click_guide_copy_example_1 | guide | copy_example_1 | KBGuideTopCell `kb_onTapQ1` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"guide", "element_id":"copy_example_1" }` |
|
||||||
|
| 引导页点击复制示例2 | click | click_guide_copy_example_2 | guide | copy_example_2 | KBGuideTopCell `kb_onTapQ2` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"guide", "element_id":"copy_example_2" }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. 键盘扩展(CustomKeyboard)
|
||||||
|
|
||||||
|
### B1)页面曝光(触发:显示/切换时机)
|
||||||
|
|
||||||
|
| 注释 | 事件类型 | 事件名称 | page_id | iOS 对应页面/视图 | Android 对应页面 | 触发时机 | 事件参数(示例) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 键盘首次显示 | page_exposure | enter_keyboard | keyboard | KeyboardViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"keyboard" }` |
|
||||||
|
| 打开功能面板 | page_exposure | enter_keyboard_function_panel | keyboard_function_panel | KBFunctionView | Android 自定义 | showFunctionPanel:YES | `{ "token":"", "page_id":"keyboard_function_panel" }` |
|
||||||
|
| 关闭功能面板(回到主键盘) | page_exposure | enter_keyboard_main_panel | keyboard_main_panel | KBKeyBoardMainView | Android 自定义 | showFunctionPanel:NO | `{ "token":"", "page_id":"keyboard_main_panel" }` |
|
||||||
|
| 打开设置页 | page_exposure | enter_keyboard_settings | keyboard_settings | KBSettingView | Android 自定义 | showSettingView:YES | `{ "token":"", "page_id":"keyboard_settings" }` |
|
||||||
|
| 打开订阅/充值面板 | page_exposure | enter_keyboard_subscription_panel | keyboard_subscription_panel | KBKeyboardSubscriptionView | Android 自定义 | showSubscriptionPanel | `{ "token":"", "page_id":"keyboard_subscription_panel" }` |
|
||||||
|
|
||||||
|
### B2)点击事件(键盘工具栏 / 功能面板 / 订阅面板)
|
||||||
|
|
||||||
|
| 注释 | 事件类型 | 事件名称 | page_id | element_id | iOS 对应控件/方法 | Android 对应控件 | 触发时机 | 事件参数(示例) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 点击键盘顶部工具栏(index=0 打开功能面板) | click | click_keyboard_toolbar_action | keyboard_main_panel | toolbar_action | KBKeyBoardMainViewDelegate `didTapToolActionAtIndex:` | Android 自定义 | 点击工具栏 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"toolbar_action", "index":0 }` |
|
||||||
|
| 点击键盘设置按钮 | click | click_keyboard_settings_btn | keyboard_main_panel | settings_btn | `keyBoardMainViewDidTapSettings:` | Android 自定义 | 点击设置 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"settings_btn" }` |
|
||||||
|
| 点击设置页返回 | click | click_keyboard_settings_back_btn | keyboard_settings | back_btn | KeyboardViewController `onTapSettingsBack` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_settings", "element_id":"back_btn" }` |
|
||||||
|
| 点击撤销删除 | click | click_keyboard_undo_btn | keyboard_main_panel | undo_btn | `keyBoardMainViewDidTapUndo:` | Android 自定义 | 点击撤销 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"undo_btn" }` |
|
||||||
|
| 点击表情面板搜索 | click | click_keyboard_emoji_search_btn | keyboard_main_panel | emoji_search_btn | `keyBoardMainViewDidTapEmojiSearch:` | Android 自定义 | 点击搜索 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"emoji_search_btn" }` |
|
||||||
|
| 点击联想词条 | click | click_keyboard_suggestion_item | keyboard_main_panel | suggestion_item | `didSelectSuggestion:` | Android 自定义 | 点击候选 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"suggestion_item", "index":0 }` |
|
||||||
|
| 功能面板点击“粘贴” | click | click_keyboard_function_paste_btn | keyboard_function_panel | paste_btn | KBFunctionView `onTapPaste` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"paste_btn" }` |
|
||||||
|
| 功能面板点击“删除” | click | click_keyboard_function_delete_btn | keyboard_function_panel | delete_btn | KBFunctionView `onTapDelete` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"delete_btn" }` |
|
||||||
|
| 功能面板点击“清空” | click | click_keyboard_function_clear_btn | keyboard_function_panel | clear_btn | KBFunctionView `onTapClear` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"clear_btn" }` |
|
||||||
|
| 功能面板点击“发送” | click | click_keyboard_function_send_btn | keyboard_function_panel | send_btn | KBFunctionView `onTapSend` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"send_btn" }` |
|
||||||
|
| 功能面板点击“人设/标签”条目 | click | click_keyboard_function_tag_item | keyboard_function_panel | renshe_item | KBFunctionTagListView `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"renshe_item", "index":0, "id":456, "name":"" }` |
|
||||||
|
| 功能面板右侧点击“登录/充值”入口(未登录走登录) | click | click_keyboard_function_right_action | keyboard_function_panel | right_action | KeyboardViewController `didRightTapToolActionAtIndex:` | Android 自定义 | 点击右侧入口 | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"right_action", "action":"login_or_recharge" }` |
|
||||||
|
| 订阅面板点击关闭 | click | click_keyboard_subscription_close_btn | keyboard_subscription_panel | close_btn | `subscriptionViewDidTapClose:` | Android 自定义 | 点击关闭 | `{ "token":"", "page_id":"keyboard_subscription_panel", "element_id":"close_btn" }` |
|
||||||
|
| 订阅面板点击购买某商品 | click | click_keyboard_subscription_product_btn | keyboard_subscription_panel | product_btn | `didTapPurchaseForProduct:` | Android 自定义 | 点击购买 | `{ "token":"", "page_id":"keyboard_subscription_panel", "element_id":"product_btn", "product_id":"", "index":0 }` |
|
||||||
BIN
KBMaiPointEventTable.xlsx
Normal file
1
Podfile
@@ -29,6 +29,7 @@ target 'CustomKeyboard' do
|
|||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
pod 'AFNetworking','4.0.1'
|
pod 'AFNetworking','4.0.1'
|
||||||
|
pod 'SDWebImage', '5.21.1'
|
||||||
|
|
||||||
pod 'Masonry', '1.1.0'
|
pod 'Masonry', '1.1.0'
|
||||||
pod 'MBProgressHUD', '1.2.0'
|
pod 'MBProgressHUD', '1.2.0'
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ SPEC CHECKSUMS:
|
|||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||||
|
|
||||||
PODFILE CHECKSUM: acf7541bd40dd969fa4950d6c000005b2889c85b
|
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
2
Pods/Manifest.lock
generated
@@ -96,6 +96,6 @@ SPEC CHECKSUMS:
|
|||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||||
|
|
||||||
PODFILE CHECKSUM: acf7541bd40dd969fa4950d6c000005b2889c85b
|
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
1922
Pods/Pods.xcodeproj/project.pbxproj
generated
@@ -104,6 +104,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
## SDWebImage
|
||||||
|
|
||||||
|
Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## SSZipArchive
|
## SSZipArchive
|
||||||
|
|
||||||
Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
||||||
|
|||||||
@@ -145,6 +145,36 @@ THE SOFTWARE.</string>
|
|||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
<string>PSGroupSpecifier</string>
|
<string>PSGroupSpecifier</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>FooterText</key>
|
||||||
|
<string>Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
</string>
|
||||||
|
<key>License</key>
|
||||||
|
<string>MIT</string>
|
||||||
|
<key>Title</key>
|
||||||
|
<string>SDWebImage</string>
|
||||||
|
<key>Type</key>
|
||||||
|
<string>PSGroupSpecifier</string>
|
||||||
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>FooterText</key>
|
<key>FooterText</key>
|
||||||
<string>Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
<string>Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
||||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "ImageIO" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SDWebImage" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
||||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "ImageIO" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SDWebImage" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
APPLICATION_EXTENSION_API_ONLY = YES
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
||||||
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
APPLICATION_EXTENSION_API_ONLY = YES
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
||||||
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#define API_UPDATA_INFO @"/user/updateInfo" // 更新用户
|
#define API_UPDATA_INFO @"/user/updateInfo" // 更新用户
|
||||||
|
|
||||||
#define KB_API_USER_DETAIL @"/user/detail" // 用户详情
|
#define KB_API_USER_DETAIL @"/user/detail" // 用户详情
|
||||||
|
#define API_USER_INVITE_CODE @"/user/inviteCode" // 查询邀请码
|
||||||
#define API_CHARACTER_LIST @"/character/list" // 排行榜角色列表(综合)
|
#define API_CHARACTER_LIST @"/character/list" // 排行榜角色列表(综合)
|
||||||
#define API_NOT_LOGIN_CHARACTER_LIST @"/character/listWithNotLogin" //未登录用户人设列表
|
#define API_NOT_LOGIN_CHARACTER_LIST @"/character/listWithNotLogin" //未登录用户人设列表
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@
|
|||||||
#define API_THEME_DOWNLOAD @"/themes/download" // 主题下载信息
|
#define API_THEME_DOWNLOAD @"/themes/download" // 主题下载信息
|
||||||
#define API_THEME_RECOMMENDED @"/themes/recommended" // 推荐主题列表
|
#define API_THEME_RECOMMENDED @"/themes/recommended" // 推荐主题列表
|
||||||
#define API_THEME_SEARCH @"/themes/search" // 搜索主题(themeName)
|
#define API_THEME_SEARCH @"/themes/search" // 搜索主题(themeName)
|
||||||
|
#define API_USER_THEMES_BATCH_DELETE @"/user-themes/batch-delete" // 批量删除用户主题
|
||||||
|
#define API_THEME_PURCHASE_LIST @"/themes/purchase/list" // 查询主题购买记录
|
||||||
|
#define API_THEME_RESTORE @"/themes/restore" // 恢复已删除的主题
|
||||||
|
#define API_WALLET_TRANSACTIONS @"/wallet/transactions" // 分页查询钱包交易记录
|
||||||
|
|
||||||
/// pay
|
/// pay
|
||||||
#define API_VALIDATE_RECEIPT @"/apple/validate-receipt" // 排行榜标签列表
|
#define API_VALIDATE_RECEIPT @"/apple/validate-receipt" // 排行榜标签列表
|
||||||
@@ -63,7 +68,12 @@
|
|||||||
#define API_SUBSCRIPTION_PRODUCT_LIST @"/products/subscription/list" // 查询订阅商品列表
|
#define API_SUBSCRIPTION_PRODUCT_LIST @"/products/subscription/list" // 查询订阅商品列表
|
||||||
|
|
||||||
/// AI
|
/// AI
|
||||||
#define API_AI_TALK @"/chat/talk" // 排行榜标签列表
|
#define API_AI_TALK @"/chat/talk"
|
||||||
|
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
|
||||||
|
#define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话
|
||||||
|
#define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色
|
||||||
|
#define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径)
|
||||||
|
#define API_AI_SPEECH_TRANSCRIBE @"/speech/transcribe" // 语音转文字
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
/// 键盘 -> 主 App 订阅页预填充数据(用于免二次请求)
|
/// 键盘 -> 主 App 订阅页预填充数据(用于免二次请求)
|
||||||
#define AppGroup_SubscriptionPrefillPayload @"AppGroup_SubscriptionPrefillPayload"
|
#define AppGroup_SubscriptionPrefillPayload @"AppGroup_SubscriptionPrefillPayload"
|
||||||
|
|
||||||
|
/// 用户头像 URL(主 App 写入,键盘扩展读取)
|
||||||
|
#define AppGroup_UserAvatarURL @"AppGroup_UserAvatarURL"
|
||||||
|
|
||||||
/// 皮肤图标加载模式:
|
/// 皮肤图标加载模式:
|
||||||
/// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon")
|
/// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon")
|
||||||
/// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a")
|
/// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a")
|
||||||
@@ -38,7 +41,8 @@
|
|||||||
// 基础baseUrl
|
// 基础baseUrl
|
||||||
#ifndef KB_BASE_URL
|
#ifndef KB_BASE_URL
|
||||||
//#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/"
|
//#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/"
|
||||||
#define KB_BASE_URL @"http://192.168.2.21:7529/api"
|
//#define KB_BASE_URL @"http://192.168.2.22:7529/api"
|
||||||
|
#define KB_BASE_URL @"https://devcallback.loveamorkey.com/api"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#import "KBFont.h"
|
#import "KBFont.h"
|
||||||
@@ -87,7 +91,8 @@
|
|||||||
|
|
||||||
#if __OBJC__
|
#if __OBJC__
|
||||||
static inline CGFloat KBScreenWidth(void) {
|
static inline CGFloat KBScreenWidth(void) {
|
||||||
return [UIScreen mainScreen].bounds.size.width;
|
CGSize size = [UIScreen mainScreen].bounds.size;
|
||||||
|
return MIN(size.width, size.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
static inline CGFloat KBScaleFactor(void) {
|
static inline CGFloat KBScaleFactor(void) {
|
||||||
|
|||||||
20
Shared/KBLog.h
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// KBLog.h
|
||||||
|
// Shared debug logging macro (App + Extension)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#ifndef KBLOG
|
||||||
|
// 调试专用日志(DEBUG 打印,RELEASE 不打印)。尽量显眼,包含函数与行号。
|
||||||
|
#if DEBUG
|
||||||
|
#define KBLOG(fmt, ...) do { \
|
||||||
|
NSString *kb_msg__ = [NSString stringWithFormat:(fmt), ##__VA_ARGS__]; \
|
||||||
|
NSString *kb_full_msg__ = [NSString stringWithFormat:@"\n==============================[KB DEBUG]==============================\n[Function] %s\n[Line] %d\n%@\n=====================================================================\n", __PRETTY_FUNCTION__, __LINE__, kb_msg__]; \
|
||||||
|
fprintf(stderr, "%s", kb_full_msg__.UTF8String); \
|
||||||
|
} while(0)
|
||||||
|
#else
|
||||||
|
#define KBLOG(...)
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
87
Shared/KBMaiPointReporter.h
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// KBMaiPointReporter.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
#ifndef KB_MAI_POINT_BASE_URL
|
||||||
|
#define KB_MAI_POINT_BASE_URL @"http://192.168.2.21:35310/api"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef KB_MAI_POINT_PATH_NEW_ACCOUNT
|
||||||
|
#define KB_MAI_POINT_PATH_NEW_ACCOUNT @"/newAccount"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef KB_MAI_POINT_PATH_GENERIC_DATA
|
||||||
|
#define KB_MAI_POINT_PATH_GENERIC_DATA @"/genericData"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
extern NSString * const KBMaiPointErrorDomain;
|
||||||
|
extern NSString * const KBMaiPointEventTypePageExposure;
|
||||||
|
extern NSString * const KBMaiPointEventTypeClick;
|
||||||
|
|
||||||
|
typedef void (^KBMaiPointReportCompletion)(BOOL success, NSError * _Nullable error);
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBMaiPointGenericReportType) {
|
||||||
|
/// 未知/默认类型(按需扩展,具体含义以服务端约定为准)
|
||||||
|
KBMaiPointGenericReportTypeUnknown = 0,
|
||||||
|
/// 点击
|
||||||
|
KBMaiPointGenericReportTypeClick = 1,
|
||||||
|
/// 曝光
|
||||||
|
KBMaiPointGenericReportTypeExposure = 2,
|
||||||
|
/// 页面/进入
|
||||||
|
KBMaiPointGenericReportTypePage = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Lightweight reporter for Mai point tracking. Safe for app + extension.
|
||||||
|
@interface KBMaiPointReporter : NSObject
|
||||||
|
|
||||||
|
+ (instancetype)sharedReporter;
|
||||||
|
|
||||||
|
/// 统一埋点:POST /genericData
|
||||||
|
/// - eventType: 建议取值 `page_exposure` / `click`
|
||||||
|
/// - eventName: 统一事件名(如 enter_xxx / click_xxx)
|
||||||
|
/// - value: 事件参数字典(内部会自动注入 token;无 token 时为 @"")
|
||||||
|
- (void)reportEventType:(NSString *)eventType
|
||||||
|
eventName:(NSString *)eventName
|
||||||
|
value:(nullable NSDictionary *)value
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
/// 页面曝光快捷方法:内部会补齐 page_id
|
||||||
|
- (void)reportPageExposureWithEventName:(NSString *)eventName
|
||||||
|
pageId:(NSString *)pageId
|
||||||
|
extra:(nullable NSDictionary *)extra
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
/// 点击快捷方法:内部会补齐 page_id / element_id
|
||||||
|
- (void)reportClickWithEventName:(NSString *)eventName
|
||||||
|
pageId:(NSString *)pageId
|
||||||
|
elementId:(NSString *)elementId
|
||||||
|
extra:(nullable NSDictionary *)extra
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
/// POST /newAccount with type + account.
|
||||||
|
- (void)reportNewAccountWithType:(NSString *)type
|
||||||
|
account:(nullable NSString *)account
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
//- (void)reportGenericDataWithEvent:(NSString *)event
|
||||||
|
// account:(nullable NSString *)account
|
||||||
|
// completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
/// POST /genericData with type + event + account.
|
||||||
|
- (void)reportGenericDataWithEventType:(KBMaiPointGenericReportType)type
|
||||||
|
account:(nullable NSString *)account
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
/// Generic POST for future endpoints.
|
||||||
|
- (void)postPath:(NSString *)path
|
||||||
|
parameters:(NSDictionary *)parameters
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
399
Shared/KBMaiPointReporter.m
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
//
|
||||||
|
// KBMaiPointReporter.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBMaiPointReporter.h"
|
||||||
|
#import "KBLog.h"
|
||||||
|
#import "KBAuthManager.h"
|
||||||
|
#if __has_include(<UIKit/UIKit.h>)
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#import <objc/runtime.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
NSString * const KBMaiPointErrorDomain = @"KBMaiPointErrorDomain";
|
||||||
|
NSString * const KBMaiPointEventTypePageExposure = @"page_exposure";
|
||||||
|
NSString * const KBMaiPointEventTypeClick = @"click";
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static void KBMaiPoint_DebugLogURL(NSURLRequest *request) {
|
||||||
|
NSString *url = request.URL.absoluteString ?: @"";
|
||||||
|
KBLOG(@"🍃[KBMaiPointReporter] url=%@", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void KBMaiPoint_DebugLogError(NSURLResponse *response, NSError *error) {
|
||||||
|
if (error) {
|
||||||
|
NSString *msg = error.localizedDescription ?: @"(no description)";
|
||||||
|
KBLOG(@"🍃[KBMaiPointReporter] error=%@ domain=%@ code=%ld", msg, error.domain ?: @"", (long)error.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([response isKindOfClass:NSHTTPURLResponse.class]) {
|
||||||
|
NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode;
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
KBLOG(@"🍃[KBMaiPointReporter] status=HTTP_%ld", (long)statusCode);
|
||||||
|
} else {
|
||||||
|
KBLOG(@"🍃[KBMaiPointReporter] error=HTTP_%ld", (long)statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@implementation KBMaiPointReporter
|
||||||
|
|
||||||
|
+ (instancetype)sharedReporter {
|
||||||
|
static KBMaiPointReporter *reporter = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
reporter = [[KBMaiPointReporter alloc] init];
|
||||||
|
});
|
||||||
|
return reporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_trimmedStringOrEmpty:(NSString * _Nullable)string {
|
||||||
|
NSString *value = [string isKindOfClass:[NSString class]] ? string : @"";
|
||||||
|
return [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_currentTokenOrEmpty {
|
||||||
|
NSString *t = [KBAuthManager shared].current.accessToken;
|
||||||
|
return [self kb_trimmedStringOrEmpty:t];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)reportEventType:(NSString *)eventType
|
||||||
|
eventName:(NSString *)eventName
|
||||||
|
value:(NSDictionary * _Nullable)value
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
NSString *trimmedType = [self kb_trimmedStringOrEmpty:eventType];
|
||||||
|
NSString *trimmedName = [self kb_trimmedStringOrEmpty:eventName];
|
||||||
|
if (trimmedType.length == 0 || trimmedName.length == 0) {
|
||||||
|
NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:-1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}];
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(NO, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary *val = [NSMutableDictionary dictionary];
|
||||||
|
if ([value isKindOfClass:[NSDictionary class]] && value.count > 0) {
|
||||||
|
[val addEntriesFromDictionary:value];
|
||||||
|
}
|
||||||
|
if (![val[@"token"] isKindOfClass:NSString.class]) {
|
||||||
|
val[@"token"] = [self kb_currentTokenOrEmpty];
|
||||||
|
} else {
|
||||||
|
// 若外部传了 token,也做一次兜底(nil -> @"" / trim)
|
||||||
|
val[@"token"] = [self kb_trimmedStringOrEmpty:val[@"token"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *params = @{
|
||||||
|
// 字段兼容:后端若用 eventId 统计,也能直接用 eventName
|
||||||
|
@"eventType": trimmedType,
|
||||||
|
@"eventName": trimmedName,
|
||||||
|
@"eventId": trimmedName,
|
||||||
|
@"value": val.copy
|
||||||
|
};
|
||||||
|
[self postPath:KB_MAI_POINT_PATH_GENERIC_DATA parameters:params completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)reportPageExposureWithEventName:(NSString *)eventName
|
||||||
|
pageId:(NSString *)pageId
|
||||||
|
extra:(NSDictionary * _Nullable)extra
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
NSString *pid = [self kb_trimmedStringOrEmpty:pageId];
|
||||||
|
NSMutableDictionary *val = [NSMutableDictionary dictionary];
|
||||||
|
if (pid.length > 0) {
|
||||||
|
val[@"page_id"] = pid;
|
||||||
|
}
|
||||||
|
if ([extra isKindOfClass:[NSDictionary class]] && extra.count > 0) {
|
||||||
|
[val addEntriesFromDictionary:extra];
|
||||||
|
}
|
||||||
|
[self reportEventType:KBMaiPointEventTypePageExposure eventName:eventName value:val completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)reportClickWithEventName:(NSString *)eventName
|
||||||
|
pageId:(NSString *)pageId
|
||||||
|
elementId:(NSString *)elementId
|
||||||
|
extra:(NSDictionary * _Nullable)extra
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
NSString *pid = [self kb_trimmedStringOrEmpty:pageId];
|
||||||
|
NSString *eid = [self kb_trimmedStringOrEmpty:elementId];
|
||||||
|
NSMutableDictionary *val = [NSMutableDictionary dictionary];
|
||||||
|
if (pid.length > 0) {
|
||||||
|
val[@"page_id"] = pid;
|
||||||
|
}
|
||||||
|
if (eid.length > 0) {
|
||||||
|
val[@"element_id"] = eid;
|
||||||
|
}
|
||||||
|
if ([extra isKindOfClass:[NSDictionary class]] && extra.count > 0) {
|
||||||
|
[val addEntriesFromDictionary:extra];
|
||||||
|
}
|
||||||
|
[self reportEventType:KBMaiPointEventTypeClick eventName:eventName value:val completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)reportNewAccountWithType:(NSString *)type
|
||||||
|
account:(NSString * _Nullable)account
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
NSString *trimmedType = [self kb_trimmedStringOrEmpty:type];
|
||||||
|
NSString *trimmedAccount = [self kb_trimmedStringOrEmpty:account];
|
||||||
|
if (trimmedType.length == 0) {
|
||||||
|
NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:-1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}];
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(NO, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *params = @{
|
||||||
|
@"type": trimmedType,
|
||||||
|
@"account": trimmedAccount ?: @"",
|
||||||
|
@"token": [self kb_currentTokenOrEmpty]
|
||||||
|
};
|
||||||
|
[self postPath:KB_MAI_POINT_PATH_NEW_ACCOUNT parameters:params completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
//- (void)reportGenericDataWithEvent:(NSString *)event
|
||||||
|
// account:(NSString * _Nullable)account
|
||||||
|
// completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
// [self reportGenericDataWithType:KBMaiPointGenericReportTypeUnknown
|
||||||
|
// event:event
|
||||||
|
// account:account
|
||||||
|
// completion:completion];
|
||||||
|
//}
|
||||||
|
|
||||||
|
- (void)reportGenericDataWithEventType:(KBMaiPointGenericReportType)eventType
|
||||||
|
account:(nullable NSString *)account
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion{
|
||||||
|
// 兼容旧接口:没有 eventName 时给一个默认值,避免调用方崩溃
|
||||||
|
NSString *typeStr = @"unknown";
|
||||||
|
switch (eventType) {
|
||||||
|
case KBMaiPointGenericReportTypeClick: typeStr = KBMaiPointEventTypeClick; break;
|
||||||
|
case KBMaiPointGenericReportTypeExposure: typeStr = @"exposure"; break;
|
||||||
|
case KBMaiPointGenericReportTypePage: typeStr = KBMaiPointEventTypePageExposure; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
NSMutableDictionary *val = [NSMutableDictionary dictionary];
|
||||||
|
NSString *trimmedAccount = [self kb_trimmedStringOrEmpty:account];
|
||||||
|
if (trimmedAccount.length > 0) {
|
||||||
|
val[@"account"] = trimmedAccount;
|
||||||
|
}
|
||||||
|
[self reportEventType:typeStr eventName:@"generic_event" value:val completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)postPath:(NSString *)path
|
||||||
|
parameters:(NSDictionary *)parameters
|
||||||
|
completion:(KBMaiPointReportCompletion _Nullable)completion {
|
||||||
|
if (path.length == 0 || ![parameters isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:-1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid parameter"}];
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(NO, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *safePath = [path hasPrefix:@"/"] ? path : [@"/" stringByAppendingString:path];
|
||||||
|
NSString *urlString = [NSString stringWithFormat:@"%@%@", KB_MAI_POINT_BASE_URL, safePath];
|
||||||
|
NSURL *url = [NSURL URLWithString:urlString];
|
||||||
|
if (!url) {
|
||||||
|
NSError *error = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:-2
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid URL"}];
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(NO, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *jsonError = nil;
|
||||||
|
NSData *body = [NSJSONSerialization dataWithJSONObject:parameters options:0 error:&jsonError];
|
||||||
|
if (jsonError) {
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(NO, jsonError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||||
|
request.HTTPMethod = @"POST";
|
||||||
|
request.timeoutInterval = 10.0;
|
||||||
|
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||||
|
request.HTTPBody = body;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
KBMaiPoint_DebugLogURL(request);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||||
|
config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||||
|
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
|
||||||
|
NSURLSessionDataTask *task = [session dataTaskWithRequest:request
|
||||||
|
completionHandler:^(NSData *data,
|
||||||
|
NSURLResponse *response,
|
||||||
|
NSError *error) {
|
||||||
|
BOOL success = NO;
|
||||||
|
NSError *finalError = error;
|
||||||
|
|
||||||
|
if (!finalError) {
|
||||||
|
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||||
|
NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode;
|
||||||
|
success = (statusCode >= 200 && statusCode < 300);
|
||||||
|
if (!success) {
|
||||||
|
finalError = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:statusCode
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid response"}];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalError = [NSError errorWithDomain:KBMaiPointErrorDomain
|
||||||
|
code:-3
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Invalid response"}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
KBMaiPoint_DebugLogError(response, finalError);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (completion) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
completion(success, finalError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
[task resume];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
#if __has_include(<UIKit/UIKit.h>)
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// 自动页面曝光(viewDidAppear)
|
||||||
|
// 说明:仅对“在表里登记过的 VC”生效;其它 VC 不处理。
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
static NSDictionary<NSString *, NSDictionary *> *KBMaiPoint_PageExposureMap(void) {
|
||||||
|
static NSDictionary<NSString *, NSDictionary *> *m;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
m = @{
|
||||||
|
// 主工程
|
||||||
|
@"HomeMainVC": @{@"event_name": @"enter_home_main", @"page_id": @"home_main"},
|
||||||
|
@"HomeVC": @{@"event_name": @"enter_home", @"page_id": @"home"},
|
||||||
|
@"HomeHotVC": @{@"event_name": @"enter_home_hot", @"page_id": @"home_hot"},
|
||||||
|
@"HomeRankVC": @{@"event_name": @"enter_home_rank", @"page_id": @"home_rank"},
|
||||||
|
@"HomeRankContentVC": @{@"event_name": @"enter_home_rank_content", @"page_id": @"home_rank_content"},
|
||||||
|
@"HomeSheetVC": @{@"event_name": @"enter_home_sheet", @"page_id": @"home_sheet"},
|
||||||
|
@"KBCommunityVC": @{@"event_name": @"enter_community", @"page_id": @"community"},
|
||||||
|
@"KBSearchVC": @{@"event_name": @"enter_search", @"page_id": @"search"},
|
||||||
|
@"KBSearchResultVC": @{@"event_name": @"enter_search_result", @"page_id": @"search_result"},
|
||||||
|
@"KBShopVC": @{@"event_name": @"enter_shop", @"page_id": @"shop"},
|
||||||
|
@"KBShopItemVC": @{@"event_name": @"enter_shop_item_list", @"page_id": @"shop_item_list"},
|
||||||
|
@"KBSkinDetailVC": @{@"event_name": @"enter_skin_detail", @"page_id": @"skin_detail"},
|
||||||
|
@"MyVC": @{@"event_name": @"enter_my", @"page_id": @"my"},
|
||||||
|
@"MySkinVC": @{@"event_name": @"enter_my_skin", @"page_id": @"my_skin"},
|
||||||
|
@"KBMyKeyBoardVC": @{@"event_name": @"enter_my_keyboard", @"page_id": @"my_keyboard"},
|
||||||
|
@"KBPersonInfoVC": @{@"event_name": @"enter_person_info", @"page_id": @"person_info"},
|
||||||
|
@"KBFeedBackVC": @{@"event_name": @"enter_feedback", @"page_id": @"feedback"},
|
||||||
|
@"KBNoticeVC": @{@"event_name": @"enter_notice", @"page_id": @"notice"},
|
||||||
|
@"KBConsumptionRecordVC": @{@"event_name": @"enter_consumption_record", @"page_id": @"consumption_record"},
|
||||||
|
@"KBVipPay": @{@"event_name": @"enter_vip_pay", @"page_id": @"vip_pay"},
|
||||||
|
@"KBJfPay": @{@"event_name": @"enter_points_recharge", @"page_id": @"points_recharge"},
|
||||||
|
@"KBLoginVC": @{@"event_name": @"enter_login", @"page_id": @"login"},
|
||||||
|
@"KBEmailLoginVC": @{@"event_name": @"enter_login_email", @"page_id": @"login_email"},
|
||||||
|
@"KBEmailRegistVC": @{@"event_name": @"enter_register_email", @"page_id": @"register_email"},
|
||||||
|
@"KBRegistVerEmailVC": @{@"event_name": @"enter_register_verify_email", @"page_id": @"register_verify_email"},
|
||||||
|
@"KBForgetPwdVC": @{@"event_name": @"enter_forgot_password_email", @"page_id": @"forgot_password_email"},
|
||||||
|
@"KBForgetVerPwdVC": @{@"event_name": @"enter_forgot_password_verify", @"page_id": @"forgot_password_verify"},
|
||||||
|
@"KBForgetPwdNewPwdVC": @{@"event_name": @"enter_forgot_password_newpwd", @"page_id": @"forgot_password_newpwd"},
|
||||||
|
@"KBPermissionViewController": @{@"event_name": @"enter_keyboard_permission_guide", @"page_id": @"keyboard_permission_guide"},
|
||||||
|
@"KBGuideVC": @{@"event_name": @"enter_guide", @"page_id": @"guide"},
|
||||||
|
@"KBSexSelVC": @{@"event_name": @"enter_sex_select", @"page_id": @"sex_select"},
|
||||||
|
@"KBWebViewViewController": @{@"event_name": @"enter_webview", @"page_id": @"webview"},
|
||||||
|
|
||||||
|
// 键盘扩展
|
||||||
|
@"KeyboardViewController": @{@"event_name": @"enter_keyboard", @"page_id": @"keyboard"},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void KBMaiPoint_SwizzleInstanceMethod(Class cls, SEL originalSel, SEL swizzledSel) {
|
||||||
|
Method original = class_getInstanceMethod(cls, originalSel);
|
||||||
|
Method swizzled = class_getInstanceMethod(cls, swizzledSel);
|
||||||
|
if (!original || !swizzled) return;
|
||||||
|
BOOL added = class_addMethod(cls,
|
||||||
|
originalSel,
|
||||||
|
method_getImplementation(swizzled),
|
||||||
|
method_getTypeEncoding(swizzled));
|
||||||
|
if (added) {
|
||||||
|
class_replaceMethod(cls,
|
||||||
|
swizzledSel,
|
||||||
|
method_getImplementation(original),
|
||||||
|
method_getTypeEncoding(original));
|
||||||
|
} else {
|
||||||
|
method_exchangeImplementations(original, swizzled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@interface UIViewController (KBMaiPointAutoReport)
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation UIViewController (KBMaiPointAutoReport)
|
||||||
|
|
||||||
|
+ (void)load {
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
KBMaiPoint_SwizzleInstanceMethod(self, @selector(viewDidAppear:), @selector(kb_maipoint_viewDidAppear:));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_maipoint_viewDidAppear:(BOOL)animated {
|
||||||
|
[self kb_maipoint_viewDidAppear:animated];
|
||||||
|
|
||||||
|
NSString *clsName = NSStringFromClass(self.class);
|
||||||
|
NSDictionary *cfg = KBMaiPoint_PageExposureMap()[clsName];
|
||||||
|
if (![cfg isKindOfClass:NSDictionary.class]) { return; }
|
||||||
|
|
||||||
|
NSString *eventName = cfg[@"event_name"];
|
||||||
|
NSString *pageId = cfg[@"page_id"];
|
||||||
|
if (![eventName isKindOfClass:NSString.class] || ![pageId isKindOfClass:NSString.class]) { return; }
|
||||||
|
|
||||||
|
// 少数页面带额外参数(尽量不取敏感信息)
|
||||||
|
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||||
|
if ([clsName isEqualToString:@"KBSkinDetailVC"]) {
|
||||||
|
id themeId = nil;
|
||||||
|
@try { themeId = [self valueForKey:@"themeId"]; } @catch (__unused NSException *e) { themeId = nil; }
|
||||||
|
if ([themeId isKindOfClass:NSString.class] && ((NSString *)themeId).length > 0) {
|
||||||
|
extra[@"theme_id"] = themeId;
|
||||||
|
}
|
||||||
|
} else if ([clsName isEqualToString:@"KBWebViewViewController"]) {
|
||||||
|
id url = nil;
|
||||||
|
@try { url = [self valueForKey:@"url"]; } @catch (__unused NSException *e) { url = nil; }
|
||||||
|
if ([url isKindOfClass:NSString.class] && ((NSString *)url).length > 0) {
|
||||||
|
extra[@"url"] = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:eventName
|
||||||
|
pageId:pageId
|
||||||
|
extra:(extra.count > 0 ? extra.copy : nil)
|
||||||
|
completion:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -24,6 +24,7 @@ static NSString * const kKBSkinPendingKindKey = @"kind";
|
|||||||
static NSString * const kKBSkinPendingTimestampKey = @"timestamp";
|
static NSString * const kKBSkinPendingTimestampKey = @"timestamp";
|
||||||
static NSString * const kKBSkinPendingIconShortKey = @"iconShortNames";
|
static NSString * const kKBSkinPendingIconShortKey = @"iconShortNames";
|
||||||
static NSString * const kKBSkinMetadataFileName = @"metadata.plist";
|
static NSString * const kKBSkinMetadataFileName = @"metadata.plist";
|
||||||
|
static NSString * const kKBSkinForceDownloadKey = @"force_download";
|
||||||
static NSString * const kKBSkinMetadataNameKey = @"name";
|
static NSString * const kKBSkinMetadataNameKey = @"name";
|
||||||
static NSString * const kKBSkinMetadataPreviewKey = @"preview";
|
static NSString * const kKBSkinMetadataPreviewKey = @"preview";
|
||||||
static NSString * const kKBSkinMetadataZipKey = @"zip_url";
|
static NSString * const kKBSkinMetadataZipKey = @"zip_url";
|
||||||
@@ -220,6 +221,17 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
NSString *skinId = skinJSON[@"id"] ?: @"remote";
|
NSString *skinId = skinJSON[@"id"] ?: @"remote";
|
||||||
NSString *name = skinJSON[@"name"] ?: skinId;
|
NSString *name = skinJSON[@"name"] ?: skinId;
|
||||||
NSString *zipURL = skinJSON[@"zip_url"] ?: @"";
|
NSString *zipURL = skinJSON[@"zip_url"] ?: @"";
|
||||||
|
BOOL forceDownload = NO;
|
||||||
|
id forceValue = skinJSON[kKBSkinForceDownloadKey];
|
||||||
|
if ([forceValue respondsToSelector:@selector(boolValue)]) {
|
||||||
|
forceDownload = [forceValue boolValue];
|
||||||
|
}
|
||||||
|
id serverIcons = skinJSON[@"key_icons"];
|
||||||
|
NSUInteger serverIconCount = [serverIcons isKindOfClass:NSDictionary.class] ? ((NSDictionary *)serverIcons).count : 0;
|
||||||
|
NSLog(@"[SkinBridge] request id=%@ zip=%@ force=%d key_icons_class=%@ count=%tu",
|
||||||
|
skinId, zipURL, forceDownload,
|
||||||
|
serverIcons ? NSStringFromClass([serverIcons class]) : @"nil",
|
||||||
|
serverIconCount);
|
||||||
|
|
||||||
// key_icons 可选:
|
// key_icons 可选:
|
||||||
// - 若后端提供 key_icons,则优先使用服务端映射;
|
// - 若后端提供 key_icons,则优先使用服务端映射;
|
||||||
@@ -230,6 +242,9 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
} else {
|
} else {
|
||||||
iconShortNames = [self defaultIconShortNames];
|
iconShortNames = [self defaultIconShortNames];
|
||||||
}
|
}
|
||||||
|
NSLog(@"[SkinBridge] iconShortNames source=%@ count=%tu",
|
||||||
|
[skinJSON[@"key_icons"] isKindOfClass:NSDictionary.class] ? @"server" : @"default",
|
||||||
|
iconShortNames.count);
|
||||||
|
|
||||||
NSFileManager *fm = [NSFileManager defaultManager];
|
NSFileManager *fm = [NSFileManager defaultManager];
|
||||||
NSURL *containerURL = [fm containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
NSURL *containerURL = [fm containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||||
@@ -256,8 +271,24 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
NSArray *contents = hasIconsDir ? [fm contentsOfDirectoryAtPath:iconsDir error:NULL] : nil;
|
NSArray *contents = hasIconsDir ? [fm contentsOfDirectoryAtPath:iconsDir error:NULL] : nil;
|
||||||
// 标记在本次请求发起前是否已经有缓存资源(用于“有缓存但本次下载失败”时仍允许切换皮肤)。
|
// 标记在本次请求发起前是否已经有缓存资源(用于“有缓存但本次下载失败”时仍允许切换皮肤)。
|
||||||
BOOL hasCachedAssets = (contents.count > 0);
|
BOOL hasCachedAssets = (contents.count > 0);
|
||||||
|
NSLog(@"[SkinBridge] assets cache id=%@ cached=%d iconsDir=%@", skinId, hasCachedAssets, iconsDir);
|
||||||
|
|
||||||
NSString *bgPath = [skinRoot stringByAppendingPathComponent:@"background.png"];
|
NSString *bgPath = [skinRoot stringByAppendingPathComponent:@"background.png"];
|
||||||
|
BOOL useTempRoot = forceDownload;
|
||||||
|
NSString *tempToken = nil;
|
||||||
|
NSString *workingRoot = skinRoot;
|
||||||
|
NSString *workingIconsDir = iconsDir;
|
||||||
|
NSString *workingBgPath = bgPath;
|
||||||
|
if (useTempRoot) {
|
||||||
|
tempToken = [NSString stringWithFormat:@"%lld", (long long)([[NSDate date] timeIntervalSince1970] * 1000)];
|
||||||
|
NSString *tmpName = [NSString stringWithFormat:@"%@__tmp_%@", skinId, tempToken];
|
||||||
|
workingRoot = [skinsRoot stringByAppendingPathComponent:tmpName];
|
||||||
|
workingIconsDir = [workingRoot stringByAppendingPathComponent:@"icons"];
|
||||||
|
workingBgPath = [workingRoot stringByAppendingPathComponent:@"background.png"];
|
||||||
|
[fm removeItemAtPath:workingRoot error:nil];
|
||||||
|
}
|
||||||
|
NSLog(@"⬇️[SkinBridge] request id=%@ force=%d cached=%d zip=%@",
|
||||||
|
skinId, forceDownload, hasCachedAssets, zipURL);
|
||||||
|
|
||||||
dispatch_group_t group = dispatch_group_create();
|
dispatch_group_t group = dispatch_group_create();
|
||||||
__block BOOL zipOK = YES;
|
__block BOOL zipOK = YES;
|
||||||
@@ -265,8 +296,8 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
__block NSError *innerError = nil;
|
__block NSError *innerError = nil;
|
||||||
|
|
||||||
#if __has_include(<SSZipArchive/SSZipArchive.h>)
|
#if __has_include(<SSZipArchive/SSZipArchive.h>)
|
||||||
// 若本地尚未缓存该皮肤资源且提供了 zip_url,则通过网络下载并解压 Zip 包。
|
// 若需要强制下载,或本地尚未缓存该皮肤资源且提供了 zip_url,则下载并解压 Zip 包。
|
||||||
if (!hasCachedAssets && zipURL.length > 0) {
|
if ((forceDownload || !hasCachedAssets) && zipURL.length > 0) {
|
||||||
dispatch_group_enter(group);
|
dispatch_group_enter(group);
|
||||||
|
|
||||||
void (^handleZipData)(NSData *) = ^(NSData *data) {
|
void (^handleZipData)(NSData *) = ^(NSData *data) {
|
||||||
@@ -277,15 +308,17 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
code:KBSkinBridgeErrorZipMissing
|
code:KBSkinBridgeErrorZipMissing
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"Zip data is empty"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"Zip data is empty"}];
|
||||||
}
|
}
|
||||||
|
NSLog(@"❌[SkinBridge] zip data empty id=%@", skinId);
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
NSLog(@"📦[SkinBridge] unzip start id=%@ temp=%d", skinId, useTempRoot);
|
||||||
// 将 Zip 写入临时路径再解压
|
// 将 Zip 写入临时路径再解压
|
||||||
[fm createDirectoryAtPath:skinRoot
|
[fm createDirectoryAtPath:workingRoot
|
||||||
withIntermediateDirectories:YES
|
withIntermediateDirectories:YES
|
||||||
attributes:nil
|
attributes:nil
|
||||||
error:NULL];
|
error:NULL];
|
||||||
NSString *zipPath = [skinRoot stringByAppendingPathComponent:@"skin.zip"];
|
NSString *zipPath = [workingRoot stringByAppendingPathComponent:@"skin.zip"];
|
||||||
if (![data writeToFile:zipPath atomically:YES]) {
|
if (![data writeToFile:zipPath atomically:YES]) {
|
||||||
zipOK = NO;
|
zipOK = NO;
|
||||||
if (!innerError) {
|
if (!innerError) {
|
||||||
@@ -293,13 +326,14 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
code:KBSkinBridgeErrorUnzipFailed
|
code:KBSkinBridgeErrorUnzipFailed
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"Failed to write zip file"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to write zip file"}];
|
||||||
}
|
}
|
||||||
|
NSLog(@"❌[SkinBridge] zip write failed id=%@", skinId);
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSError *unzipError = nil;
|
NSError *unzipError = nil;
|
||||||
BOOL ok = [SSZipArchive unzipFileAtPath:zipPath
|
BOOL ok = [SSZipArchive unzipFileAtPath:zipPath
|
||||||
toDestination:skinRoot
|
toDestination:workingRoot
|
||||||
overwrite:YES
|
overwrite:YES
|
||||||
password:nil
|
password:nil
|
||||||
error:&unzipError];
|
error:&unzipError];
|
||||||
@@ -311,24 +345,22 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
code:KBSkinBridgeErrorUnzipFailed
|
code:KBSkinBridgeErrorUnzipFailed
|
||||||
userInfo:nil];
|
userInfo:nil];
|
||||||
}
|
}
|
||||||
|
NSLog(@"❌[SkinBridge] unzip failed id=%@ error=%@", skinId, unzipError);
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记已成功解压一次(即使 icons 目录结构需要后续整理)。
|
|
||||||
didUnzip = YES;
|
|
||||||
|
|
||||||
// 兼容“额外包一层目录”的压缩结构:
|
// 兼容“额外包一层目录”的压缩结构:
|
||||||
// 若 Skins/<skinId>/icons 为空,但存在 Skins/<skinId>/<子目录>/icons,
|
// 若 Skins/<skinId>/icons 为空,但存在 Skins/<skinId>/<子目录>/icons,
|
||||||
// 则将实际 icons 与 background.png 上移到预期位置。
|
// 则将实际 icons 与 background.png 上移到预期位置。
|
||||||
BOOL isDir2 = NO;
|
BOOL isDir2 = NO;
|
||||||
NSArray *iconsContent = [fm contentsOfDirectoryAtPath:iconsDir error:NULL];
|
NSArray *iconsContent = [fm contentsOfDirectoryAtPath:workingIconsDir error:NULL];
|
||||||
BOOL iconsValid = ([fm fileExistsAtPath:iconsDir isDirectory:&isDir2] && isDir2 && iconsContent.count > 0);
|
BOOL iconsValid = ([fm fileExistsAtPath:workingIconsDir isDirectory:&isDir2] && isDir2 && iconsContent.count > 0);
|
||||||
if (!iconsValid) {
|
if (!iconsValid) {
|
||||||
NSArray<NSString *> *subItems = [fm contentsOfDirectoryAtPath:skinRoot error:NULL];
|
NSArray<NSString *> *subItems = [fm contentsOfDirectoryAtPath:workingRoot error:NULL];
|
||||||
for (NSString *subName in subItems) {
|
for (NSString *subName in subItems) {
|
||||||
if ([subName isEqualToString:@"icons"] || [subName isEqualToString:@"__MACOSX"]) continue;
|
if ([subName isEqualToString:@"icons"] || [subName isEqualToString:@"__MACOSX"]) continue;
|
||||||
NSString *nestedRoot = [skinRoot stringByAppendingPathComponent:subName];
|
NSString *nestedRoot = [workingRoot stringByAppendingPathComponent:subName];
|
||||||
BOOL isDirNested = NO;
|
BOOL isDirNested = NO;
|
||||||
if (![fm fileExistsAtPath:nestedRoot isDirectory:&isDirNested] || !isDirNested) continue;
|
if (![fm fileExistsAtPath:nestedRoot isDirectory:&isDirNested] || !isDirNested) continue;
|
||||||
|
|
||||||
@@ -338,14 +370,14 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
NSArray *nestedFiles = [fm contentsOfDirectoryAtPath:nestedIcons error:NULL];
|
NSArray *nestedFiles = [fm contentsOfDirectoryAtPath:nestedIcons error:NULL];
|
||||||
if (nestedFiles.count > 0) {
|
if (nestedFiles.count > 0) {
|
||||||
// 确保目标 icons 目录存在
|
// 确保目标 icons 目录存在
|
||||||
[fm createDirectoryAtPath:iconsDir
|
[fm createDirectoryAtPath:workingIconsDir
|
||||||
withIntermediateDirectories:YES
|
withIntermediateDirectories:YES
|
||||||
attributes:nil
|
attributes:nil
|
||||||
error:NULL];
|
error:NULL];
|
||||||
// 将 icons 下所有文件上移一层
|
// 将 icons 下所有文件上移一层
|
||||||
for (NSString *fn in nestedFiles) {
|
for (NSString *fn in nestedFiles) {
|
||||||
NSString *from = [nestedIcons stringByAppendingPathComponent:fn];
|
NSString *from = [nestedIcons stringByAppendingPathComponent:fn];
|
||||||
NSString *to = [iconsDir stringByAppendingPathComponent:fn];
|
NSString *to = [workingIconsDir stringByAppendingPathComponent:fn];
|
||||||
[fm removeItemAtPath:to error:nil];
|
[fm removeItemAtPath:to error:nil];
|
||||||
[fm moveItemAtPath:from toPath:to error:nil];
|
[fm moveItemAtPath:from toPath:to error:nil];
|
||||||
}
|
}
|
||||||
@@ -355,20 +387,65 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
// 处理 background.png:若在子目录下存在,则上移到 skinRoot
|
// 处理 background.png:若在子目录下存在,则上移到 skinRoot
|
||||||
NSString *nestedBg = [nestedRoot stringByAppendingPathComponent:@"background.png"];
|
NSString *nestedBg = [nestedRoot stringByAppendingPathComponent:@"background.png"];
|
||||||
if ([fm fileExistsAtPath:nestedBg]) {
|
if ([fm fileExistsAtPath:nestedBg]) {
|
||||||
[fm removeItemAtPath:bgPath error:nil];
|
[fm removeItemAtPath:workingBgPath error:nil];
|
||||||
[fm moveItemAtPath:nestedBg toPath:bgPath error:nil];
|
[fm moveItemAtPath:nestedBg toPath:workingBgPath error:nil];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useTempRoot) {
|
||||||
|
NSString *backupName = [NSString stringWithFormat:@"%@__bak_%@", skinId, (tempToken ?: @"0")];
|
||||||
|
NSString *backupRoot = [skinsRoot stringByAppendingPathComponent:backupName];
|
||||||
|
[fm removeItemAtPath:backupRoot error:nil];
|
||||||
|
NSError *swapError = nil;
|
||||||
|
BOOL movedOld = NO;
|
||||||
|
if ([fm fileExistsAtPath:skinRoot]) {
|
||||||
|
movedOld = [fm moveItemAtPath:skinRoot toPath:backupRoot error:&swapError];
|
||||||
|
if (!movedOld && swapError) {
|
||||||
|
zipOK = NO;
|
||||||
|
if (!innerError) {
|
||||||
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
||||||
|
code:KBSkinBridgeErrorUnzipFailed
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to backup old skin"}];
|
||||||
|
}
|
||||||
|
NSLog(@"❌[SkinBridge] backup failed id=%@ error=%@", skinId, swapError);
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BOOL movedNew = [fm moveItemAtPath:workingRoot toPath:skinRoot error:&swapError];
|
||||||
|
if (!movedNew || swapError) {
|
||||||
|
zipOK = NO;
|
||||||
|
if (!innerError) {
|
||||||
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
||||||
|
code:KBSkinBridgeErrorUnzipFailed
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to replace skin assets"}];
|
||||||
|
}
|
||||||
|
if (movedOld) {
|
||||||
|
[fm moveItemAtPath:backupRoot toPath:skinRoot error:nil];
|
||||||
|
}
|
||||||
|
NSLog(@"❌[SkinBridge] replace failed id=%@ error=%@", skinId, swapError);
|
||||||
|
dispatch_group_leave(group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (movedOld) {
|
||||||
|
[fm removeItemAtPath:backupRoot error:nil];
|
||||||
|
}
|
||||||
|
NSLog(@"🧹[SkinBridge] replaced old skin id=%@", skinId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记已成功解压一次(即使 icons 目录结构需要后续整理)。
|
||||||
|
didUnzip = YES;
|
||||||
|
NSLog(@"✅[SkinBridge] unzip done id=%@", skinId);
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
};
|
};
|
||||||
|
|
||||||
#if __has_include("KBNetworkManager.h")
|
#if __has_include("KBNetworkManager.h")
|
||||||
// 远程下载(http/https)
|
// 远程下载(http/https)
|
||||||
NSLog(@"[SkinBridge] will GET zip: %@", zipURL);
|
NSLog(@"🌐[SkinBridge] will GET zip: %@", zipURL);
|
||||||
[KBHUD show];
|
[KBHUD showWithStatus:@"正在下载..."];
|
||||||
[[KBNetworkManager shared] GETData:zipURL parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
[[KBNetworkManager shared] GETData:zipURL parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||||
NSLog(@"[SkinBridge] GET finished, error = %@", error);
|
NSLog(@"🌐[SkinBridge] GET finished id=%@ error=%@", skinId, error);
|
||||||
if (error || data.length == 0) {
|
if (error || data.length == 0) {
|
||||||
zipOK = NO;
|
zipOK = NO;
|
||||||
if (!innerError) {
|
if (!innerError) {
|
||||||
@@ -399,6 +476,9 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
#endif
|
#endif
|
||||||
|
} else {
|
||||||
|
NSLog(@"ℹ️[SkinBridge] skip download id=%@ force=%d cached=%d zip=%@",
|
||||||
|
skinId, forceDownload, hasCachedAssets, zipURL);
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
zipOK = NO;
|
zipOK = NO;
|
||||||
@@ -411,16 +491,21 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||||
// 若既没有预先存在的缓存资源,也没有在本次流程中成功解压出资源,
|
// 若既没有预先存在的缓存资源,也没有在本次流程中成功解压出资源,
|
||||||
// 说明当前皮肤 B 的资源完全不可用,此时不应覆盖现有皮肤主题。
|
// 说明当前皮肤 B 的资源完全不可用,此时不应覆盖现有皮肤主题。
|
||||||
BOOL hasAssets = (hasCachedAssets || didUnzip);
|
BOOL hasAssets = (didUnzip || (!forceDownload && hasCachedAssets));
|
||||||
|
NSLog(@"[SkinBridge] apply check id=%@ hasAssets=%d didUnzip=%d cached=%d",
|
||||||
|
skinId, hasAssets, didUnzip, hasCachedAssets);
|
||||||
if (!hasAssets) {
|
if (!hasAssets) {
|
||||||
NSError *finalError = innerError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
NSError *finalError = innerError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
||||||
code:KBSkinBridgeErrorZipMissing
|
code:KBSkinBridgeErrorZipMissing
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"Zip resource not available"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"Zip resource not available"}];
|
||||||
|
NSLog(@"❌[SkinBridge] apply aborted id=%@ error=%@", skinId, finalError);
|
||||||
if (completion) completion(NO, finalError);
|
if (completion) completion(NO, finalError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造 key_icons -> App Group 相对路径 映射
|
// 构造 key_icons -> App Group 相对路径 映射(仅保留实际存在的图标)
|
||||||
|
NSString *iconsDirFinal = [skinRoot stringByAppendingPathComponent:@"icons"];
|
||||||
|
__block NSUInteger missingCount = 0;
|
||||||
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
||||||
[iconShortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
|
[iconShortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
|
||||||
if (![shortName isKindOfClass:NSString.class] || shortName.length == 0) return;
|
if (![shortName isKindOfClass:NSString.class] || shortName.length == 0) return;
|
||||||
@@ -429,9 +514,27 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
if (fileName.pathExtension.length == 0) {
|
if (fileName.pathExtension.length == 0) {
|
||||||
fileName = [fileName stringByAppendingPathExtension:@"png"];
|
fileName = [fileName stringByAppendingPathExtension:@"png"];
|
||||||
}
|
}
|
||||||
|
NSString *fullPath = [iconsDirFinal stringByAppendingPathComponent:fileName];
|
||||||
|
if (![fm fileExistsAtPath:fullPath]) {
|
||||||
|
missingCount += 1;
|
||||||
|
if (missingCount <= 5) {
|
||||||
|
NSLog(@"[SkinBridge] icon missing id=%@ short=%@", identifier, fileName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
|
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
|
||||||
iconPathMap[identifier] = relative;
|
iconPathMap[identifier] = relative;
|
||||||
}];
|
}];
|
||||||
|
if (missingCount > 0) {
|
||||||
|
NSLog(@"[SkinBridge] icon missing count=%tu total=%tu", missingCount, iconShortNames.count);
|
||||||
|
}
|
||||||
|
NSLog(@"[SkinBridge] iconPathMap count=%tu shift=%@ shift_upper=%@ backspace=%@ mode_123=%@ return=%@",
|
||||||
|
iconPathMap.count,
|
||||||
|
iconPathMap[@"shift"],
|
||||||
|
iconPathMap[@"shift_upper"],
|
||||||
|
iconPathMap[@"backspace"],
|
||||||
|
iconPathMap[@"mode_123"],
|
||||||
|
iconPathMap[@"return"]);
|
||||||
|
|
||||||
NSMutableDictionary *themeJSON = [skinJSON mutableCopy];
|
NSMutableDictionary *themeJSON = [skinJSON mutableCopy];
|
||||||
themeJSON[@"id"] = skinId;
|
themeJSON[@"id"] = skinId;
|
||||||
@@ -444,6 +547,8 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
// 背景图优先从 Zip 解压出的 background.png 读取
|
// 背景图优先从 Zip 解压出的 background.png 读取
|
||||||
NSData *bgData = [NSData dataWithContentsOfFile:bgPath];
|
NSData *bgData = [NSData dataWithContentsOfFile:bgPath];
|
||||||
BOOL ok = themeOK;
|
BOOL ok = themeOK;
|
||||||
|
NSLog(@"[SkinBridge] theme apply id=%@ themeOK=%d bg=%d",
|
||||||
|
skinId, themeOK, (bgData.length > 0));
|
||||||
if (bgData.length > 0) {
|
if (bgData.length > 0) {
|
||||||
ok = [[KBSkinManager shared] applyImageSkinWithData:bgData skinId:skinId name:name];
|
ok = [[KBSkinManager shared] applyImageSkinWithData:bgData skinId:skinId name:name];
|
||||||
}
|
}
|
||||||
@@ -459,6 +564,10 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
userInfo:nil];
|
userInfo:nil];
|
||||||
}
|
}
|
||||||
if (completion) completion(ok, finalError);
|
if (completion) completion(ok, finalError);
|
||||||
|
NSLog(@"%@ [SkinBridge] apply %@ id=%@",
|
||||||
|
(ok ? @"✅" : @"❌"),
|
||||||
|
(ok ? @"ok" : @"failed"),
|
||||||
|
skinId);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
NSString *preview = [skinJSON[@"preview"] isKindOfClass:NSString.class] ? skinJSON[@"preview"] : nil;
|
NSString *preview = [skinJSON[@"preview"] isKindOfClass:NSString.class] ? skinJSON[@"preview"] : nil;
|
||||||
[self recordInstalledSkinWithId:skinId
|
[self recordInstalledSkinWithId:skinId
|
||||||
@@ -673,6 +782,8 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
shortNames = [self defaultIconShortNames];
|
shortNames = [self defaultIconShortNames];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSString *iconsDirFinal = iconsDir;
|
||||||
|
__block NSUInteger missingCount = 0;
|
||||||
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
||||||
[shortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
|
[shortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
|
||||||
if (identifier.length == 0 || ![shortName isKindOfClass:NSString.class] || shortName.length == 0) return;
|
if (identifier.length == 0 || ![shortName isKindOfClass:NSString.class] || shortName.length == 0) return;
|
||||||
@@ -680,9 +791,20 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
if (fileName.pathExtension.length == 0) {
|
if (fileName.pathExtension.length == 0) {
|
||||||
fileName = [fileName stringByAppendingPathExtension:@"png"];
|
fileName = [fileName stringByAppendingPathExtension:@"png"];
|
||||||
}
|
}
|
||||||
|
NSString *fullPath = [iconsDirFinal stringByAppendingPathComponent:fileName];
|
||||||
|
if (![fm fileExistsAtPath:fullPath]) {
|
||||||
|
missingCount += 1;
|
||||||
|
if (missingCount <= 5) {
|
||||||
|
NSLog(@"[SkinBridge] icon missing(bundle) id=%@ short=%@", identifier, fileName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
|
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
|
||||||
iconPathMap[identifier] = relative;
|
iconPathMap[identifier] = relative;
|
||||||
}];
|
}];
|
||||||
|
if (missingCount > 0) {
|
||||||
|
NSLog(@"[SkinBridge] icon missing(bundle) count=%tu total=%tu", missingCount, shortNames.count);
|
||||||
|
}
|
||||||
|
|
||||||
NSMutableDictionary *themeJSON = [NSMutableDictionary dictionary];
|
NSMutableDictionary *themeJSON = [NSMutableDictionary dictionary];
|
||||||
themeJSON[@"id"] = skinId;
|
themeJSON[@"id"] = skinId;
|
||||||
@@ -766,4 +888,3 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
|||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -152,11 +152,24 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
|||||||
if ([icons isKindOfClass:NSDictionary.class]) {
|
if ([icons isKindOfClass:NSDictionary.class]) {
|
||||||
t.keyIconMap = icons;
|
t.keyIconMap = icons;
|
||||||
}
|
}
|
||||||
|
NSUInteger iconCount = [t.keyIconMap isKindOfClass:NSDictionary.class] ? t.keyIconMap.count : 0;
|
||||||
|
NSUInteger hiddenCount = t.hiddenKeyTextIdentifiers.count;
|
||||||
|
NSLog(@"[SkinManager] applyThemeFromJSON id=%@ name=%@ iconMap=%tu hiddenKeys=%tu",
|
||||||
|
t.skinId, t.name, iconCount, hiddenCount);
|
||||||
|
if (iconCount > 0) {
|
||||||
|
NSLog(@"[SkinManager] iconMap sample shift=%@ shift_upper=%@ backspace=%@ mode_123=%@ return=%@",
|
||||||
|
t.keyIconMap[@"shift"],
|
||||||
|
t.keyIconMap[@"shift_upper"],
|
||||||
|
t.keyIconMap[@"backspace"],
|
||||||
|
t.keyIconMap[@"mode_123"],
|
||||||
|
t.keyIconMap[@"return"]);
|
||||||
|
}
|
||||||
return [self applyTheme:t];
|
return [self applyTheme:t];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (BOOL)applyTheme:(KBSkinTheme *)theme {
|
- (BOOL)applyTheme:(KBSkinTheme *)theme {
|
||||||
if (!theme) return NO;
|
if (!theme) return NO;
|
||||||
|
NSLog(@"🎨[SkinManager] apply theme id=%@ name=%@", theme.skinId, theme.name);
|
||||||
// 将主题写入 App Group 存储(失败也不影响本次进程内的使用)
|
// 将主题写入 App Group 存储(失败也不影响本次进程内的使用)
|
||||||
[self p_saveToStore:theme];
|
[self p_saveToStore:theme];
|
||||||
// 始终更新当前主题并广播通知,确保当前进程和扩展之间保持同步。
|
// 始终更新当前主题并广播通知,确保当前进程和扩展之间保持同步。
|
||||||
@@ -248,6 +261,19 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (UIImage *)iconImageForKeyIdentifier:(NSString *)identifier caseVariant:(NSInteger)caseVariant {
|
- (UIImage *)iconImageForKeyIdentifier:(NSString *)identifier caseVariant:(NSInteger)caseVariant {
|
||||||
|
#if DEBUG
|
||||||
|
static NSSet<NSString *> *kb_debugIconIds;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
kb_debugIconIds = [NSSet setWithObjects:
|
||||||
|
@"shift", @"backspace", @"mode_123", @"mode_abc",
|
||||||
|
@"symbols_toggle_more", @"symbols_toggle_123",
|
||||||
|
@"return", @"space", @"emoji_panel", @"letter_q",
|
||||||
|
nil];
|
||||||
|
});
|
||||||
|
BOOL shouldLog = [kb_debugIconIds containsObject:identifier];
|
||||||
|
#endif
|
||||||
|
|
||||||
NSDictionary<NSString *, NSString *> *map = self.current.keyIconMap;
|
NSDictionary<NSString *, NSString *> *map = self.current.keyIconMap;
|
||||||
NSString *value = nil;
|
NSString *value = nil;
|
||||||
|
|
||||||
@@ -291,10 +317,23 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
|||||||
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
|
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
|
||||||
if (img) return img;
|
if (img) return img;
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
if (shouldLog) {
|
||||||
|
NSLog(@"[SkinManager] icon file missing id=%@ value=%@ skin=%@",
|
||||||
|
identifier, value, self.current.skinId ?: @"");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
// 否则按本地 Assets 名称加载(兼容旧实现)
|
// 否则按本地 Assets 名称加载(兼容旧实现)
|
||||||
return [UIImage imageNamed:value];
|
UIImage *img = [UIImage imageNamed:value];
|
||||||
|
#if DEBUG
|
||||||
|
if (!img && shouldLog) {
|
||||||
|
NSLog(@"[SkinManager] icon asset missing id=%@ value=%@ skin=%@",
|
||||||
|
identifier, value, self.current.skinId ?: @"");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:若 keyIconMap 中没有该键,则按照约定的命名规则直接从 App Group 读取:
|
// 兜底:若 keyIconMap 中没有该键,则按照约定的命名规则直接从 App Group 读取:
|
||||||
@@ -328,6 +367,12 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
|||||||
if (img) return img;
|
if (img) return img;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
if (shouldLog) {
|
||||||
|
NSLog(@"[SkinManager] icon fallback missing id=%@ variant=%ld skin=%@",
|
||||||
|
identifier, (long)caseVariant, self.current.skinId ?: @"");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
"current_lang" = "Current: %@";
|
"current_lang" = "Current: %@";
|
||||||
"common_back" = "Back";
|
"common_back" = "Back";
|
||||||
|
|
||||||
|
// search
|
||||||
|
"Recommended Skin" = "Recommended Skin";
|
||||||
|
"Historical Search" = "Historical Search";
|
||||||
|
"Search Themes" = "Search Themes";
|
||||||
|
"Search" = "Search";
|
||||||
|
|
||||||
// Login & account
|
// Login & account
|
||||||
"Log In" = "Log In";
|
"Log In" = "Log In";
|
||||||
"Signed in successfully" = "Signed in successfully";
|
"Signed in successfully" = "Signed in successfully";
|
||||||
@@ -31,7 +37,8 @@
|
|||||||
"Invalid login credential" = "Invalid login credential";
|
"Invalid login credential" = "Invalid login credential";
|
||||||
"No token returned" = "No token returned";
|
"No token returned" = "No token returned";
|
||||||
"Failed to save login state" = "Failed to save login state";
|
"Failed to save login state" = "Failed to save login state";
|
||||||
"请切换到主App完成登录" = "Please switch to the main app to finish signing in";
|
"Sign-in canceled" = "Sign-in canceled";
|
||||||
|
"Please switch to the key of love app to finish signing in" = "Please switch to the key of love app to finish signing in";
|
||||||
"Continue Via Email" = "Continue Via Email";
|
"Continue Via Email" = "Continue Via Email";
|
||||||
"Login With Email Password" = "Login With Email Password";
|
"Login With Email Password" = "Login With Email Password";
|
||||||
"Enter Email Address" = "Enter Email Address";
|
"Enter Email Address" = "Enter Email Address";
|
||||||
@@ -119,7 +126,7 @@
|
|||||||
"Personal" = "Personal";
|
"Personal" = "Personal";
|
||||||
"My Keyboard" = "My Keyboard";
|
"My Keyboard" = "My Keyboard";
|
||||||
"Notice" = "Notice";
|
"Notice" = "Notice";
|
||||||
"Share App" = "Share App";
|
"invite" = "invite";
|
||||||
"Feedback" = "Feedback";
|
"Feedback" = "Feedback";
|
||||||
"E-mail" = "E-mail";
|
"E-mail" = "E-mail";
|
||||||
"Agreement" = "Agreement";
|
"Agreement" = "Agreement";
|
||||||
@@ -162,31 +169,7 @@
|
|||||||
"Log Out" = "Log Out";
|
"Log Out" = "Log Out";
|
||||||
"Ranking List" = "Ranking List";
|
"Ranking List" = "Ranking List";
|
||||||
"Persona circle" = "Persona circle";
|
"Persona circle" = "Persona circle";
|
||||||
|
"Clear" = "Clear";
|
||||||
|
|
||||||
|
|
||||||
// Skin sample names
|
|
||||||
"极光" = "Aurora";
|
|
||||||
"雪山" = "Snow Mountain";
|
|
||||||
"湖面" = "Lake";
|
|
||||||
|
|
||||||
// Sample tags & copy
|
|
||||||
"高情商" = "High EQ";
|
|
||||||
"暖味拉扯" = "Ambiguous flirting";
|
|
||||||
"风趣幽默" = "Witty & humorous";
|
|
||||||
"撩女生" = "Flirt with girls";
|
|
||||||
"社交惬匿" = "Relaxed socializing";
|
|
||||||
"情场高手" = "Dating expert";
|
|
||||||
"一枚暖男" = "Warm-hearted guy";
|
|
||||||
"聊天搭子" = "Chat buddy";
|
|
||||||
"表达爱意" = "Express love";
|
|
||||||
"更多话术" = "More prompts";
|
|
||||||
"Tap to paste their message" = "Tap to paste their message";
|
|
||||||
"Tap any conversation to paste, then try any reply style~" = "Tap any conversation to paste, then try any reply style~";
|
|
||||||
"What are you doing?" = "What are you doing?";
|
|
||||||
"I'm going to take a shower." = "I'm going to take a shower.";
|
|
||||||
"Welcome to use the [key of love] keyboard" = "Welcome to use the [key of love] keyboard";
|
|
||||||
"👋 Welcome to Key of Love Keyboard" = "👋 Welcome to Key of Love Keyboard";
|
|
||||||
|
|
||||||
// Payment & IAP
|
// Payment & IAP
|
||||||
"Payment successful" = "Payment successful";
|
"Payment successful" = "Payment successful";
|
||||||
@@ -194,60 +177,12 @@
|
|||||||
"Purchase: %@ Coins %@" = "Purchase: %@ Coins %@";
|
"Purchase: %@ Coins %@" = "Purchase: %@ Coins %@";
|
||||||
"Pay clicked" = "Pay clicked";
|
"Pay clicked" = "Pay clicked";
|
||||||
"Points Recharge" = "Points Recharge";
|
"Points Recharge" = "Points Recharge";
|
||||||
|
"Recharge" = "Recharge";
|
||||||
|
"Consumption Record" = "Consumption Record";
|
||||||
"My Points" = "My Points";
|
"My Points" = "My Points";
|
||||||
|
"Consumption Details" = "Consumption Details";
|
||||||
"No data" = "No data";
|
"No data" = "No data";
|
||||||
|
|
||||||
// Example categories/items
|
|
||||||
"能力" = "Ability";
|
|
||||||
"能力2" = "Ability 2";
|
|
||||||
"爱好" = "Hobby";
|
|
||||||
"爱好2" = "Hobby 2";
|
|
||||||
"队友" = "Teammates";
|
|
||||||
"队友2" = "Teammates 2";
|
|
||||||
"高级能力" = "Advanced abilities";
|
|
||||||
"高级爱好" = "Advanced hobbies";
|
|
||||||
"高级队友" = "Advanced teammates";
|
|
||||||
|
|
||||||
// Example fruits etc.
|
|
||||||
"果冻橙" = "Jelly orange";
|
|
||||||
"芒果" = "Mango";
|
|
||||||
"有机水果卷心菜" = "Organic cabbage";
|
|
||||||
"水果萝卜" = "Fruit radish";
|
|
||||||
"熟冻帝王蟹" = "Cooked king crab";
|
|
||||||
"赣南脐橙" = "Gannan navel orange";
|
|
||||||
"苹果" = "Apple";
|
|
||||||
"胡萝卜" = "Carrot";
|
|
||||||
"葡萄" = "Grape";
|
|
||||||
"西瓜" = "Watermelon";
|
|
||||||
"小龙虾" = "Crawfish";
|
|
||||||
"吃烤肉" = "Eat barbecue";
|
|
||||||
"吃鸡腿肉" = "Eat chicken drumsticks";
|
|
||||||
"吃牛肉" = "Eat beef";
|
|
||||||
"各种肉" = "All kinds of meat";
|
|
||||||
|
|
||||||
// One Piece sample roles
|
|
||||||
"【剑士】罗罗诺亚·索隆" = "[Swordsman] Roronoa Zoro";
|
|
||||||
"【航海士】娜美" = "[Navigator] Nami";
|
|
||||||
"【狙击手】乌索普" = "[Sniper] Usopp";
|
|
||||||
"【厨师】香吉士" = "[Cook] Sanji";
|
|
||||||
"【船医】托尼托尼·乔巴" = "[Doctor] Tony Tony Chopper";
|
|
||||||
"【船匠】 弗兰奇" = "[Shipwright] Franky";
|
|
||||||
"【音乐家】布鲁克" = "[Musician] Brook";
|
|
||||||
"【考古学家】妮可·罗宾" = "[Archaeologist] Nico Robin";
|
|
||||||
|
|
||||||
// Rubber-series sample moves
|
|
||||||
"橡胶火箭" = "Gum-Gum Rocket";
|
|
||||||
"橡胶火箭炮" = "Gum-Gum Bazooka";
|
|
||||||
"橡胶机关枪" = "Gum-Gum Gatling";
|
|
||||||
"橡胶子弹" = "Gum-Gum Bullet";
|
|
||||||
"橡胶攻城炮" = "Gum-Gum Cannon";
|
|
||||||
"橡胶象枪" = "Gum-Gum Elephant Gun";
|
|
||||||
"橡胶象枪乱打" = "Gum-Gum Elephant Gatling";
|
|
||||||
"橡胶灰熊铳" = "Gum-Gum Grizzly Magnum";
|
|
||||||
"橡胶雷神象枪" = "Gum-Gum Thor Elephant Gun";
|
|
||||||
"橡胶猿王枪" = "Gum-Gum King Kong Gun";
|
|
||||||
"橡胶犀·榴弹炮" = "Gum-Gum Rhino Grenade";
|
|
||||||
"橡胶大蛇炮" = "Gum-Gum Great Serpent Cannon";
|
|
||||||
|
|
||||||
// Misc
|
// Misc
|
||||||
"测试" = "Test";
|
"测试" = "Test";
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
"current_lang" = "当前:%@";
|
"current_lang" = "当前:%@";
|
||||||
"common_back" = "返回";
|
"common_back" = "返回";
|
||||||
|
|
||||||
|
// search
|
||||||
|
"Recommended Skin" = "推荐皮肤";
|
||||||
|
"Historical Search" = "历史搜索";
|
||||||
|
"Search Themes" = "搜索主题";
|
||||||
|
"Search" = "搜索";
|
||||||
|
|
||||||
|
|
||||||
// 登录与账号(以英文 key 为准)
|
// 登录与账号(以英文 key 为准)
|
||||||
"Log In" = "登录";
|
"Log In" = "登录";
|
||||||
"Signed in successfully" = "登录成功";
|
"Signed in successfully" = "登录成功";
|
||||||
@@ -31,7 +38,8 @@
|
|||||||
"Invalid login credential" = "无效的登录凭证";
|
"Invalid login credential" = "无效的登录凭证";
|
||||||
"No token returned" = "未返回 token";
|
"No token returned" = "未返回 token";
|
||||||
"Failed to save login state" = "保存登录态失败";
|
"Failed to save login state" = "保存登录态失败";
|
||||||
"请切换到主App完成登录" = "请切换到主App完成登录";
|
"Sign-in canceled" = "登录已取消";
|
||||||
|
"Please switch to the key of love app to finish signing in" = "请切换到Key of Love App完成登录";
|
||||||
"Continue Via Email" = "通过邮箱登录";
|
"Continue Via Email" = "通过邮箱登录";
|
||||||
"Login With Email Password" = "使用邮箱密码登录";
|
"Login With Email Password" = "使用邮箱密码登录";
|
||||||
"Enter Email Address" = "请输入邮箱地址";
|
"Enter Email Address" = "请输入邮箱地址";
|
||||||
@@ -120,7 +128,7 @@
|
|||||||
"Personal" = "个人";
|
"Personal" = "个人";
|
||||||
"My Keyboard" = "我的键盘";
|
"My Keyboard" = "我的键盘";
|
||||||
"Notice" = "通知";
|
"Notice" = "通知";
|
||||||
"Share App" = "分享app";
|
"invite" = "邀请";
|
||||||
"Feedback" = "反馈";
|
"Feedback" = "反馈";
|
||||||
"E-mail" = "联系我们";
|
"E-mail" = "联系我们";
|
||||||
"Agreement" = "协议";
|
"Agreement" = "协议";
|
||||||
@@ -162,29 +170,9 @@
|
|||||||
"Log Out" = "退出";
|
"Log Out" = "退出";
|
||||||
"Ranking List" = "排行榜";
|
"Ranking List" = "排行榜";
|
||||||
"Persona circle" = "圈子";
|
"Persona circle" = "圈子";
|
||||||
|
"Clear" = "立刻清空";
|
||||||
|
|
||||||
// 皮肤示例名称
|
// 皮肤示例名称
|
||||||
"极光" = "极光";
|
|
||||||
"雪山" = "雪山";
|
|
||||||
"湖面" = "湖面";
|
|
||||||
|
|
||||||
// 示例标签与文案
|
|
||||||
"高情商" = "高情商";
|
|
||||||
"暖味拉扯" = "暖味拉扯";
|
|
||||||
"风趣幽默" = "风趣幽默";
|
|
||||||
"撩女生" = "撩女生";
|
|
||||||
"社交惬匿" = "社交惬匿";
|
|
||||||
"情场高手" = "情场高手";
|
|
||||||
"一枚暖男" = "一枚暖男";
|
|
||||||
"聊天搭子" = "聊天搭子";
|
|
||||||
"表达爱意" = "表达爱意";
|
|
||||||
"更多话术" = "更多话术";
|
|
||||||
"点击粘贴TA的话" = "点击粘贴TA的话";
|
|
||||||
"点击任一对话去粘贴,选择任意回复方式去试用吧~" = "点击任一对话去粘贴,选择任意回复方式去试用吧~";
|
|
||||||
"在干嘛?" = "在干嘛?";
|
|
||||||
"我去洗澡了" = "我去洗澡了";
|
|
||||||
"🎉 如您遇到其他问题,可点击在线客服帮您解决~" = "🎉 如您遇到其他问题,可点击在线客服帮您解决~";
|
|
||||||
"👋 欢迎使用『Lovekey 键盘』" = "👋 欢迎使用『Lovekey 键盘』";
|
|
||||||
|
|
||||||
// 支付与内购(英文 key)
|
// 支付与内购(英文 key)
|
||||||
"Payment successful" = "支付成功";
|
"Payment successful" = "支付成功";
|
||||||
@@ -192,60 +180,12 @@
|
|||||||
"Purchase: %@ Coins %@" = "购买:%@ Coins %@";
|
"Purchase: %@ Coins %@" = "购买:%@ Coins %@";
|
||||||
"Pay clicked" = "点击支付";
|
"Pay clicked" = "点击支付";
|
||||||
"Points Recharge" = "积分充值";
|
"Points Recharge" = "积分充值";
|
||||||
|
"Recharge" = "充值";
|
||||||
|
"Consumption Record" = "消费记录";
|
||||||
"My Points" = "我的积分";
|
"My Points" = "我的积分";
|
||||||
|
"Consumption Details" = "消费明细";
|
||||||
"No data" = "暂无数据";
|
"No data" = "暂无数据";
|
||||||
|
|
||||||
// 示例商品/分类
|
|
||||||
"能力" = "能力";
|
|
||||||
"能力2" = "能力2";
|
|
||||||
"爱好" = "爱好";
|
|
||||||
"爱好2" = "爱好2";
|
|
||||||
"队友" = "队友";
|
|
||||||
"队友2" = "队友2";
|
|
||||||
"高级能力" = "高级能力";
|
|
||||||
"高级爱好" = "高级爱好";
|
|
||||||
"高级队友" = "高级队友";
|
|
||||||
|
|
||||||
// 示例水果等
|
|
||||||
"果冻橙" = "果冻橙";
|
|
||||||
"芒果" = "芒果";
|
|
||||||
"有机水果卷心菜" = "有机水果卷心菜";
|
|
||||||
"水果萝卜" = "水果萝卜";
|
|
||||||
"熟冻帝王蟹" = "熟冻帝王蟹";
|
|
||||||
"赣南脐橙" = "赣南脐橙";
|
|
||||||
"苹果" = "苹果";
|
|
||||||
"胡萝卜" = "胡萝卜";
|
|
||||||
"葡萄" = "葡萄";
|
|
||||||
"西瓜" = "西瓜";
|
|
||||||
"小龙虾" = "小龙虾";
|
|
||||||
"吃烤肉" = "吃烤肉";
|
|
||||||
"吃鸡腿肉" = "吃鸡腿肉";
|
|
||||||
"吃牛肉" = "吃牛肉";
|
|
||||||
"各种肉" = "各种肉";
|
|
||||||
|
|
||||||
// One Piece 示例角色
|
|
||||||
"【剑士】罗罗诺亚·索隆" = "【剑士】罗罗诺亚·索隆";
|
|
||||||
"【航海士】娜美" = "【航海士】娜美";
|
|
||||||
"【狙击手】乌索普" = "【狙击手】乌索普";
|
|
||||||
"【厨师】香吉士" = "【厨师】香吉士";
|
|
||||||
"【船医】托尼托尼·乔巴" = "【船医】托尼托尼·乔巴";
|
|
||||||
"【船匠】 弗兰奇" = "【船匠】 弗兰奇";
|
|
||||||
"【音乐家】布鲁克" = "【音乐家】布鲁克";
|
|
||||||
"【考古学家】妮可·罗宾" = "【考古学家】妮可·罗宾";
|
|
||||||
|
|
||||||
// 橡胶系列示例文案
|
|
||||||
"橡胶火箭" = "橡胶火箭";
|
|
||||||
"橡胶火箭炮" = "橡胶火箭炮";
|
|
||||||
"橡胶机关枪" = "橡胶机关枪";
|
|
||||||
"橡胶子弹" = "橡胶子弹";
|
|
||||||
"橡胶攻城炮" = "橡胶攻城炮";
|
|
||||||
"橡胶象枪" = "橡胶象枪";
|
|
||||||
"橡胶象枪乱打" = "橡胶象枪乱打";
|
|
||||||
"橡胶灰熊铳" = "橡胶灰熊铳";
|
|
||||||
"橡胶雷神象枪" = "橡胶雷神象枪";
|
|
||||||
"橡胶猿王枪" = "橡胶猿王枪";
|
|
||||||
"橡胶犀·榴弹炮" = "橡胶犀·榴弹炮";
|
|
||||||
"橡胶大蛇炮" = "橡胶大蛇炮";
|
|
||||||
|
|
||||||
// 其它
|
// 其它
|
||||||
"Test" = "测试";
|
"Test" = "测试";
|
||||||
|
|||||||
10
_DerivedData/Logs/Build/LogStoreManifest.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
_DerivedData/Logs/Launch/LogStoreManifest.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
_DerivedData/Logs/Localization/LogStoreManifest.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
47
_DerivedData/Logs/Package/LogStoreManifest.plist
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict>
|
||||||
|
<key>800731DD-5595-43EC-B207-003BAB7870CE</key>
|
||||||
|
<dict>
|
||||||
|
<key>className</key>
|
||||||
|
<string>IDECommandLineBuildLog</string>
|
||||||
|
<key>documentTypeString</key>
|
||||||
|
<string><nil></string>
|
||||||
|
<key>domainType</key>
|
||||||
|
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
||||||
|
<key>fileName</key>
|
||||||
|
<string>800731DD-5595-43EC-B207-003BAB7870CE.xcactivitylog</string>
|
||||||
|
<key>hasPrimaryLog</key>
|
||||||
|
<true/>
|
||||||
|
<key>primaryObservable</key>
|
||||||
|
<dict>
|
||||||
|
<key>highLevelStatus</key>
|
||||||
|
<string>E</string>
|
||||||
|
<key>totalNumberOfAnalyzerIssues</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>totalNumberOfErrors</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>totalNumberOfTestFailures</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>totalNumberOfWarnings</key>
|
||||||
|
<integer>3</integer>
|
||||||
|
</dict>
|
||||||
|
<key>signature</key>
|
||||||
|
<string>Resolve Packages</string>
|
||||||
|
<key>timeStartedRecording</key>
|
||||||
|
<real>788359220.39837599</real>
|
||||||
|
<key>timeStoppedRecording</key>
|
||||||
|
<real>788359220.51885402</real>
|
||||||
|
<key>title</key>
|
||||||
|
<string>Resolve Packages</string>
|
||||||
|
<key>uniqueIdentifier</key>
|
||||||
|
<string>800731DD-5595-43EC-B207-003BAB7870CE</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
_DerivedData/Logs/Test/LogStoreManifest.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>logFormatVersion</key>
|
||||||
|
<integer>11</integer>
|
||||||
|
<key>logs</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
469
_spm/repositories/swift-collections-9a58d5cf/FETCH_HEAD
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
1f8dcd4726717a4b724e5408ca24829bdef67a49 not-for-merge branch 'action-xcode-versions' of https://github.com/apple/swift-collections
|
||||||
|
7ae9880f0fb622992a8a5328fd4af78af5b6b26f not-for-merge branch 'dn/remove-retain-release-during-find' of https://github.com/apple/swift-collections
|
||||||
|
ddff9004d745c4b2d13e35ebdd1a3d0b67e14717 not-for-merge branch 'doc-basic-span-index-mutation' of https://github.com/apple/swift-collections
|
||||||
|
ebd51171f0e45788e2cdb3cc6d54233e11d7443c not-for-merge branch 'future' of https://github.com/apple/swift-collections
|
||||||
|
534c0cbbfef9d2f6e7a6519930967d75f7c60336 not-for-merge branch 'main' of https://github.com/apple/swift-collections
|
||||||
|
68c32ed80b138d1f8a6ec5dcf1b105f32fcaaf12 not-for-merge branch 'maxd/run-wasm-tests' of https://github.com/apple/swift-collections
|
||||||
|
762bc5757de4bcf86cc3b3266e3df93f76469089 not-for-merge branch 'nate/zip_dispatch_benchmarking' of https://github.com/apple/swift-collections
|
||||||
|
d029d9d39c87bed85b1c50adee7c41795261a192 not-for-merge branch 'release/1.0' of https://github.com/apple/swift-collections
|
||||||
|
c11818f3cae0780656baa430b49e7f163f08dffd not-for-merge branch 'release/1.1' of https://github.com/apple/swift-collections
|
||||||
|
2d1f4172091ca272b461397748fd2c32847036dd not-for-merge branch 'release/1.2' of https://github.com/apple/swift-collections
|
||||||
|
c55cbe540366c704c279298ca6ad4c09a067ebe0 not-for-merge branch 'release/1.3' of https://github.com/apple/swift-collections
|
||||||
|
a26fc8e7e4c6e0d63b08951f5b7268044fb95c83 not-for-merge branch 'span-prototype' of https://github.com/apple/swift-collections
|
||||||
|
434d30dae30fbb57699370260c69c2108c1abf8f not-for-merge branch 'wasm-tests' of https://github.com/apple/swift-collections
|
||||||
|
c65cd649fb91f64636f2f1ef5da4991987139f68 not-for-merge 'refs/pull/10/head' of https://github.com/apple/swift-collections
|
||||||
|
6a9703c3f13061291eff1baf8af4fce1e6ce35af not-for-merge 'refs/pull/100/head' of https://github.com/apple/swift-collections
|
||||||
|
8303a77bc2915be76777d6fc17b702146b6fb192 not-for-merge 'refs/pull/102/head' of https://github.com/apple/swift-collections
|
||||||
|
6cd03922372c85e2dca476f665527976911e7397 not-for-merge 'refs/pull/105/head' of https://github.com/apple/swift-collections
|
||||||
|
96100d4b6ad61bbfbf549ee42dc73239325b353f not-for-merge 'refs/pull/106/head' of https://github.com/apple/swift-collections
|
||||||
|
f69104b5071ef9a44112e3c4fef8904b5e8b6d73 not-for-merge 'refs/pull/107/head' of https://github.com/apple/swift-collections
|
||||||
|
5f255a887e51093f058e7ee9ea0b959051be38d4 not-for-merge 'refs/pull/108/head' of https://github.com/apple/swift-collections
|
||||||
|
c46c77f7f85f9efe83cc5cb8d54b32c538b06450 not-for-merge 'refs/pull/109/head' of https://github.com/apple/swift-collections
|
||||||
|
cfca1038b53eecd4fc50da15cc715f966904012e not-for-merge 'refs/pull/11/head' of https://github.com/apple/swift-collections
|
||||||
|
103008aca495f3b80a50713086d451b08cef9f50 not-for-merge 'refs/pull/110/head' of https://github.com/apple/swift-collections
|
||||||
|
39a16a15cc8c7e11486015d2deb78468b4a64c85 not-for-merge 'refs/pull/111/head' of https://github.com/apple/swift-collections
|
||||||
|
7f57a0ed1c8b1d44eacab95d3cfd57d87482b25a not-for-merge 'refs/pull/112/head' of https://github.com/apple/swift-collections
|
||||||
|
efefae56531b30d75553f234134e3c83bd26c6a6 not-for-merge 'refs/pull/113/head' of https://github.com/apple/swift-collections
|
||||||
|
565b1ab20bef052f52fad4ebe112803facb325e3 not-for-merge 'refs/pull/114/head' of https://github.com/apple/swift-collections
|
||||||
|
8b3af12ece11ea7b0e748749c39a59c59269ca25 not-for-merge 'refs/pull/115/head' of https://github.com/apple/swift-collections
|
||||||
|
8be0f192e8f120969427cb2645aabe6f5fdf15a1 not-for-merge 'refs/pull/116/head' of https://github.com/apple/swift-collections
|
||||||
|
771bca404c0ee603bb6cc2ea3a19cfc4ebc3d001 not-for-merge 'refs/pull/117/head' of https://github.com/apple/swift-collections
|
||||||
|
95be379a3ac1e4278d114a22501267065062b39a not-for-merge 'refs/pull/118/head' of https://github.com/apple/swift-collections
|
||||||
|
f41f2cae69dc6ffcc178227a0a46607561fa925c not-for-merge 'refs/pull/119/head' of https://github.com/apple/swift-collections
|
||||||
|
276ddee5df4ea0f44c5edea0dbe726cb74682498 not-for-merge 'refs/pull/121/head' of https://github.com/apple/swift-collections
|
||||||
|
1c1e6206fa1a4b0a6bf97ac979f841c1d1821ad7 not-for-merge 'refs/pull/122/head' of https://github.com/apple/swift-collections
|
||||||
|
074b0ad21387e767b5b3ff1cb014a118a3b80bde not-for-merge 'refs/pull/123/head' of https://github.com/apple/swift-collections
|
||||||
|
ddc8b9c66d07cb0af38e98ca160f1f7cc85c88d3 not-for-merge 'refs/pull/126/head' of https://github.com/apple/swift-collections
|
||||||
|
9dde4b560323d43ec2ca8225a6c5c79a9bc0735d not-for-merge 'refs/pull/128/head' of https://github.com/apple/swift-collections
|
||||||
|
453adb695f615886cd7a897d62676713aeb25bf9 not-for-merge 'refs/pull/13/head' of https://github.com/apple/swift-collections
|
||||||
|
286fedb1e8f6b2567cd214cf1127a9322dea1400 not-for-merge 'refs/pull/130/head' of https://github.com/apple/swift-collections
|
||||||
|
e77d82c71271b7848e3ac9e007a52f36f04a1c7e not-for-merge 'refs/pull/132/head' of https://github.com/apple/swift-collections
|
||||||
|
1bde9674f42e41bfb62deb96d8b247a537171ba7 not-for-merge 'refs/pull/132/merge' of https://github.com/apple/swift-collections
|
||||||
|
cee04e96ed96fefa5527e33a4a055482e4ea14e9 not-for-merge 'refs/pull/14/head' of https://github.com/apple/swift-collections
|
||||||
|
50e6a1ac71df96e5cd257c565e9a3299f491ba3e not-for-merge 'refs/pull/140/head' of https://github.com/apple/swift-collections
|
||||||
|
29120ea6406a856c36496b4f5d3b13ca381436a6 not-for-merge 'refs/pull/141/head' of https://github.com/apple/swift-collections
|
||||||
|
867231031ac792328eff9133a289ce9e693a03c3 not-for-merge 'refs/pull/15/head' of https://github.com/apple/swift-collections
|
||||||
|
6cb037037d45f78622fd7cab3f62e3c6a4ff5ddb not-for-merge 'refs/pull/153/head' of https://github.com/apple/swift-collections
|
||||||
|
7016a13f0e8d0d0a36f3566a6dc6acaf5b83fbf4 not-for-merge 'refs/pull/155/head' of https://github.com/apple/swift-collections
|
||||||
|
00a99aa77c4982a6e7343982220e44b15e4d5974 not-for-merge 'refs/pull/156/head' of https://github.com/apple/swift-collections
|
||||||
|
74a800ac534877875d5d1187ac04f35b31a0b98c not-for-merge 'refs/pull/159/head' of https://github.com/apple/swift-collections
|
||||||
|
b9d73a5bab8d71e121687f79ed4aabfbd73d016b not-for-merge 'refs/pull/16/head' of https://github.com/apple/swift-collections
|
||||||
|
ddb6d141b3e4e8726a15e3e9e916ee697bd310db not-for-merge 'refs/pull/160/head' of https://github.com/apple/swift-collections
|
||||||
|
3437abecc49cfbbb91893e3a0d0f124db163637f not-for-merge 'refs/pull/161/head' of https://github.com/apple/swift-collections
|
||||||
|
1071b372942289dbd3209ede40f613d339cca8e1 not-for-merge 'refs/pull/162/head' of https://github.com/apple/swift-collections
|
||||||
|
b9d91f4a9aa3cfd6c8589aee997c4399e6d7d83d not-for-merge 'refs/pull/163/head' of https://github.com/apple/swift-collections
|
||||||
|
ee1b2de06edf18545084d99a5c99995e9c1ff8d4 not-for-merge 'refs/pull/165/head' of https://github.com/apple/swift-collections
|
||||||
|
b379fa7aee604738bd0b4076984c934e242bb7a5 not-for-merge 'refs/pull/168/head' of https://github.com/apple/swift-collections
|
||||||
|
71aa4a39ae59addf40e9dd40e79a4968f1c857cc not-for-merge 'refs/pull/169/head' of https://github.com/apple/swift-collections
|
||||||
|
feca3b6f4c7bf7adf9fa4a0fd200f96cf1b5a4b5 not-for-merge 'refs/pull/170/head' of https://github.com/apple/swift-collections
|
||||||
|
9a9d7d87d844b2a14ec602792d9ae216ea57ea53 not-for-merge 'refs/pull/172/head' of https://github.com/apple/swift-collections
|
||||||
|
8cc0b1d43975a9d547053cf0e943498c0d6d7992 not-for-merge 'refs/pull/173/head' of https://github.com/apple/swift-collections
|
||||||
|
aa428d0489be5888c72de4a4327f0f316a586632 not-for-merge 'refs/pull/174/head' of https://github.com/apple/swift-collections
|
||||||
|
14f90620c89e311a067a1fa49813d8bbcd539950 not-for-merge 'refs/pull/175/head' of https://github.com/apple/swift-collections
|
||||||
|
77edbef16d24e657fc3d7b0bc9adb57e6b0567d9 not-for-merge 'refs/pull/176/head' of https://github.com/apple/swift-collections
|
||||||
|
9d9659a079644d300d3571fb64ac7bb14322e506 not-for-merge 'refs/pull/177/head' of https://github.com/apple/swift-collections
|
||||||
|
9f38e7424baa3497e4d481790fc6ec2950725de8 not-for-merge 'refs/pull/178/head' of https://github.com/apple/swift-collections
|
||||||
|
d45f264ef0dc625a37801ee6e05b764ccbef30b4 not-for-merge 'refs/pull/179/head' of https://github.com/apple/swift-collections
|
||||||
|
58579394fc929c1b89b762c3ef68a1657442ca7d not-for-merge 'refs/pull/18/head' of https://github.com/apple/swift-collections
|
||||||
|
39fc818e0efaa148e038e3d89cd78be64567849a not-for-merge 'refs/pull/180/head' of https://github.com/apple/swift-collections
|
||||||
|
4c4154ca2350d981cd6abeadbdcad18071fe3b95 not-for-merge 'refs/pull/181/head' of https://github.com/apple/swift-collections
|
||||||
|
59858a77210f506e622a68e5b92e36bae6fc9215 not-for-merge 'refs/pull/182/head' of https://github.com/apple/swift-collections
|
||||||
|
ba303dca1d72860f0b253778f193010af20f9b79 not-for-merge 'refs/pull/184/head' of https://github.com/apple/swift-collections
|
||||||
|
778b8ae977c420dfe323b27ed3a1cf3f747f936b not-for-merge 'refs/pull/185/head' of https://github.com/apple/swift-collections
|
||||||
|
0f92943ba349d0337d254789bc49e9a5c45ff74e not-for-merge 'refs/pull/187/head' of https://github.com/apple/swift-collections
|
||||||
|
f71ebd9494b3b0b8c1450017f8c30fb61e4dcc37 not-for-merge 'refs/pull/188/head' of https://github.com/apple/swift-collections
|
||||||
|
66651795d89e760188908bb65b3398aa30b84897 not-for-merge 'refs/pull/189/head' of https://github.com/apple/swift-collections
|
||||||
|
832e58027cdb86e618089ebdf2e3474da915c357 not-for-merge 'refs/pull/190/head' of https://github.com/apple/swift-collections
|
||||||
|
9b7c6ecb10599568876b1108005b189de54d9ce9 not-for-merge 'refs/pull/191/head' of https://github.com/apple/swift-collections
|
||||||
|
4604c1bc381c4d164a030d11830701b2897a0002 not-for-merge 'refs/pull/192/head' of https://github.com/apple/swift-collections
|
||||||
|
0b86bb11d24e187ab6ab930f582f75ae00bcabf9 not-for-merge 'refs/pull/193/head' of https://github.com/apple/swift-collections
|
||||||
|
c7c0a094f91bd5cce90b1dc1f3feeb9d2508e7f5 not-for-merge 'refs/pull/194/head' of https://github.com/apple/swift-collections
|
||||||
|
1b71d2cd942459049b870264b7db649ba7509046 not-for-merge 'refs/pull/195/head' of https://github.com/apple/swift-collections
|
||||||
|
7cec5d1ed71d0c29fc7515725401c8a711a01074 not-for-merge 'refs/pull/196/head' of https://github.com/apple/swift-collections
|
||||||
|
b83d06b43337cc2855c1b55ba1cdba854724110b not-for-merge 'refs/pull/197/head' of https://github.com/apple/swift-collections
|
||||||
|
6286cd0b99741db984e5c1115a4c748bc42efec1 not-for-merge 'refs/pull/198/head' of https://github.com/apple/swift-collections
|
||||||
|
8b06c61c7e4ecb6777895a8fba275a44ec8c5e01 not-for-merge 'refs/pull/199/head' of https://github.com/apple/swift-collections
|
||||||
|
5aed92655d73228211985c933e0d5141d8203a09 not-for-merge 'refs/pull/200/head' of https://github.com/apple/swift-collections
|
||||||
|
51604cd196265a1e4444cef0a7ac749e34f5e64e not-for-merge 'refs/pull/201/head' of https://github.com/apple/swift-collections
|
||||||
|
2a81cb1186ac3483183df32ba8c3e2fe7360cbcb not-for-merge 'refs/pull/202/head' of https://github.com/apple/swift-collections
|
||||||
|
6dbc8b8c4f36ff470060464d69066e2e094a194f not-for-merge 'refs/pull/203/head' of https://github.com/apple/swift-collections
|
||||||
|
6b428b5a7fadfae0120db4fc96a3d5b14f7b7c36 not-for-merge 'refs/pull/204/head' of https://github.com/apple/swift-collections
|
||||||
|
b1506b62250f074d23cddc6d0318277dda5d21dc not-for-merge 'refs/pull/205/head' of https://github.com/apple/swift-collections
|
||||||
|
2e872739d3a6201e2b888c73f08ef54ae4125d6d not-for-merge 'refs/pull/206/head' of https://github.com/apple/swift-collections
|
||||||
|
4c842df1b8cfc9611762bdf5fbd9023ab2d3d333 not-for-merge 'refs/pull/207/head' of https://github.com/apple/swift-collections
|
||||||
|
1228e9c1628925843575a11c1de8342e402c7c30 not-for-merge 'refs/pull/208/head' of https://github.com/apple/swift-collections
|
||||||
|
b6184b667bd8f636558522f18cc2fe993a825679 not-for-merge 'refs/pull/21/head' of https://github.com/apple/swift-collections
|
||||||
|
fc8092d0441e0073831d6b170c74b6cca0da9bf5 not-for-merge 'refs/pull/210/head' of https://github.com/apple/swift-collections
|
||||||
|
cfc3d4a5179e61c36d77babd9fd3029a0f85cff4 not-for-merge 'refs/pull/211/head' of https://github.com/apple/swift-collections
|
||||||
|
02ea4e849230bc2326c83c5560822a228d83afab not-for-merge 'refs/pull/212/head' of https://github.com/apple/swift-collections
|
||||||
|
1490835b289653b145b91a16fe5522d928509efb not-for-merge 'refs/pull/213/head' of https://github.com/apple/swift-collections
|
||||||
|
2b4ec22949acbfa99f9e22c390d850a650f3d298 not-for-merge 'refs/pull/214/head' of https://github.com/apple/swift-collections
|
||||||
|
5e0a0149a4c9b2ab407f65b257b5b36bc589307d not-for-merge 'refs/pull/215/head' of https://github.com/apple/swift-collections
|
||||||
|
734730d72460e7bd536e8021d4aa711cb3aa75b2 not-for-merge 'refs/pull/216/head' of https://github.com/apple/swift-collections
|
||||||
|
381cf5d17e46f2d3266bd1be7295118647443313 not-for-merge 'refs/pull/217/head' of https://github.com/apple/swift-collections
|
||||||
|
3c6cb8cd56d04392bf9f79a2d5020fc2abf1958d not-for-merge 'refs/pull/218/head' of https://github.com/apple/swift-collections
|
||||||
|
67fdff5a25539a51ce2a8bf574a7e4a6b72ebfb8 not-for-merge 'refs/pull/219/head' of https://github.com/apple/swift-collections
|
||||||
|
fbdf73e1b787a0ac113e930dff73382f612698d4 not-for-merge 'refs/pull/22/head' of https://github.com/apple/swift-collections
|
||||||
|
d3987c79a1214e279a49a09304c13322560c8a1b not-for-merge 'refs/pull/220/head' of https://github.com/apple/swift-collections
|
||||||
|
5783b82d72e620f37545924d46bdbfb429f03cbb not-for-merge 'refs/pull/221/head' of https://github.com/apple/swift-collections
|
||||||
|
2df7f6d3a8fe5f9bb12b5034da2e095424694d44 not-for-merge 'refs/pull/222/head' of https://github.com/apple/swift-collections
|
||||||
|
72e2df59e3ea600f4d5e67a324bd6df1a7acbd41 not-for-merge 'refs/pull/223/head' of https://github.com/apple/swift-collections
|
||||||
|
98d62d236bef8c30af5b7015ccbb7db66f43399e not-for-merge 'refs/pull/224/head' of https://github.com/apple/swift-collections
|
||||||
|
ba7e3927e76ff55b0579770fb036ffce6ea07b3c not-for-merge 'refs/pull/225/head' of https://github.com/apple/swift-collections
|
||||||
|
207029a613dca008e708370c09fc69010cf6a11d not-for-merge 'refs/pull/226/head' of https://github.com/apple/swift-collections
|
||||||
|
68bc6567be4c1ae96cfea1217b9e5cee63421c6a not-for-merge 'refs/pull/227/head' of https://github.com/apple/swift-collections
|
||||||
|
1c147e3b06edbe0b00217e38e94d2d3bc4b4c6a7 not-for-merge 'refs/pull/228/head' of https://github.com/apple/swift-collections
|
||||||
|
5aa603edfe8c13e0256141cb45f70ec427fe94c1 not-for-merge 'refs/pull/23/head' of https://github.com/apple/swift-collections
|
||||||
|
d19fb14c46266a20cba9c5362923c6a076e74407 not-for-merge 'refs/pull/230/head' of https://github.com/apple/swift-collections
|
||||||
|
a32048d44ee028b915565c3093ec5305e41aa6f1 not-for-merge 'refs/pull/231/head' of https://github.com/apple/swift-collections
|
||||||
|
4448454fbc79b8052acc6b6e7eb6c75c70d1ee2f not-for-merge 'refs/pull/232/head' of https://github.com/apple/swift-collections
|
||||||
|
14e86daeebe18bd32466a1f2bd2d8da936aa6508 not-for-merge 'refs/pull/233/head' of https://github.com/apple/swift-collections
|
||||||
|
581490a7351be07e465100628ded472aaa137a99 not-for-merge 'refs/pull/234/head' of https://github.com/apple/swift-collections
|
||||||
|
ff2d526c1baa53df478db47156bbdd68fcaed4df not-for-merge 'refs/pull/235/head' of https://github.com/apple/swift-collections
|
||||||
|
98d519ec12ec6bc209ad61fed2e7e2101e312566 not-for-merge 'refs/pull/237/head' of https://github.com/apple/swift-collections
|
||||||
|
339f1677450d7a8f43518d61cfd93be85fa080c3 not-for-merge 'refs/pull/238/head' of https://github.com/apple/swift-collections
|
||||||
|
ff85ea9e4f1cf6fd50fb758cde3dedf01a742f52 not-for-merge 'refs/pull/240/head' of https://github.com/apple/swift-collections
|
||||||
|
18c4f6dc5a771cd3f90ef3e7ae02a99e09e89115 not-for-merge 'refs/pull/241/head' of https://github.com/apple/swift-collections
|
||||||
|
3d66da5f5baa3585aa4b5fe8f9d9eaaee5e5a6ef not-for-merge 'refs/pull/242/head' of https://github.com/apple/swift-collections
|
||||||
|
2c33149e4e2cb9a5b2616cdbad9eabb5f51f0e0c not-for-merge 'refs/pull/243/head' of https://github.com/apple/swift-collections
|
||||||
|
a2f4b18ae02cd14a8488c357c75fef3f2c2a9013 not-for-merge 'refs/pull/244/head' of https://github.com/apple/swift-collections
|
||||||
|
69768b6eacd442681bc8424cc76516e7e24fdacc not-for-merge 'refs/pull/245/head' of https://github.com/apple/swift-collections
|
||||||
|
f8252436e5e4384eaa1a68560db380d83b7c71eb not-for-merge 'refs/pull/246/head' of https://github.com/apple/swift-collections
|
||||||
|
07032a92671dea282cbc935383dd3d5a0a598682 not-for-merge 'refs/pull/246/merge' of https://github.com/apple/swift-collections
|
||||||
|
b8ed4950368d4d54cefd2b8bfe14c329e057172c not-for-merge 'refs/pull/247/head' of https://github.com/apple/swift-collections
|
||||||
|
10a01bd9fb383668a987ec8f512da627dbe55f9e not-for-merge 'refs/pull/248/head' of https://github.com/apple/swift-collections
|
||||||
|
470bf29d014757caae7222f3a41aea0b86b6ee1c not-for-merge 'refs/pull/249/head' of https://github.com/apple/swift-collections
|
||||||
|
b43076ca7b46b8216590766fd446ae538ab91c5a not-for-merge 'refs/pull/25/head' of https://github.com/apple/swift-collections
|
||||||
|
c0853ebef8dad7eddb6a44b76dac0f42f699b96a not-for-merge 'refs/pull/251/head' of https://github.com/apple/swift-collections
|
||||||
|
089666b32ba82c06a166e072ebcdf988108b66b7 not-for-merge 'refs/pull/252/head' of https://github.com/apple/swift-collections
|
||||||
|
fef38395004569688a967b88c70d8774c63f5fc5 not-for-merge 'refs/pull/254/head' of https://github.com/apple/swift-collections
|
||||||
|
972578a44da6463eb8f88552cc7d30cf0b6de9a4 not-for-merge 'refs/pull/259/head' of https://github.com/apple/swift-collections
|
||||||
|
0c2071ecda7a50dcdc8ec435a45b3b830b3e5bcf not-for-merge 'refs/pull/259/merge' of https://github.com/apple/swift-collections
|
||||||
|
4e2f86b6cd24a124d69bf9257d2ffa751d11da8f not-for-merge 'refs/pull/26/head' of https://github.com/apple/swift-collections
|
||||||
|
580beb4c121a85f60aaf6f10403e7f457c81ccb9 not-for-merge 'refs/pull/260/head' of https://github.com/apple/swift-collections
|
||||||
|
5b4e4e032fc56ca77b888ca36b7a342e9932e5fa not-for-merge 'refs/pull/260/merge' of https://github.com/apple/swift-collections
|
||||||
|
3e6df33472d54ed83dffe3bca7a3c8f65bab4c43 not-for-merge 'refs/pull/263/head' of https://github.com/apple/swift-collections
|
||||||
|
fcd6800b9d1171fbe0a00f51a72afac32b0a1987 not-for-merge 'refs/pull/264/head' of https://github.com/apple/swift-collections
|
||||||
|
5dc7a8970eabcf6aaa49529b5ef6401f5128637e not-for-merge 'refs/pull/265/head' of https://github.com/apple/swift-collections
|
||||||
|
1666222de2200b2a614a0d5184751b8b6f7309b0 not-for-merge 'refs/pull/266/head' of https://github.com/apple/swift-collections
|
||||||
|
e6ecdac581c3f03f442111b87655702e0664df67 not-for-merge 'refs/pull/267/head' of https://github.com/apple/swift-collections
|
||||||
|
26de4aa094c87a9efd793f3ec37ec7facea24184 not-for-merge 'refs/pull/268/head' of https://github.com/apple/swift-collections
|
||||||
|
73936bba69bd49e025aa8113fa81caa7f76ed571 not-for-merge 'refs/pull/269/head' of https://github.com/apple/swift-collections
|
||||||
|
454c84b5e4253e462dac5472b853ee222ca93368 not-for-merge 'refs/pull/270/head' of https://github.com/apple/swift-collections
|
||||||
|
9c3328e235e5f17c06328f021a5d11e7221690f5 not-for-merge 'refs/pull/271/head' of https://github.com/apple/swift-collections
|
||||||
|
96d71628887275fc3010a0979cdebef15edde911 not-for-merge 'refs/pull/272/head' of https://github.com/apple/swift-collections
|
||||||
|
4b6978bf8b220c9c7d03d1f8124d537fbbddc32a not-for-merge 'refs/pull/273/head' of https://github.com/apple/swift-collections
|
||||||
|
7f63914a7161923e9af6889ed0f954de1ed4c7f0 not-for-merge 'refs/pull/275/head' of https://github.com/apple/swift-collections
|
||||||
|
ac0c075ccc0fe2754a41dd777bb39507e73efe5b not-for-merge 'refs/pull/276/head' of https://github.com/apple/swift-collections
|
||||||
|
a8dad62bc9c76f1f455a372c91759e7bb1e92e63 not-for-merge 'refs/pull/277/head' of https://github.com/apple/swift-collections
|
||||||
|
99adbe97af48017fb51d884936f6bb87c27166c2 not-for-merge 'refs/pull/278/head' of https://github.com/apple/swift-collections
|
||||||
|
b0fe7ca8bad02599faa6a308b561f71d4d5e3b4b not-for-merge 'refs/pull/279/head' of https://github.com/apple/swift-collections
|
||||||
|
dce7b156e7276d44c3641f05bf675d90fea16523 not-for-merge 'refs/pull/280/head' of https://github.com/apple/swift-collections
|
||||||
|
c4ad48826105dd299f45473784d22bf395b56863 not-for-merge 'refs/pull/281/head' of https://github.com/apple/swift-collections
|
||||||
|
1f204555aa1678c0aa01cfba5836920acb8c5537 not-for-merge 'refs/pull/282/head' of https://github.com/apple/swift-collections
|
||||||
|
992e0f3446b4f608f5ead9dd6edc92f6bbc0b0a0 not-for-merge 'refs/pull/283/head' of https://github.com/apple/swift-collections
|
||||||
|
3f4d8f9912c9c3f5bacef6e6543b3f5b93168f65 not-for-merge 'refs/pull/284/head' of https://github.com/apple/swift-collections
|
||||||
|
9befd06185369d287deee36f3d0de65f41edabb1 not-for-merge 'refs/pull/285/head' of https://github.com/apple/swift-collections
|
||||||
|
0c002200bbee27acc8a663075da69120f666b4e5 not-for-merge 'refs/pull/286/head' of https://github.com/apple/swift-collections
|
||||||
|
de0f59418503fc0c4866395751d930be3abbf17c not-for-merge 'refs/pull/287/head' of https://github.com/apple/swift-collections
|
||||||
|
a5fda7c368e06561fba87dd33c47443e879dd6da not-for-merge 'refs/pull/288/head' of https://github.com/apple/swift-collections
|
||||||
|
989bd7f2db6d9e7aa91881389359e369d621e3d9 not-for-merge 'refs/pull/29/head' of https://github.com/apple/swift-collections
|
||||||
|
03c9ef743ad9de4eea816225afa2fc52490339cc not-for-merge 'refs/pull/290/head' of https://github.com/apple/swift-collections
|
||||||
|
03e7f0c401e771f4a4c529eae4d3fde91f5bd355 not-for-merge 'refs/pull/291/head' of https://github.com/apple/swift-collections
|
||||||
|
9c88dce30d213e1844ce60476e7d3eba2b1f1e60 not-for-merge 'refs/pull/292/head' of https://github.com/apple/swift-collections
|
||||||
|
671cff0b3e579cbdad3a0818031063588d866ad2 not-for-merge 'refs/pull/296/head' of https://github.com/apple/swift-collections
|
||||||
|
b3d089e37d0d4adc07bdbf1ecd14e9be3786bf25 not-for-merge 'refs/pull/297/head' of https://github.com/apple/swift-collections
|
||||||
|
2f6606401781d32f3ffe3e245f2ba946acefe97e not-for-merge 'refs/pull/298/head' of https://github.com/apple/swift-collections
|
||||||
|
8fbc1d318cf4c48931ffc6d204123284087fad0c not-for-merge 'refs/pull/299/head' of https://github.com/apple/swift-collections
|
||||||
|
cf2cbd6ce39cbbdf7c60b76a8dca8e38b80486fc not-for-merge 'refs/pull/300/head' of https://github.com/apple/swift-collections
|
||||||
|
09c09f0f734c006186df57595dc823159e0d49ef not-for-merge 'refs/pull/302/head' of https://github.com/apple/swift-collections
|
||||||
|
a58628449c5e398433472b1bfd7e67e0acbe09fc not-for-merge 'refs/pull/303/head' of https://github.com/apple/swift-collections
|
||||||
|
a82138e714dcd28ef5b7bacc4b708acc118e87ac not-for-merge 'refs/pull/304/head' of https://github.com/apple/swift-collections
|
||||||
|
f15c276542595cb0f55368f8abf9f277e8eb51c9 not-for-merge 'refs/pull/304/merge' of https://github.com/apple/swift-collections
|
||||||
|
1cf3b88cb3d237183cacd6482e4adaec48b17bb5 not-for-merge 'refs/pull/307/head' of https://github.com/apple/swift-collections
|
||||||
|
ccd11e55dd4991055b260bb39d7e20ceb09046e9 not-for-merge 'refs/pull/31/head' of https://github.com/apple/swift-collections
|
||||||
|
9efa05c583441159bd505dfde340cf774d6144e0 not-for-merge 'refs/pull/314/head' of https://github.com/apple/swift-collections
|
||||||
|
bf742617f30870372ed235c6ccf4515ea0a204d3 not-for-merge 'refs/pull/315/head' of https://github.com/apple/swift-collections
|
||||||
|
b6e227b9d779fe7405dc410812cb3aba6d2b529a not-for-merge 'refs/pull/316/head' of https://github.com/apple/swift-collections
|
||||||
|
5c9ee680d20c4a758b0ffe06876fdca823f8068a not-for-merge 'refs/pull/316/merge' of https://github.com/apple/swift-collections
|
||||||
|
de2c8a90865dcdbd97defe3c2c7396a2400e455a not-for-merge 'refs/pull/318/head' of https://github.com/apple/swift-collections
|
||||||
|
bd1a62273592d5ad6ef388a70f53a61e33ffcba4 not-for-merge 'refs/pull/319/head' of https://github.com/apple/swift-collections
|
||||||
|
171edb86e347f70d85ac593c139d26d6b4d3bbea not-for-merge 'refs/pull/32/head' of https://github.com/apple/swift-collections
|
||||||
|
33f23b6a06f4a9f6b013fdef108cc1c82153afe4 not-for-merge 'refs/pull/320/head' of https://github.com/apple/swift-collections
|
||||||
|
b67553e0c27857bbc5310f40cd6e68daa2d585de not-for-merge 'refs/pull/321/head' of https://github.com/apple/swift-collections
|
||||||
|
b72a6cfd9384bc3ace8ea2412e79d5ec34c24934 not-for-merge 'refs/pull/322/head' of https://github.com/apple/swift-collections
|
||||||
|
90b67c44eae7277d57e79e3c25c5dc72939a3b93 not-for-merge 'refs/pull/323/head' of https://github.com/apple/swift-collections
|
||||||
|
19ec155f6089f6de453f8835aad46c09f5fd0b4d not-for-merge 'refs/pull/326/head' of https://github.com/apple/swift-collections
|
||||||
|
069dd4e5df442e813eb30e3216aafa1cc1696a5f not-for-merge 'refs/pull/327/head' of https://github.com/apple/swift-collections
|
||||||
|
53831b3a2264e24ca6c7ffcfe33c8653a72d51e1 not-for-merge 'refs/pull/328/head' of https://github.com/apple/swift-collections
|
||||||
|
0b7ebf49775a8e5177eeaea6513083363a20e08c not-for-merge 'refs/pull/330/head' of https://github.com/apple/swift-collections
|
||||||
|
3bd3d3ba28c6e72515e56b535b8a638144c12413 not-for-merge 'refs/pull/331/head' of https://github.com/apple/swift-collections
|
||||||
|
cd03e5b471755b00aa948f7de950932c2c5a5d79 not-for-merge 'refs/pull/332/head' of https://github.com/apple/swift-collections
|
||||||
|
e732790fde08a5e3d4f2347ac4a3e74399a5b457 not-for-merge 'refs/pull/333/head' of https://github.com/apple/swift-collections
|
||||||
|
d2662892cd218eec7b56e505e4a343e5c676f02c not-for-merge 'refs/pull/335/head' of https://github.com/apple/swift-collections
|
||||||
|
2bc657fad1def9519f88d851ef9e91bc01dc0e47 not-for-merge 'refs/pull/336/head' of https://github.com/apple/swift-collections
|
||||||
|
6e91291cb24b777f0db29a6d50a728a2183ab660 not-for-merge 'refs/pull/337/head' of https://github.com/apple/swift-collections
|
||||||
|
f9c400fb1c9bb8f2f76484b1a398135175d87a7f not-for-merge 'refs/pull/338/head' of https://github.com/apple/swift-collections
|
||||||
|
071a1011d1f02ac96f92164b0313c429a159ad80 not-for-merge 'refs/pull/339/head' of https://github.com/apple/swift-collections
|
||||||
|
85b00faba7071b5d7babb426bc06aa401ac8814d not-for-merge 'refs/pull/34/head' of https://github.com/apple/swift-collections
|
||||||
|
525bb545c4c1f7dfe3ca461136c072af11cb6e4f not-for-merge 'refs/pull/340/head' of https://github.com/apple/swift-collections
|
||||||
|
2122327782b71345cce48f23b688deebf582adc3 not-for-merge 'refs/pull/341/head' of https://github.com/apple/swift-collections
|
||||||
|
40e737cdaf9341d821cba518ee990406fdcfd7e3 not-for-merge 'refs/pull/342/head' of https://github.com/apple/swift-collections
|
||||||
|
8bfcaddd5fadea396620871451ca9ffc43b0985d not-for-merge 'refs/pull/343/head' of https://github.com/apple/swift-collections
|
||||||
|
9d4a1e5ad279e6bc1c4230f90208b0886608a387 not-for-merge 'refs/pull/346/head' of https://github.com/apple/swift-collections
|
||||||
|
cdcf9e606a12221e123a642353fbcfdc5d48bcf6 not-for-merge 'refs/pull/347/head' of https://github.com/apple/swift-collections
|
||||||
|
1a0ac8c06c5a27f330a55a61464eaebc840d89d1 not-for-merge 'refs/pull/348/head' of https://github.com/apple/swift-collections
|
||||||
|
258df0792818d6c331ccee923736ea234c24cf07 not-for-merge 'refs/pull/349/head' of https://github.com/apple/swift-collections
|
||||||
|
3c9df19f7da674295f58f07f6dffa5d9e114fbc8 not-for-merge 'refs/pull/351/head' of https://github.com/apple/swift-collections
|
||||||
|
c61e5e23c89b99d26aaaec6ec6c58bda1b502ebc not-for-merge 'refs/pull/352/head' of https://github.com/apple/swift-collections
|
||||||
|
8dd4e591b3789ea14e8bd8cfde3ed81aa6edfefe not-for-merge 'refs/pull/353/head' of https://github.com/apple/swift-collections
|
||||||
|
50f6307501712d87110f3f4d2e4959e84ace3bc8 not-for-merge 'refs/pull/354/head' of https://github.com/apple/swift-collections
|
||||||
|
8e64f064f562a918993b147a3872e92c157e8e01 not-for-merge 'refs/pull/355/head' of https://github.com/apple/swift-collections
|
||||||
|
ab744afc39dcd099948bfeb7fa4ac14f3f53dd36 not-for-merge 'refs/pull/356/head' of https://github.com/apple/swift-collections
|
||||||
|
75395832459d3e7a05ec51ca2ea081fe99be5e8e not-for-merge 'refs/pull/357/head' of https://github.com/apple/swift-collections
|
||||||
|
e004cd36fa3900c67d97f9944320a213e0d62c42 not-for-merge 'refs/pull/358/head' of https://github.com/apple/swift-collections
|
||||||
|
f6380bb6608eb7d43c199b3d0ab2f932d300a338 not-for-merge 'refs/pull/36/head' of https://github.com/apple/swift-collections
|
||||||
|
e424b487611d755ee5fe94271dd69677cc7688f0 not-for-merge 'refs/pull/360/head' of https://github.com/apple/swift-collections
|
||||||
|
a45e90653a50d7cd8813775cbf3d4ae546db1479 not-for-merge 'refs/pull/361/head' of https://github.com/apple/swift-collections
|
||||||
|
1e132acbb4f4aceea67ac8cdd5a4afdb1053e13d not-for-merge 'refs/pull/362/head' of https://github.com/apple/swift-collections
|
||||||
|
09566acb3a06bcb80147555395ae564402a4013f not-for-merge 'refs/pull/367/head' of https://github.com/apple/swift-collections
|
||||||
|
7425af3a9e29913aeb7cae881f9583f204ca5310 not-for-merge 'refs/pull/368/head' of https://github.com/apple/swift-collections
|
||||||
|
e9112f40dd90d800c46adf88c7ad58653276bb5b not-for-merge 'refs/pull/370/head' of https://github.com/apple/swift-collections
|
||||||
|
f46333d4a7c3a3f408131e268589203a68b84a09 not-for-merge 'refs/pull/371/head' of https://github.com/apple/swift-collections
|
||||||
|
37feda1bb815269044e98991edf011dd713eda6a not-for-merge 'refs/pull/372/head' of https://github.com/apple/swift-collections
|
||||||
|
ba5154b934dde320698eb31623e6c61eb63675e8 not-for-merge 'refs/pull/373/head' of https://github.com/apple/swift-collections
|
||||||
|
e3954cdae4f9e97fbd7fd3ec1e80f01abbd6c06c not-for-merge 'refs/pull/375/head' of https://github.com/apple/swift-collections
|
||||||
|
89b3ca0146f7371dd1008b0f6dd1d1452840deb2 not-for-merge 'refs/pull/377/head' of https://github.com/apple/swift-collections
|
||||||
|
8d9e1e32484fa7fbbfe49df058fee41304c47213 not-for-merge 'refs/pull/378/head' of https://github.com/apple/swift-collections
|
||||||
|
5d891350f44eecd7767b85259b1fd3068afe3c16 not-for-merge 'refs/pull/379/head' of https://github.com/apple/swift-collections
|
||||||
|
d46a563351a5785e7ece59366fb80d1ffc21885c not-for-merge 'refs/pull/38/head' of https://github.com/apple/swift-collections
|
||||||
|
ead6314a1cb17c2ecce50c80876f1998dcff6286 not-for-merge 'refs/pull/381/head' of https://github.com/apple/swift-collections
|
||||||
|
35d5746117b42a556f81cff9fe016fdff8a6ddaa not-for-merge 'refs/pull/382/head' of https://github.com/apple/swift-collections
|
||||||
|
a0b839d7de3278c5003836ad27d8ab75b994cbf5 not-for-merge 'refs/pull/384/head' of https://github.com/apple/swift-collections
|
||||||
|
d10a328f79966ada76c0f5e1f13922363e1d7da9 not-for-merge 'refs/pull/385/head' of https://github.com/apple/swift-collections
|
||||||
|
21264913c5162a74f58ef8af8d675417b188b8b9 not-for-merge 'refs/pull/386/head' of https://github.com/apple/swift-collections
|
||||||
|
dbf90bd36bcfdd3659670e26259e1a1cf40b053a not-for-merge 'refs/pull/388/head' of https://github.com/apple/swift-collections
|
||||||
|
5c2623b1a8d01d670455c739d07d94ba4f731f72 not-for-merge 'refs/pull/389/head' of https://github.com/apple/swift-collections
|
||||||
|
7d18b37a95732fa58da3863efb4f8f142524abdb not-for-merge 'refs/pull/390/head' of https://github.com/apple/swift-collections
|
||||||
|
094bf7ff2c87c735eb7ef41e1eeaedb41c8c0625 not-for-merge 'refs/pull/391/head' of https://github.com/apple/swift-collections
|
||||||
|
16fcea238788bd5d298d96a199ffbbeea9f0845a not-for-merge 'refs/pull/392/head' of https://github.com/apple/swift-collections
|
||||||
|
b7ccc7206cefa77e0139cd01af053895a5021fce not-for-merge 'refs/pull/393/head' of https://github.com/apple/swift-collections
|
||||||
|
79e3fee891cf132262e66a192424cc5a5892c732 not-for-merge 'refs/pull/394/head' of https://github.com/apple/swift-collections
|
||||||
|
7afbd7686e3ed22f4fff4f12721e944fb94c3476 not-for-merge 'refs/pull/394/merge' of https://github.com/apple/swift-collections
|
||||||
|
36d53620b57f2c90b897525049d50d327f174edf not-for-merge 'refs/pull/395/head' of https://github.com/apple/swift-collections
|
||||||
|
ce574378f856d15eec794157225005947534a6ca not-for-merge 'refs/pull/396/head' of https://github.com/apple/swift-collections
|
||||||
|
99b025049e3fd4f1a791349716d7e3a6e6742ea7 not-for-merge 'refs/pull/397/head' of https://github.com/apple/swift-collections
|
||||||
|
09f9340dcec6744e7238c0d966cad57e302a7419 not-for-merge 'refs/pull/398/head' of https://github.com/apple/swift-collections
|
||||||
|
f049c7ae66996b3be81be9688aecc313fb62d2bd not-for-merge 'refs/pull/399/head' of https://github.com/apple/swift-collections
|
||||||
|
3595ca05808ce018fb0fe6ca985a94d162cee300 not-for-merge 'refs/pull/4/head' of https://github.com/apple/swift-collections
|
||||||
|
ec1893ac2372b7f596f4bdcff9b3fdbf1017dddc not-for-merge 'refs/pull/40/head' of https://github.com/apple/swift-collections
|
||||||
|
105afd86fbc2a40752f4d522f7168c4f5c68f551 not-for-merge 'refs/pull/400/head' of https://github.com/apple/swift-collections
|
||||||
|
3dd72d1c289e56fac76e5a5416c397da4946e98d not-for-merge 'refs/pull/401/head' of https://github.com/apple/swift-collections
|
||||||
|
edb00bd980448f956a07b3d513f27fca32a1bb08 not-for-merge 'refs/pull/402/head' of https://github.com/apple/swift-collections
|
||||||
|
cf241c3b6c32196768d825f961fc9b00e7e6b53f not-for-merge 'refs/pull/402/merge' of https://github.com/apple/swift-collections
|
||||||
|
6b8276e2521d63ae1adc2cfd41d9e5a03e099ebe not-for-merge 'refs/pull/403/head' of https://github.com/apple/swift-collections
|
||||||
|
6f4d9d856088be4b13c96717e45e94f902bb3fbd not-for-merge 'refs/pull/404/head' of https://github.com/apple/swift-collections
|
||||||
|
5e1fe6e8f04e609273ee4fafe2fb77922ac2012f not-for-merge 'refs/pull/405/head' of https://github.com/apple/swift-collections
|
||||||
|
93efdb9e4e967a0ad0cb735721b0a67447cd12b2 not-for-merge 'refs/pull/406/head' of https://github.com/apple/swift-collections
|
||||||
|
effb33569a926c12dc8e7bae979ac2075c219189 not-for-merge 'refs/pull/408/head' of https://github.com/apple/swift-collections
|
||||||
|
e14970493019b12d03046f2fdd01b76fd4f9fa3c not-for-merge 'refs/pull/409/head' of https://github.com/apple/swift-collections
|
||||||
|
897b7c1a387e7afc2c95b4022c3b303137631c62 not-for-merge 'refs/pull/41/head' of https://github.com/apple/swift-collections
|
||||||
|
5dd817c33df1a62776320437413ad7afbeaab414 not-for-merge 'refs/pull/410/head' of https://github.com/apple/swift-collections
|
||||||
|
75aa5b075534ab11b77bc694f4c6d7cc29eab26f not-for-merge 'refs/pull/411/head' of https://github.com/apple/swift-collections
|
||||||
|
0f138be4021524c1b9b8fa2dcd343876695b8101 not-for-merge 'refs/pull/414/head' of https://github.com/apple/swift-collections
|
||||||
|
ca0c7a28b9b94243cac476dc4275db29f5d0be46 not-for-merge 'refs/pull/415/head' of https://github.com/apple/swift-collections
|
||||||
|
0030b455f42d9a8588cb9791fdf9e62a4d0e401c not-for-merge 'refs/pull/417/head' of https://github.com/apple/swift-collections
|
||||||
|
0030b455f42d9a8588cb9791fdf9e62a4d0e401c not-for-merge 'refs/pull/418/head' of https://github.com/apple/swift-collections
|
||||||
|
7f49fa5164ce43bd1bfcdfe2b43b56cdf6845bd6 not-for-merge 'refs/pull/419/head' of https://github.com/apple/swift-collections
|
||||||
|
ea4ab58dbc0a886c8a50752068de817ba1e49e81 not-for-merge 'refs/pull/42/head' of https://github.com/apple/swift-collections
|
||||||
|
fec5a5a6302e3bd30b71cbb8a22706d417eb5e72 not-for-merge 'refs/pull/421/head' of https://github.com/apple/swift-collections
|
||||||
|
a49afbe1be5e5d26f74b32d77057bf01ecee83bf not-for-merge 'refs/pull/422/head' of https://github.com/apple/swift-collections
|
||||||
|
ccff1fbf71b315e0eee2ba2b46b82dda44fac55d not-for-merge 'refs/pull/423/head' of https://github.com/apple/swift-collections
|
||||||
|
3f6522fef12acbebec4e425fec2a0d63a4bbac60 not-for-merge 'refs/pull/424/head' of https://github.com/apple/swift-collections
|
||||||
|
b33922ed298ce150cd04d0ee330d0c85b3cee104 not-for-merge 'refs/pull/426/head' of https://github.com/apple/swift-collections
|
||||||
|
84087c786e25744e47affbaae0b44415a973bdc9 not-for-merge 'refs/pull/427/head' of https://github.com/apple/swift-collections
|
||||||
|
ebe01ea88769ec788cdcd91bdd9d24ae5ceb4f55 not-for-merge 'refs/pull/43/head' of https://github.com/apple/swift-collections
|
||||||
|
2709b9694779797954b7bebb18764c2a85876e7e not-for-merge 'refs/pull/433/head' of https://github.com/apple/swift-collections
|
||||||
|
98d345bd66496d36a1d62506d405e6d7b07e8cde not-for-merge 'refs/pull/436/head' of https://github.com/apple/swift-collections
|
||||||
|
2d4df7d85dea6c6e81edb7a51de92c413318b6c9 not-for-merge 'refs/pull/438/head' of https://github.com/apple/swift-collections
|
||||||
|
8d73c7cced4d9a8403e2353f02359844bd33b5b9 not-for-merge 'refs/pull/438/merge' of https://github.com/apple/swift-collections
|
||||||
|
117c81053b70de031bac51b53288c001662c9305 not-for-merge 'refs/pull/44/head' of https://github.com/apple/swift-collections
|
||||||
|
4450b7c62274bc08f21b94269e0316e2799afe90 not-for-merge 'refs/pull/445/head' of https://github.com/apple/swift-collections
|
||||||
|
99db3179fcfab1add1a2418ed5f5392f25156aa0 not-for-merge 'refs/pull/446/head' of https://github.com/apple/swift-collections
|
||||||
|
40adeb7fd7e86a3d84779a2c43c2211ae805b804 not-for-merge 'refs/pull/447/head' of https://github.com/apple/swift-collections
|
||||||
|
1cb3ca50a63e8eac9dc503b3fba69ef56a99bce2 not-for-merge 'refs/pull/449/head' of https://github.com/apple/swift-collections
|
||||||
|
0f73f0ace74ea44f39ab10f23a3d2c03afd54a8f not-for-merge 'refs/pull/450/head' of https://github.com/apple/swift-collections
|
||||||
|
7ae9880f0fb622992a8a5328fd4af78af5b6b26f not-for-merge 'refs/pull/451/head' of https://github.com/apple/swift-collections
|
||||||
|
c5ac12e2bc1ad8df1df0ee9d1236ab6366a2c32d not-for-merge 'refs/pull/452/head' of https://github.com/apple/swift-collections
|
||||||
|
cfbebf4c80d0bc18609e7d603366f384dbdc8ef3 not-for-merge 'refs/pull/453/head' of https://github.com/apple/swift-collections
|
||||||
|
dbc2866bb5f564d3be3fa5efdda769f2946ee968 not-for-merge 'refs/pull/454/head' of https://github.com/apple/swift-collections
|
||||||
|
225159599d37106d63795d2df1bcbe0ccf7b3479 not-for-merge 'refs/pull/455/head' of https://github.com/apple/swift-collections
|
||||||
|
25509c0b883738629b6a13c013b6274538112fbe not-for-merge 'refs/pull/456/head' of https://github.com/apple/swift-collections
|
||||||
|
c42b4d1ebc5d198401f3efbcda36874b987e29d4 not-for-merge 'refs/pull/457/head' of https://github.com/apple/swift-collections
|
||||||
|
fd9ee69e0ba4e389a5ecdd85822a0932b0496e3c not-for-merge 'refs/pull/458/head' of https://github.com/apple/swift-collections
|
||||||
|
769bc7b07c3b986d928540d74d53bdd3d73f4374 not-for-merge 'refs/pull/459/head' of https://github.com/apple/swift-collections
|
||||||
|
ca939f84e132eb64a44db49c8092c8ec261b3092 not-for-merge 'refs/pull/46/head' of https://github.com/apple/swift-collections
|
||||||
|
75837a77174c0dd3243a599fae8c1675c64860d1 not-for-merge 'refs/pull/460/head' of https://github.com/apple/swift-collections
|
||||||
|
3fffe2ad2062eabe93dddb4d2e37bffe9bf278dc not-for-merge 'refs/pull/461/head' of https://github.com/apple/swift-collections
|
||||||
|
fea1d24e7be3284646858ef7bc9950f685adaa61 not-for-merge 'refs/pull/462/head' of https://github.com/apple/swift-collections
|
||||||
|
b765db28be0063341ef9048a8c4f6efb8b0e4cbd not-for-merge 'refs/pull/463/head' of https://github.com/apple/swift-collections
|
||||||
|
6385ebbe4d5fd7235dca833a4b041b14f06d46cf not-for-merge 'refs/pull/464/head' of https://github.com/apple/swift-collections
|
||||||
|
7ec497fb53a8d0a680576f1e0b0781fb71c99429 not-for-merge 'refs/pull/47/head' of https://github.com/apple/swift-collections
|
||||||
|
a7fec81fc4c4af5e49d2ce19f3b39ef51202fd22 not-for-merge 'refs/pull/470/head' of https://github.com/apple/swift-collections
|
||||||
|
117a1aad2d8194b8632bb46483573bb0b7aa50f1 not-for-merge 'refs/pull/471/head' of https://github.com/apple/swift-collections
|
||||||
|
0bd8dec08b8bb484f4a39e8d241a40c8e0c8a051 not-for-merge 'refs/pull/472/head' of https://github.com/apple/swift-collections
|
||||||
|
d5f3b81e690242c750deb4ede721539250297bd6 not-for-merge 'refs/pull/473/head' of https://github.com/apple/swift-collections
|
||||||
|
c25a388c1375c634d42a6f5608a863a4b6aace2c not-for-merge 'refs/pull/474/head' of https://github.com/apple/swift-collections
|
||||||
|
a09d7cdf458769f6f9586a2782b03d624d710428 not-for-merge 'refs/pull/475/head' of https://github.com/apple/swift-collections
|
||||||
|
e1be57c0f55f90d9489773dec69449bd86fce692 not-for-merge 'refs/pull/476/head' of https://github.com/apple/swift-collections
|
||||||
|
1812091001b264fc9b0db6a4b847ef9b330f5fd4 not-for-merge 'refs/pull/478/head' of https://github.com/apple/swift-collections
|
||||||
|
e337c2b936320f538fc8b9e8b66d51bff56646e4 not-for-merge 'refs/pull/479/head' of https://github.com/apple/swift-collections
|
||||||
|
137bd464c14792cadc721d90afd412c2e7735424 not-for-merge 'refs/pull/48/head' of https://github.com/apple/swift-collections
|
||||||
|
6819c6d1491caf6819649a0635d35956700cd165 not-for-merge 'refs/pull/480/head' of https://github.com/apple/swift-collections
|
||||||
|
c50c57f52eaae3f241dd3a4338d05f199d0bd94d not-for-merge 'refs/pull/481/head' of https://github.com/apple/swift-collections
|
||||||
|
83457e19c8b17548bf30f43bdfe7563cc6621633 not-for-merge 'refs/pull/482/head' of https://github.com/apple/swift-collections
|
||||||
|
d5568ee899f3b9487cf752cb28027910f84aad46 not-for-merge 'refs/pull/483/head' of https://github.com/apple/swift-collections
|
||||||
|
65f99e3eff346e5033d9ac9310021a4ff55a9a64 not-for-merge 'refs/pull/485/head' of https://github.com/apple/swift-collections
|
||||||
|
fc411858ee8c2d1ca4113555c185752f5d8212f5 not-for-merge 'refs/pull/486/head' of https://github.com/apple/swift-collections
|
||||||
|
05ad8633a8d6e407bd9dfce36da47b3fe36b3da2 not-for-merge 'refs/pull/487/head' of https://github.com/apple/swift-collections
|
||||||
|
a1f8f33a5b5ae60c6bdad045176e3fc96427a0a5 not-for-merge 'refs/pull/488/head' of https://github.com/apple/swift-collections
|
||||||
|
3fe754c0804d97b446a8ca70b3e4aa8d9c17c831 not-for-merge 'refs/pull/49/head' of https://github.com/apple/swift-collections
|
||||||
|
2d5ed365ff0a9d7c7bf9a84e8ac0692b80caed92 not-for-merge 'refs/pull/490/head' of https://github.com/apple/swift-collections
|
||||||
|
9f68408a70172d6c4ce3ba4e3a7528f489629e43 not-for-merge 'refs/pull/491/head' of https://github.com/apple/swift-collections
|
||||||
|
2fe5c4a31d9e599dfb55d21a02b8f9b74c5b3165 not-for-merge 'refs/pull/492/head' of https://github.com/apple/swift-collections
|
||||||
|
1f8dcd4726717a4b724e5408ca24829bdef67a49 not-for-merge 'refs/pull/493/head' of https://github.com/apple/swift-collections
|
||||||
|
c883a1729ac36de8cfc30729dd9650a98ce5c5a3 not-for-merge 'refs/pull/494/head' of https://github.com/apple/swift-collections
|
||||||
|
f44eabc30ee11beb77577f4c059bc6cf1643885b not-for-merge 'refs/pull/495/head' of https://github.com/apple/swift-collections
|
||||||
|
ab46d6415275a4f1c66923aad5d9759394b9999f not-for-merge 'refs/pull/496/head' of https://github.com/apple/swift-collections
|
||||||
|
31183bf059d3d1698d2b5e10a17bfb892a57876b not-for-merge 'refs/pull/497/head' of https://github.com/apple/swift-collections
|
||||||
|
b33592ebce263de67a30234f5952833e58f519f8 not-for-merge 'refs/pull/498/head' of https://github.com/apple/swift-collections
|
||||||
|
efac873c1357fd397b246e601021930c2a1561b9 not-for-merge 'refs/pull/499/head' of https://github.com/apple/swift-collections
|
||||||
|
efac873c1357fd397b246e601021930c2a1561b9 not-for-merge 'refs/pull/500/head' of https://github.com/apple/swift-collections
|
||||||
|
f6256df2628c6dc9b8637a81c9ce3bd9354b4df5 not-for-merge 'refs/pull/501/head' of https://github.com/apple/swift-collections
|
||||||
|
a98fbaba8b4236b1f98a9a4ee8269f5beb054425 not-for-merge 'refs/pull/502/head' of https://github.com/apple/swift-collections
|
||||||
|
8db0c6eb7539e9e73dce1a74be02093f531c8c60 not-for-merge 'refs/pull/503/head' of https://github.com/apple/swift-collections
|
||||||
|
890f33b56a7c03f5d8c680b5b566b07669adf6fb not-for-merge 'refs/pull/504/head' of https://github.com/apple/swift-collections
|
||||||
|
16f8d5b6bf91315e87e3922d2acae52d38c6bfe1 not-for-merge 'refs/pull/505/head' of https://github.com/apple/swift-collections
|
||||||
|
4ea19dd03e4c9632ee09a94f32f18a12df02da9e not-for-merge 'refs/pull/506/head' of https://github.com/apple/swift-collections
|
||||||
|
21757b6add20f67be68cad7c3b36e3ef3ec6875a not-for-merge 'refs/pull/507/head' of https://github.com/apple/swift-collections
|
||||||
|
5cd51978a44752b0432329904e72fe3ffbe22523 not-for-merge 'refs/pull/508/head' of https://github.com/apple/swift-collections
|
||||||
|
5f92d48d0d590f68340a9a6acdf0c7ae9638bb7e not-for-merge 'refs/pull/509/head' of https://github.com/apple/swift-collections
|
||||||
|
dff2b1783badd696c3135abd8e26c3b7ad2e67fd not-for-merge 'refs/pull/51/head' of https://github.com/apple/swift-collections
|
||||||
|
434d30dae30fbb57699370260c69c2108c1abf8f not-for-merge 'refs/pull/512/head' of https://github.com/apple/swift-collections
|
||||||
|
fb75c41e703f7ddaf22edfce3488c888cbb3136e not-for-merge 'refs/pull/512/merge' of https://github.com/apple/swift-collections
|
||||||
|
43367db377713ef76db6e16df128a96a7db5a805 not-for-merge 'refs/pull/513/head' of https://github.com/apple/swift-collections
|
||||||
|
50a9c6c655766bcbabac0453a5dce8cddd3e8cf7 not-for-merge 'refs/pull/514/head' of https://github.com/apple/swift-collections
|
||||||
|
101bac2d30cce81a24eb2b223aa64d928ca6598d not-for-merge 'refs/pull/515/head' of https://github.com/apple/swift-collections
|
||||||
|
6340fa0e9b818a19be3982951106ec6071075843 not-for-merge 'refs/pull/516/head' of https://github.com/apple/swift-collections
|
||||||
|
90934a152b26db2cacafaf26c0f85261815f2344 not-for-merge 'refs/pull/517/head' of https://github.com/apple/swift-collections
|
||||||
|
3fe1bb8561a92c45a56afb6dc3af95755cc2273c not-for-merge 'refs/pull/519/head' of https://github.com/apple/swift-collections
|
||||||
|
42e6045ca1307cdd5b13a69aef57336abb7ea486 not-for-merge 'refs/pull/519/merge' of https://github.com/apple/swift-collections
|
||||||
|
5e6b95649be69c3e9d6c326f2a7c2ba88c25f460 not-for-merge 'refs/pull/52/head' of https://github.com/apple/swift-collections
|
||||||
|
377d2a55a8b9506d56d4eedefe26565240b208f0 not-for-merge 'refs/pull/520/head' of https://github.com/apple/swift-collections
|
||||||
|
4cc6cad50849690b2c551f306265509d7168730b not-for-merge 'refs/pull/521/head' of https://github.com/apple/swift-collections
|
||||||
|
b88e7d0b00bcd6721cc0b11ac2b948a32a9c444d not-for-merge 'refs/pull/522/head' of https://github.com/apple/swift-collections
|
||||||
|
f4d851a739f6f1cfd0364b1c1ceb9aadd1b07f2f not-for-merge 'refs/pull/523/head' of https://github.com/apple/swift-collections
|
||||||
|
68eca941248011fdc278bd752f39fbd3f62e46c7 not-for-merge 'refs/pull/524/head' of https://github.com/apple/swift-collections
|
||||||
|
ccb60599696d69e9744224367ca927e6ba3f74b3 not-for-merge 'refs/pull/525/head' of https://github.com/apple/swift-collections
|
||||||
|
bb8d56683375160bdcc35d0ca7dcf06bb875bece not-for-merge 'refs/pull/526/head' of https://github.com/apple/swift-collections
|
||||||
|
ab7ca1353732f13ba1b24f4b95efe3dc148a0a73 not-for-merge 'refs/pull/527/head' of https://github.com/apple/swift-collections
|
||||||
|
f9669dc4186220345d8a856550386492aae74e5c not-for-merge 'refs/pull/528/head' of https://github.com/apple/swift-collections
|
||||||
|
b0f915ac6981dc529e3aa27bfe13b93c067da83e not-for-merge 'refs/pull/529/head' of https://github.com/apple/swift-collections
|
||||||
|
cf2685d6213f8966f3e453d66aa51c5f993cb514 not-for-merge 'refs/pull/529/merge' of https://github.com/apple/swift-collections
|
||||||
|
4432aa8d79607328e6e96ae2a89f878ea58b27fe not-for-merge 'refs/pull/53/head' of https://github.com/apple/swift-collections
|
||||||
|
fa45e0e0cc63150df8ff27c49a8385f1f989d2fb not-for-merge 'refs/pull/530/head' of https://github.com/apple/swift-collections
|
||||||
|
a2a4bbd3dcd04b7ec7fc84258c1732298fb95d60 not-for-merge 'refs/pull/530/merge' of https://github.com/apple/swift-collections
|
||||||
|
ea71bf2ce9a4420dc5716b94c0e02032ef3b0486 not-for-merge 'refs/pull/531/head' of https://github.com/apple/swift-collections
|
||||||
|
347825dd43e9c7f3b346aff777ae4a0cffe93ea0 not-for-merge 'refs/pull/532/head' of https://github.com/apple/swift-collections
|
||||||
|
ab649ca5fc756ed6b3b290662fb273460c4a038b not-for-merge 'refs/pull/533/head' of https://github.com/apple/swift-collections
|
||||||
|
a7e40d20551cab8bdf753c9ea809f80443903290 not-for-merge 'refs/pull/534/head' of https://github.com/apple/swift-collections
|
||||||
|
55130cecb0b8eb39da9c281e9124168d1068079e not-for-merge 'refs/pull/535/head' of https://github.com/apple/swift-collections
|
||||||
|
e5e653088cb61fae41189623b208cb100624b0d8 not-for-merge 'refs/pull/535/merge' of https://github.com/apple/swift-collections
|
||||||
|
fa408c9e853e04971c32351bab2e2200c4e07349 not-for-merge 'refs/pull/536/head' of https://github.com/apple/swift-collections
|
||||||
|
f2b51f49978351bc6225aec3cb18a916b0c4245a not-for-merge 'refs/pull/536/merge' of https://github.com/apple/swift-collections
|
||||||
|
65d174cc77577c972ed34fa03efa8c982c72f0ac not-for-merge 'refs/pull/537/head' of https://github.com/apple/swift-collections
|
||||||
|
e2e2d0088182c22f26a007d3ea4fdb3497cc830a not-for-merge 'refs/pull/538/head' of https://github.com/apple/swift-collections
|
||||||
|
0cf0071c1db25a01454339aa1dac6f8c984f8244 not-for-merge 'refs/pull/538/merge' of https://github.com/apple/swift-collections
|
||||||
|
691855a60fca67d1176bbca0871ce9beb9170bbb not-for-merge 'refs/pull/539/head' of https://github.com/apple/swift-collections
|
||||||
|
788c17c16b5bc88466d1a0a96f0bf7eaef8b9352 not-for-merge 'refs/pull/54/head' of https://github.com/apple/swift-collections
|
||||||
|
8b3d2878dea8451badb5b66094e7b4d0e4d1c658 not-for-merge 'refs/pull/540/head' of https://github.com/apple/swift-collections
|
||||||
|
ddff9004d745c4b2d13e35ebdd1a3d0b67e14717 not-for-merge 'refs/pull/541/head' of https://github.com/apple/swift-collections
|
||||||
|
5e091e541bc88b91fc750bb908aab665a4f13334 not-for-merge 'refs/pull/541/merge' of https://github.com/apple/swift-collections
|
||||||
|
1d63cdd629a5fc9e2b15a25b667f804d872f9e47 not-for-merge 'refs/pull/542/head' of https://github.com/apple/swift-collections
|
||||||
|
a617f5e2b1e7bee60846a2033645f28015990d2b not-for-merge 'refs/pull/543/head' of https://github.com/apple/swift-collections
|
||||||
|
92baa3289dee72e28b5add8cfd88c3a909d68f17 not-for-merge 'refs/pull/545/head' of https://github.com/apple/swift-collections
|
||||||
|
cf642a237ea2c2c0e345fdb856e203d418aedc81 not-for-merge 'refs/pull/545/merge' of https://github.com/apple/swift-collections
|
||||||
|
347825dd43e9c7f3b346aff777ae4a0cffe93ea0 not-for-merge 'refs/pull/547/head' of https://github.com/apple/swift-collections
|
||||||
|
2b5cbd54cf8d59e4780dad338b31cf31c3ea4aa8 not-for-merge 'refs/pull/547/merge' of https://github.com/apple/swift-collections
|
||||||
|
68c32ed80b138d1f8a6ec5dcf1b105f32fcaaf12 not-for-merge 'refs/pull/548/head' of https://github.com/apple/swift-collections
|
||||||
|
c35ca292f71bd0c2d7eb37e1dbac9318fae5bb68 not-for-merge 'refs/pull/548/merge' of https://github.com/apple/swift-collections
|
||||||
|
ad176827391e97cc1a919f38f3f6af655184facb not-for-merge 'refs/pull/549/head' of https://github.com/apple/swift-collections
|
||||||
|
ff76bbdc775050687a8495f75122faa8fef37d84 not-for-merge 'refs/pull/55/head' of https://github.com/apple/swift-collections
|
||||||
|
353496019475df6047027998d1cf69505fef172f not-for-merge 'refs/pull/550/head' of https://github.com/apple/swift-collections
|
||||||
|
7f8a32c5a9c9b07f57ebef314a5a289c7eb51021 not-for-merge 'refs/pull/551/head' of https://github.com/apple/swift-collections
|
||||||
|
a0e7df98638abbdaa452dc08b929538bda94ae18 not-for-merge 'refs/pull/552/head' of https://github.com/apple/swift-collections
|
||||||
|
174b3135c3a20094b65edb2e2c4e3e1c13693d58 not-for-merge 'refs/pull/553/head' of https://github.com/apple/swift-collections
|
||||||
|
c5d5d97c43f38162bb49fef37149ea6c4a136c50 not-for-merge 'refs/pull/554/head' of https://github.com/apple/swift-collections
|
||||||
|
7c926083822b8eea2c51cf6c6f425b9101b5d2bf not-for-merge 'refs/pull/556/head' of https://github.com/apple/swift-collections
|
||||||
|
b36804e15608a070213059cf803cc353ef88263a not-for-merge 'refs/pull/556/merge' of https://github.com/apple/swift-collections
|
||||||
|
80b8a6fb80ebb1ce7f5e918027b77a99fc57759c not-for-merge 'refs/pull/557/head' of https://github.com/apple/swift-collections
|
||||||
|
6274c2a3b1ed55f5227f54b37c1437b74a060b3c not-for-merge 'refs/pull/558/head' of https://github.com/apple/swift-collections
|
||||||
|
81514112d770a2ceeddcbba2207204661f40c650 not-for-merge 'refs/pull/558/merge' of https://github.com/apple/swift-collections
|
||||||
|
fdaf3fe24c72ac54998e4d2361aaa5d9fedd12b5 not-for-merge 'refs/pull/560/head' of https://github.com/apple/swift-collections
|
||||||
|
59bb505b1a18e05342fd58d873a967b484f2f268 not-for-merge 'refs/pull/562/head' of https://github.com/apple/swift-collections
|
||||||
|
de54709ca9dffef94ee4f1b04f6a6483d620d1a3 not-for-merge 'refs/pull/563/head' of https://github.com/apple/swift-collections
|
||||||
|
d1efcb19db72befae31841116b43586350a60a72 not-for-merge 'refs/pull/565/head' of https://github.com/apple/swift-collections
|
||||||
|
b7d2cf8908b42f35b5a3929e18ab63d3a321a3a1 not-for-merge 'refs/pull/58/head' of https://github.com/apple/swift-collections
|
||||||
|
956ea437dfdfcc6b431315fe54a9388271f5b512 not-for-merge 'refs/pull/6/head' of https://github.com/apple/swift-collections
|
||||||
|
175e1eb6b3395e3808218e72910235658819bdc5 not-for-merge 'refs/pull/60/head' of https://github.com/apple/swift-collections
|
||||||
|
083102e1e2bad3bfc869f3072f134fc48a401550 not-for-merge 'refs/pull/61/head' of https://github.com/apple/swift-collections
|
||||||
|
0b963fe790c694a35c34b2b31caa84a681f74945 not-for-merge 'refs/pull/62/head' of https://github.com/apple/swift-collections
|
||||||
|
cfe64fa1f270ff99ae4c5bf36502656d5c77596f not-for-merge 'refs/pull/63/head' of https://github.com/apple/swift-collections
|
||||||
|
787c4fac1492d4d877127ccf086195b5051b5c01 not-for-merge 'refs/pull/64/head' of https://github.com/apple/swift-collections
|
||||||
|
478d3cc73fe173775883d35a2b0a073567da3b4b not-for-merge 'refs/pull/65/head' of https://github.com/apple/swift-collections
|
||||||
|
2a60245369a3e88485ed4f8c92ca5c378ce77c06 not-for-merge 'refs/pull/76/head' of https://github.com/apple/swift-collections
|
||||||
|
b70b6fec29949244914e7501725deb52370da0b0 not-for-merge 'refs/pull/77/head' of https://github.com/apple/swift-collections
|
||||||
|
303e1990d6317fc39c5c072483d18e4f0235db60 not-for-merge 'refs/pull/78/head' of https://github.com/apple/swift-collections
|
||||||
|
cfaab9a73140595755a5bcc138c45ad817dc4f44 not-for-merge 'refs/pull/79/head' of https://github.com/apple/swift-collections
|
||||||
|
8de85365808b3d984148d0adb5dc36125a71c36f not-for-merge 'refs/pull/8/head' of https://github.com/apple/swift-collections
|
||||||
|
976a8c276338a00a436f620f9cb8d3c0f74f6d81 not-for-merge 'refs/pull/80/head' of https://github.com/apple/swift-collections
|
||||||
|
1146f2644c44ff629c5dfc8f5588d6d4cc734a22 not-for-merge 'refs/pull/80/merge' of https://github.com/apple/swift-collections
|
||||||
|
07134a5bce472917a2fc2f2a19401861a688530b not-for-merge 'refs/pull/81/head' of https://github.com/apple/swift-collections
|
||||||
|
3d4aca47308287244f2dd7b9c2085dea63c27b60 not-for-merge 'refs/pull/82/head' of https://github.com/apple/swift-collections
|
||||||
|
140a1752fdfa9788db9e5b23ee32d8bdbed4c9f7 not-for-merge 'refs/pull/83/head' of https://github.com/apple/swift-collections
|
||||||
|
40ab3405b25719a490abea591d80b7ce27ab8803 not-for-merge 'refs/pull/84/head' of https://github.com/apple/swift-collections
|
||||||
|
52f77c4486ea96a3c5ed373932ee0220d5a7ebee not-for-merge 'refs/pull/85/head' of https://github.com/apple/swift-collections
|
||||||
|
029dd258a1d19cbcd8e22523b0811c7dcb6d450a not-for-merge 'refs/pull/86/head' of https://github.com/apple/swift-collections
|
||||||
|
8ef7b779cd17e95baeef138a612c48b063036aca not-for-merge 'refs/pull/87/head' of https://github.com/apple/swift-collections
|
||||||
|
ace0073826e3e2bdc1b08d78e6fe6f1efc700aab not-for-merge 'refs/pull/9/head' of https://github.com/apple/swift-collections
|
||||||
|
e78605942c7fb08c3e8812a166e85bcae2ddd15e not-for-merge 'refs/pull/91/head' of https://github.com/apple/swift-collections
|
||||||
|
7c68b05ed94391100c1933770cd18e95533bd53d not-for-merge 'refs/pull/92/head' of https://github.com/apple/swift-collections
|
||||||
|
d6774be62f7f857f0b30dae49a3a6f02b58e5679 not-for-merge 'refs/pull/93/head' of https://github.com/apple/swift-collections
|
||||||
|
6192ad1fc8795b647f8fb9eec20257ae2cb4716d not-for-merge 'refs/pull/95/head' of https://github.com/apple/swift-collections
|
||||||
|
dcdd7ebabbdcac6442e45db68f6e3d10b96f0c88 not-for-merge 'refs/pull/96/head' of https://github.com/apple/swift-collections
|
||||||
|
41bfaaae0af3809b808405df0fab4a5e02ddd206 not-for-merge 'refs/pull/97/head' of https://github.com/apple/swift-collections
|
||||||
|
462fb1d3fc318f4e1f81d6945b87f1dc32dd8079 not-for-merge 'refs/pull/98/head' of https://github.com/apple/swift-collections
|
||||||
|
c1c9541a3b91ec076076ad8e9a4e28a00b80cb6b not-for-merge 'refs/pull/99/head' of https://github.com/apple/swift-collections
|
||||||
|
bd924007a25488900d2e3f3437a0388a97636020 not-for-merge tag '0.0.1' of https://github.com/apple/swift-collections
|
||||||
|
2d719d75a2065f213e58a5164384a3d2fcf9b59a not-for-merge tag '0.0.2' of https://github.com/apple/swift-collections
|
||||||
|
d45e63421d3dff834949ac69d3c37691e994bd69 not-for-merge tag '0.0.3' of https://github.com/apple/swift-collections
|
||||||
|
3426dba9ee5c9f8e4981b0fc9d39a818d36eec28 not-for-merge tag '0.0.4' of https://github.com/apple/swift-collections
|
||||||
|
0959ba76a1d4a98fd11163aa83fd49c25b93bfae not-for-merge tag '0.0.5' of https://github.com/apple/swift-collections
|
||||||
|
9d8719c8bebdc79740b6969c912ac706eb721d7a not-for-merge tag '0.0.7' of https://github.com/apple/swift-collections
|
||||||
|
07e47b1e93e5a1e0ef0c50fcb2d6739fb6be4003 not-for-merge tag '1.0.0' of https://github.com/apple/swift-collections
|
||||||
|
2d33a0ea89c961dcb2b3da2157963d9c0370347e not-for-merge tag '1.0.1' of https://github.com/apple/swift-collections
|
||||||
|
48254824bb4248676bf7ce56014ff57b142b77eb not-for-merge tag '1.0.2' of https://github.com/apple/swift-collections
|
||||||
|
f504716c27d2e5d4144fa4794b12129301d17729 not-for-merge tag '1.0.3' of https://github.com/apple/swift-collections
|
||||||
|
937e904258d22af6e447a0b72c0bc67583ef64a2 not-for-merge tag '1.0.4' of https://github.com/apple/swift-collections
|
||||||
|
a902f1823a7ff3c9ab2fba0f992396b948eda307 not-for-merge tag '1.0.5' of https://github.com/apple/swift-collections
|
||||||
|
d029d9d39c87bed85b1c50adee7c41795261a192 not-for-merge tag '1.0.6' of https://github.com/apple/swift-collections
|
||||||
|
94cf62b3ba8d4bed62680a282d4c25f9c63c2efb not-for-merge tag '1.1.0' of https://github.com/apple/swift-collections
|
||||||
|
ee97538f5b81ae89698fd95938896dec5217b148 not-for-merge tag '1.1.1' of https://github.com/apple/swift-collections
|
||||||
|
3d2dc41a01f9e49d84f0a3925fb858bed64f702d not-for-merge tag '1.1.2' of https://github.com/apple/swift-collections
|
||||||
|
9bf03ff58ce34478e66aaee630e491823326fd06 not-for-merge tag '1.1.3' of https://github.com/apple/swift-collections
|
||||||
|
671108c96644956dddcd89dd59c203dcdb36cec7 not-for-merge tag '1.1.4' of https://github.com/apple/swift-collections
|
||||||
|
ad9f6723fd29591e6c0a19a9c4982cae51d65b1d not-for-merge tag '1.1.5' of https://github.com/apple/swift-collections
|
||||||
|
c11818f3cae0780656baa430b49e7f163f08dffd not-for-merge tag '1.1.6' of https://github.com/apple/swift-collections
|
||||||
|
c1805596154bb3a265fd91b8ac0c4433b4348fb0 not-for-merge tag '1.2.0' of https://github.com/apple/swift-collections
|
||||||
|
8c0c0a8b49e080e54e5e328cc552821ff07cd341 not-for-merge tag '1.2.1' of https://github.com/apple/swift-collections
|
||||||
|
7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e not-for-merge tag '1.3.0' of https://github.com/apple/swift-collections
|
||||||
1
_spm/repositories/swift-collections-9a58d5cf/HEAD
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ref: refs/heads/main
|
||||||
10
_spm/repositories/swift-collections-9a58d5cf/config
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[core]
|
||||||
|
repositoryformatversion = 0
|
||||||
|
filemode = true
|
||||||
|
bare = true
|
||||||
|
ignorecase = true
|
||||||
|
precomposeunicode = true
|
||||||
|
[remote "origin"]
|
||||||
|
url = https://github.com/apple/swift-collections.git
|
||||||
|
fetch = +refs/*:refs/*
|
||||||
|
mirror = true
|
||||||
1
_spm/repositories/swift-collections-9a58d5cf/description
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Unnamed repository; edit this file 'description' to name the repository.
|
||||||
15
_spm/repositories/swift-collections-9a58d5cf/hooks/applypatch-msg.sample
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to check the commit log message taken by
|
||||||
|
# applypatch from an e-mail message.
|
||||||
|
#
|
||||||
|
# The hook should exit with non-zero status after issuing an
|
||||||
|
# appropriate message if it wants to stop the commit. The hook is
|
||||||
|
# allowed to edit the commit message file.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "applypatch-msg".
|
||||||
|
|
||||||
|
. git-sh-setup
|
||||||
|
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
||||||
|
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
||||||
|
:
|
||||||
24
_spm/repositories/swift-collections-9a58d5cf/hooks/commit-msg.sample
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to check the commit log message.
|
||||||
|
# Called by "git commit" with one argument, the name of the file
|
||||||
|
# that has the commit message. The hook should exit with non-zero
|
||||||
|
# status after issuing an appropriate message if it wants to stop the
|
||||||
|
# commit. The hook is allowed to edit the commit message file.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "commit-msg".
|
||||||
|
|
||||||
|
# Uncomment the below to add a Signed-off-by line to the message.
|
||||||
|
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
||||||
|
# hook is more suited to it.
|
||||||
|
#
|
||||||
|
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||||
|
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
||||||
|
|
||||||
|
# This example catches duplicate Signed-off-by lines.
|
||||||
|
|
||||||
|
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||||
|
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
||||||
|
echo >&2 Duplicate Signed-off-by lines.
|
||||||
|
exit 1
|
||||||
|
}
|
||||||