Compare commits
452 Commits
cb5819e330
...
dev_语音_tab
| 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 | |||
| 9cdd024ce2 | |||
| d612346db5 | |||
| 2cacaab974 | |||
| 200b1ab9f8 | |||
| 8d0939cd78 | |||
| df51620ca9 | |||
| 70520fb7d9 | |||
| 7587fe6714 | |||
| 1c8834caf6 | |||
| 68306aa07f | |||
| 9544ad75ff | |||
| d90e080981 | |||
| fc65052583 | |||
| e0d5ae0257 | |||
| 639ce7eafd | |||
| e0379d3717 | |||
| c108077178 | |||
| 182e5b9da1 | |||
| ea4ecc05b4 | |||
| ae05127292 | |||
| 38a3d2879e | |||
| 10ba4cd80f | |||
| 857822c49c | |||
| 85012eab78 | |||
| 8aa43d723a | |||
| 1ecb7d60e5 | |||
| 904a6c932a | |||
| 886de394d0 | |||
| 8bad475288 | |||
| 4a26419e67 | |||
| 1e04e7c39a | |||
| dde8716262 | |||
| c6b4444589 | |||
| 5bd20a911f | |||
| b43567748c | |||
| 59297eac77 | |||
| 9f7d805a52 | |||
| 6800864866 | |||
| e8a980ff5b | |||
| dfbd5efe69 | |||
| 4a474e9b44 | |||
| 30f2e4f24f | |||
| c898d16688 | |||
| f10ddd9a31 | |||
| 1651258eec | |||
| fd0ddfd45a | |||
| 444877fb73 | |||
| 1436464eca | |||
| 05b9a0b823 | |||
| 053001170a | |||
| 9cafb0f70e | |||
| a399af53b5 | |||
| a7574cd286 | |||
| 06dee39566 | |||
| d5b4ef2b59 | |||
| 2620bd6845 | |||
| 1f0bdb1bd4 | |||
| b21a2a8193 | |||
| 1f07120289 | |||
| 64e0218add | |||
| 7972188ac3 | |||
| 6963c8016f | |||
| 633e6a9123 | |||
| 437f796a08 | |||
| 2b08dd44ee | |||
| 1eeeef266b | |||
| 3813974eae | |||
| 6a5bda44e6 | |||
| 704553cd4e | |||
| 35597f89ca | |||
| 577b749198 | |||
| cccced6afa | |||
| 14637a21ad | |||
| 111fe42782 | |||
| 58da905ade | |||
| f338a54e41 | |||
| 526ac1a7df | |||
| 7f90240731 | |||
| e4442afe72 | |||
| be1d1ad70d | |||
| 4fd0a52a36 | |||
| 04c7d19c37 | |||
| 94269209e0 | |||
| 2b4123741a | |||
| 45695364e9 | |||
| d348b35984 | |||
| e39104c431 | |||
| 7b86b739eb | |||
| ade23e7a20 | |||
| 1b2b0c1143 | |||
| 0400d2020b | |||
| 2cc93e0b48 | |||
| fd8c08316b | |||
| 0a1c30f669 | |||
| ca3ea0630e | |||
| a26b6b58a9 | |||
| 6fd4a86a7e | |||
| fa999f502f | |||
| ad18a47d21 | |||
| d2258883df | |||
| f7d11c5f8b | |||
| 40d9b5aad4 | |||
| 6ac6514f89 | |||
| eb7ad1a9f1 | |||
| 17ce91d40a | |||
| 515487e748 | |||
| 7a25a6a5fa | |||
| 64887054e0 | |||
| 8f63741d8c | |||
| f593ef0b4a | |||
| 231f7f8c13 | |||
| c9863cd353 | |||
| ce4c4f0531 | |||
| af2bcc42fd | |||
| 1596aac717 | |||
| d13bb734b1 | |||
| 2665b5ad1f | |||
| cffd77eeb5 | |||
| b8f8d2e6b0 | |||
| 279255a14c | |||
| b216ddaa61 | |||
| f770f8055e | |||
| 819d74cc8d | |||
| 9651ae7ad7 | |||
| eca168957d | |||
| f026b9f9fd | |||
| b368ba0159 | |||
| 43e8b85656 | |||
| 716f91bdd0 | |||
| b00283cd96 | |||
| 25edf2d817 | |||
| 1d6371c37e | |||
| 82123fc232 | |||
| 6f02bc7cf5 | |||
| a5ff2ce51b | |||
| 4deccb76dc | |||
| 93556ddb9c | |||
| 681fced59d | |||
| 91e2b047eb | |||
| c1c4c85bd2 | |||
| d06e0499bc | |||
| 49f730b609 | |||
| 04a392e7c7 | |||
| 22e393e588 | |||
| 6556689c8f | |||
| a50d18b486 | |||
| 599a5de3bc | |||
| c1eb6a3458 | |||
| b87998549c | |||
| 27aa723e7d | |||
| 6be90ebb10 | |||
| 2f55e7bfa1 | |||
| c56655c728 | |||
| 5e4c16c577 | |||
| 8245e7b3d1 | |||
| cafde48f4a | |||
| c897111855 | |||
| a2bb61408b | |||
| 9268a21eb8 | |||
| d4c553f072 | |||
| 73802b6e80 | |||
| 1a0a444a99 | |||
| c37038f163 | |||
| 3144315de5 | |||
| 8f16250cbe | |||
| 95d5e2b972 | |||
| 2760a070a3 | |||
| 2435d760e8 | |||
| 80e4db86e4 | |||
| 4ab8a61a3c | |||
| 73c83153f9 | |||
| 1b67998f6a | |||
| b8cc38aa61 | |||
| c4398a689b | |||
| b660eb19f4 | |||
| 1eb73f5257 | |||
| 71423df1c0 | |||
| 709e0f4453 | |||
| 18df76a2b4 | |||
| 15e37841bb | |||
| 8e93f8f86f | |||
| fd35c5c993 | |||
| fd7b3a7f75 | |||
| af8fff5b13 | |||
| fc87c545a0 | |||
| 0f4ca89060 | |||
| c371c7224e | |||
| 31bb72c8f4 | |||
| faa05e2a10 | |||
| 6bdd111a3a | |||
| 8296ac12b6 | |||
| b2994adc1c | |||
| 75d2e4072a | |||
| b23927968f | |||
| c27fd099f6 | |||
| bc1264e28f | |||
| 799b0f3989 | |||
| b3ce856ad4 | |||
| f51fe1fac9 | |||
| 8dbaa9dcf6 | |||
| 0196128008 | |||
| 4108aed4e0 | |||
| cc55bb107a | |||
| 7518a29d2f | |||
| 37e131eb09 | |||
| 3dcc4932c3 | |||
| 254e65906a | |||
| ced0b88ca4 | |||
| b2021dcb3c | |||
| 0ef7b7d1d8 | |||
| 7254e2dbd9 | |||
| 26ef29ac4e | |||
| 005e3c7581 | |||
| ee433db4ad | |||
| ea4b8168b7 | |||
| f366a4aa6c | |||
| d849b201ca | |||
| dc813fcabc | |||
| 1d215ffdb3 | |||
| d9bfc30c88 | |||
| 9305acb69b | |||
| f9a8955384 | |||
| 1f9dbba39d | |||
| dace0a9309 | |||
| 4f2e80e482 | |||
| b27b9f9ee1 | |||
| 66a1ddef66 | |||
| eacac8425c | |||
| d164514fcf | |||
| ae79d1b1ba | |||
| 50163d02a7 | |||
| 5ec950cc61 | |||
| a61b5fa2fd | |||
| f406416698 | |||
| debbe2777b | |||
| bc261661ae | |||
| 0aead49816 | |||
| 66b7a9218e | |||
| 2f4205ad1a | |||
| fea22aecab | |||
| 62f3ddae4a | |||
| c317afc0fe | |||
| 1dbe04cdf9 | |||
| afc44cb471 | |||
| 39d8b3d547 | |||
| f387b95d0d | |||
| 1d064c1f31 | |||
| 3440cc4773 | |||
| 20b13bcffa | |||
| 105e2ddf9b | |||
| a1a38d821c | |||
| 83987db5ac | |||
| d10114572e | |||
| e34288ae56 | |||
| 17b8bf2bfd | |||
| 57bd4ba109 | |||
| e4ba237a00 | |||
| 9059a24637 | |||
| dc0c55c495 | |||
| a007a77db9 | |||
| 3eb3a86376 | |||
| 1dc9560a1f | |||
| 8069b08fab | |||
| 2c8142c0d2 | |||
| 9f4110b24a | |||
| 1cdc17b710 | |||
| 97316c7989 | |||
| fac5e7657c | |||
| 50dcb78417 | |||
| 998fa7aa67 | |||
| 5e1a1f540e | |||
| 2415e97c97 | |||
| aa71cc3c4f | |||
| e5ddcc4308 | |||
| 883b222254 | |||
| dc9ee10023 | |||
| 2c4a4329ff | |||
| 553238de0c | |||
| 80b6102673 | |||
| 705b0f374e | |||
| 5bdc7ddec0 | |||
| 5d2a3de2f4 | |||
| 675a9f6d64 | |||
| 41b14ceea4 | |||
| a729396401 | |||
| 3b0beb52da | |||
| faeb930fe3 | |||
| 9a39c29e88 | |||
| b23c9a678b | |||
| 96cd32ed99 | |||
| 50dd53b0c0 | |||
| 48a12f0919 | |||
| 91d754b389 | |||
| 450798c8bd | |||
| c3acc11f6a | |||
| d592c9f12e | |||
| 26e39ce416 | |||
| 32521208a0 | |||
| 6e969648c6 | |||
| 074596ebcb | |||
| f0542c11c8 | |||
| 0fa3d10284 | |||
| 0d13192723 | |||
| a72aae84ef | |||
| 6ba1339c0b | |||
| a75afbe4c1 | |||
| 1f45564539 | |||
| 41aec6b89e | |||
| a1db745b6c | |||
| 15fc9621cd | |||
| d7874829d9 | |||
| abf32e8457 | |||
| efdcf60ed1 | |||
| 7a1b17d060 | |||
| f43f94b94d | |||
| 3e2dc4bcb6 | |||
| 6fb9e56720 | |||
| e2cff76d13 | |||
| 6e57f1c853 | |||
| 4fe18c77dc | |||
| 6f20e6aeb1 |
@@ -0,0 +1,17 @@
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
@@ -2,10 +2,17 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
|
||||
</array>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:app.tknb.net</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.loveKey.nyx</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风进行语音输入</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/App_icon.imageset/App_icon.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/App_icon.imageset/App_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/App_icon.imageset/App_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
23
CustomKeyboard/KeyboardAssets.xcassets/App_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "App_icon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "App_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "App_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
CustomKeyboard/KeyboardAssets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
22
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 271@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 271@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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 |
22
CustomKeyboard/KeyboardAssets.xcassets/back_keybord_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "back_keybord_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "back_keybord_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/back_keybord_icon.imageset/back_keybord_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/back_keybord_icon.imageset/back_keybord_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/buy_sel_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "buy_sel_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "buy_sel_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/buy_sel_icon.imageset/buy_sel_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/buy_sel_icon.imageset/buy_sel_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "close_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "close_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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 |
22
CustomKeyboard/KeyboardAssets.xcassets/home_ai_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_ai_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_ai_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_ai_icon.imageset/home_ai_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_ai_icon.imageset/home_ai_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/home_chat_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_chat_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_chat_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_chat_icon.imageset/home_chat_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_chat_icon.imageset/home_chat_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/home_emotion_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_emotion_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_emotion_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_emotion_icon.imageset/home_emotion_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_emotion_icon.imageset/home_emotion_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/home_keyboard_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_keyboard_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "home_keyboard_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_keyboard_icon.imageset/home_keyboard_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/home_keyboard_icon.imageset/home_keyboard_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 25 KiB |
86
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"filename" : "kb_del_icon@2x 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "切图 256@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"filename" : "kb_del_icon@3x 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "切图 256@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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@2x.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/kb_del_icon@3x.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 |
22
CustomKeyboard/KeyboardAssets.xcassets/kb_zt_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_zt_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_zt_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_zt_icon.imageset/kb_zt_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1014 B |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_zt_icon.imageset/kb_zt_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_revoke@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_revoke@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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 |
22
CustomKeyboard/KeyboardAssets.xcassets/keybord_bg_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "keybord_bg_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "keybord_bg_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/keybord_bg_icon.imageset/keybord_bg_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/keybord_bg_icon.imageset/keybord_bg_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
22
CustomKeyboard/KeyboardAssets.xcassets/upgrad_vip_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "upgrad_vip_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "upgrad_vip_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/upgrad_vip_icon.imageset/upgrad_vip_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/upgrad_vip_icon.imageset/upgrad_vip_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
46
CustomKeyboard/Manager/KBEmojiDataProvider.h
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// KBEmojiDataProvider.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
FOUNDATION_EXPORT NSString * const KBEmojiRecentsDidChangeNotification;
|
||||
|
||||
@class KBEmojiCategory, KBEmojiItem;
|
||||
|
||||
@interface KBEmojiItem : NSObject <NSCopying>
|
||||
@property (nonatomic, copy, readonly) NSString *value;
|
||||
@property (nonatomic, copy, readonly) NSString *name;
|
||||
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name;
|
||||
@end
|
||||
|
||||
@interface KBEmojiCategory : NSObject
|
||||
@property (nonatomic, copy, readonly) NSString *identifier;
|
||||
@property (nonatomic, copy, readonly) NSString *displayTitle;
|
||||
@property (nonatomic, copy, readonly) NSString *iconSymbol;
|
||||
@property (nonatomic, assign, readonly, getter=isDynamic) BOOL dynamic;
|
||||
@property (nonatomic, copy, readonly) NSArray<KBEmojiItem *> *items;
|
||||
@end
|
||||
|
||||
@interface KBEmojiDataProvider : NSObject
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
/// 所有分类(按系统顺序),包含“常用”。
|
||||
@property (nonatomic, copy, readonly) NSArray<KBEmojiCategory *> *categories;
|
||||
|
||||
/// 记录一次 emoji 选择,并刷新“常用”分类。
|
||||
- (void)recordEmojiSelection:(NSString *)emoji;
|
||||
|
||||
/// 重新加载 JSON(若首次调用)。
|
||||
- (void)reloadIfNeeded;
|
||||
|
||||
/// 更新当前语言对应的分类标题。
|
||||
- (void)refreshLocalizedTitles;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
270
CustomKeyboard/Manager/KBEmojiDataProvider.m
Normal file
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// KBEmojiDataProvider.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBEmojiDataProvider.h"
|
||||
#import "KBLocalizationManager.h"
|
||||
#import "KBConfig.h"
|
||||
|
||||
NSString * const KBEmojiRecentsDidChangeNotification = @"KBEmojiRecentsDidChangeNotification";
|
||||
|
||||
static NSString * const kKBEmojiJSONFileName = @"emoji_categories";
|
||||
static NSString * const kKBEmojiRecentsStoreKey = @"KBEmojiRecentEmojis";
|
||||
static NSString * const kKBEmojiRecentsCategoryId = @"recents";
|
||||
static const NSUInteger kKBEmojiRecentsLimit = 32;
|
||||
|
||||
#pragma mark - Model Implementations
|
||||
|
||||
@interface KBEmojiItem ()
|
||||
@property (nonatomic, copy, readwrite) NSString *value;
|
||||
@property (nonatomic, copy, readwrite) NSString *name;
|
||||
@end
|
||||
|
||||
@implementation KBEmojiItem
|
||||
|
||||
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name {
|
||||
if (self = [super init]) {
|
||||
_value = [value copy];
|
||||
_name = [name copy];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (id)copyWithZone:(NSZone *)zone {
|
||||
KBEmojiItem *item = [[[self class] allocWithZone:zone] initWithValue:self.value name:self.name];
|
||||
return item;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface KBEmojiCategory ()
|
||||
@property (nonatomic, copy, readwrite) NSString *identifier;
|
||||
@property (nonatomic, copy) NSDictionary<NSString *, NSString *> *titleMap;
|
||||
@property (nonatomic, copy, readwrite) NSString *displayTitle;
|
||||
@property (nonatomic, copy, readwrite) NSString *iconSymbol;
|
||||
@property (nonatomic, assign, readwrite, getter=isDynamic) BOOL dynamic;
|
||||
@property (nonatomic, copy, readwrite) NSArray<KBEmojiItem *> *items;
|
||||
@end
|
||||
|
||||
@implementation KBEmojiCategory
|
||||
|
||||
- (void)refreshDisplayTitleForLanguage:(NSString *)lang {
|
||||
if (lang.length == 0) {
|
||||
lang = KBLanguageCodeEnglish;
|
||||
}
|
||||
NSString *title = self.titleMap[lang];
|
||||
if (title.length == 0) {
|
||||
if ([lang.lowercaseString hasPrefix:@"zh"]) {
|
||||
title = self.titleMap[@"zh-Hans"] ?: self.titleMap[@"zh-hans"];
|
||||
}
|
||||
}
|
||||
if (title.length == 0) {
|
||||
title = self.titleMap[@"en"];
|
||||
}
|
||||
if (title.length == 0) {
|
||||
title = self.titleMap.allValues.firstObject;
|
||||
}
|
||||
self.displayTitle = title ?: @"";
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Data Provider
|
||||
|
||||
@interface KBEmojiDataProvider ()
|
||||
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categoriesInternal;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, KBEmojiItem *> *itemLookup;
|
||||
@property (nonatomic, strong) NSMutableOrderedSet<NSString *> *recentValues;
|
||||
@end
|
||||
|
||||
@implementation KBEmojiDataProvider
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBEmojiDataProvider *m;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
m = [KBEmojiDataProvider new];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:m
|
||||
selector:@selector(onLocalizationChanged:)
|
||||
name:KBLocalizationDidChangeNotification
|
||||
object:nil];
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (NSArray<KBEmojiCategory *> *)categories {
|
||||
[self reloadIfNeeded];
|
||||
return self.categoriesInternal ?: @[];
|
||||
}
|
||||
|
||||
- (void)reloadIfNeeded {
|
||||
if (self.categoriesInternal.count > 0) { return; }
|
||||
[self loadEmojiJSON];
|
||||
[self refreshLocalizedTitles];
|
||||
[self loadRecentsFromStore];
|
||||
[self rebuildRecentsCategory];
|
||||
}
|
||||
|
||||
- (void)loadEmojiJSON {
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBEmojiJSONFileName ofType:@"json"];
|
||||
if (path.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||
if (data.length == 0) { return; }
|
||||
|
||||
NSError *err = nil;
|
||||
NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
|
||||
if (!root || err) {
|
||||
NSLog(@"[Emoji] failed to parse json: %@", err);
|
||||
return;
|
||||
}
|
||||
NSArray *catArray = root[@"categories"];
|
||||
if (![catArray isKindOfClass:NSArray.class]) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableArray<KBEmojiCategory *> *tmpCats = [NSMutableArray arrayWithCapacity:catArray.count];
|
||||
self.itemLookup = [NSMutableDictionary dictionary];
|
||||
|
||||
for (NSDictionary *catDict in catArray) {
|
||||
if (![catDict isKindOfClass:NSDictionary.class]) continue;
|
||||
KBEmojiCategory *category = [KBEmojiCategory new];
|
||||
category.identifier = catDict[@"id"] ?: @"";
|
||||
NSDictionary *titleMap = catDict[@"title"];
|
||||
if ([titleMap isKindOfClass:NSDictionary.class]) {
|
||||
category.titleMap = titleMap;
|
||||
} else {
|
||||
category.titleMap = @{};
|
||||
}
|
||||
NSString *iconKey = catDict[@"icon"];
|
||||
category.iconSymbol = [self symbolForIconKey:iconKey];
|
||||
NSString *type = catDict[@"type"];
|
||||
category.dynamic = [type.lowercaseString isEqualToString:@"dynamic"];
|
||||
|
||||
NSArray *emojiArray = catDict[@"emojis"];
|
||||
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:[emojiArray count]];
|
||||
if ([emojiArray isKindOfClass:NSArray.class]) {
|
||||
for (NSDictionary *emojiDict in emojiArray) {
|
||||
if (![emojiDict isKindOfClass:NSDictionary.class]) continue;
|
||||
NSString *value = emojiDict[@"value"];
|
||||
if (value.length == 0) continue;
|
||||
NSString *name = emojiDict[@"name"] ?: @"";
|
||||
KBEmojiItem *item = [[KBEmojiItem alloc] initWithValue:value name:name];
|
||||
[items addObject:item];
|
||||
if (value.length > 0) {
|
||||
self.itemLookup[value] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
category.items = items.copy;
|
||||
[tmpCats addObject:category];
|
||||
}
|
||||
self.categoriesInternal = tmpCats.copy;
|
||||
}
|
||||
|
||||
- (NSString *)symbolForIconKey:(NSString *)key {
|
||||
static NSDictionary<NSString *, NSString *> *map;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
map = @{
|
||||
@"emoji_tab_recent": @"🕘",
|
||||
@"emoji_tab_people": @"😊",
|
||||
@"emoji_tab_nature": @"🌿",
|
||||
@"emoji_tab_food": @"🍔",
|
||||
@"emoji_tab_activity": @"🏀",
|
||||
@"emoji_tab_travel": @"✈️",
|
||||
@"emoji_tab_objects": @"💡",
|
||||
@"emoji_tab_symbols": @"♾",
|
||||
@"emoji_tab_flags": @"🏳️"
|
||||
};
|
||||
});
|
||||
NSString *symbol = map[key];
|
||||
return symbol.length ? symbol : @"●";
|
||||
}
|
||||
|
||||
- (void)refreshLocalizedTitles {
|
||||
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
||||
for (KBEmojiCategory *cat in self.categoriesInternal) {
|
||||
[cat refreshDisplayTitleForLanguage:lang];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onLocalizationChanged:(__unused NSNotification *)note {
|
||||
[self refreshLocalizedTitles];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
||||
}
|
||||
|
||||
- (void)recordEmojiSelection:(NSString *)emoji {
|
||||
if (emoji.length == 0) return;
|
||||
[self reloadIfNeeded];
|
||||
if (!self.recentValues) {
|
||||
self.recentValues = [NSMutableOrderedSet orderedSet];
|
||||
}
|
||||
[self.recentValues removeObject:emoji];
|
||||
[self.recentValues insertObject:emoji atIndex:0];
|
||||
while (self.recentValues.count > kKBEmojiRecentsLimit) {
|
||||
[self.recentValues removeObjectAtIndex:self.recentValues.count - 1];
|
||||
}
|
||||
[self saveRecentsToStore];
|
||||
[self rebuildRecentsCategory];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
||||
}
|
||||
|
||||
- (void)loadRecentsFromStore {
|
||||
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
|
||||
NSArray *stored = [defs objectForKey:kKBEmojiRecentsStoreKey];
|
||||
NSMutableOrderedSet *set = [NSMutableOrderedSet orderedSet];
|
||||
if ([stored isKindOfClass:NSArray.class]) {
|
||||
for (id obj in stored) {
|
||||
if (![obj isKindOfClass:NSString.class]) continue;
|
||||
NSString *str = (NSString *)obj;
|
||||
if (str.length == 0) continue;
|
||||
[set addObject:str];
|
||||
if (set.count >= kKBEmojiRecentsLimit) break;
|
||||
}
|
||||
}
|
||||
self.recentValues = set;
|
||||
}
|
||||
|
||||
- (void)saveRecentsToStore {
|
||||
if (!self.recentValues) return;
|
||||
NSArray *arr = self.recentValues.array;
|
||||
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
|
||||
[defs setObject:arr forKey:kKBEmojiRecentsStoreKey];
|
||||
[defs synchronize];
|
||||
}
|
||||
|
||||
- (void)rebuildRecentsCategory {
|
||||
KBEmojiCategory *recent = [self categoryForIdentifier:kKBEmojiRecentsCategoryId];
|
||||
if (!recent) return;
|
||||
NSArray<NSString *> *values = self.recentValues.array ?: @[];
|
||||
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:values.count];
|
||||
for (NSString *value in values) {
|
||||
KBEmojiItem *item = self.itemLookup[value];
|
||||
if (!item) {
|
||||
item = [[KBEmojiItem alloc] initWithValue:value name:@""];
|
||||
}
|
||||
[items addObject:item];
|
||||
}
|
||||
recent.items = items.copy;
|
||||
}
|
||||
|
||||
- (KBEmojiCategory *)categoryForIdentifier:(NSString *)identifier {
|
||||
if (identifier.length == 0) return nil;
|
||||
for (KBEmojiCategory *cat in self.categoriesInternal) {
|
||||
if ([cat.identifier isEqualToString:identifier]) {
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
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
|
||||
@@ -16,17 +16,35 @@ typedef NS_ENUM(NSInteger, KBKeyType) {
|
||||
KBKeyTypeSpace, // 空格
|
||||
KBKeyTypeReturn, // 回车/发送
|
||||
KBKeyTypeGlobe, // 系统地球键
|
||||
KBKeyTypeCustom, // 自定义功能占位
|
||||
KBKeyTypeCustom, // 自定义功能占位(如 AI/Emoji)
|
||||
KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换
|
||||
};
|
||||
|
||||
FOUNDATION_EXPORT NSString * const KBKeyIdentifierEmojiPanel;
|
||||
|
||||
/// 字母键的大小写变体标记(非字母键使用 KBKeyCaseVariantNone)
|
||||
typedef NS_ENUM(NSInteger, KBKeyCaseVariant) {
|
||||
KBKeyCaseVariantNone = 0,
|
||||
KBKeyCaseVariantLower = 1,
|
||||
KBKeyCaseVariantUpper = 2,
|
||||
};
|
||||
|
||||
@interface KBKey : NSObject
|
||||
|
||||
@property (nonatomic, assign) KBKeyType type;
|
||||
@property (nonatomic, copy) NSString *title; // 显示标题
|
||||
@property (nonatomic, copy) NSString *output; // 字符键插入的文本
|
||||
/// 逻辑按键标识,用于皮肤映射(如 @"letter_q" @"space" @"backspace")
|
||||
@property (nonatomic, copy, nullable) NSString *identifier;
|
||||
/// 字母键的大小写变体(便于皮肤为大小写准备不同图)
|
||||
@property (nonatomic, assign) KBKeyCaseVariant caseVariant;
|
||||
|
||||
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output;
|
||||
+ (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type;
|
||||
/// 通用构造方法:用于指定 identifier,便于皮肤做精细控制
|
||||
+ (instancetype)keyWithIdentifier:(nullable NSString *)identifier
|
||||
title:(NSString *)title
|
||||
output:(NSString *)output
|
||||
type:(KBKeyType)type;
|
||||
|
||||
@end
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#import "KBKey.h"
|
||||
|
||||
NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
|
||||
|
||||
@implementation KBKey
|
||||
|
||||
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output {
|
||||
@@ -12,6 +14,7 @@
|
||||
k.type = KBKeyTypeCharacter;
|
||||
k.title = title ?: @"";
|
||||
k.output = output ?: title ?: @"";
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
|
||||
@@ -20,8 +23,21 @@
|
||||
k.type = type;
|
||||
k.title = title ?: @"";
|
||||
k.output = @"";
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
|
||||
+ (instancetype)keyWithIdentifier:(NSString *)identifier
|
||||
title:(NSString *)title
|
||||
output:(NSString *)output
|
||||
type:(KBKeyType)type {
|
||||
KBKey *k = [[KBKey alloc] init];
|
||||
k.type = type;
|
||||
k.identifier = identifier;
|
||||
k.title = title ?: @"";
|
||||
k.output = output ?: @"";
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
|
||||
@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
|
||||
43
CustomKeyboard/Model/KBKeyboardSubscriptionProduct.h
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionProduct.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 订阅商品模型(键盘扩展专用),用于展示与主 App 相同的订阅列表。
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardSubscriptionProduct : NSObject
|
||||
/// 主键 id
|
||||
@property (nonatomic, assign) NSInteger identifier;
|
||||
/// Apple 商品编号
|
||||
@property (nonatomic, copy, nullable) NSString *productId;
|
||||
/// 商品名称,如 Monthly
|
||||
@property (nonatomic, copy, nullable) NSString *name;
|
||||
/// 单位,如 Subscription
|
||||
@property (nonatomic, copy, nullable) NSString *unit;
|
||||
/// 商品描述
|
||||
@property (nonatomic, copy, nullable) NSString *productDescription;
|
||||
/// 货币符号
|
||||
@property (nonatomic, copy, nullable) NSString *currency;
|
||||
/// 现价
|
||||
@property (nonatomic, assign) double price;
|
||||
/// 原价(如接口未返回,则回退为 price 的 1.25 倍)
|
||||
@property (nonatomic, assign) double originPrice;
|
||||
/// 有效期数值
|
||||
@property (nonatomic, assign) NSInteger durationValue;
|
||||
/// 有效期单位
|
||||
@property (nonatomic, copy, nullable) NSString *durationUnit;
|
||||
|
||||
/// 标题(描述 > name+unit > name > unit)
|
||||
- (NSString *)displayTitle;
|
||||
/// 当前价格文本
|
||||
- (NSString *)priceDisplayText;
|
||||
/// 划线价文本
|
||||
- (nullable NSString *)strikePriceDisplayText;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
55
CustomKeyboard/Model/KBKeyboardSubscriptionProduct.m
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionProduct.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import <MJExtension/MJExtension.h>
|
||||
#import "KBLocalizationManager.h"
|
||||
|
||||
@implementation KBKeyboardSubscriptionProduct
|
||||
|
||||
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
|
||||
return @{
|
||||
@"identifier": @"id",
|
||||
@"productDescription": @"description",
|
||||
};
|
||||
}
|
||||
|
||||
- (NSString *)displayTitle {
|
||||
if (self.productDescription.length > 0) {
|
||||
return self.productDescription;
|
||||
}
|
||||
NSString *name = self.name ?: @"";
|
||||
NSString *unit = self.unit ?: @"";
|
||||
if (name.length && unit.length) {
|
||||
return [NSString stringWithFormat:@"%@ %@", name, unit];
|
||||
}
|
||||
if (name.length) { return name; }
|
||||
if (unit.length) { return unit; }
|
||||
if (self.durationValue > 0 && self.durationUnit.length > 0) {
|
||||
return [NSString stringWithFormat:@"%ld %@", (long)self.durationValue, self.durationUnit];
|
||||
}
|
||||
return KBLocalized(@"Subscription");
|
||||
}
|
||||
|
||||
- (NSString *)priceDisplayText {
|
||||
double priceValue = self.price;
|
||||
if (priceValue <= 0) {
|
||||
return @"$0.00";
|
||||
}
|
||||
NSString *currency = self.currency.length ? self.currency : @"$";
|
||||
return [NSString stringWithFormat:@"%@%.2f", currency, priceValue];
|
||||
}
|
||||
|
||||
- (nullable NSString *)strikePriceDisplayText {
|
||||
double rawValue = self.originPrice;
|
||||
if (rawValue <= 0 && self.price > 0) {
|
||||
rawValue = self.price * 1.25;
|
||||
}
|
||||
if (rawValue <= 0) { return nil; }
|
||||
NSString *currency = self.currency.length ? self.currency : @"$";
|
||||
return [NSString stringWithFormat:@"%@%.2f", currency, rawValue];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -19,8 +19,15 @@ typedef NS_ERROR_ENUM(KBNetworkErrorDomain, KBNetworkError) {
|
||||
KBNetworkErrorDecodeFailed = 4,
|
||||
};
|
||||
|
||||
/// 简单的 JSON 回调:json 为 NSDictionary/NSArray 或者在非 JSON 情况下返回 NSData
|
||||
typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error);
|
||||
/// JSON 回调(扩展侧目前很少使用 JSON,可按需扩展)
|
||||
typedef void(^KBNetworkCompletion)(NSDictionary *_Nullable json,
|
||||
NSURLResponse * _Nullable response,
|
||||
NSError * _Nullable error);
|
||||
|
||||
/// 二进制回调:用于下载 zip、图片等原始数据
|
||||
typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
||||
NSURLResponse *_Nullable response,
|
||||
NSError *_Nullable error);
|
||||
|
||||
@interface KBNetworkManager : NSObject
|
||||
|
||||
@@ -45,13 +52,27 @@ typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nul
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
/// GET 原始二进制数据(不做 JSON 解析)
|
||||
- (nullable NSURLSessionDataTask *)GETData:(NSString *)path
|
||||
parameters:(nullable NSDictionary *)parameters
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkDataCompletion)completion;
|
||||
|
||||
/// POST JSON 请求,jsonBody 会以 application/json 发送
|
||||
- (nullable NSURLSessionDataTask *)POST:(NSString *)path
|
||||
jsonBody:(nullable id)jsonBody
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
/// POST multipart 上传文件(常用于语音/图片等文件)
|
||||
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||
fileURL:(NSURL *)fileURL
|
||||
name:(NSString *)name
|
||||
mimeType:(NSString *)mimeType
|
||||
parameters:(nullable NSDictionary *)parameters
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
#import "KBNetworkManager.h"
|
||||
#import "AFNetworking.h"
|
||||
#import "KBAuthManager.h"
|
||||
|
||||
//#import "KBUserSessionManager.h"
|
||||
#import "KBSignUtils.h"
|
||||
NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
|
||||
@interface KBNetworkManager ()
|
||||
@@ -26,37 +27,87 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
if (self = [super init]) {
|
||||
_enabled = NO; // 键盘扩展默认无网络能力,需外部显式开启
|
||||
_timeout = 10.0;
|
||||
_defaultHeaders = @{ @"Accept": @"application/json" };
|
||||
// 默认请求头:Accept 任意类型 + 使用项目多语言管理器设置 Accept-Language
|
||||
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
||||
// NSString *token = [KBUserSessionManager shared].accessToken ? [KBUserSessionManager shared].accessToken : @"";
|
||||
_defaultHeaders = @{
|
||||
@"Accept": @"*/*",
|
||||
@"Accept-Language": lang
|
||||
};
|
||||
// 设置基础域名,路径可相对该地址拼接
|
||||
_baseURL = [NSURL URLWithString:KB_BASE_URL];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
||||
|
||||
NSString *appId = @"loveKeyboard";
|
||||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
||||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
||||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
||||
// 1. 组装参与签名的所有参数
|
||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
||||
signParams[@"appId"] = appId;
|
||||
signParams[@"timestamp"] = timestamp;
|
||||
signParams[@"nonce"] = nonce;
|
||||
// 把 body 里的字段也加入签名参数
|
||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
signParams[key] = obj;
|
||||
} else {
|
||||
signParams[key] = [obj description];
|
||||
}
|
||||
}];
|
||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
||||
|
||||
// 将签名相关字段合并进默认请求头
|
||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
|
||||
if (sign.length > 0) {
|
||||
headers[@"X-Sign"] = sign;
|
||||
}
|
||||
headers[@"X-App-Id"] = appId;
|
||||
headers[@"X-Timestamp"] = timestamp;
|
||||
headers[@"X-Nonce"] = nonce;
|
||||
|
||||
// 触发 copy 语义,确保对外仍是不可变字典
|
||||
self.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (NSURLSessionDataTask *)GET:(NSString *)path
|
||||
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; }
|
||||
// 使用 AFHTTPRequestSerializer 生成带参数与头的请求
|
||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||
serializer.timeoutInterval = self.timeout;
|
||||
NSError *serror = nil;
|
||||
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
|
||||
URLString:urlString
|
||||
parameters:parameters
|
||||
error:NULL];
|
||||
error:&serror];
|
||||
if (serror || !req) {
|
||||
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
||||
return nil;
|
||||
}
|
||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||
return [self startAFTaskWithRequest:req completion:completion];
|
||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)POST:(NSString *)path
|
||||
jsonBody:(id)jsonBody
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion {
|
||||
[self getSignWithParare:jsonBody];
|
||||
|
||||
if (![self ensureEnabled:completion]) return nil;
|
||||
NSString *urlString = [self buildURLStringWithPath:path];
|
||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||
@@ -70,14 +121,127 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
error:&error];
|
||||
if (error) { if (completion) completion(nil, nil, error); return nil; }
|
||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||
return [self startAFTaskWithRequest: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
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
completion:(KBNetworkDataCompletion)completion {
|
||||
[self getSignWithParare:parameters];
|
||||
if (!self.isEnabled) {
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||
code:KBNetworkErrorDisabled
|
||||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
|
||||
if (completion) completion(nil, nil, e);
|
||||
return nil;
|
||||
}
|
||||
NSString *urlString = [self buildURLStringWithPath:path];
|
||||
if (!urlString) {
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||
code:KBNetworkErrorInvalidURL
|
||||
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}];
|
||||
if (completion) completion(nil, nil, e);
|
||||
return nil;
|
||||
}
|
||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||
serializer.timeoutInterval = self.timeout;
|
||||
NSError *serror = nil;
|
||||
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
|
||||
URLString:urlString
|
||||
parameters:parameters
|
||||
error:&serror];
|
||||
if (serror || !req) {
|
||||
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
||||
return nil;
|
||||
}
|
||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||
return [self startAFDataTaskWithRequest:req completion:completion];
|
||||
}
|
||||
|
||||
#pragma mark - Core
|
||||
|
||||
- (BOOL)ensureEnabled:(KBNetworkCompletion)completion {
|
||||
if (!self.isEnabled) {
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: @"网络未启用(可能未开启完全访问)"}];
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
|
||||
if (completion) completion(nil, nil, e);
|
||||
return NO;
|
||||
}
|
||||
@@ -90,7 +254,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
return path;
|
||||
}
|
||||
if (self.baseURL) {
|
||||
return [NSURL URLWithString:path relativeToURL:self.baseURL].absoluteURL.absoluteString;
|
||||
// 统一为目录型 base(以 / 结尾),并剥掉 path 的前导 /,避免覆盖 base 路径
|
||||
NSString *base = self.baseURL.absoluteString ?: @"";
|
||||
if (![base hasSuffix:@"/"]) { base = [base stringByAppendingString:@"/"]; }
|
||||
NSURL *dirBase = [NSURL URLWithString:base];
|
||||
NSString *relative = ([path hasPrefix:@"/"]) ? [path substringFromIndex:1] : path;
|
||||
return [NSURL URLWithString:relative relativeToURL:dirBase].absoluteURL.absoluteString;
|
||||
}
|
||||
return path; // 当无 baseURL 且 path 不是完整 URL 时,让 AFN 处理(可能失败)
|
||||
}
|
||||
@@ -98,6 +267,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
- (void)applyHeaders:(NSDictionary<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
|
||||
// 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。
|
||||
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
|
||||
NSString *token = [KBAuthManager shared].current.accessToken;
|
||||
if (token.length > 0) {
|
||||
all[@"auth-token"] = token;
|
||||
} else {
|
||||
[all removeObjectForKey:@"auth-token"];
|
||||
}
|
||||
NSDictionary *auth = [[KBAuthManager shared] authorizationHeader];
|
||||
[auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }];
|
||||
if (contentType) all[@"Content-Type"] = contentType;
|
||||
@@ -105,42 +280,82 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
[all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }];
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)startAFTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
|
||||
- (NSURLSessionDataTask *)startAFJSONTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
|
||||
// 响应先用原始数据返回,再按 Content-Type 解析 JSON(与原实现一致)
|
||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||
if (error) { if (completion) completion(nil, response, error); return; }
|
||||
// AFN 默认对非 2xx 的状态码返回 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:@"无数据"}]);
|
||||
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:@"application/json"]);
|
||||
// 更宽松的 JSON 判定:Content-Type 里包含 json;或首字符是 { / [
|
||||
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
||||
if (!looksJSON) {
|
||||
// 内容嗅探(不依赖服务端声明)
|
||||
const unsigned char *bytes = data.bytes;
|
||||
NSUInteger len = data.length;
|
||||
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
||||
unsigned char c = bytes[i];
|
||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
||||
looksJSON = (c == '{' || c == '[');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (looksJSON) {
|
||||
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:@"JSON解析失败"}]); return; }
|
||||
if (completion) completion(json, response, nil);
|
||||
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(data, response, nil);
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||
}
|
||||
}];
|
||||
[task resume];
|
||||
return task;
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)startAFDataTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkDataCompletion)completion {
|
||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||
if (error) {
|
||||
if (completion) completion(nil, response, error);
|
||||
return;
|
||||
}
|
||||
NSData *data = (NSData *)responseObject;
|
||||
if (![data isKindOfClass:[NSData class]]) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
||||
return;
|
||||
}
|
||||
if (completion) completion(data, response, nil);
|
||||
}];
|
||||
[task resume];
|
||||
return task;
|
||||
}
|
||||
|
||||
#pragma mark - AFHTTPSessionManager
|
||||
|
||||
- (AFHTTPSessionManager *)manager {
|
||||
if (!_manager) {
|
||||
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
cfg.timeoutIntervalForRequest = self.timeout;
|
||||
cfg.timeoutIntervalForResource = MAX(self.timeout, 30.0);
|
||||
// 不在会话级别设置超时,避免与 per-request 的 serializer.timeoutInterval 产生不一致
|
||||
if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; }
|
||||
_manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg];
|
||||
// 默认不使用 JSON 解析器,保持原生数据,再按需解析
|
||||
@@ -152,12 +367,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
#pragma mark - Private helpers
|
||||
|
||||
- (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion {
|
||||
NSString *msg = @"网络错误";
|
||||
NSString *msg = KBLocalized(@"Network error");
|
||||
switch (code) {
|
||||
case KBNetworkErrorDisabled: msg = @"网络未启用(可能未开启完全访问)"; break;
|
||||
case KBNetworkErrorInvalidURL: msg = @"无效的URL"; break;
|
||||
case KBNetworkErrorInvalidResponse: msg = @"无效的响应"; break;
|
||||
case KBNetworkErrorDecodeFailed: msg = @"解析失败"; break;
|
||||
case KBNetworkErrorDisabled: msg = KBLocalized(@"Network disabled (Full Access may be off)"); break;
|
||||
case KBNetworkErrorInvalidURL: msg = KBLocalized(@"Invalid URL"); break;
|
||||
case KBNetworkErrorInvalidResponse: msg = KBLocalized(@"Invalid response"); break;
|
||||
case KBNetworkErrorDecodeFailed: msg = KBLocalized(@"Parse failed"); break;
|
||||
default: break;
|
||||
}
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||
|
||||
60
CustomKeyboard/Network/KBStreamFetcher.h
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// KBStreamFetcher.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 轻量网络流拉取器:支持纯文本分块与 SSE(text/event-stream) 两种形式的“边下边显”。
|
||||
// - 增量解码:按 UTF-8 安全前缀逐步转成字符串,避免半个多字节字符导致阻塞/乱码
|
||||
// - SSE 解析:按 \n\n 切事件,合并 data: 行,移除前缀,仅回传正文
|
||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||
//
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef void (^KBStreamFetcherChunkHandler)(NSString *chunk);
|
||||
typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
||||
|
||||
@interface KBStreamFetcher : NSObject <NSURLSessionDataDelegate>
|
||||
|
||||
// 便利构造
|
||||
+ (instancetype)fetcherWithURL:(NSURL *)url;
|
||||
|
||||
// 必填:请求地址
|
||||
@property (nonatomic, strong) NSURL *url;
|
||||
|
||||
/// HTTP Method,默认为 GET
|
||||
@property (nonatomic, copy, nullable) NSString *httpMethod;
|
||||
|
||||
/// 自定义请求体(例如 POST 的 JSON body)
|
||||
@property (nonatomic, strong, nullable) NSData *httpBody;
|
||||
|
||||
// 可选 Header
|
||||
@property (nonatomic, copy, nullable) NSDictionary<NSString *, NSString *> *extraHeaders;
|
||||
|
||||
// 配置项(默认值见注释)
|
||||
@property (nonatomic, assign) BOOL acceptEventStream; // 默认 NO;置 YES 时发送 Accept: text/event-stream
|
||||
@property (nonatomic, assign) BOOL disableCompression; // 默认 YES;发送 Accept-Encoding: identity
|
||||
@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES;将“/t”替换为“\t”
|
||||
@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES;首次正文起始的“\t”删一个(忽略前导空白)
|
||||
@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s
|
||||
/// UI 刷新节奏:当一次回调内解析出多段(如多条 SSE 事件)时,按该间隔逐条回调(默认 0.10s)。
|
||||
@property (nonatomic, assign) NSTimeInterval flushInterval;
|
||||
/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。
|
||||
@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace;
|
||||
|
||||
/// 调试日志:默认 YES。输出起止时刻、首包耗时、各分片内容(截断)等关键信息。
|
||||
@property (nonatomic, assign) BOOL loggingEnabled;
|
||||
|
||||
// 回调(统一在主线程触发)
|
||||
@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
|
||||
@property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish;
|
||||
|
||||
// 控制
|
||||
- (void)start;
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
519
CustomKeyboard/Network/KBStreamFetcher.m
Normal file
@@ -0,0 +1,519 @@
|
||||
//
|
||||
// KBStreamFetcher.m
|
||||
//
|
||||
|
||||
#import "KBStreamFetcher.h"
|
||||
#import "KBLocalizationManager.h"
|
||||
|
||||
@interface KBStreamFetcher () <NSURLSessionDataDelegate>
|
||||
@property (nonatomic, strong) NSURLSession *session;
|
||||
@property (nonatomic, strong) NSURLSessionDataTask *task;
|
||||
@property (nonatomic, strong) NSMutableData *buffer; // 网络原始字节累加
|
||||
@property (nonatomic, assign) NSStringEncoding textEncoding; // 推断得到的文本编码(默认 UTF-8)
|
||||
@property (nonatomic, assign) BOOL isSSE; // 是否为 SSE 响应
|
||||
@property (nonatomic, strong) NSMutableString *sseTextBuffer; // SSE 文本缓冲(已解码)
|
||||
@property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE)
|
||||
@property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量)
|
||||
@property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”)
|
||||
@property (nonatomic, assign) BOOL lastChunkEndedWithTab; // 上一个已输出分片是否以 "\t" 结尾(用于跨分片去除“\t 后空格”)
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *pendingQueue; // 待回调的分片(节流输出)
|
||||
@property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调
|
||||
@property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调)
|
||||
@property (nonatomic, copy, nullable) NSString *pendingSplitTokenPrefix; // `<SPLIT>` 跨分片残留
|
||||
|
||||
// Metrics
|
||||
@property (nonatomic, assign) CFAbsoluteTime tStart; // start() 被调用的时刻
|
||||
@property (nonatomic, assign) CFAbsoluteTime tFirstByte; // 第一次拿到可解码内容
|
||||
@property (nonatomic, assign) CFAbsoluteTime tFinish; // 完成/失败时刻
|
||||
@property (nonatomic, assign) NSInteger emittedChunkCount; // 已输出分片数量
|
||||
@end
|
||||
|
||||
// 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符
|
||||
static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
const unsigned char *bytes = (const unsigned char *)data.bytes;
|
||||
NSUInteger n = data.length;
|
||||
if (n == 0) return 0;
|
||||
NSInteger i = (NSInteger)n - 1;
|
||||
while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { i--; } // 10xxxxxx 续字节
|
||||
if (i < 0) return 0; // 全是续字节,等下次
|
||||
unsigned char b = bytes[i];
|
||||
NSUInteger expected = 1;
|
||||
if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx
|
||||
else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx
|
||||
else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx
|
||||
else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx
|
||||
else return (NSUInteger)i; // 非法起始,截到 i 之前
|
||||
NSUInteger remain = n - (NSUInteger)i;
|
||||
return (remain >= expected) ? n : (NSUInteger)i;
|
||||
}
|
||||
|
||||
static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
||||
|
||||
@implementation KBStreamFetcher
|
||||
|
||||
+ (instancetype)fetcherWithURL:(NSURL *)url {
|
||||
KBStreamFetcher *f = [[self alloc] init];
|
||||
f.url = url;
|
||||
return f;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_httpMethod = @"GET";
|
||||
_acceptEventStream = NO;
|
||||
_disableCompression = YES;
|
||||
_treatSlashTAsTab = YES;
|
||||
_trimLeadingTabOnce = YES;
|
||||
_requestTimeout = 30.0;
|
||||
_textEncoding = NSUTF8StringEncoding;
|
||||
_buffer = [NSMutableData data];
|
||||
_sseTextBuffer = [NSMutableString string];
|
||||
_pendingQueue = [NSMutableArray array];
|
||||
_flushInterval = 0.1;
|
||||
_splitLargeDeltasOnWhitespace = YES;
|
||||
_loggingEnabled = YES;
|
||||
_pendingSplitTokenPrefix = nil;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)start {
|
||||
if (!self.url) return;
|
||||
[self cancel];
|
||||
|
||||
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
cfg.timeoutIntervalForRequest = self.requestTimeout;
|
||||
cfg.timeoutIntervalForResource = MAX(self.requestTimeout, 60.0);
|
||||
|
||||
self.session = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]];
|
||||
|
||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:self.url];
|
||||
NSString *method = self.httpMethod.length > 0 ? self.httpMethod : @"GET";
|
||||
req.HTTPMethod = method;
|
||||
if (self.httpBody.length > 0) {
|
||||
req.HTTPBody = self.httpBody;
|
||||
}
|
||||
if (self.disableCompression) { [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; }
|
||||
if (self.acceptEventStream) { [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; }
|
||||
[req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
|
||||
[req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
||||
[self.extraHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *k, NSString *v, BOOL *stop){ [req setValue:v forHTTPHeaderField:k]; }];
|
||||
|
||||
// 状态复位
|
||||
[self.buffer setLength:0];
|
||||
[self.sseTextBuffer setString:@""];
|
||||
self.isSSE = NO;
|
||||
self.textEncoding = NSUTF8StringEncoding;
|
||||
self.decodedPrefixBytes = 0;
|
||||
self.deliveredCharCount = 0;
|
||||
self.hasEmitted = NO;
|
||||
self.lastChunkEndedWithTab = NO;
|
||||
[self.pendingQueue removeAllObjects];
|
||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
||||
self.finishError = nil;
|
||||
self.pendingSplitTokenPrefix = nil;
|
||||
|
||||
self.tStart = CFAbsoluteTimeGetCurrent();
|
||||
self.tFirstByte = 0;
|
||||
self.tFinish = 0;
|
||||
self.emittedChunkCount = 0;
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] start url=%@ acceptSSE=%@ disableCompression=%@ flush=%.0fms splitWords=%@",
|
||||
self.url.absoluteString,
|
||||
self.acceptEventStream?@"YES":@"NO",
|
||||
self.disableCompression?@"YES":@"NO",
|
||||
self.flushInterval*1000.0,
|
||||
self.splitLargeDeltasOnWhitespace?@"YES":@"NO");
|
||||
}
|
||||
self.task = [self.session dataTaskWithRequest:req];
|
||||
[self.task resume];
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
[self.task cancel];
|
||||
self.task = nil;
|
||||
[self.session invalidateAndCancel];
|
||||
self.session = nil;
|
||||
[self.buffer setLength:0];
|
||||
[self.sseTextBuffer setString:@""];
|
||||
self.decodedPrefixBytes = 0;
|
||||
self.deliveredCharCount = 0;
|
||||
self.hasEmitted = NO;
|
||||
self.lastChunkEndedWithTab = NO;
|
||||
[self.pendingQueue removeAllObjects];
|
||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
||||
self.finishError = nil;
|
||||
self.pendingSplitTokenPrefix = nil;
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionDataDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
|
||||
self.isSSE = NO;
|
||||
self.textEncoding = NSUTF8StringEncoding;
|
||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
NSHTTPURLResponse *r = (NSHTTPURLResponse *)response;
|
||||
NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"];
|
||||
if ([ct isKindOfClass:[NSString class]]) {
|
||||
NSString *lower = [ct lowercaseString];
|
||||
if ([lower containsString:@"text/event-stream"]) self.isSSE = YES;
|
||||
NSRange pos = [lower rangeOfString:@"charset="];
|
||||
if (pos.location != NSNotFound) {
|
||||
NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0];
|
||||
if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) {
|
||||
self.textEncoding = NSUTF8StringEncoding;
|
||||
} else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) {
|
||||
self.textEncoding = NSISOLatin1StringEncoding;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[self.sseTextBuffer setString:@""];
|
||||
self.decodedPrefixBytes = 0;
|
||||
if (completionHandler) completionHandler(NSURLSessionResponseAllow);
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
|
||||
if (data.length == 0) return;
|
||||
[self.buffer appendData:data];
|
||||
|
||||
NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding)
|
||||
? kb_validUTF8PrefixLen(self.buffer)
|
||||
: self.buffer.length;
|
||||
if (validLen > 0 && self.tFirstByte == 0) {
|
||||
self.tFirstByte = CFAbsoluteTimeGetCurrent();
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] first-bytes after %.0fms (encoding=%@, SSE=%@)",
|
||||
(self.tFirstByte - self.tStart)*1000.0,
|
||||
(self.textEncoding==NSUTF8StringEncoding?@"UTF-8":@"Other"),
|
||||
self.isSSE?@"YES":@"NO");
|
||||
}
|
||||
}
|
||||
if (validLen == 0) return; // 末尾可能卡着半个字符
|
||||
|
||||
if (self.isSSE) {
|
||||
if ((NSUInteger)self.decodedPrefixBytes < validLen) {
|
||||
NSRange rng = NSMakeRange((NSUInteger)self.decodedPrefixBytes, validLen - (NSUInteger)self.decodedPrefixBytes);
|
||||
NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.buffer.bytes + rng.location
|
||||
length:rng.length
|
||||
encoding:self.textEncoding];
|
||||
if (piece.length > 0) {
|
||||
[self.sseTextBuffer appendString:piece];
|
||||
self.decodedPrefixBytes = (NSInteger)validLen;
|
||||
}
|
||||
}
|
||||
// 统一换行并按 SSE 事件 \n\n 切开
|
||||
if (self.sseTextBuffer.length > 0) {
|
||||
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
|
||||
[self.sseTextBuffer setString:normalized];
|
||||
while (1) {
|
||||
NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; // 完整事件
|
||||
if (sep.location == NSNotFound) break;
|
||||
NSString *event = [self.sseTextBuffer substringToIndex:sep.location];
|
||||
[self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)];
|
||||
|
||||
// 合并 data: 行为正文
|
||||
NSArray<NSString *> *lines = [event componentsSeparatedByString:@"\n"];
|
||||
NSMutableString *payload = [NSMutableString string];
|
||||
for (NSString *ln in lines) {
|
||||
if ([ln hasPrefix:@"data:"]) {
|
||||
NSString *v = [ln substringFromIndex:5];
|
||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
||||
[payload appendString:v ?: @""];
|
||||
}
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
||||
}
|
||||
NSString *llmText = nil;
|
||||
if ([self processLLMChunkPayload:payload output:&llmText]) {
|
||||
if (llmText.length > 0) { [self enqueueChunk:llmText]; }
|
||||
} else {
|
||||
[self enqueueChunk:payload];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 SSE:直接对“可解码前缀”做增量输出
|
||||
NSString *prefix = [[NSString alloc] initWithBytes:self.buffer.bytes length:validLen encoding:self.textEncoding];
|
||||
if (!prefix) return;
|
||||
if (self.deliveredCharCount < (NSInteger)prefix.length) {
|
||||
NSString *delta = [prefix substringFromIndex:self.deliveredCharCount];
|
||||
self.deliveredCharCount = prefix.length;
|
||||
if (self.splitLargeDeltasOnWhitespace && delta.length > 16) {
|
||||
// 按空格切词逐条回调(保留空格,使观感更自然)
|
||||
NSArray<NSString *> *parts = [delta componentsSeparatedByString:@" "];
|
||||
for (NSUInteger i = 0; i < parts.count; i++) {
|
||||
NSString *w = parts[i];
|
||||
if (w.length == 0) { [self enqueueChunk:@" "]; continue; }
|
||||
if (i + 1 < parts.count) {
|
||||
[self enqueueChunk:[w stringByAppendingString:@" "]];
|
||||
} else {
|
||||
[self enqueueChunk:w];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
[self enqueueChunk:delta];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
|
||||
if (!error && self.isSSE && self.sseTextBuffer.length > 0) {
|
||||
// 处理最后一条未以 \n\n 结束的事件
|
||||
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
|
||||
NSArray<NSString *> *lines = [normalized componentsSeparatedByString:@"\n"];
|
||||
NSMutableString *payload = [NSMutableString string];
|
||||
for (NSString *ln in lines) {
|
||||
if ([ln hasPrefix:@"data:"]) {
|
||||
NSString *v = [ln substringFromIndex:5];
|
||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
||||
[payload appendString:v ?: @""];
|
||||
}
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
||||
}
|
||||
NSString *delta = nil;
|
||||
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
||||
delta = [payload substringFromIndex:self.deliveredCharCount];
|
||||
} else {
|
||||
delta = payload;
|
||||
}
|
||||
self.deliveredCharCount = payload.length;
|
||||
if (delta.length > 0) {
|
||||
NSString *llmText = nil;
|
||||
if ([self processLLMChunkPayload:delta output:&llmText]) {
|
||||
if (llmText.length > 0) { [self emitChunk:llmText]; }
|
||||
} else {
|
||||
[self emitChunk:delta];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (self.pendingSplitTokenPrefix.length > 0) {
|
||||
NSString *carry = self.pendingSplitTokenPrefix;
|
||||
self.pendingSplitTokenPrefix = nil;
|
||||
if (carry.length > 0) { [self enqueueChunk:carry]; }
|
||||
}
|
||||
|
||||
self.tFinish = CFAbsoluteTimeGetCurrent();
|
||||
if (self.loggingEnabled) {
|
||||
double t0 = (self.tFirstByte>0? (self.tFirstByte - self.tStart)*1000.0 : -1);
|
||||
double t1 = (self.tFirstByte>0? (self.tFinish - self.tFirstByte)*1000.0 : -1);
|
||||
double tt = (self.tFinish - self.tStart)*1000.0;
|
||||
NSLog(@"[KBStream] finish chunks=%ld firstByte=%.0fms after start, tail=%.0fms, total=%.0fms error=%@",
|
||||
(long)self.emittedChunkCount, t0, t1, tt, error);
|
||||
}
|
||||
// 若队列还有待输出内容,等队列清空再回调 finish
|
||||
if (self.pendingQueue.count > 0) {
|
||||
self.finishError = error;
|
||||
[self startFlushTimerIfNeeded];
|
||||
} else {
|
||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); });
|
||||
[self cancel];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (void)emitChunk:(NSString *)rawText {
|
||||
if (rawText.length == 0) return;
|
||||
// 调试:在任何处理之前打印后端“原始文本分片”,便于对照排查
|
||||
if (self.loggingEnabled) {
|
||||
// NSLog(@"[KBStream] RAW chunk#%ld len=%lu text=\"%@\"",
|
||||
// (long)(self.emittedChunkCount + 1),
|
||||
// (unsigned long)rawText.length,
|
||||
// KBPrintableSnippet(rawText, 160));
|
||||
}
|
||||
NSString *text = rawText;
|
||||
// 0) 规范化换行与段起始:去掉位于片段开头的 \r/\n;将 "\n\t"、"\r\n\t"、"\r\t" 归一为 "\t"
|
||||
text = [text stringByReplacingOccurrencesOfString:@"\r\n\t" withString:@"\t"];
|
||||
text = [text stringByReplacingOccurrencesOfString:@"\n\t" withString:@"\t"];
|
||||
text = [text stringByReplacingOccurrencesOfString:@"\r\t" withString:@"\t"];
|
||||
while (text.length > 0) {
|
||||
unichar c0 = [text characterAtIndex:0];
|
||||
if (c0 == '\n' || c0 == '\r') { text = [text substringFromIndex:1]; continue; }
|
||||
break;
|
||||
}
|
||||
// 1) 统一处理 “/t” -> “\t”
|
||||
if (self.treatSlashTAsTab) {
|
||||
text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"];
|
||||
}
|
||||
// 2) 仅在整体首段:去掉一个起始的 "\t",以及其后紧邻的一个空格(若存在)
|
||||
if (!self.hasEmitted && self.trimLeadingTabOnce) {
|
||||
if (text.length > 0 && [text characterAtIndex:0] == '\t') {
|
||||
NSUInteger start = 1;
|
||||
if (start < text.length && [text characterAtIndex:start] == ' ') start++;
|
||||
text = [text substringFromIndex:start];
|
||||
}
|
||||
}
|
||||
// 3) 从第二段开始:去掉每个段首的一个空格(即 “\t ” -> “\t”),跨分片也处理
|
||||
if (text.length > 0) {
|
||||
// 跨分片:若上个分片以 \t 结尾,本分片起始的一个或多个空格去掉一个
|
||||
if (self.lastChunkEndedWithTab) {
|
||||
NSUInteger j = 0;
|
||||
while (j < text.length && [text characterAtIndex:j] == ' ') { j++; }
|
||||
if (j > 0) {
|
||||
text = [text substringFromIndex:1]; // 仅去一个空格
|
||||
}
|
||||
}
|
||||
// 同一分片内:将 “\t ” 规范化为 “\t”(仅去一个空格)
|
||||
text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"];
|
||||
}
|
||||
if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; }
|
||||
self.emittedChunkCount += 1;
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] chunk#%ld len=%lu text=\"%@\"",
|
||||
(long)self.emittedChunkCount, (unsigned long)text.length, KBPrintableSnippet(text, 160));
|
||||
}
|
||||
if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); });
|
||||
self.hasEmitted = YES;
|
||||
// 记录末尾是否为分段分隔符 \t(用于跨分片处理)
|
||||
unichar lastc = [text characterAtIndex:text.length - 1];
|
||||
self.lastChunkEndedWithTab = (lastc == '\t');
|
||||
}
|
||||
|
||||
- (BOOL)processLLMChunkPayload:(NSString *)payload output:(NSString * _Nullable __autoreleasing *)output {
|
||||
if (output) { *output = nil; }
|
||||
if (payload.length == 0) { return NO; }
|
||||
NSData *jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!jsonData) { return NO; }
|
||||
NSError *jsonError = nil;
|
||||
id obj = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&jsonError];
|
||||
if (jsonError || ![obj isKindOfClass:[NSDictionary class]]) { return NO; }
|
||||
NSString *type = ((NSDictionary *)obj)[@"type"];
|
||||
if (![type isKindOfClass:[NSString class]]) { return NO; }
|
||||
if ([type isEqualToString:@"llm_chunk"]) {
|
||||
id dataValue = ((NSDictionary *)obj)[@"data"];
|
||||
if (![dataValue isKindOfClass:[NSString class]]) {
|
||||
if (output) { *output = @""; }
|
||||
return YES;
|
||||
}
|
||||
NSString *normalized = [self normalizedLLMDataString:(NSString *)dataValue];
|
||||
if (output) { *output = normalized; }
|
||||
return YES;
|
||||
}
|
||||
if ([type isEqualToString:@"search_result"]) {
|
||||
NSString *searchText = [self normalizedSearchResultString:((NSDictionary *)obj)[@"data"]];
|
||||
if (output) { *output = searchText ?: @""; }
|
||||
return YES;
|
||||
}
|
||||
if ([type isEqualToString:@"done"]) {
|
||||
if (output) { *output = @""; }
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (NSString *)normalizedLLMDataString:(NSString *)dataString {
|
||||
NSString *combined = dataString ?: @"";
|
||||
if (self.pendingSplitTokenPrefix.length > 0) {
|
||||
combined = [self.pendingSplitTokenPrefix stringByAppendingString:combined];
|
||||
self.pendingSplitTokenPrefix = nil;
|
||||
}
|
||||
if (combined.length == 0) { return @""; }
|
||||
NSString *result = [combined stringByReplacingOccurrencesOfString:kKBStreamSplitToken withString:@"\t"];
|
||||
NSString *suffix = [self pendingSplitPrefixSuffixForString:result];
|
||||
if (suffix.length > 0) {
|
||||
self.pendingSplitTokenPrefix = suffix;
|
||||
result = [result substringToIndex:result.length - suffix.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSString *)normalizedSearchResultString:(id)dataValue {
|
||||
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
|
||||
NSArray *list = (NSArray *)dataValue;
|
||||
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
||||
for (NSUInteger i = 0; i < list.count; i++) {
|
||||
id item = list[i];
|
||||
NSString *payload = nil;
|
||||
if ([item isKindOfClass:[NSDictionary class]]) {
|
||||
id val = ((NSDictionary *)item)[@"payload"];
|
||||
if ([val isKindOfClass:[NSString class]]) {
|
||||
payload = (NSString *)val;
|
||||
}
|
||||
} else if ([item isKindOfClass:[NSString class]]) {
|
||||
payload = (NSString *)item;
|
||||
}
|
||||
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (payload.length == 0) { continue; }
|
||||
NSString *line = [NSString stringWithFormat:@"%lu. %@", (unsigned long)(segments.count + 1), payload];
|
||||
[segments addObject:line];
|
||||
}
|
||||
if (segments.count == 0) { return @""; }
|
||||
NSString *title = KBLocalized(@"Search result");
|
||||
NSMutableString *text = [NSMutableString string];
|
||||
[text appendString:@"\t"];
|
||||
[text appendFormat:@"%@:", title.length > 0 ? title : @"Search result"];
|
||||
for (NSString *line in segments) {
|
||||
[text appendString:@"\t"];
|
||||
[text appendString:line];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
- (NSString *)pendingSplitPrefixSuffixForString:(NSString *)text {
|
||||
if (text.length == 0) { return @""; }
|
||||
NSUInteger tokenLen = kKBStreamSplitToken.length;
|
||||
if (tokenLen <= 1) { return @""; }
|
||||
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
|
||||
for (NSUInteger len = maxLen; len > 0; len--) {
|
||||
NSString *suffix = [text substringFromIndex:text.length - len];
|
||||
NSString *prefix = [kKBStreamSplitToken substringToIndex:len];
|
||||
if ([suffix isEqualToString:prefix]) {
|
||||
return suffix;
|
||||
}
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
#pragma mark - Queue/Flush
|
||||
|
||||
- (void)enqueueChunk:(NSString *)s {
|
||||
if (s.length == 0) return;
|
||||
[self.pendingQueue addObject:s];
|
||||
[self startFlushTimerIfNeeded];
|
||||
}
|
||||
|
||||
- (void)startFlushTimerIfNeeded {
|
||||
if (self.flushTimer) return;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.flushTimer = [NSTimer scheduledTimerWithTimeInterval:MAX(0.01, self.flushInterval)
|
||||
repeats:YES
|
||||
block:^(NSTimer * _Nonnull t) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) { [t invalidate]; return; }
|
||||
if (self.pendingQueue.count == 0) {
|
||||
[t invalidate]; self.flushTimer = nil;
|
||||
if (self.finishError || self.finishError == nil) {
|
||||
NSError *err = self.finishError; self.finishError = nil;
|
||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(err); });
|
||||
[self cancel];
|
||||
}
|
||||
return;
|
||||
}
|
||||
NSString *first = self.pendingQueue.firstObject;
|
||||
[self.pendingQueue removeObjectAtIndex:0];
|
||||
[self emitChunk:first];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Logging helpers
|
||||
|
||||
static NSString *KBPrintableSnippet(NSString *s, NSUInteger maxLen) {
|
||||
if (!s) return @"";
|
||||
NSString *x = [s stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
|
||||
if (x.length > maxLen) {
|
||||
x = [[x substringToIndex:maxLen] stringByAppendingString:@"…"];
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@end
|
||||
71
CustomKeyboard/Network/NetworkStreamHandler.h
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// NetworkStreamHandler.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/11/12.
|
||||
//
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSUInteger, NetworkStreamState) {
|
||||
NetworkStreamStateIdle,
|
||||
NetworkStreamStateConnecting,
|
||||
NetworkStreamStateReceiving,
|
||||
NetworkStreamStateCompleted,
|
||||
NetworkStreamStateError
|
||||
};
|
||||
|
||||
@class NetworkStreamHandler;
|
||||
|
||||
@protocol NetworkStreamDelegate <NSObject>
|
||||
|
||||
@optional
|
||||
// 接收到数据块
|
||||
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveData:(NSData *)data;
|
||||
// 接收到文本数据(如果是文本内容)
|
||||
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveText:(NSString *)text;
|
||||
// 进度更新
|
||||
- (void)networkStream:(NetworkStreamHandler *)stream downloadProgress:(float)progress;
|
||||
// 状态改变
|
||||
- (void)networkStream:(NetworkStreamHandler *)stream stateChanged:(NetworkStreamState)state;
|
||||
// 请求完成
|
||||
- (void)networkStream:(NetworkStreamHandler *)stream didCompleteWithError:(NSError * _Nullable)error;
|
||||
|
||||
@end
|
||||
|
||||
typedef void (^NetworkStreamProgressBlock)(float progress);
|
||||
typedef void (^NetworkStreamDataBlock)(NSData *data);
|
||||
typedef void (^NetworkStreamTextBlock)(NSString *text);
|
||||
typedef void (^NetworkStreamCompletionBlock)(NSError * _Nullable error);
|
||||
|
||||
@interface NetworkStreamHandler : NSObject <NSURLSessionDataDelegate>
|
||||
|
||||
@property (nonatomic, weak) id<NetworkStreamDelegate> delegate;
|
||||
@property (nonatomic, assign, readonly) NetworkStreamState state;
|
||||
@property (nonatomic, strong, readonly) NSURLResponse *response;
|
||||
@property (nonatomic, assign, readonly) long long totalBytesReceived;
|
||||
|
||||
// 初始化方法
|
||||
- (instancetype)initWithURL:(NSURL *)url;
|
||||
- (instancetype)initWithRequest:(NSURLRequest *)request;
|
||||
|
||||
// 开始请求(使用代理回调)
|
||||
- (void)startRequest;
|
||||
|
||||
// 开始请求(使用 Block 回调)
|
||||
- (void)startRequestWithProgress:(NetworkStreamProgressBlock _Nullable)progress
|
||||
onData:(NetworkStreamDataBlock _Nullable)dataBlock
|
||||
onText:(NetworkStreamTextBlock _Nullable)textBlock
|
||||
completion:(NetworkStreamCompletionBlock _Nullable)completion;
|
||||
|
||||
// 取消请求
|
||||
- (void)cancelRequest;
|
||||
|
||||
// 构建默认请求(包含常见的请求头)
|
||||
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
253
CustomKeyboard/Network/NetworkStreamHandler.m
Normal file
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// NetworkStreamHandler.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/11/12.
|
||||
//
|
||||
|
||||
#import "NetworkStreamHandler.h"
|
||||
|
||||
@interface NetworkStreamHandler ()
|
||||
|
||||
@property (nonatomic, strong) NSURLSession *session;
|
||||
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
|
||||
@property (nonatomic, strong) NSURLRequest *request;
|
||||
@property (nonatomic, strong) NSMutableData *receivedData;
|
||||
@property (nonatomic, assign) long long expectedContentLength;
|
||||
@property (nonatomic, assign) NetworkStreamState state;
|
||||
@property (nonatomic, strong) NSURLResponse *response;
|
||||
|
||||
// Block 回调
|
||||
@property (nonatomic, copy) NetworkStreamProgressBlock progressBlock;
|
||||
@property (nonatomic, copy) NetworkStreamDataBlock dataBlock;
|
||||
@property (nonatomic, copy) NetworkStreamTextBlock textBlock;
|
||||
@property (nonatomic, copy) NetworkStreamCompletionBlock completionBlock;
|
||||
|
||||
@end
|
||||
|
||||
@implementation NetworkStreamHandler
|
||||
|
||||
- (instancetype)initWithURL:(NSURL *)url {
|
||||
NSURLRequest *request = [NetworkStreamHandler createDefaultRequestWithURL:url method:@"GET"];
|
||||
return [self initWithRequest:request];
|
||||
}
|
||||
|
||||
- (instancetype)initWithRequest:(NSURLRequest *)request {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_request = request;
|
||||
_receivedData = [NSMutableData data];
|
||||
_state = NetworkStreamStateIdle;
|
||||
_totalBytesReceived = 0;
|
||||
|
||||
// 创建 URLSession 配置
|
||||
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 30.0;
|
||||
config.timeoutIntervalForResource = 300.0;
|
||||
config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
|
||||
// 创建 URLSession
|
||||
_session = [NSURLSession sessionWithConfiguration:config
|
||||
delegate:self
|
||||
delegateQueue:[NSOperationQueue mainQueue]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self cancelRequest];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)startRequest {
|
||||
if (self.state != NetworkStreamStateIdle) {
|
||||
NSLog(@"Request already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
[self updateState:NetworkStreamStateConnecting];
|
||||
self.dataTask = [self.session dataTaskWithRequest:self.request];
|
||||
[self.dataTask resume];
|
||||
}
|
||||
|
||||
- (void)startRequestWithProgress:(NetworkStreamProgressBlock)progress
|
||||
onData:(NetworkStreamDataBlock)dataBlock
|
||||
onText:(NetworkStreamTextBlock)textBlock
|
||||
completion:(NetworkStreamCompletionBlock)completion {
|
||||
|
||||
self.progressBlock = progress;
|
||||
self.dataBlock = dataBlock;
|
||||
self.textBlock = textBlock;
|
||||
self.completionBlock = completion;
|
||||
|
||||
[self startRequest];
|
||||
}
|
||||
|
||||
- (void)cancelRequest {
|
||||
if (self.dataTask) {
|
||||
[self.dataTask cancel];
|
||||
self.dataTask = nil;
|
||||
}
|
||||
[self updateState:NetworkStreamStateIdle];
|
||||
}
|
||||
|
||||
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method {
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||
request.HTTPMethod = method;
|
||||
request.timeoutInterval = 30.0;
|
||||
|
||||
// 设置常见的请求头(根据您的截图)
|
||||
[request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"];
|
||||
[request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
|
||||
[request setValue:@"zh-CN, zh; q=0.9, ko; q=0.8, ja; q=0.7" forHTTPHeaderField:@"Accept-Language"];
|
||||
[request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
||||
[request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
|
||||
|
||||
// 用户代理(可选)
|
||||
NSString *userAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1";
|
||||
[request setValue:userAgent forHTTPHeaderField:@"User-Agent"];
|
||||
|
||||
return [request copy];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)updateState:(NetworkStreamState)newState {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
|
||||
// 通知代理状态改变
|
||||
if ([self.delegate respondsToSelector:@selector(networkStream:stateChanged:)]) {
|
||||
[self.delegate networkStream:self stateChanged:newState];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)notifyProgress:(float)progress {
|
||||
if (self.progressBlock) {
|
||||
self.progressBlock(progress);
|
||||
}
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(networkStream:downloadProgress:)]) {
|
||||
[self.delegate networkStream:self downloadProgress:progress];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)notifyReceivedData:(NSData *)data {
|
||||
if (self.dataBlock) {
|
||||
self.dataBlock(data);
|
||||
}
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveData:)]) {
|
||||
[self.delegate networkStream:self didReceiveData:data];
|
||||
}
|
||||
|
||||
// 如果是文本数据,尝试转换为字符串
|
||||
if (self.textBlock || [self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
|
||||
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
if (text) {
|
||||
if (self.textBlock) {
|
||||
self.textBlock(text);
|
||||
}
|
||||
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
|
||||
[self.delegate networkStream:self didReceiveText:text];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)notifyCompletionWithError:(NSError * _Nullable)error {
|
||||
if (self.completionBlock) {
|
||||
self.completionBlock(error);
|
||||
}
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(networkStream:didCompleteWithError:)]) {
|
||||
[self.delegate networkStream:self didCompleteWithError:error];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionDataDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
dataTask:(NSURLSessionDataTask *)dataTask
|
||||
didReceiveResponse:(NSURLResponse *)response
|
||||
completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
|
||||
|
||||
self.response = response;
|
||||
self.expectedContentLength = response.expectedContentLength;
|
||||
_totalBytesReceived = 0;
|
||||
[self.receivedData setLength:0];
|
||||
|
||||
[self updateState:NetworkStreamStateReceiving];
|
||||
|
||||
// 检查响应头,处理 CORS 等
|
||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
|
||||
NSLog(@"Response headers: %@", httpResponse.allHeaderFields);
|
||||
|
||||
// 可以在这里检查 CORS 头等信息
|
||||
NSString *allowOrigin = httpResponse.allHeaderFields[@"Access-Control-Allow-Origin"];
|
||||
if (allowOrigin) {
|
||||
NSLog(@"CORS Allow Origin: %@", allowOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(NSURLSessionResponseAllow);
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
dataTask:(NSURLSessionDataTask *)dataTask
|
||||
didReceiveData:(NSData *)data {
|
||||
|
||||
_totalBytesReceived += data.length;
|
||||
[self.receivedData appendData:data];
|
||||
|
||||
// 通知接收到数据块
|
||||
[self notifyReceivedData:data];
|
||||
|
||||
// 计算并通知进度
|
||||
if (self.expectedContentLength != NSURLResponseUnknownLength) {
|
||||
float progress = (float)self.totalBytesReceived / (float)self.expectedContentLength;
|
||||
[self notifyProgress:progress];
|
||||
} else {
|
||||
// 对于 chunked 传输,可能没有确切的内容长度
|
||||
[self notifyProgress:-1]; // 使用 -1 表示未知进度
|
||||
}
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
task:(NSURLSessionTask *)task
|
||||
didCompleteWithError:(NSError *)error {
|
||||
|
||||
if (error) {
|
||||
[self updateState:NetworkStreamStateError];
|
||||
NSLog(@"Request failed with error: %@", error);
|
||||
} else {
|
||||
[self updateState:NetworkStreamStateCompleted];
|
||||
NSLog(@"Request completed successfully. Total bytes: %lld", self.totalBytesReceived);
|
||||
}
|
||||
|
||||
[self notifyCompletionWithError:error];
|
||||
|
||||
// 清理
|
||||
[self.session finishTasksAndInvalidate];
|
||||
self.dataTask = nil;
|
||||
}
|
||||
|
||||
#pragma mark - URL Session Delegate (处理 SSL/认证)
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
|
||||
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
|
||||
|
||||
// 处理 SSL 认证挑战
|
||||
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
||||
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
||||
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
|
||||
} else {
|
||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
70
CustomKeyboard/Network/WJXEventSource/WJXEventSource.h
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// WJXEventSource.h
|
||||
// WJXEventSource
|
||||
//
|
||||
// Created by JiuxingWang on 2025/2/9.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#ifdef __cplusplus
|
||||
#define WJX_EXTERN extern "C" __attribute__((visibility ("default")))
|
||||
#else
|
||||
#define WJX_EXTERN extern __attribute__((visibility ("default")))
|
||||
#endif
|
||||
|
||||
/// 消息事件
|
||||
typedef NSString *WJXEventName NS_TYPED_EXTENSIBLE_ENUM;
|
||||
|
||||
/// 消息事件
|
||||
WJX_EXTERN WJXEventName const WJXEventNameMessage;
|
||||
|
||||
/// readyState 变化事件
|
||||
WJX_EXTERN WJXEventName const WJXEventNameReadyState;
|
||||
|
||||
/// open 事件
|
||||
WJX_EXTERN WJXEventName const WJXEventNameOpen;
|
||||
|
||||
/// error 事件
|
||||
WJX_EXTERN WJXEventName const WJXEventNameError;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, WJXEventState) {
|
||||
WJXEventStateConnecting = 0,
|
||||
WJXEventStateOpen,
|
||||
WJXEventStateClosed,
|
||||
};
|
||||
|
||||
@interface WJXEvent : NSObject
|
||||
|
||||
@property (nonatomic, strong, nullable) id eventId;
|
||||
|
||||
@property (nonatomic, copy, nullable) NSString *event;
|
||||
@property (nonatomic, copy, nullable) NSString *data;
|
||||
|
||||
@property (nonatomic, assign) WJXEventState readyState;
|
||||
@property (nonatomic, strong, nullable) NSError *error;
|
||||
|
||||
- (instancetype)initWithReadyState:(WJXEventState)readyState;
|
||||
|
||||
@end
|
||||
|
||||
typedef void(^WJXEventSourceEventHandler)(WJXEvent *event);
|
||||
|
||||
@interface WJXEventSource : NSObject
|
||||
|
||||
@property (nonatomic, assign) BOOL ignoreRetryAction;
|
||||
|
||||
- (instancetype)initWithRquest:(NSURLRequest *)request;
|
||||
|
||||
- (void)addListener:(WJXEventSourceEventHandler)listener
|
||||
forEvent:(WJXEventName)eventName
|
||||
queue:(nullable NSOperationQueue *)queue;
|
||||
|
||||
- (void)open;
|
||||
- (void)close;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
309
CustomKeyboard/Network/WJXEventSource/WJXEventSource.m
Normal file
@@ -0,0 +1,309 @@
|
||||
//
|
||||
// WJXEventSource.m
|
||||
// WJXEventSource
|
||||
//
|
||||
// Created by JiuxingWang on 2025/2/9.
|
||||
//
|
||||
|
||||
#import "WJXEventSource.h"
|
||||
|
||||
/// 消息事件
|
||||
WJXEventName const WJXEventNameMessage = @"message";
|
||||
|
||||
/// readyState 变化事件
|
||||
WJXEventName const WJXEventNameReadyState = @"readyState";
|
||||
|
||||
/// open 事件
|
||||
WJXEventName const WJXEventNameOpen = @"open";
|
||||
|
||||
/// error 事件
|
||||
WJXEventName const WJXEventNameError = @"error";
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark WJXEvent
|
||||
|
||||
@implementation WJXEvent
|
||||
|
||||
- (instancetype)initWithReadyState:(WJXEventState)readyState;
|
||||
{
|
||||
if (self = [super init]) {
|
||||
self.readyState = readyState;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
NSString *state = nil;
|
||||
switch (_readyState) {
|
||||
case WJXEventStateConnecting: {
|
||||
state = @"CONNECTING";
|
||||
} break;
|
||||
|
||||
case WJXEventStateOpen: {
|
||||
state = @"OPEN";
|
||||
} break;
|
||||
|
||||
case WJXEventStateClosed: {
|
||||
state = @"CLOSED";
|
||||
} break;
|
||||
}
|
||||
|
||||
return [NSString stringWithFormat:@"<%@: readyState: %@, id: %@; event: %@; data: %@>", [self class], state, _eventId, _event, _data];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark WJXEventHandler
|
||||
|
||||
@interface WJXEventHandler : NSObject
|
||||
|
||||
@property (nonatomic, copy, nonnull) WJXEventSourceEventHandler handler;
|
||||
@property (nonatomic, strong, nullable) NSOperationQueue *queue;
|
||||
|
||||
@end
|
||||
|
||||
@implementation WJXEventHandler
|
||||
|
||||
- (instancetype)initWithHandler:(WJXEventSourceEventHandler)handler queue:(NSOperationQueue *)queue
|
||||
{
|
||||
if (self = [super init]) {
|
||||
self.handler = handler;
|
||||
self.queue = queue;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark WJXEventSource
|
||||
|
||||
@interface WJXEventSource () <NSURLSessionDataDelegate>
|
||||
|
||||
@property (nonatomic, strong) NSMutableURLRequest *request;
|
||||
@property (nonatomic, strong) NSMutableDictionary<WJXEventName, NSMutableArray<WJXEventHandler *> *> *listeners;
|
||||
|
||||
@property (nonatomic, strong) NSURLSession *session;
|
||||
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
|
||||
@property (nonatomic, copy) NSString *lastEventId;
|
||||
@property (nonatomic, assign) NSTimeInterval retryInterval;
|
||||
|
||||
@property (nonatomic, assign) BOOL closedByUser;
|
||||
@property (nonatomic, strong) NSMutableData *buffer;
|
||||
|
||||
@end
|
||||
|
||||
@implementation WJXEventSource
|
||||
|
||||
- (instancetype)initWithRquest:(NSURLRequest *)request;
|
||||
{
|
||||
if (self = [super init]) {
|
||||
self.request = [request mutableCopy];
|
||||
self.listeners = [NSMutableDictionary dictionary];
|
||||
self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration] delegate:self delegateQueue:NSOperationQueue.mainQueue];
|
||||
self.buffer = [NSMutableData data];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[_session finishTasksAndInvalidate];
|
||||
}
|
||||
|
||||
- (void)addListener:(WJXEventSourceEventHandler)listener
|
||||
forEvent:(WJXEventName)eventName
|
||||
queue:(nullable NSOperationQueue *)queue;
|
||||
{
|
||||
if (nil == listener) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableArray *listeners = self.listeners[eventName];
|
||||
if (nil == listeners) {
|
||||
self.listeners[eventName] = listeners = [NSMutableArray array];
|
||||
}
|
||||
[listeners addObject:[[WJXEventHandler alloc] initWithHandler:listener queue:queue]];
|
||||
}
|
||||
|
||||
- (void)open;
|
||||
{
|
||||
if (_lastEventId.length) {
|
||||
[_request setValue:_lastEventId forHTTPHeaderField:@"Last-Event-ID"];
|
||||
}
|
||||
|
||||
self.dataTask = [_session dataTaskWithRequest:_request];
|
||||
[_dataTask resume];
|
||||
|
||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateConnecting];
|
||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
||||
}
|
||||
|
||||
- (void)close;
|
||||
{
|
||||
self.closedByUser = YES;
|
||||
[_dataTask cancel];
|
||||
[_session finishTasksAndInvalidate];
|
||||
_buffer = [NSMutableData data];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark NSURLSessionDataDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
dataTask:(NSURLSessionDataTask *)dataTask
|
||||
didReceiveResponse:(NSURLResponse *)response
|
||||
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
|
||||
{
|
||||
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
|
||||
if (200 == HTTPResponse.statusCode) {
|
||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
|
||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
||||
[self _dispatchEvent:event forName:WJXEventNameOpen];
|
||||
}
|
||||
|
||||
if (nil != completionHandler) {
|
||||
completionHandler(NSURLSessionResponseAllow);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
dataTask:(NSURLSessionDataTask *)dataTask
|
||||
didReceiveData:(NSData *)data;
|
||||
{
|
||||
[_buffer appendData:data];
|
||||
[self _processBuffer];
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
task:(NSURLSessionTask *)task
|
||||
didCompleteWithError:(nullable NSError *)error;
|
||||
{
|
||||
if (_closedByUser) {
|
||||
_buffer = [NSMutableData data];
|
||||
return;
|
||||
}
|
||||
|
||||
[self _dispatchPlainBufferIfNeeded];
|
||||
|
||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateClosed];
|
||||
if (nil == (event.error = error)) {
|
||||
event.error = [NSError errorWithDomain:@"WJXEventSource" code:event.readyState userInfo:@{
|
||||
NSLocalizedDescriptionKey: @"Connection with the event source was closed without error",
|
||||
}];
|
||||
}
|
||||
[self _dispatchEvent:event forName:WJXEventNameReadyState];
|
||||
|
||||
if (nil != error) {
|
||||
[self _dispatchEvent:event forName:WJXEventNameError];
|
||||
if (!_ignoreRetryAction) {
|
||||
[self performSelector:@selector(open) withObject:nil afterDelay:_retryInterval];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark Private
|
||||
|
||||
- (void)_processBuffer
|
||||
{
|
||||
NSData *separatorLFLFData = [NSData dataWithBytes:"\n\n" length:2];
|
||||
|
||||
NSRange range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
|
||||
.length = _buffer.length
|
||||
}];
|
||||
|
||||
while (NSNotFound != range.location) {
|
||||
// Extract event data
|
||||
NSData *eventData = [_buffer subdataWithRange:(NSRange) {
|
||||
.length = range.location
|
||||
}];
|
||||
[_buffer replaceBytesInRange:(NSRange) {
|
||||
.length = range.location + 2
|
||||
} withBytes:NULL length:0];
|
||||
|
||||
[self _parseEventData:eventData];
|
||||
|
||||
// Look for next event
|
||||
range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
|
||||
.length = _buffer.length
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_parseEventData:(NSData *)data
|
||||
{
|
||||
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
|
||||
|
||||
NSString *eventString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
if (eventString.length == 0) { return; }
|
||||
NSArray *lines = [eventString componentsSeparatedByCharactersInSet:NSCharacterSet.newlineCharacterSet];
|
||||
BOOL hasDataLine = NO;
|
||||
for (NSString *line in lines) {
|
||||
if ([line hasPrefix:@"id:"]) {
|
||||
event.eventId = [[line substringFromIndex:3] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
} else if ([line hasPrefix:@"event:"]) {
|
||||
event.event = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
} else if ([line hasPrefix:@"data:"]) {
|
||||
hasDataLine = YES;
|
||||
NSString *data = [[line substringFromIndex:5] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
event.data = event.data ? [event.data stringByAppendingFormat:@"\n%@", data] : data;
|
||||
} else if ([line hasPrefix:@"retry:"]) {
|
||||
NSString *retryString = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
self.retryInterval = [retryString doubleValue] / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDataLine) {
|
||||
NSString *trimmed = [eventString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trimmed.length > 0) {
|
||||
event.data = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.eventId) {
|
||||
self.lastEventId = event.eventId;
|
||||
}
|
||||
|
||||
[self _dispatchEvent:event forName:WJXEventNameMessage];
|
||||
}
|
||||
|
||||
- (void)_dispatchEvent:(WJXEvent *)event forName:(WJXEventName)name
|
||||
{
|
||||
NSMutableArray<WJXEventHandler *> *listeners = self.listeners[name];
|
||||
[listeners enumerateObjectsUsingBlock:^(WJXEventHandler * _Nonnull handler, NSUInteger idx, BOOL * _Nonnull stop) {
|
||||
NSOperationQueue *queue = handler.queue ?: NSOperationQueue.mainQueue;
|
||||
[queue addOperationWithBlock:^{
|
||||
handler.handler(event);
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)_dispatchPlainBufferIfNeeded
|
||||
{
|
||||
if (_buffer.length == 0) { return; }
|
||||
NSData *data = [_buffer copy];
|
||||
[_buffer setLength:0];
|
||||
if (data.length == 0) { return; }
|
||||
[self _parseEventData:data];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark -
|
||||
#pragma mark Setters
|
||||
|
||||
- (void)setDataTask:(NSURLSessionDataTask *)dataTask
|
||||
{
|
||||
self.closedByUser = YES; {
|
||||
[_dataTask cancel];
|
||||
_dataTask = dataTask;
|
||||
} self.closedByUser = NO;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -17,15 +17,25 @@
|
||||
|
||||
// 公共配置
|
||||
#import "KBConfig.h"
|
||||
#import "KBAPI.h" // 接口路径宏(统一管理)
|
||||
#import "Masonry.h"
|
||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
||||
#import "KBMaiPointReporter.h"
|
||||
//#import "KBLog.h"
|
||||
|
||||
|
||||
// 通用链接(Universal Links)统一配置
|
||||
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
||||
#define KB_UL_BASE @"https://your.domain/ul" // 替换为你的真实域名与前缀路径
|
||||
#define KB_UL_BASE @"https://app.tknb.net/ul" // 与 Associated Domains 一致
|
||||
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
||||
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
|
||||
|
||||
// 在扩展内,启用 URL Bridge(仅在显式的用户点击动作中使用)
|
||||
// 这样即便宿主 App(如备忘录)拒绝 extensionContext 的 openURL,仍可通过响应链兜底拉起容器 App。
|
||||
#ifndef KB_URL_BRIDGE_ENABLE
|
||||
#define KB_URL_BRIDGE_ENABLE 1
|
||||
#endif
|
||||
|
||||
|
||||
#endif /* PrefixHeader_pch */
|
||||
|
||||
BIN
CustomKeyboard/Resource/002.zip
Normal file
BIN
CustomKeyboard/Resource/Christmas.zip
Normal file
248
CustomKeyboard/Resource/KBSkinIconMap.strings
Normal file
@@ -0,0 +1,248 @@
|
||||
/* 字母 q(小写) */
|
||||
"letter_q_lower" = "key_q";
|
||||
/* 字母 Q(大写) */
|
||||
"letter_q_upper" = "key_q_up";
|
||||
|
||||
/* 字母 w(小写) */
|
||||
"letter_w_lower" = "key_w";
|
||||
/* 字母 W(大写) */
|
||||
"letter_w_upper" = "key_w_up";
|
||||
|
||||
/* 字母 e(小写) */
|
||||
"letter_e_lower" = "key_e";
|
||||
/* 字母 E(大写) */
|
||||
"letter_e_upper" = "key_e_up";
|
||||
|
||||
/* 字母 r(小写) */
|
||||
"letter_r_lower" = "key_r";
|
||||
/* 字母 R(大写) */
|
||||
"letter_r_upper" = "key_r_up";
|
||||
|
||||
/* 字母 t(小写) */
|
||||
"letter_t_lower" = "key_t";
|
||||
/* 字母 T(大写) */
|
||||
"letter_t_upper" = "key_t_up";
|
||||
|
||||
/* 字母 y(小写) */
|
||||
"letter_y_lower" = "key_y";
|
||||
/* 字母 Y(大写) */
|
||||
"letter_y_upper" = "key_y_up";
|
||||
|
||||
/* 字母 u(小写) */
|
||||
"letter_u_lower" = "key_u";
|
||||
/* 字母 U(大写) */
|
||||
"letter_u_upper" = "key_u_up";
|
||||
|
||||
/* 字母 i(小写) */
|
||||
"letter_i_lower" = "key_i";
|
||||
/* 字母 I(大写) */
|
||||
"letter_i_upper" = "key_i_up";
|
||||
|
||||
/* 字母 o(小写) */
|
||||
"letter_o_lower" = "key_o";
|
||||
/* 字母 O(大写) */
|
||||
"letter_o_upper" = "key_o_up";
|
||||
|
||||
/* 字母 p(小写) */
|
||||
"letter_p_lower" = "key_p";
|
||||
/* 字母 P(大写) */
|
||||
"letter_p_upper" = "key_p_up";
|
||||
|
||||
/* 字母 a(小写) */
|
||||
"letter_a_lower" = "key_a";
|
||||
/* 字母 A(大写) */
|
||||
"letter_a_upper" = "key_a_up";
|
||||
|
||||
/* 字母 s(小写) */
|
||||
"letter_s_lower" = "key_s";
|
||||
/* 字母 S(大写) */
|
||||
"letter_s_upper" = "key_s_up";
|
||||
|
||||
/* 字母 d(小写) */
|
||||
"letter_d_lower" = "key_d";
|
||||
/* 字母 D(大写) */
|
||||
"letter_d_upper" = "key_d_up";
|
||||
|
||||
/* 字母 f(小写) */
|
||||
"letter_f_lower" = "key_f";
|
||||
/* 字母 F(大写) */
|
||||
"letter_f_upper" = "key_f_up";
|
||||
|
||||
/* 字母 g(小写) */
|
||||
"letter_g_lower" = "key_g";
|
||||
/* 字母 G(大写) */
|
||||
"letter_g_upper" = "key_g_up";
|
||||
|
||||
/* 字母 h(小写) */
|
||||
"letter_h_lower" = "key_h";
|
||||
/* 字母 H(大写) */
|
||||
"letter_h_upper" = "key_h_up";
|
||||
|
||||
/* 字母 j(小写) */
|
||||
"letter_j_lower" = "key_j";
|
||||
/* 字母 J(大写) */
|
||||
"letter_j_upper" = "key_j_up";
|
||||
|
||||
/* 字母 k(小写) */
|
||||
"letter_k_lower" = "key_k";
|
||||
/* 字母 K(大写) */
|
||||
"letter_k_upper" = "key_k_up";
|
||||
|
||||
/* 字母 l(小写) */
|
||||
"letter_l_lower" = "key_l";
|
||||
/* 字母 L(大写) */
|
||||
"letter_l_upper" = "key_l_up";
|
||||
|
||||
/* 字母 z(小写) */
|
||||
"letter_z_lower" = "key_z";
|
||||
/* 字母 Z(大写) */
|
||||
"letter_z_upper" = "key_z_up";
|
||||
|
||||
/* 字母 x(小写) */
|
||||
"letter_x_lower" = "key_x";
|
||||
/* 字母 X(大写) */
|
||||
"letter_x_upper" = "key_x_up";
|
||||
|
||||
/* 字母 c(小写) */
|
||||
"letter_c_lower" = "key_c";
|
||||
/* 字母 C(大写) */
|
||||
"letter_c_upper" = "key_c_up";
|
||||
|
||||
/* 字母 v(小写) */
|
||||
"letter_v_lower" = "key_v";
|
||||
/* 字母 V(大写) */
|
||||
"letter_v_upper" = "key_v_up";
|
||||
|
||||
/* 字母 b(小写) */
|
||||
"letter_b_lower" = "key_b";
|
||||
/* 字母 B(大写) */
|
||||
"letter_b_upper" = "key_b_up";
|
||||
|
||||
/* 字母 n(小写) */
|
||||
"letter_n_lower" = "key_n";
|
||||
/* 字母 N(大写) */
|
||||
"letter_n_upper" = "key_n_up";
|
||||
|
||||
/* 字母 m(小写) */
|
||||
"letter_m_lower" = "key_m";
|
||||
/* 字母 M(大写) */
|
||||
"letter_m_upper" = "key_m_up";
|
||||
|
||||
/* 数字 1 */
|
||||
"digit_1" = "key_1";
|
||||
/* 数字 2 */
|
||||
"digit_2" = "key_2";
|
||||
/* 数字 3 */
|
||||
"digit_3" = "key_3";
|
||||
/* 数字 4 */
|
||||
"digit_4" = "key_4";
|
||||
/* 数字 5 */
|
||||
"digit_5" = "key_5";
|
||||
/* 数字 6 */
|
||||
"digit_6" = "key_6";
|
||||
/* 数字 7 */
|
||||
"digit_7" = "key_7";
|
||||
/* 数字 8 */
|
||||
"digit_8" = "key_8";
|
||||
/* 数字 9 */
|
||||
"digit_9" = "key_9";
|
||||
/* 数字 0 */
|
||||
"digit_0" = "key_0";
|
||||
|
||||
/* '-' */
|
||||
"sym_minus" = "key_minus";
|
||||
/* '/' */
|
||||
"sym_slash" = "key_slash";
|
||||
/* ':' */
|
||||
"sym_colon" = "key_colon";
|
||||
/* ';' */
|
||||
"sym_semicolon" = "key_semicolon";
|
||||
/* '(' */
|
||||
"sym_paren_l" = "key_paren_l";
|
||||
/* ')' */
|
||||
"sym_paren_r" = "key_paren_r";
|
||||
/* '$' */
|
||||
"sym_dollar" = "key_dollar";
|
||||
/* '&' */
|
||||
"sym_amp" = "key_amp";
|
||||
/* '@' */
|
||||
"sym_at" = "key_at";
|
||||
/* 双引号 " */
|
||||
"sym_quote_double" = "key_quote_d";
|
||||
|
||||
/* ',' */
|
||||
"sym_comma" = "key_comma";
|
||||
/* '.' */
|
||||
"sym_dot" = "key_dot";
|
||||
/* '?' */
|
||||
"sym_question" = "key_question";
|
||||
/* '!' */
|
||||
"sym_exclam" = "key_exclam";
|
||||
/* 单引号 ' */
|
||||
"sym_quote_single" = "key_quote";
|
||||
|
||||
/* '[' */
|
||||
"sym_bracket_l" = "key_bracket_l";
|
||||
/* ']' */
|
||||
"sym_bracket_r" = "key_bracket_r";
|
||||
/* '{' */
|
||||
"sym_brace_l" = "key_brace_l";
|
||||
/* '}' */
|
||||
"sym_brace_r" = "key_brace_r";
|
||||
/* '#' */
|
||||
"sym_hash" = "key_hash";
|
||||
/* '%' */
|
||||
"sym_percent" = "key_percent";
|
||||
/* '^' */
|
||||
"sym_caret" = "key_caret";
|
||||
/* '*' */
|
||||
"sym_asterisk" = "key_asterisk";
|
||||
/* '+' */
|
||||
"sym_plus" = "key_plus";
|
||||
/* '=' */
|
||||
"sym_equal" = "key_equal";
|
||||
|
||||
/* '_' */
|
||||
"sym_underscore" = "key_underscore";
|
||||
/* '\' */
|
||||
"sym_backslash" = "key_backslash";
|
||||
/* '|' */
|
||||
"sym_pipe" = "key_pipe";
|
||||
/* '~' */
|
||||
"sym_tilde" = "key_tilde";
|
||||
/* '<' */
|
||||
"sym_lt" = "key_lt";
|
||||
/* '>' */
|
||||
"sym_gt" = "key_gt";
|
||||
/* '¥' */
|
||||
"sym_money" = "key_money";
|
||||
/* '€' */
|
||||
"sym_euro" = "key_euro";
|
||||
/* '£' */
|
||||
"sym_pound" = "key_pound";
|
||||
/* '•' */
|
||||
"sym_bullet" = "key_bullet";
|
||||
|
||||
/* 空格键 */
|
||||
"space" = "key_space";
|
||||
/* 删除键(⌫) */
|
||||
"backspace" = "key_del";
|
||||
/* Shift(⇧) */
|
||||
"shift" = "key_up";
|
||||
/* Shift(⇧)大写 */
|
||||
"shift_upper" = "key_up_upper";
|
||||
/* 字母面板左下角 "123" */
|
||||
"mode_123" = "key_123";
|
||||
/* 数字面板左下角 "abc" */
|
||||
"mode_abc" = "key_abc";
|
||||
/* 数字面板内 "123 -> #+=" */
|
||||
"symbols_toggle_more" = "key_symbols_more";
|
||||
/* 数字面板内 "#+= -> 123" */
|
||||
"symbols_toggle_123" = "key_symbols_123";
|
||||
/* 自定义 AI 功能键 */
|
||||
"ai" = "key_ai";
|
||||
/* Emoji功能键 */
|
||||
//"emoji" = "key_emoji";
|
||||
"emoji_panel" = "key_emoji";
|
||||
/* 发送/换行键 */
|
||||
"return" = "key_send";
|
||||
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
14802
CustomKeyboard/Resource/emoji_categories.json
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
22
CustomKeyboard/Utils/KBBackspaceLongPressHandler.h
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// KBBackspaceLongPressHandler.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBBackspaceLongPressHandler : NSObject
|
||||
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView;
|
||||
|
||||
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
||||
|
||||
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
||||
- (void)performClearAction;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
662
CustomKeyboard/Utils/KBBackspaceLongPressHandler.m
Normal file
@@ -0,0 +1,662 @@
|
||||
//
|
||||
// KBBackspaceLongPressHandler.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.6;
|
||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
||||
static const NSInteger kKBBackspaceChunkSize = 8;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHeight = 34;
|
||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassUnknown = 0,
|
||||
KBBackspaceChunkClassWhitespace,
|
||||
KBBackspaceChunkClassASCIIWord,
|
||||
KBBackspaceChunkClassPunctuation,
|
||||
KBBackspaceChunkClassOther
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
KBClearPhaseSkipWhitespace = 0,
|
||||
KBClearPhaseSkipTrailingBoundary,
|
||||
KBClearPhaseDeleteUntilBoundary
|
||||
};
|
||||
|
||||
@interface KBBackspaceLongPressHandler ()
|
||||
@property (nonatomic, weak) UIView *containerView;
|
||||
@property (nonatomic, weak) UIView *backspaceButton;
|
||||
@property (nonatomic, strong) UILongPressGestureRecognizer *longPress;
|
||||
@property (nonatomic, assign) BOOL showClearLabelEnabled;
|
||||
@property (nonatomic, assign) BOOL backspaceHoldActive;
|
||||
@property (nonatomic, assign) NSTimeInterval backspaceHoldStartTime;
|
||||
@property (nonatomic, assign) BOOL backspaceChunkModeActive;
|
||||
@property (nonatomic, assign) BOOL backspaceClearHighlighted;
|
||||
@property (nonatomic, assign) NSUInteger backspaceHoldToken;
|
||||
@property (nonatomic, assign) BOOL backspaceHasLastTouchPoint;
|
||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
||||
@property (nonatomic, copy) NSString *pendingClearBefore;
|
||||
@property (nonatomic, copy) NSString *pendingClearAfter;
|
||||
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceLongPressHandler
|
||||
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||
if (self = [super init]) {
|
||||
_containerView = containerView;
|
||||
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)bindDeleteButton:(UIView *)button showClearLabel:(BOOL)showClearLabel {
|
||||
if (self.backspaceButton == button) { return; }
|
||||
|
||||
if (self.longPress && self.backspaceButton) {
|
||||
[self.backspaceButton removeGestureRecognizer:self.longPress];
|
||||
}
|
||||
self.backspaceButton = button;
|
||||
self.showClearLabelEnabled = showClearLabel;
|
||||
self.backspaceHoldActive = NO;
|
||||
self.backspaceChunkModeActive = NO;
|
||||
self.backspaceClearHighlighted = NO;
|
||||
self.backspaceHasLastTouchPoint = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
|
||||
if (!button) { return; }
|
||||
|
||||
self.longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(onBackspaceLongPress:)];
|
||||
self.longPress.minimumPressDuration = kKBBackspaceLongPressMinDuration;
|
||||
self.longPress.allowableMovement = CGFLOAT_MAX;
|
||||
self.longPress.cancelsTouchesInView = YES;
|
||||
[button addGestureRecognizer:self.longPress];
|
||||
}
|
||||
|
||||
- (void)performClearAction {
|
||||
[self kb_clearAllInput];
|
||||
}
|
||||
|
||||
#pragma mark - Long Press
|
||||
|
||||
- (void)onBackspaceLongPress:(UILongPressGestureRecognizer *)gr {
|
||||
UIView *hostView = [self kb_hostView];
|
||||
if (!hostView) { return; }
|
||||
if (gr) {
|
||||
self.backspaceLastTouchPointInSelf = [gr locationInView:hostView];
|
||||
self.backspaceHasLastTouchPoint = YES;
|
||||
}
|
||||
switch (gr.state) {
|
||||
case UIGestureRecognizerStateBegan: {
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||
[[KBInputBufferManager shared] prepareSnapshotForDeleteWithContextBefore:proxy.documentContextBeforeInput
|
||||
after:proxy.documentContextAfterInput];
|
||||
}
|
||||
if (self.showClearLabelEnabled) {
|
||||
[self kb_capturePendingClearSnapshotIfNeeded];
|
||||
[[KBInputBufferManager shared] beginPendingClearSnapshot];
|
||||
}
|
||||
self.backspaceHoldToken += 1;
|
||||
NSUInteger token = self.backspaceHoldToken;
|
||||
self.backspaceHoldActive = YES;
|
||||
self.backspaceHoldStartTime = [NSDate date].timeIntervalSinceReferenceDate;
|
||||
self.backspaceChunkModeActive = NO;
|
||||
[self kb_setBackspaceClearHighlighted:NO];
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
if (self.showClearLabelEnabled) {
|
||||
[self kb_showBackspaceClearLabelIfNeeded];
|
||||
}
|
||||
[self kb_backspaceStepForToken:token];
|
||||
} break;
|
||||
case UIGestureRecognizerStateChanged: {
|
||||
[self kb_handleBackspaceLongPressChanged:gr];
|
||||
} break;
|
||||
case UIGestureRecognizerStateEnded:
|
||||
case UIGestureRecognizerStateCancelled:
|
||||
case UIGestureRecognizerStateFailed: {
|
||||
[self kb_handleBackspaceLongPressEnded:gr];
|
||||
} break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Delete Steps
|
||||
|
||||
- (void)kb_backspaceStepForToken:(NSUInteger)token {
|
||||
if (!self.backspaceHoldActive) { return; }
|
||||
if (token != self.backspaceHoldToken) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
||||
NSInteger deleteCount = 1;
|
||||
if (before.length > 0) {
|
||||
deleteCount = [self kb_backspaceDeleteCountForContext:before elapsed:elapsed];
|
||||
}
|
||||
if (!self.backspaceChunkModeActive && elapsed >= kKBBackspaceChunkStartDelay) {
|
||||
self.backspaceChunkModeActive = YES;
|
||||
if (self.showClearLabelEnabled) {
|
||||
[self kb_showBackspaceClearLabelIfNeeded];
|
||||
}
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
||||
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
||||
|
||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||
(int64_t)(interval * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) selfStrong = weakSelf;
|
||||
[selfStrong kb_backspaceStepForToken:token];
|
||||
});
|
||||
}
|
||||
|
||||
- (NSTimeInterval)kb_backspaceRepeatIntervalForElapsed:(NSTimeInterval)elapsed {
|
||||
if (elapsed >= kKBBackspaceChunkStartDelay) {
|
||||
return kKBBackspaceChunkRepeatInterval;
|
||||
}
|
||||
return kKBBackspaceRepeatInterval;
|
||||
}
|
||||
|
||||
- (NSInteger)kb_backspaceDeleteCountForContext:(NSString *)context elapsed:(NSTimeInterval)elapsed {
|
||||
if (elapsed < kKBBackspaceChunkStartDelay) {
|
||||
return 1;
|
||||
}
|
||||
NSInteger maxCount = (elapsed >= kKBBackspaceChunkFastDelay)
|
||||
? kKBBackspaceChunkSizeFast : kKBBackspaceChunkSize;
|
||||
return [self kb_backspaceChunkDeleteCountForContext:context maxCount:maxCount];
|
||||
}
|
||||
|
||||
- (NSInteger)kb_backspaceChunkDeleteCountForContext:(NSString *)context maxCount:(NSInteger)maxCount {
|
||||
if (context.length == 0) { return 1; }
|
||||
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static NSCharacterSet *asciiWordSet = nil;
|
||||
static NSCharacterSet *punctuationSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
||||
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
||||
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
||||
punctuationSet = [punct copy];
|
||||
});
|
||||
|
||||
__block NSInteger deleteCount = 0;
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkPhase) {
|
||||
KBBackspaceChunkPhaseWhitespace = 0,
|
||||
KBBackspaceChunkPhasePunctuation,
|
||||
KBBackspaceChunkPhaseCore
|
||||
};
|
||||
__block KBBackspaceChunkPhase phase = KBBackspaceChunkPhaseWhitespace;
|
||||
__block KBBackspaceChunkClass coreClass = KBBackspaceChunkClassUnknown;
|
||||
|
||||
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
if (substring.length == 0) { return; }
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassWhitespace;
|
||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassPunctuation;
|
||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
||||
}
|
||||
|
||||
BOOL consumed = NO;
|
||||
while (!consumed) {
|
||||
if (phase == KBBackspaceChunkPhaseWhitespace) {
|
||||
if (currentClass == KBBackspaceChunkClassWhitespace) {
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
} else {
|
||||
phase = KBBackspaceChunkPhasePunctuation;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase == KBBackspaceChunkPhasePunctuation) {
|
||||
if (currentClass == KBBackspaceChunkClassPunctuation) {
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
} else {
|
||||
phase = KBBackspaceChunkPhaseCore;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// phase == Core:连续删同一类(ASCII 单词 / 其它),让效果更像微信“几个字一组”
|
||||
if (coreClass == KBBackspaceChunkClassUnknown) {
|
||||
coreClass = currentClass;
|
||||
}
|
||||
if (currentClass != coreClass) {
|
||||
*stop = YES;
|
||||
consumed = YES;
|
||||
continue;
|
||||
}
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
}
|
||||
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
}];
|
||||
|
||||
return MAX(deleteCount, 1);
|
||||
}
|
||||
|
||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||
hitBoundary:(BOOL *)hitBoundary {
|
||||
if (context.length == 0) {
|
||||
if (hitBoundary) { *hitBoundary = NO; }
|
||||
return 1;
|
||||
}
|
||||
|
||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
NSInteger length = context.length;
|
||||
NSInteger end = length;
|
||||
while (end > 0) {
|
||||
unichar ch = [context characterAtIndex:end - 1];
|
||||
if ([whitespaceSet characterIsMember:ch]) {
|
||||
end -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
NSInteger searchEnd = end;
|
||||
while (searchEnd > 0) {
|
||||
unichar ch = [context characterAtIndex:searchEnd - 1];
|
||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
||||
searchEnd -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSInteger boundaryIndex = NSNotFound;
|
||||
for (NSInteger i = searchEnd - 1; i >= 0; i--) {
|
||||
unichar ch = [context characterAtIndex:i];
|
||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
||||
boundaryIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL boundaryFound = (boundaryIndex != NSNotFound);
|
||||
NSInteger deleteCount = length;
|
||||
if (boundaryIndex != NSNotFound) {
|
||||
deleteCount = length - (boundaryIndex + 1);
|
||||
}
|
||||
deleteCount = MAX(deleteCount, 1);
|
||||
if (hitBoundary) {
|
||||
*hitBoundary = boundaryFound;
|
||||
}
|
||||
return MIN(deleteCount, kKBBackspaceClearMaxStep);
|
||||
}
|
||||
|
||||
#pragma mark - Long Press State
|
||||
|
||||
- (void)kb_handleBackspaceLongPressChanged:(UILongPressGestureRecognizer *)gr {
|
||||
if (!self.backspaceHoldActive) { return; }
|
||||
if (!self.showClearLabelEnabled) { return; }
|
||||
[self kb_showBackspaceClearLabelIfNeeded];
|
||||
UIView *hostView = [self kb_hostView];
|
||||
if (!hostView) { return; }
|
||||
CGPoint point = [gr locationInView:hostView];
|
||||
self.backspaceLastTouchPointInSelf = point;
|
||||
self.backspaceHasLastTouchPoint = YES;
|
||||
BOOL inside = [self kb_isPointInsideBackspaceClearLabel:point];
|
||||
[self kb_setBackspaceClearHighlighted:inside];
|
||||
}
|
||||
|
||||
- (void)kb_handleBackspaceLongPressEnded:(UILongPressGestureRecognizer *)gr {
|
||||
BOOL shouldClear = NO;
|
||||
if (self.showClearLabelEnabled) {
|
||||
shouldClear = self.backspaceClearHighlighted;
|
||||
if (!shouldClear) {
|
||||
UIView *hostView = [self kb_hostView];
|
||||
CGPoint point = CGPointZero;
|
||||
if (gr && hostView) {
|
||||
point = [gr locationInView:hostView];
|
||||
} else if (self.backspaceHasLastTouchPoint) {
|
||||
point = self.backspaceLastTouchPointInSelf;
|
||||
}
|
||||
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
NSLog(@"[kb_handleBackspaceLongPressEnded] shouldClear=%@ highlighted=%@ labelHidden=%@",
|
||||
shouldClear ? @"YES" : @"NO",
|
||||
self.backspaceClearHighlighted ? @"YES" : @"NO",
|
||||
self.backspaceClearLabel.hidden ? @"YES" : @"NO");
|
||||
#endif
|
||||
self.backspaceHoldActive = NO;
|
||||
self.backspaceChunkModeActive = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
self.backspaceHasLastTouchPoint = NO;
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
if (shouldClear) {
|
||||
[self kb_clearAllInput];
|
||||
} else {
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
[[KBInputBufferManager shared] commitLiveToManual];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Clear Label
|
||||
|
||||
- (void)kb_showBackspaceClearLabelIfNeeded {
|
||||
UIView *hostView = [self kb_hostView];
|
||||
if (!hostView || !self.backspaceButton) { return; }
|
||||
UILabel *label = self.backspaceClearLabel;
|
||||
[self kb_refreshBackspaceClearLabelColors];
|
||||
if (!label.superview) {
|
||||
[hostView addSubview:label];
|
||||
}
|
||||
[self kb_updateBackspaceClearLabelFrame];
|
||||
[hostView bringSubviewToFront:label];
|
||||
if (label.hidden) {
|
||||
label.alpha = 0.0;
|
||||
label.hidden = NO;
|
||||
[self kb_playLightHaptic];
|
||||
[UIView animateWithDuration:0.12 animations:^{
|
||||
label.alpha = 1.0;
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_hideBackspaceClearLabel {
|
||||
if (!_backspaceClearLabel || _backspaceClearLabel.hidden) { return; }
|
||||
_backspaceClearLabel.hidden = YES;
|
||||
_backspaceClearLabel.alpha = 1.0;
|
||||
[self kb_setBackspaceClearHighlighted:NO];
|
||||
}
|
||||
|
||||
- (void)kb_updateBackspaceClearLabelFrame {
|
||||
UIView *hostView = [self kb_hostView];
|
||||
if (!hostView || !self.backspaceButton || !self.backspaceClearLabel) { return; }
|
||||
CGRect btnFrame = [self.backspaceButton convertRect:self.backspaceButton.bounds toView:hostView];
|
||||
UILabel *label = self.backspaceClearLabel;
|
||||
CGSize textSize = [label sizeThatFits:CGSizeMake(CGFLOAT_MAX, kKBBackspaceClearLabelHeight)];
|
||||
CGFloat width = MAX(textSize.width + kKBBackspaceClearLabelPaddingX * 2.0, 60.0);
|
||||
CGFloat height = kKBBackspaceClearLabelHeight;
|
||||
CGFloat x = CGRectGetMidX(btnFrame) - width * 0.5;
|
||||
CGFloat y = CGRectGetMinY(btnFrame) - height - kKBBackspaceClearLabelTopGap;
|
||||
if (x < kKBBackspaceClearLabelHorizontalInset) { x = kKBBackspaceClearLabelHorizontalInset; }
|
||||
CGFloat maxX = CGRectGetWidth(hostView.bounds) - kKBBackspaceClearLabelHorizontalInset - width;
|
||||
if (x > maxX) { x = maxX; }
|
||||
if (y < 0) { y = 0; }
|
||||
label.frame = CGRectIntegral(CGRectMake(x, y, width, height));
|
||||
}
|
||||
|
||||
- (BOOL)kb_isPointInsideBackspaceClearLabel:(CGPoint)point {
|
||||
if (!self.backspaceClearLabel || self.backspaceClearLabel.hidden) { return NO; }
|
||||
[self kb_updateBackspaceClearLabelFrame];
|
||||
CGRect hitFrame = CGRectInset(self.backspaceClearLabel.frame, -12.0, -10.0);
|
||||
return CGRectContainsPoint(hitFrame, point);
|
||||
}
|
||||
|
||||
- (void)kb_setBackspaceClearHighlighted:(BOOL)highlighted {
|
||||
if (self.backspaceClearHighlighted == highlighted) { return; }
|
||||
self.backspaceClearHighlighted = highlighted;
|
||||
[self kb_refreshBackspaceClearLabelColors];
|
||||
}
|
||||
|
||||
- (void)kb_refreshBackspaceClearLabelColors {
|
||||
UILabel *label = self.backspaceClearLabel;
|
||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
||||
label.backgroundColor = self.backspaceClearHighlighted
|
||||
? [self kb_backspaceClearLabelHighlightedColor]
|
||||
: [self kb_backspaceClearLabelNormalColor];
|
||||
}
|
||||
|
||||
- (UIColor *)kb_backspaceClearLabelNormalColor {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
return t.keyHighlightBackground ?: [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
}
|
||||
|
||||
- (UIColor *)kb_backspaceClearLabelHighlightedColor {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
return t.accentColor ?: t.keyHighlightBackground ?: [UIColor colorWithWhite:0.8 alpha:1.0];
|
||||
}
|
||||
|
||||
- (void)kb_playLightHaptic {
|
||||
if (@available(iOS 10.0, *)) {
|
||||
UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[gen prepare];
|
||||
[gen impactOccurred];
|
||||
}
|
||||
}
|
||||
|
||||
- (UILabel *)backspaceClearLabel {
|
||||
if (!_backspaceClearLabel) {
|
||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
label.text = KBLocalized(@"Clear");
|
||||
label.textAlignment = NSTextAlignmentCenter;
|
||||
label.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
||||
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
||||
label.layer.masksToBounds = YES;
|
||||
label.hidden = YES;
|
||||
label.userInteractionEnabled = NO;
|
||||
_backspaceClearLabel = label;
|
||||
}
|
||||
return _backspaceClearLabel;
|
||||
}
|
||||
|
||||
#pragma mark - Clear
|
||||
|
||||
- (void)kb_clearAllInput {
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||
}
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
self.backspaceClearToken += 1;
|
||||
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
NSUInteger token = self.backspaceClearToken;
|
||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
||||
}
|
||||
|
||||
- (void)kb_clearAllInputStepForToken:(NSUInteger)token
|
||||
guard:(NSInteger)guard
|
||||
emptyRounds:(NSInteger)emptyRounds {
|
||||
if (token != self.backspaceClearToken) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSInteger nextEmptyRounds = emptyRounds;
|
||||
static NSCharacterSet *stopBoundarySet = nil;
|
||||
static NSCharacterSet *trailingBoundarySet = nil;
|
||||
static NSCharacterSet *trailingWhitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// stopBoundary: 遇到这些符号就停(不删除它)
|
||||
// - 句末符号:. ! ? 。!?
|
||||
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
||||
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
||||
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
||||
|
||||
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
||||
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
||||
trailingBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||
|
||||
// trailingWhitespace: 只跳过空格/Tab(不包含换行,换行由 stopBoundarySet 处理)
|
||||
trailingWhitespaceSet = [NSCharacterSet whitespaceCharacterSet];
|
||||
});
|
||||
KBClearPhase phase = self.backspaceClearPhase;
|
||||
|
||||
NSInteger deletedThisTick = 0;
|
||||
BOOL shouldStop = NO;
|
||||
NSString *lastBefore = nil;
|
||||
for (NSInteger i = 0; i < kKBBackspaceClearDeletesPerTick; i++) {
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length == 0) {
|
||||
nextEmptyRounds += 1;
|
||||
// 宿主(微信/QQ 等)可能在长文本场景下返回空 context,即使还有很多内容。
|
||||
// 为了避免一次“清空”误删全文:一旦拿不到 before,就立刻停止本次清空。
|
||||
shouldStop = YES;
|
||||
break;
|
||||
}
|
||||
nextEmptyRounds = 0;
|
||||
|
||||
if (lastBefore && [before isEqualToString:lastBefore] && deletedThisTick > 0) {
|
||||
// 宿主未及时刷新 context,留到下一 tick 再继续,避免越界/重复记录
|
||||
break;
|
||||
}
|
||||
lastBefore = before;
|
||||
|
||||
// 取最后一个组合字符
|
||||
__block NSString *lastChar = @"";
|
||||
[before enumerateSubstringsInRange:NSMakeRange(0, before.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
lastChar = substring ?: @"";
|
||||
*stop = YES;
|
||||
}];
|
||||
if (lastChar.length == 0) { break; }
|
||||
|
||||
BOOL isWhitespace = ([lastChar rangeOfCharacterFromSet:trailingWhitespaceSet].location != NSNotFound);
|
||||
BOOL isStopBoundary = ([lastChar rangeOfCharacterFromSet:stopBoundarySet].location != NSNotFound);
|
||||
BOOL isTrailingBoundary = ([lastChar rangeOfCharacterFromSet:trailingBoundarySet].location != NSNotFound);
|
||||
|
||||
if (phase == KBClearPhaseSkipWhitespace) {
|
||||
if (isWhitespace) {
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
continue;
|
||||
}
|
||||
phase = KBClearPhaseSkipTrailingBoundary;
|
||||
}
|
||||
|
||||
if (phase == KBClearPhaseSkipTrailingBoundary) {
|
||||
if (isTrailingBoundary) {
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
continue;
|
||||
}
|
||||
phase = KBClearPhaseDeleteUntilBoundary;
|
||||
}
|
||||
|
||||
// phase == DeleteUntilBoundary
|
||||
if (isStopBoundary) {
|
||||
shouldStop = YES; // 保留该句末符号
|
||||
break;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
if (guard + deletedThisTick >= kKBBackspaceClearMaxDeletes) { break; }
|
||||
if (deletedThisTick >= kKBBackspaceClearMaxStep) { break; }
|
||||
}
|
||||
|
||||
self.backspaceClearPhase = phase;
|
||||
NSInteger nextGuard = guard + deletedThisTick;
|
||||
if (nextGuard >= kKBBackspaceClearMaxDeletes ||
|
||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds ||
|
||||
shouldStop) {
|
||||
return;
|
||||
}
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) selfStrong = weakSelf;
|
||||
[selfStrong kb_clearAllInputStepForToken:token
|
||||
guard:nextGuard
|
||||
emptyRounds:nextEmptyRounds];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (UIView *)kb_hostView {
|
||||
if (self.containerView) { return self.containerView; }
|
||||
return self.backspaceButton.superview;
|
||||
}
|
||||
|
||||
- (void)kb_captureDeletionSnapshotIfNeeded {
|
||||
if ([KBBackspaceUndoManager shared].hasUndo) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBBackspaceUndoManager shared] recordDeletionSnapshotBefore:proxy.documentContextBeforeInput
|
||||
after:proxy.documentContextAfterInput];
|
||||
}
|
||||
|
||||
- (void)kb_capturePendingClearSnapshotIfNeeded {
|
||||
if (self.pendingClearBefore.length > 0 || self.pendingClearAfter.length > 0) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
self.pendingClearBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
self.pendingClearAfter = proxy.documentContextAfterInput ?: @"";
|
||||
#if DEBUG
|
||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/before] len=%lu text=%@", (unsigned long)self.pendingClearBefore.length, self.pendingClearBefore);
|
||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/after] len=%lu text=%@", (unsigned long)self.pendingClearAfter.length, self.pendingClearAfter);
|
||||
#endif
|
||||
}
|
||||
|
||||
@end
|
||||
35
CustomKeyboard/Utils/KBBackspaceUndoManager.h
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// KBBackspaceUndoManager.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
||||
|
||||
@interface KBBackspaceUndoManager : NSObject
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasUndo;
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
/// 记录一次删除前的快照(不改变撤销按钮显示)。
|
||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after;
|
||||
|
||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput/AfterInput)。
|
||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after;
|
||||
|
||||
/// 记录本次将被 deleteBackward 的内容,并执行 deleteBackward(支持多次累计,撤销时一次性插回)。
|
||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count;
|
||||
|
||||
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||
|
||||
/// 非删除行为触发时,清理撤销状态
|
||||
- (void)registerNonClearAction;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
304
CustomKeyboard/Utils/KBBackspaceUndoManager.m
Normal file
@@ -0,0 +1,304 @@
|
||||
//
|
||||
// KBBackspaceUndoManager.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
||||
|
||||
#if DEBUG
|
||||
static NSString *KBLogString(NSString *tag, NSString *text) {
|
||||
NSString *safeTag = tag ?: @"";
|
||||
NSString *safeText = text ?: @"";
|
||||
if (safeText.length <= 2000) {
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||||
}
|
||||
NSString *head = [safeText substringToIndex:800];
|
||||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||||
}
|
||||
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
||||
#else
|
||||
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
||||
#endif
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
||||
KBUndoSnapshotSourceNone = 0,
|
||||
KBUndoSnapshotSourceDeletionSnapshot,
|
||||
KBUndoSnapshotSourceClear
|
||||
};
|
||||
|
||||
@interface KBBackspaceUndoManager ()
|
||||
@property (nonatomic, copy) NSString *undoText;
|
||||
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||||
@property (nonatomic, assign) BOOL hasUndo;
|
||||
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceUndoManager
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBBackspaceUndoManager *mgr = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
mgr = [[KBBackspaceUndoManager alloc] init];
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_undoText = @"";
|
||||
_undoAfterLength = 0;
|
||||
_snapshotSource = KBUndoSnapshotSourceNone;
|
||||
_undoDeletedPieces = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
||||
if (!proxy || count == 0) { return; }
|
||||
|
||||
NSString *selected = proxy.selectedText ?: @"";
|
||||
NSString *ctxBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
NSString *ctxAfter = proxy.documentContextAfterInput ?: @"";
|
||||
NSUInteger ctxLen = ctxBefore.length + ctxAfter.length;
|
||||
BOOL isSelectAllLike = (selected.length > 0 &&
|
||||
(ctxLen == 0 || selected.length >= MAX((NSUInteger)40, ctxLen * 2)));
|
||||
if (isSelectAllLike) {
|
||||
// “全选删除”在微信/QQ中通常拿不到可靠的全文,因此禁用撤销,避免插回错误/不完整内容。
|
||||
if (self.hasUndo) {
|
||||
[self registerNonClearAction];
|
||||
}
|
||||
#if DEBUG
|
||||
KB_UNDO_LOG(@"captureAndDelete/selectAllDisableUndo", selected);
|
||||
#endif
|
||||
[proxy deleteBackward];
|
||||
[[KBInputBufferManager shared] resetWithText:@""];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.hasUndo) {
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
BOOL didAppend = NO;
|
||||
NSString *lastObservedBefore = nil;
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length > 0) {
|
||||
// 若宿主在同一 runloop 内不更新 context,则跳过记录,避免把同一个字符重复记录成“多句”。
|
||||
if (lastObservedBefore && [before isEqualToString:lastObservedBefore]) {
|
||||
// still delete, but don't record
|
||||
} else {
|
||||
NSString *piece = [self kb_lastComposedCharacterFromString:before];
|
||||
if (piece.length > 0) {
|
||||
[self.undoDeletedPieces addObject:piece];
|
||||
didAppend = YES;
|
||||
}
|
||||
lastObservedBefore = before;
|
||||
}
|
||||
}
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if (didAppend) {
|
||||
NSUInteger piecesCount = self.undoDeletedPieces.count;
|
||||
if (piecesCount <= 20) {
|
||||
KB_UNDO_LOG(@"captureAndDelete/undoInsertTextNow", [self kb_buildUndoInsertTextFromPieces]);
|
||||
} else if (piecesCount % 50 == 0) {
|
||||
NSString *lastPiece = self.undoDeletedPieces.lastObject ?: @"";
|
||||
NSLog(@"[captureAndDelete/undoPieces] pieces=%lu lastPiece=%@",
|
||||
(unsigned long)piecesCount,
|
||||
lastPiece);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
||||
if (self.hasUndo) { return; }
|
||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||
if (fallbackText.length > 0) {
|
||||
self.undoText = fallbackText;
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||||
if (full.length == 0) { return; }
|
||||
self.undoText = full;
|
||||
self.undoAfterLength = (NSInteger)safeAfter.length;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
||||
|
||||
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
||||
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
||||
|
||||
if (candidate.length == 0) { return; }
|
||||
|
||||
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
||||
|
||||
if (self.undoText.length > 0) {
|
||||
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
||||
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
||||
if (candidate.length > self.undoText.length) {
|
||||
self.undoText = candidate;
|
||||
self.undoAfterLength = candidateAfterLen;
|
||||
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
||||
} else {
|
||||
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
||||
}
|
||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.undoText = candidate;
|
||||
self.undoAfterLength = candidateAfterLen;
|
||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||
if (!self.hasUndo) { return; }
|
||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||||
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||||
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||||
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||||
if (insertText.length > 0) {
|
||||
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
||||
[proxy insertText:insertText];
|
||||
[[KBInputBufferManager shared] appendText:insertText];
|
||||
} else if (self.undoText.length > 0) {
|
||||
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
||||
[self kb_clearAllTextForProxy:proxy];
|
||||
[proxy insertText:self.undoText];
|
||||
if (self.undoAfterLength > 0 &&
|
||||
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
||||
}
|
||||
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
[self kb_updateHasUndo:NO];
|
||||
}
|
||||
|
||||
- (void)registerNonClearAction {
|
||||
if (!self.hasUndo) { return; }
|
||||
if (self.undoText.length > 0) {
|
||||
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
||||
}
|
||||
if (self.undoDeletedPieces.count > 0) {
|
||||
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
||||
}
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
[self kb_updateHasUndo:NO];
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (void)kb_updateHasUndo:(BOOL)hasUndo {
|
||||
if (self.hasUndo == hasUndo) { return; }
|
||||
self.hasUndo = hasUndo;
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||||
}
|
||||
|
||||
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
||||
if (text.length == 0) { return @""; }
|
||||
__block NSString *last = @"";
|
||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
last = substring ?: @"";
|
||||
*stop = YES;
|
||||
}];
|
||||
return last ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
||||
if (self.undoDeletedPieces.count == 0) { return @""; }
|
||||
NSMutableString *result = [NSMutableString string];
|
||||
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
||||
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
||||
if (piece.length == 0) { continue; }
|
||||
[result appendString:piece];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static const NSInteger kKBUndoClearMaxRounds = 200;
|
||||
|
||||
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
||||
if (!proxy) { return; }
|
||||
|
||||
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||
NSInteger guard = 0;
|
||||
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||
NSInteger offset = (NSInteger)contextAfter.length;
|
||||
[proxy adjustTextPositionByCharacterOffset:offset];
|
||||
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
guard += 1;
|
||||
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
NSInteger guard = 0;
|
||||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
guard += 1;
|
||||
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
36
CustomKeyboard/Utils/KBExtensionAppLauncher.h
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// KBExtensionAppLauncher.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link + 响应链兜底)。
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBExtensionAppLauncher : NSObject
|
||||
|
||||
/// 通用入口:优先尝试 primaryURL,失败后尝试 fallbackURL,
|
||||
/// 两者都失败时再通过响应链(openURL:)做兜底。
|
||||
/// - Parameters:
|
||||
/// - primaryURL: 第一优先尝试的 URL(可为 Scheme 或 UL)
|
||||
/// - fallbackURL: 失败时的备用 URL(可为 nil)
|
||||
/// - ivc: 当前的 UIInputViewController(用于 extensionContext openURL)
|
||||
/// - source: 兜底时用作起点的 responder(通常传 self 或 self.view)
|
||||
/// - completion: 最终是否“看起来已成功发起”打开动作(不保证一定跳转到 App)
|
||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||
|
||||
/// 简化版:只针对单一 Scheme 做尝试 + 响应链兜底。
|
||||
+ (void)openScheme:(NSURL *)scheme
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
121
CustomKeyboard/Utils/KBExtensionAppLauncher.m
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// KBExtensionAppLauncher.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBExtensionAppLauncher.h"
|
||||
#import <objc/message.h>
|
||||
|
||||
@implementation KBExtensionAppLauncher
|
||||
|
||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||
if (!ivc || (!primaryURL && !fallbackURL)) {
|
||||
if (completion) { completion(NO); }
|
||||
return;
|
||||
}
|
||||
|
||||
// 保证在主线程回调,避免调用方再做一次 dispatch。
|
||||
void (^finish)(BOOL) = ^(BOOL ok){
|
||||
if (!completion) return;
|
||||
if ([NSThread isMainThread]) {
|
||||
completion(ok);
|
||||
} else {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(ok); });
|
||||
}
|
||||
};
|
||||
|
||||
NSURL *first = primaryURL ?: fallbackURL;
|
||||
NSURL *second = (first == primaryURL) ? fallbackURL : nil;
|
||||
|
||||
if (!first) {
|
||||
finish(NO);
|
||||
return;
|
||||
}
|
||||
|
||||
[ivc.extensionContext openURL:first completionHandler:^(BOOL ok) {
|
||||
if (ok) {
|
||||
finish(YES);
|
||||
return;
|
||||
}
|
||||
|
||||
if (second) {
|
||||
[ivc.extensionContext openURL:second completionHandler:^(BOOL ok2) {
|
||||
if (ok2) {
|
||||
finish(YES);
|
||||
return;
|
||||
}
|
||||
BOOL bridged = [self p_bridgeFirst:first second:second from:source];
|
||||
finish(bridged);
|
||||
}];
|
||||
} else {
|
||||
BOOL bridged = [self p_bridgeFirst:first second:nil from:source];
|
||||
finish(bridged);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
+ (void)openScheme:(NSURL *)scheme
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||
[self openPrimaryURL:scheme
|
||||
fallbackURL:nil
|
||||
usingInputController:ivc
|
||||
source:source
|
||||
completion:completion];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
// 通过响应链尝试调用 openURL:(等价于原 KBURLOpenBridge 实现)
|
||||
+ (BOOL)p_openURLViaResponder:(NSURL *)url from:(UIResponder *)start {
|
||||
#if KB_URL_BRIDGE_ENABLE
|
||||
if (!url || !start) return NO;
|
||||
SEL sel = NSSelectorFromString(@"openURL:");
|
||||
UIResponder *responder = start;
|
||||
while (responder) {
|
||||
@try {
|
||||
if ([responder respondsToSelector:sel]) {
|
||||
BOOL handled = NO;
|
||||
BOOL (*funcBool)(id, SEL, NSURL *) = (BOOL (*)(id, SEL, NSURL *))objc_msgSend;
|
||||
if (funcBool) {
|
||||
handled = funcBool(responder, sel, url);
|
||||
} else {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[responder performSelector:sel withObject:url];
|
||||
handled = YES;
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
} @catch (__unused NSException *e) {
|
||||
// ignore and continue
|
||||
}
|
||||
responder = responder.nextResponder;
|
||||
}
|
||||
return NO;
|
||||
#else
|
||||
(void)url; (void)start;
|
||||
return NO;
|
||||
#endif
|
||||
}
|
||||
|
||||
+ (BOOL)p_bridgeFirst:(NSURL * _Nullable)first
|
||||
second:(NSURL * _Nullable)second
|
||||
from:(UIResponder *)source {
|
||||
BOOL bridged = NO;
|
||||
if (first) {
|
||||
bridged = [self p_openURLViaResponder:first from:source];
|
||||
}
|
||||
if (!bridged && second) {
|
||||
bridged = [self p_openURLViaResponder:second from:source];
|
||||
}
|
||||
return bridged;
|
||||
}
|
||||
|
||||
@end
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionFeatureItemView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 顶部滚动的功能点 Item(左图右文)
|
||||
@interface KBKeyboardSubscriptionFeatureItemView : UIView
|
||||
|
||||
- (void)configureWithImage:(UIImage *)image title:(NSString *)title;
|
||||
|
||||
/// 根据 title 计算推荐宽度:textWidth + 50(图片 35 + 间距 5 + 左右内边距各 5)
|
||||
+ (CGFloat)preferredWidthForTitle:(NSString *)title;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionFeatureItemView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardSubscriptionFeatureItemView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBKeyboardSubscriptionFeatureItemView ()
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardSubscriptionFeatureItemView
|
||||
|
||||
static const CGFloat kKBFeatureItemPadding = 5.0;
|
||||
static const CGFloat kKBFeatureItemIconSize = 35.0;
|
||||
static const CGFloat kKBFeatureItemGap = 5.0;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
// self.layer.cornerRadius = 24;
|
||||
// self.layer.masksToBounds = YES;
|
||||
// self.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.85];
|
||||
|
||||
[self addSubview:self.iconView];
|
||||
[self addSubview:self.titleLabel];
|
||||
|
||||
[self.iconView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(kKBFeatureItemPadding);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.width.height.mas_equalTo(kKBFeatureItemIconSize);
|
||||
}];
|
||||
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.iconView.mas_right).offset(kKBFeatureItemGap);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.right.equalTo(self.mas_right).offset(-kKBFeatureItemPadding);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)configureWithImage:(UIImage *)image title:(NSString *)title {
|
||||
self.iconView.image = image;
|
||||
self.titleLabel.text = title ?: @"";
|
||||
}
|
||||
|
||||
+ (CGFloat)preferredWidthForTitle:(NSString *)title {
|
||||
UIFont *font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
NSString *text = title ?: @"";
|
||||
NSArray<NSString *> *lines = [text componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
||||
CGFloat maxLineWidth = 0;
|
||||
for (NSString *line in lines) {
|
||||
NSString *trimLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
if (trimLine.length == 0) { continue; }
|
||||
CGSize size = [trimLine sizeWithAttributes:@{NSFontAttributeName: font}];
|
||||
maxLineWidth = MAX(maxLineWidth, ceil(size.width));
|
||||
}
|
||||
if (maxLineWidth <= 0) { maxLineWidth = 80; }
|
||||
|
||||
CGFloat width = maxLineWidth + 50.0; // 5 + 35 + 5 + 5
|
||||
width = MIN(MAX(width, 120.0), 240.0);
|
||||
return width;
|
||||
}
|
||||
|
||||
- (UIImageView *)iconView {
|
||||
if (!_iconView) {
|
||||
_iconView = [[UIImageView alloc] init];
|
||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
}
|
||||
return _iconView;
|
||||
}
|
||||
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [[UILabel alloc] init];
|
||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
_titleLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
||||
_titleLabel.numberOfLines = 0;
|
||||
_titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||
}
|
||||
return _titleLabel;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionFeatureMarqueeView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 顶部功能点横向自动滚动视图
|
||||
@interface KBKeyboardSubscriptionFeatureMarqueeView : UIView
|
||||
|
||||
/// titles/images 数量不一致时,以较小的 count 为准
|
||||
- (void)configureWithTitles:(NSArray<NSString *> *)titles
|
||||
images:(NSArray<UIImage *> *)images;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionFeatureMarqueeView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardSubscriptionFeatureMarqueeView.h"
|
||||
#import "KBKeyboardSubscriptionFeatureItemView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBKeyboardSubscriptionFeatureMarqueeView ()
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIView *contentView;
|
||||
@property (nonatomic, strong) CADisplayLink *displayLink;
|
||||
@property (nonatomic, assign) CGFloat loopWidth;
|
||||
@property (nonatomic, copy) NSArray<NSDictionary *> *items;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardSubscriptionFeatureMarqueeView
|
||||
|
||||
static const CGFloat kKBFeatureMarqueeItemSpacing = 12.0;
|
||||
static const CGFloat kKBFeatureMarqueeSpeedPerFrame = 0.35f;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
[self addSubview:self.scrollView];
|
||||
[self.scrollView addSubview:self.contentView];
|
||||
|
||||
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(self.scrollView);
|
||||
make.left.equalTo(self.scrollView);
|
||||
make.height.equalTo(self.scrollView);
|
||||
make.right.equalTo(self.scrollView);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopTicker];
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
if (self.window) {
|
||||
[self startTickerIfNeeded];
|
||||
} else {
|
||||
[self stopTicker];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setHidden:(BOOL)hidden {
|
||||
BOOL oldHidden = self.isHidden;
|
||||
[super setHidden:hidden];
|
||||
if (oldHidden == hidden) { return; }
|
||||
if (hidden) {
|
||||
[self stopTicker];
|
||||
} else if (self.window) {
|
||||
[self startTickerIfNeeded];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
// 宽度变化时重新评估是否需要滚动
|
||||
[self rebuildIfNeeded];
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)configureWithTitles:(NSArray<NSString *> *)titles images:(NSArray<UIImage *> *)images {
|
||||
NSInteger count = MIN(titles.count, images.count);
|
||||
if (count <= 0) {
|
||||
self.items = @[];
|
||||
[self rebuildIfNeeded];
|
||||
return;
|
||||
}
|
||||
NSMutableArray *arr = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
||||
for (NSInteger i = 0; i < count; i++) {
|
||||
NSString *t = titles[(NSUInteger)i] ?: @"";
|
||||
UIImage *img = images[(NSUInteger)i] ?: [UIImage new];
|
||||
[arr addObject:@{@"title": t, @"image": img}];
|
||||
}
|
||||
self.items = [arr copy];
|
||||
[self rebuildIfNeeded];
|
||||
}
|
||||
|
||||
#pragma mark - Build
|
||||
|
||||
- (void)rebuildIfNeeded {
|
||||
[self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
if (self.items.count == 0) {
|
||||
self.loopWidth = 0;
|
||||
self.scrollView.contentSize = CGSizeZero;
|
||||
self.scrollView.contentOffset = CGPointZero;
|
||||
[self stopTicker];
|
||||
return;
|
||||
}
|
||||
|
||||
BOOL shouldLoop = (self.items.count > 1);
|
||||
NSInteger baseCount = self.items.count;
|
||||
|
||||
NSMutableArray<NSNumber *> *baseWidths = [NSMutableArray arrayWithCapacity:(NSUInteger)baseCount];
|
||||
CGFloat baseTotalWidth = 0;
|
||||
for (NSInteger i = 0; i < baseCount; i++) {
|
||||
NSDictionary *info = self.items[(NSUInteger)i];
|
||||
NSString *title = [info[@"title"] isKindOfClass:NSString.class] ? info[@"title"] : @"";
|
||||
CGFloat w = [KBKeyboardSubscriptionFeatureItemView preferredWidthForTitle:title];
|
||||
[baseWidths addObject:@(w)];
|
||||
baseTotalWidth += w;
|
||||
if (i > 0) { baseTotalWidth += kKBFeatureMarqueeItemSpacing; }
|
||||
}
|
||||
|
||||
NSArray *loopData = shouldLoop ? [self.items arrayByAddingObjectsFromArray:self.items] : self.items;
|
||||
CGFloat totalWidth = shouldLoop ? (baseTotalWidth * 2 + kKBFeatureMarqueeItemSpacing) : baseTotalWidth;
|
||||
|
||||
UIView *previous = nil;
|
||||
for (NSInteger idx = 0; idx < loopData.count; idx++) {
|
||||
NSDictionary *info = loopData[(NSUInteger)idx];
|
||||
UIImage *img = [info[@"image"] isKindOfClass:UIImage.class] ? info[@"image"] : nil;
|
||||
NSString *title = [info[@"title"] isKindOfClass:NSString.class] ? info[@"title"] : @"";
|
||||
CGFloat width = baseWidths[(NSUInteger)(idx % baseCount)].doubleValue;
|
||||
|
||||
KBKeyboardSubscriptionFeatureItemView *itemView = [[KBKeyboardSubscriptionFeatureItemView alloc] init];
|
||||
[itemView configureWithImage:(img ?: [UIImage new]) title:title];
|
||||
[self.contentView addSubview:itemView];
|
||||
|
||||
[itemView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(self.contentView);
|
||||
make.width.mas_equalTo(width);
|
||||
if (previous) {
|
||||
make.left.equalTo(previous.mas_right).offset(kKBFeatureMarqueeItemSpacing);
|
||||
} else {
|
||||
make.left.equalTo(self.contentView.mas_left);
|
||||
}
|
||||
}];
|
||||
previous = itemView;
|
||||
}
|
||||
|
||||
[self.contentView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(self.scrollView);
|
||||
make.left.equalTo(self.scrollView);
|
||||
make.height.equalTo(self.scrollView);
|
||||
if (previous) {
|
||||
make.right.equalTo(previous.mas_right);
|
||||
} else {
|
||||
make.right.equalTo(self.scrollView);
|
||||
}
|
||||
}];
|
||||
|
||||
CGFloat minWidth = CGRectGetWidth(self.bounds);
|
||||
if (minWidth <= 0) { minWidth = 1; }
|
||||
CGFloat height = CGRectGetHeight(self.bounds);
|
||||
if (height <= 0) { height = 48; }
|
||||
|
||||
CGFloat contentWidth = totalWidth;
|
||||
if (contentWidth <= minWidth) {
|
||||
contentWidth = minWidth;
|
||||
self.loopWidth = 0;
|
||||
[self stopTicker];
|
||||
self.scrollView.contentOffset = CGPointZero;
|
||||
} else {
|
||||
self.loopWidth = shouldLoop ? (baseTotalWidth + kKBFeatureMarqueeItemSpacing) : 0;
|
||||
[self startTickerIfNeeded];
|
||||
}
|
||||
self.scrollView.contentSize = CGSizeMake(contentWidth, height);
|
||||
}
|
||||
|
||||
#pragma mark - Ticker
|
||||
|
||||
- (void)startTickerIfNeeded {
|
||||
if (self.displayLink || self.loopWidth <= 0) { return; }
|
||||
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleTick)];
|
||||
self.displayLink.preferredFramesPerSecond = 60;
|
||||
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
||||
}
|
||||
|
||||
- (void)stopTicker {
|
||||
[self.displayLink invalidate];
|
||||
self.displayLink = nil;
|
||||
}
|
||||
|
||||
- (void)handleTick {
|
||||
if (self.loopWidth <= 0) { return; }
|
||||
CGFloat nextX = self.scrollView.contentOffset.x + kKBFeatureMarqueeSpeedPerFrame;
|
||||
if (nextX >= self.loopWidth) {
|
||||
nextX -= self.loopWidth;
|
||||
}
|
||||
self.scrollView.contentOffset = CGPointMake(nextX, 0);
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIScrollView *)scrollView {
|
||||
if (!_scrollView) {
|
||||
_scrollView = [[UIScrollView alloc] init];
|
||||
_scrollView.showsHorizontalScrollIndicator = NO;
|
||||
_scrollView.scrollEnabled = NO;
|
||||
_scrollView.clipsToBounds = YES;
|
||||
}
|
||||
return _scrollView;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
if (!_contentView) {
|
||||
_contentView = [[UIView alloc] init];
|
||||
}
|
||||
return _contentView;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
18
CustomKeyboard/View/Buy/KBKeyboardSubscriptionOptionCell.h
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionOptionCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/12/17.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardSubscriptionOptionCell : UICollectionViewCell
|
||||
- (void)configureWithProduct:(KBKeyboardSubscriptionProduct *)product;
|
||||
- (void)applySelected:(BOOL)selected animated:(BOOL)animated;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
151
CustomKeyboard/View/Buy/KBKeyboardSubscriptionOptionCell.m
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionOptionCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/12/17.
|
||||
//
|
||||
|
||||
#import "KBKeyboardSubscriptionOptionCell.h"
|
||||
@interface KBKeyboardSubscriptionOptionCell()
|
||||
@property (nonatomic, strong) UIView *cardView;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UILabel *priceLabel;
|
||||
@property (nonatomic, strong) UILabel *strikeLabel;
|
||||
@property (nonatomic, strong) UIImageView *selectedImageView;
|
||||
@end
|
||||
@implementation KBKeyboardSubscriptionOptionCell
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
[self.contentView addSubview:self.cardView];
|
||||
[self.cardView addSubview:self.titleLabel];
|
||||
[self.cardView addSubview:self.priceLabel];
|
||||
[self.cardView addSubview:self.strikeLabel];
|
||||
[self.cardView addSubview:self.selectedImageView];
|
||||
|
||||
[self.cardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.edges.equalTo(self.contentView);
|
||||
make.left.right.top.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView).offset(-10);
|
||||
}];
|
||||
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.cardView.mas_top).offset(8);
|
||||
make.left.equalTo(self.cardView.mas_left).offset(10);
|
||||
make.right.equalTo(self.cardView.mas_right).offset(-10);
|
||||
}];
|
||||
|
||||
[self.priceLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.titleLabel.mas_left);
|
||||
make.top.equalTo(self.titleLabel.mas_bottom).offset(8);
|
||||
}];
|
||||
|
||||
[self.strikeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.priceLabel.mas_right).offset(5);
|
||||
make.centerY.equalTo(self.priceLabel);
|
||||
}];
|
||||
|
||||
[self.selectedImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
||||
make.bottom.equalTo(self.cardView.mas_bottom).offset(10);
|
||||
make.width.mas_equalTo(16);
|
||||
make.height.mas_equalTo(17);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
self.titleLabel.text = @"";
|
||||
self.priceLabel.text = @"";
|
||||
self.strikeLabel.attributedText = nil;
|
||||
self.strikeLabel.hidden = YES;
|
||||
[self applySelected:NO animated:NO];
|
||||
}
|
||||
|
||||
- (void)configureWithProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (!product) { return; }
|
||||
self.titleLabel.text = [product displayTitle];
|
||||
self.priceLabel.text = [product priceDisplayText];
|
||||
NSString *strike = [product strikePriceDisplayText];
|
||||
if (strike.length > 0) {
|
||||
NSDictionary *attr = @{
|
||||
NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle),
|
||||
NSForegroundColorAttributeName: [UIColor colorWithHex:0xCCCCCC],
|
||||
NSFontAttributeName: [UIFont systemFontOfSize:14]
|
||||
};
|
||||
self.strikeLabel.attributedText = [[NSAttributedString alloc] initWithString:strike attributes:attr];
|
||||
self.strikeLabel.hidden = NO;
|
||||
} else {
|
||||
self.strikeLabel.attributedText = nil;
|
||||
self.strikeLabel.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)applySelected:(BOOL)selected animated:(BOOL)animated {
|
||||
void (^changes)(void) = ^{
|
||||
self.cardView.layer.borderColor = selected ? [UIColor colorWithHex:0x02BEAC].CGColor : [[UIColor blackColor] colorWithAlphaComponent:0.12].CGColor;
|
||||
self.cardView.layer.borderWidth = selected ? 2.0 : 1.0;
|
||||
self.selectedImageView.alpha = selected ? 1.0 : 0.0;
|
||||
};
|
||||
if (animated) {
|
||||
self.selectedImageView.hidden = NO;
|
||||
[UIView animateWithDuration:0.18 animations:changes completion:^(BOOL finished) {
|
||||
self.selectedImageView.hidden = !selected;
|
||||
}];
|
||||
} else {
|
||||
changes();
|
||||
self.selectedImageView.hidden = !selected;
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)cardView {
|
||||
if (!_cardView) {
|
||||
_cardView = [[UIView alloc] init];
|
||||
_cardView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.96];
|
||||
_cardView.layer.cornerRadius = 20;
|
||||
_cardView.layer.borderWidth = 1.0;
|
||||
_cardView.layer.borderColor = [[UIColor blackColor] colorWithAlphaComponent:0.12].CGColor;
|
||||
}
|
||||
return _cardView;
|
||||
}
|
||||
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [[UILabel alloc] init];
|
||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
_titleLabel.numberOfLines = 2;
|
||||
_titleLabel.textColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
}
|
||||
return _titleLabel;
|
||||
}
|
||||
|
||||
- (UILabel *)priceLabel {
|
||||
if (!_priceLabel) {
|
||||
_priceLabel = [[UILabel alloc] init];
|
||||
_priceLabel.font = [UIFont systemFontOfSize:20 weight:UIFontWeightBold];
|
||||
_priceLabel.textColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
}
|
||||
return _priceLabel;
|
||||
}
|
||||
|
||||
- (UILabel *)strikeLabel {
|
||||
if (!_strikeLabel) {
|
||||
_strikeLabel = [[UILabel alloc] init];
|
||||
_strikeLabel.textColor = [UIColor colorWithHex:0xCCCCCC];
|
||||
_strikeLabel.hidden = YES;
|
||||
}
|
||||
return _strikeLabel;
|
||||
}
|
||||
|
||||
- (UIImageView *)selectedImageView {
|
||||
if (!_selectedImageView) {
|
||||
_selectedImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"buy_sel_icon"]];
|
||||
_selectedImageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_selectedImageView.hidden = YES;
|
||||
_selectedImageView.alpha = 0.0;
|
||||
}
|
||||
return _selectedImageView;
|
||||
}
|
||||
@end
|
||||
31
CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.h
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBKeyboardSubscriptionProduct;
|
||||
@class KBKeyboardSubscriptionView;
|
||||
|
||||
@protocol KBKeyboardSubscriptionViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view;
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product;
|
||||
@end
|
||||
|
||||
/// 键盘内的订阅弹层
|
||||
@interface KBKeyboardSubscriptionView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBKeyboardSubscriptionViewDelegate> delegate;
|
||||
|
||||
/// 首次展示时调用,内部会自动请求订阅商品
|
||||
- (void)refreshProductsIfNeeded;
|
||||
/// 外部强制刷新
|
||||
- (void)reloadProducts;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
456
CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.m
Normal file
@@ -0,0 +1,456 @@
|
||||
//
|
||||
// KBKeyboardSubscriptionView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBKeyboardSubscriptionFeatureMarqueeView.h"
|
||||
#import "KBKeyboardSubscriptionOptionCell.h"
|
||||
#import "KBConfig.h"
|
||||
|
||||
#import <MJExtension/MJExtension.h>
|
||||
|
||||
static NSString * const kKBKeyboardSubscriptionCellId = @"kKBKeyboardSubscriptionCellId";
|
||||
|
||||
static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
||||
if (!obj || obj == (id)kCFNull) { return nil; }
|
||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *dict = (NSDictionary *)obj;
|
||||
NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:dict.count];
|
||||
[dict enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
|
||||
(void)stop;
|
||||
if (![key isKindOfClass:[NSString class]]) { return; }
|
||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(value);
|
||||
if (!sanitized) { return; }
|
||||
result[key] = sanitized;
|
||||
}];
|
||||
return result;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSArray class]]) {
|
||||
NSArray *arr = (NSArray *)obj;
|
||||
NSMutableArray *result = [NSMutableArray arrayWithCapacity:arr.count];
|
||||
for (id item in arr) {
|
||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(item);
|
||||
if (!sanitized) { continue; }
|
||||
[result addObject:sanitized];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
@interface KBKeyboardSubscriptionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
|
||||
@property (nonatomic, strong) UIImageView *cardView;
|
||||
@property (nonatomic, strong) UIButton *closeButton;
|
||||
@property (nonatomic, strong) KBKeyboardSubscriptionFeatureMarqueeView *featureMarqueeView;
|
||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||
@property (nonatomic, strong) UIButton *purchaseButton;
|
||||
@property (nonatomic, strong) UILabel *agreementLabel;
|
||||
@property (nonatomic, strong) UIButton *agreementButton;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
||||
@property (nonatomic, strong) UILabel *emptyLabel;
|
||||
@property (nonatomic, copy) NSArray<KBKeyboardSubscriptionProduct *> *products;
|
||||
@property (nonatomic, copy, nullable) NSArray *productsRawJSON;
|
||||
@property (nonatomic, assign) NSInteger selectedIndex;
|
||||
@property (nonatomic, assign) BOOL didLoadOnce;
|
||||
@property (nonatomic, assign, getter=isLoading) BOOL loading;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardSubscriptionView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_selectedIndex = NSNotFound;
|
||||
[self setupCardView];
|
||||
[self setupFeatureItems];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)refreshProductsIfNeeded {
|
||||
if (!self.didLoadOnce) {
|
||||
[self fetchProducts];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)reloadProducts {
|
||||
[self fetchProducts];
|
||||
}
|
||||
|
||||
#pragma mark - UI
|
||||
|
||||
- (void)setupCardView {
|
||||
[self addSubview:self.cardView];
|
||||
[self.cardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(0);
|
||||
make.right.equalTo(self.mas_right).offset(0);
|
||||
make.top.equalTo(self.mas_top).offset(0);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(0);
|
||||
}];
|
||||
|
||||
[self.cardView addSubview:self.closeButton];
|
||||
[self.cardView addSubview:self.featureMarqueeView];
|
||||
[self.cardView addSubview:self.collectionView];
|
||||
[self.cardView addSubview:self.purchaseButton];
|
||||
[self.cardView addSubview:self.agreementLabel];
|
||||
[self.cardView addSubview:self.agreementButton];
|
||||
[self.cardView addSubview:self.loadingIndicator];
|
||||
[self.cardView addSubview:self.emptyLabel];
|
||||
|
||||
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.cardView.mas_left).offset(12);
|
||||
make.top.equalTo(self.cardView.mas_top).offset(25);
|
||||
make.width.height.mas_equalTo(28);
|
||||
}];
|
||||
|
||||
[self.featureMarqueeView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.closeButton.mas_right).offset(5);
|
||||
make.centerY.equalTo(self.closeButton);
|
||||
make.right.equalTo(self.cardView.mas_right).offset(-12);
|
||||
make.height.mas_equalTo(48);
|
||||
}];
|
||||
|
||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self).inset(16);
|
||||
make.top.equalTo(self.featureMarqueeView.mas_bottom).offset(0);
|
||||
make.height.mas_equalTo(76);
|
||||
}];
|
||||
|
||||
[self.purchaseButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.cardView.mas_left).offset(16);
|
||||
make.right.equalTo(self.cardView.mas_right).offset(-16);
|
||||
make.top.equalTo(self.collectionView.mas_bottom).offset(20);
|
||||
// make.bottom.equalTo(self.agreementLabel.mas_top).offset(-16);
|
||||
make.height.mas_greaterThanOrEqualTo(@45);
|
||||
}];
|
||||
|
||||
[self.agreementLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
||||
make.top.equalTo(self.purchaseButton.mas_bottom).offset(8);
|
||||
}];
|
||||
|
||||
[self.agreementButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.cardView.mas_centerX);
|
||||
make.top.equalTo(self.agreementLabel.mas_bottom).offset(4);
|
||||
}];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.collectionView);
|
||||
}];
|
||||
|
||||
[self.emptyLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.collectionView);
|
||||
}];
|
||||
|
||||
[self updatePurchaseButtonState];
|
||||
}
|
||||
|
||||
- (void)setupFeatureItems {
|
||||
NSArray *titles = @[
|
||||
KBLocalized(@"Wireless Sub-ai\nDialogue"),
|
||||
KBLocalized(@"Personalized\nKeyboard"),
|
||||
KBLocalized(@"Chat\nPersona"),
|
||||
KBLocalized(@"Emotional\nCounseling")
|
||||
];
|
||||
NSArray *images = @[
|
||||
[UIImage imageNamed:@"home_ai_icon"] ?: [UIImage new],
|
||||
[UIImage imageNamed:@"home_keyboard_icon"] ?: [UIImage new],
|
||||
[UIImage imageNamed:@"home_chat_icon"] ?: [UIImage new],
|
||||
[UIImage imageNamed:@"home_emotion_icon"] ?: [UIImage new]
|
||||
];
|
||||
[self.featureMarqueeView configureWithTitles:titles images:images];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onTapClose {
|
||||
if ([self.delegate respondsToSelector:@selector(subscriptionViewDidTapClose:)]) {
|
||||
[self.delegate subscriptionViewDidTapClose:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onTapPurchase {
|
||||
if (self.selectedIndex == NSNotFound || self.selectedIndex >= self.products.count) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please select a product")];
|
||||
return;
|
||||
}
|
||||
KBKeyboardSubscriptionProduct *product = self.products[self.selectedIndex];
|
||||
[self kb_persistPrefillPayloadForProduct:product];
|
||||
if ([self.delegate respondsToSelector:@selector(subscriptionView:didTapPurchaseForProduct:)]) {
|
||||
[self.delegate subscriptionView:self didTapPurchaseForProduct:product];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onTapAgreement {
|
||||
[KBHUD showInfo:KBLocalized(@"Agreement coming soon")];
|
||||
}
|
||||
|
||||
#pragma mark - Data
|
||||
|
||||
- (void)fetchProducts {
|
||||
if (self.isLoading) { return; }
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
[KBHUD showInfo:KBLocalized(@"Enable Full Access to continue")];
|
||||
return;
|
||||
}
|
||||
self.loading = YES;
|
||||
self.emptyLabel.hidden = YES;
|
||||
[self.loadingIndicator startAnimating];
|
||||
NSDictionary *params = @{@"type": @"subscription"};
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared] GET:API_SUBSCRIPTION_PRODUCT_LIST
|
||||
parameters:params
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) { return; }
|
||||
self.loading = NO;
|
||||
[self.loadingIndicator stopAnimating];
|
||||
if (error) {
|
||||
NSString *tip = error.localizedDescription ?: KBLocalized(@"Network error");
|
||||
[KBHUD showInfo:tip];
|
||||
self.products = @[];
|
||||
self.productsRawJSON = nil;
|
||||
self.selectedIndex = NSNotFound;
|
||||
[self.collectionView reloadData];
|
||||
self.emptyLabel.hidden = NO;
|
||||
[self updatePurchaseButtonState];
|
||||
return;
|
||||
}
|
||||
id dataObj = json[@"data"];
|
||||
if (![dataObj isKindOfClass:[NSArray class]]) {
|
||||
dataObj = json[@"list"];
|
||||
}
|
||||
if (![dataObj isKindOfClass:[NSArray class]]) {
|
||||
self.products = @[];
|
||||
self.productsRawJSON = nil;
|
||||
self.selectedIndex = NSNotFound;
|
||||
[self.collectionView reloadData];
|
||||
self.emptyLabel.hidden = NO;
|
||||
[self updatePurchaseButtonState];
|
||||
return;
|
||||
}
|
||||
id sanitized = KBKeyboardSubscriptionSanitizeJSON(dataObj);
|
||||
self.productsRawJSON = [sanitized isKindOfClass:NSArray.class] ? (NSArray *)sanitized : nil;
|
||||
NSArray *models = [KBKeyboardSubscriptionProduct mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
||||
self.products = models ?: @[];
|
||||
self.selectedIndex = self.products.count > 0 ? 0 : NSNotFound;
|
||||
self.emptyLabel.hidden = self.products.count > 0;
|
||||
[self.collectionView reloadData];
|
||||
[self selectCurrentProductAnimated:NO];
|
||||
[self updatePurchaseButtonState];
|
||||
self.didLoadOnce = YES;
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_persistPrefillPayloadForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class]) { return; }
|
||||
if (![self.productsRawJSON isKindOfClass:NSArray.class] || self.productsRawJSON.count == 0) { return; }
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
if (!ud) { return; }
|
||||
NSMutableDictionary *payload = [NSMutableDictionary dictionary];
|
||||
payload[@"ts"] = @((long long)floor([NSDate date].timeIntervalSince1970));
|
||||
payload[@"src"] = @"keyboard";
|
||||
if (product.productId.length) {
|
||||
payload[@"productId"] = product.productId;
|
||||
}
|
||||
if (self.selectedIndex != NSNotFound) {
|
||||
payload[@"selectedIndex"] = @(self.selectedIndex);
|
||||
}
|
||||
payload[@"products"] = self.productsRawJSON;
|
||||
[ud setObject:payload forKey:AppGroup_SubscriptionPrefillPayload];
|
||||
[ud synchronize];
|
||||
}
|
||||
|
||||
- (void)selectCurrentProductAnimated:(BOOL)animated {
|
||||
if (self.selectedIndex == NSNotFound || self.selectedIndex >= self.products.count) { return; }
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.selectedIndex inSection:0];
|
||||
[self.collectionView selectItemAtIndexPath:indexPath animated:animated scrollPosition:UICollectionViewScrollPositionCenteredHorizontally];
|
||||
KBKeyboardSubscriptionOptionCell *cell = (KBKeyboardSubscriptionOptionCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:KBKeyboardSubscriptionOptionCell.class]) {
|
||||
[cell applySelected:YES animated:animated];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updatePurchaseButtonState {
|
||||
BOOL enabled = (self.products.count > 0);
|
||||
self.purchaseButton.enabled = enabled;
|
||||
self.purchaseButton.alpha = enabled ? 1.0 : 0.5;
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionView DataSource
|
||||
|
||||
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
|
||||
return self.products.count;
|
||||
}
|
||||
|
||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBKeyboardSubscriptionOptionCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBKeyboardSubscriptionCellId forIndexPath:indexPath];
|
||||
if (indexPath.item < self.products.count) {
|
||||
KBKeyboardSubscriptionProduct *product = self.products[indexPath.item];
|
||||
[cell configureWithProduct:product];
|
||||
BOOL selected = (indexPath.item == self.selectedIndex);
|
||||
[cell applySelected:selected animated:NO];
|
||||
} else {
|
||||
[cell configureWithProduct:nil];
|
||||
[cell applySelected:NO animated:NO];
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionView Delegate
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.item >= self.products.count) { return; }
|
||||
NSInteger previous = self.selectedIndex;
|
||||
self.selectedIndex = indexPath.item;
|
||||
if (previous != NSNotFound && previous != indexPath.item) {
|
||||
NSIndexPath *prev = [NSIndexPath indexPathForItem:previous inSection:0];
|
||||
KBKeyboardSubscriptionOptionCell *prevCell = (KBKeyboardSubscriptionOptionCell *)[collectionView cellForItemAtIndexPath:prev];
|
||||
[prevCell applySelected:NO animated:YES];
|
||||
}
|
||||
KBKeyboardSubscriptionOptionCell *cell = (KBKeyboardSubscriptionOptionCell *)[collectionView cellForItemAtIndexPath:indexPath];
|
||||
[cell applySelected:YES animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Layout
|
||||
|
||||
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
// CGFloat width = MIN(MAX(collectionView.bounds.size.width * 0.56, 150), 220);
|
||||
return CGSizeMake(160, collectionView.bounds.size.height);
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
|
||||
return 12.0;
|
||||
}
|
||||
|
||||
- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
|
||||
return UIEdgeInsetsMake(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)cardView {
|
||||
if (!_cardView) {
|
||||
_cardView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"keybord_bg_icon"]];
|
||||
// _cardView.layer.cornerRadius = 20;
|
||||
// _cardView.layer.masksToBounds = YES;
|
||||
_cardView.userInteractionEnabled = YES;
|
||||
_cardView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
}
|
||||
return _cardView;
|
||||
}
|
||||
|
||||
- (UIButton *)closeButton {
|
||||
if (!_closeButton) {
|
||||
_closeButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_closeButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.7];
|
||||
_closeButton.layer.cornerRadius = 14;
|
||||
_closeButton.layer.masksToBounds = YES;
|
||||
[_closeButton setTitle:@"✕" forState:UIControlStateNormal];
|
||||
[_closeButton setTitleColor:[UIColor colorWithHex:0x666666] forState:UIControlStateNormal];
|
||||
_closeButton.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
[_closeButton addTarget:self action:@selector(onTapClose) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _closeButton;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionFeatureMarqueeView *)featureMarqueeView {
|
||||
if (!_featureMarqueeView) {
|
||||
_featureMarqueeView = [[KBKeyboardSubscriptionFeatureMarqueeView alloc] init];
|
||||
}
|
||||
return _featureMarqueeView;
|
||||
}
|
||||
|
||||
- (UICollectionView *)collectionView {
|
||||
if (!_collectionView) {
|
||||
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
|
||||
layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
|
||||
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
|
||||
_collectionView.backgroundColor = [UIColor clearColor];
|
||||
_collectionView.showsHorizontalScrollIndicator = NO;
|
||||
_collectionView.dataSource = self;
|
||||
_collectionView.delegate = self;
|
||||
[_collectionView registerClass:KBKeyboardSubscriptionOptionCell.class forCellWithReuseIdentifier:kKBKeyboardSubscriptionCellId];
|
||||
}
|
||||
return _collectionView;
|
||||
}
|
||||
|
||||
- (UIButton *)purchaseButton {
|
||||
if (!_purchaseButton) {
|
||||
_purchaseButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_purchaseButton.layer.cornerRadius = 26;
|
||||
_purchaseButton.layer.masksToBounds = YES;
|
||||
[_purchaseButton setTitle:KBLocalized(@"Recharge Now") forState:UIControlStateNormal];
|
||||
_purchaseButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
[_purchaseButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
[_purchaseButton setBackgroundImage:[self imageWithColor:[UIColor colorWithHex:0x02BEAC]] forState:UIControlStateNormal];
|
||||
[_purchaseButton addTarget:self action:@selector(onTapPurchase) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _purchaseButton;
|
||||
}
|
||||
|
||||
- (UILabel *)agreementLabel {
|
||||
if (!_agreementLabel) {
|
||||
_agreementLabel = [[UILabel alloc] init];
|
||||
_agreementLabel.text = KBLocalized(@"By clicking \"pay\", you agree to the");
|
||||
_agreementLabel.font = [UIFont systemFontOfSize:11];
|
||||
_agreementLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
||||
}
|
||||
return _agreementLabel;
|
||||
}
|
||||
|
||||
- (UIButton *)agreementButton {
|
||||
if (!_agreementButton) {
|
||||
_agreementButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_agreementButton setTitle:KBLocalized(@"Membership Agreement") forState:UIControlStateNormal];
|
||||
_agreementButton.titleLabel.font = [UIFont systemFontOfSize:11 weight:UIFontWeightSemibold];
|
||||
[_agreementButton setTitleColor:[UIColor colorWithHex:0x02BEAC] forState:UIControlStateNormal];
|
||||
[_agreementButton addTarget:self action:@selector(onTapAgreement) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _agreementButton;
|
||||
}
|
||||
|
||||
- (UIActivityIndicatorView *)loadingIndicator {
|
||||
if (!_loadingIndicator) {
|
||||
_loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_loadingIndicator.hidesWhenStopped = YES;
|
||||
}
|
||||
return _loadingIndicator;
|
||||
}
|
||||
|
||||
- (UILabel *)emptyLabel {
|
||||
if (!_emptyLabel) {
|
||||
_emptyLabel = [[UILabel alloc] init];
|
||||
_emptyLabel.text = KBLocalized(@"No products available");
|
||||
_emptyLabel.font = [UIFont systemFontOfSize:13];
|
||||
_emptyLabel.textColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
||||
_emptyLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_emptyLabel.hidden = YES;
|
||||
}
|
||||
return _emptyLabel;
|
||||
}
|
||||
|
||||
- (UIImage *)imageWithColor:(UIColor *)color {
|
||||
CGSize size = CGSizeMake(1, 1);
|
||||
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
|
||||
[color setFill];
|
||||
UIRectFill(CGRectMake(0, 0, size.width, size.height));
|
||||
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
return image;
|
||||
}
|
||||
|
||||
@end
|
||||