Compare commits
317 Commits
e2cff76d13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,10 @@
|
||||
<!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>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" : "切图 270@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 270@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 270@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 270@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 11 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/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 |
22
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 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/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 |
@@ -14,14 +14,42 @@
|
||||
#import "Masonry.h"
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
|
||||
static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||
@interface KeyboardViewController (KBSkinShopBridge)
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
@end
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate>
|
||||
// 以 375 宽设计稿为基准的键盘总高度(包括顶部工具栏)
|
||||
static const CGFloat kKBKeyboardDesignHeight = 250.0f;
|
||||
|
||||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
void *observer,
|
||||
CFStringRef name,
|
||||
const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
KeyboardViewController *strongSelf = (__bridge KeyboardViewController *)observer;
|
||||
if (!strongSelf) { return; }
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||||
[strongSelf kb_consumePendingShopSkin];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate, KBKeyboardSubscriptionViewDelegate>
|
||||
@property (nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property (nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property (nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@end
|
||||
|
||||
@implementation KeyboardViewController
|
||||
@@ -40,26 +68,68 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
__unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
||||
// 如需,可在此刷新与完全访问相关的 UI
|
||||
}];
|
||||
|
||||
// 皮肤变化时,立即应用
|
||||
__unused id token2 = [[NSNotificationCenter defaultCenter] addObserverForName:KBSkinDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
||||
[self kb_applyTheme];
|
||||
}];
|
||||
[self kb_applyTheme];
|
||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
KBSkinInstallNotificationCallback,
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
[self kb_consumePendingShopSkin];
|
||||
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated{
|
||||
[super viewWillAppear:animated];
|
||||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||||
}
|
||||
|
||||
|
||||
- (void)setupUI {
|
||||
// 固定键盘整体高度
|
||||
[self.view.heightAnchor constraintEqualToConstant:KEYBOARDHEIGHT].active = YES;
|
||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// 按屏幕宽度对设计值做等比缩放,避免在不同机型上键盘整体高度失真导致皮肤被压缩/拉伸
|
||||
CGFloat keyboardHeight = KBFit(kKBKeyboardDesignHeight);
|
||||
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
|
||||
CGFloat outerVerticalInset = KBFit(4.0f);
|
||||
|
||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight];
|
||||
NSLayoutConstraint *w = [self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||
|
||||
h.priority = UILayoutPriorityRequired;
|
||||
w.priority = UILayoutPriorityRequired;
|
||||
[NSLayoutConstraint activateConstraints:@[h, w]];
|
||||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||||
UIInputView *iv = (UIInputView *)self.view;
|
||||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||||
iv.allowsSelfSizing = NO;
|
||||
}
|
||||
}
|
||||
// 背景图铺底
|
||||
[self.view addSubview:self.bgImageView];
|
||||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
// 预置功能面板(默认隐藏),与键盘区域共享相同布局
|
||||
self.functionView.hidden = YES;
|
||||
[self.view addSubview:self.functionView];
|
||||
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
make.top.equalTo(self.view).offset(4);
|
||||
make.bottom.equalTo(self.view.mas_bottom).offset(-4);
|
||||
make.top.equalTo(self.view).offset(0);
|
||||
make.bottom.equalTo(self.view).offset(0);
|
||||
}];
|
||||
|
||||
[self.view addSubview:self.keyBoardMainView];
|
||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
make.top.equalTo(self.view).offset(4);
|
||||
make.bottom.equalTo(self.view.mas_bottom).offset(-4);
|
||||
make.top.equalTo(self.view).offset(0);
|
||||
make.bottom.equalTo(self.view.mas_bottom).offset(-0);
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -73,6 +143,10 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
self.functionView.hidden = !show;
|
||||
self.keyBoardMainView.hidden = show;
|
||||
|
||||
if (show) {
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
// 可选:把当前显示的视图置顶,避免层级遮挡
|
||||
if (show) {
|
||||
[self.view bringSubviewToFront:self.functionView];
|
||||
@@ -118,10 +192,67 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||
return;
|
||||
}
|
||||
// 点击充值要先判断是否登录
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
[self showFunctionPanel:NO];
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
if (!panel.superview) {
|
||||
panel.hidden = YES;
|
||||
[self.view addSubview:panel];
|
||||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.keyBoardMainView);
|
||||
}];
|
||||
}
|
||||
[self.view bringSubviewToFront:panel];
|
||||
panel.hidden = NO;
|
||||
panel.alpha = 0.0;
|
||||
CGFloat height = CGRectGetHeight(self.view.bounds);
|
||||
if (height <= 0) { height = 260; }
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
[panel refreshProductsIfNeeded];
|
||||
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)hideSubscriptionPanel {
|
||||
if (!self.subscriptionView || self.subscriptionView.hidden) { return; }
|
||||
CGFloat height = CGRectGetHeight(self.subscriptionView.bounds);
|
||||
if (height <= 0) { height = CGRectGetHeight(self.view.bounds); }
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
||||
panel.alpha = 0.0;
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
} completion:^(BOOL finished) {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
|
||||
if (key.type != KBKeyTypeShift && key.type != KBKeyTypeModeChange) {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
}
|
||||
switch (key.type) {
|
||||
case KBKeyTypeCharacter:
|
||||
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
|
||||
@@ -134,7 +265,7 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
case KBKeyTypeGlobe:
|
||||
[self advanceToNextInputMode]; break;
|
||||
case KBKeyTypeCustom:
|
||||
// 点击自定义键(如“AI”)切换到功能面板
|
||||
// 点击自定义键切换到功能面板
|
||||
[self showFunctionPanel:YES];
|
||||
break;
|
||||
case KBKeyTypeModeChange:
|
||||
@@ -147,15 +278,29 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index {
|
||||
if (index == 0) {
|
||||
[self showFunctionPanel:YES];
|
||||
} else {
|
||||
[self showFunctionPanel:NO];
|
||||
return;
|
||||
}
|
||||
[self showFunctionPanel:NO];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[self showSettingView:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji {
|
||||
if (emoji.length == 0) { return; }
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:emoji];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
||||
}
|
||||
|
||||
// MARK: - KBFunctionViewDelegate
|
||||
- (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index {
|
||||
// 需求:当 index == 0 时,切回键盘主视图
|
||||
@@ -163,6 +308,36 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
[self showFunctionPanel:NO];
|
||||
}
|
||||
}
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index{
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
//
|
||||
// if (!ul && !scheme) { return; }
|
||||
//
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
|
||||
if (!ok) {
|
||||
// 失败兜底:给个文案提示
|
||||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
||||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||||
[self showSubscriptionPanel];
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
[self hideSubscriptionPanel];
|
||||
[self kb_openRechargeForProduct:product];
|
||||
}
|
||||
|
||||
#pragma mark - lazy
|
||||
- (KBKeyBoardMainView *)keyBoardMainView{
|
||||
@@ -188,33 +363,135 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
|
||||
return _settingView;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||
if (!_subscriptionView) {
|
||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||
_subscriptionView.delegate = self;
|
||||
_subscriptionView.hidden = YES;
|
||||
_subscriptionView.alpha = 0.0;
|
||||
}
|
||||
return _subscriptionView;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || product.productId.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||||
return;
|
||||
}
|
||||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||||
NSString *title = [product displayTitle];
|
||||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||||
NSMutableArray<NSString *> *params = [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||||
if (encodedId.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||||
}
|
||||
if (encodedTitle.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||||
}
|
||||
NSString *query = [params componentsJoinedByString:@"&"];
|
||||
NSString *urlString = [NSString stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||||
if (value.length == 0) { return @""; }
|
||||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||||
NSMutableCharacterSet *allowed = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||||
[allowed removeCharactersInString:reserved];
|
||||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] ?: @"";
|
||||
}
|
||||
|
||||
- (void)onTapSettingsBack {
|
||||
[self showSettingView:NO];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL);
|
||||
}
|
||||
|
||||
|
||||
// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App 决定是否真的弹登录)。
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
_kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// 仅在未登录时尝试拉起主App登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
[self kb_tryOpenContainerForLoginIfNeeded];
|
||||
}
|
||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// // 仅在未登录时尝试拉起主App登录
|
||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
//- (void)kb_tryOpenContainerForLoginIfNeeded {
|
||||
// // 使用与主 App 一致的自定义 Scheme
|
||||
// NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]];
|
||||
// if (!url) return;
|
||||
// KBWeakSelf
|
||||
// [self.extensionContext openURL:url completionHandler:^(__unused BOOL success) {
|
||||
// // 即使失败也不重复尝试;避免打扰。
|
||||
// __unused typeof(weakSelf) selfStrong = weakSelf;
|
||||
// }];
|
||||
//}
|
||||
|
||||
#pragma mark - Theme
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIImage *img = [[KBSkinManager shared] currentBackgroundImage];
|
||||
self.bgImageView.image = img;
|
||||
BOOL hasImg = (img != nil);
|
||||
self.view.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
self.keyBoardMainView.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
// 触发键区按主题重绘
|
||||
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
// method declared in KBKeyBoardMainView.h
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.functionView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_tryOpenContainerForLoginIfNeeded {
|
||||
NSURL *url = [NSURL URLWithString:@"kbkeyboard://login?src=keyboard"];
|
||||
if (!url) return;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.extensionContext openURL:url completionHandler:^(__unused BOOL success) {
|
||||
// 即使失败也不重复尝试;避免打扰。
|
||||
__unused typeof(weakSelf) selfStrong = weakSelf;
|
||||
- (void)kb_consumePendingShopSkin {
|
||||
KBWeakSelf
|
||||
[KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(BOOL success, NSError * _Nullable error) {
|
||||
if (!success) {
|
||||
if (error) {
|
||||
NSLog(@"[Keyboard] skin request failed: %@", error);
|
||||
[KBHUD showInfo:KBLocalized(@"皮肤资源准备失败,请稍后再试")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[weakSelf kb_applyTheme];
|
||||
[KBHUD showInfo:KBLocalized(@"皮肤已更新,立即体验吧")];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)bgImageView {
|
||||
if (!_bgImageView) {
|
||||
_bgImageView = [[UIImageView alloc] init];
|
||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_bgImageView.clipsToBounds = YES;
|
||||
}
|
||||
return _bgImageView;
|
||||
}
|
||||
@end
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
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,6 +52,12 @@ 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
|
||||
@@ -54,4 +67,3 @@ typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nul
|
||||
@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,49 @@ 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 *)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 +176,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 +189,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 +202,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 +289,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,22 @@
|
||||
|
||||
// 公共配置
|
||||
#import "KBConfig.h"
|
||||
#import "KBAPI.h" // 接口路径宏(统一管理)
|
||||
#import "Masonry.h"
|
||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||
#import "KBLocalizationManager.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_f_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";
|
||||
/* 发送/换行键 */
|
||||
"return" = "key_send";
|
||||
|
||||
14802
CustomKeyboard/Resource/emoji_categories.json
Normal file
BIN
CustomKeyboard/Resource/fense.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
|
||||
492
CustomKeyboard/Utils/KBBackspaceLongPressHandler.m
Normal file
@@ -0,0 +1,492 @@
|
||||
//
|
||||
// KBBackspaceLongPressHandler.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
|
||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 1.0;
|
||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4;
|
||||
static const NSInteger kKBBackspaceChunkSize = 6;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHeight = 26.0;
|
||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||
static const NSInteger kKBBackspaceClearBatchSize = 24;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005;
|
||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassUnknown = 0,
|
||||
KBBackspaceChunkClassWhitespace,
|
||||
KBBackspaceChunkClassASCIIWord,
|
||||
KBBackspaceChunkClassPunctuation,
|
||||
KBBackspaceChunkClassOther
|
||||
};
|
||||
|
||||
@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;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceLongPressHandler
|
||||
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||
if (self = [super init]) {
|
||||
_containerView = containerView;
|
||||
}
|
||||
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];
|
||||
|
||||
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: {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
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 ?: @"";
|
||||
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];
|
||||
}
|
||||
}
|
||||
for (NSInteger i = 0; i < deleteCount; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
|
||||
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_"];
|
||||
punctuationSet = [NSCharacterSet punctuationCharacterSet];
|
||||
});
|
||||
|
||||
__block NSInteger deleteCount = 0;
|
||||
__block KBBackspaceChunkClass chunkClass = 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; }
|
||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassWhitespace;
|
||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassPunctuation;
|
||||
}
|
||||
|
||||
if (chunkClass == KBBackspaceChunkClassUnknown) {
|
||||
chunkClass = currentClass;
|
||||
} else if (chunkClass != currentClass) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
deleteCount += 1;
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
}
|
||||
}];
|
||||
|
||||
return MAX(deleteCount, 1);
|
||||
}
|
||||
|
||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||
hitBoundary:(BOOL *)hitBoundary {
|
||||
if (context.length == 0) { return kKBBackspaceClearBatchSize; }
|
||||
|
||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
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];
|
||||
}
|
||||
}
|
||||
self.backspaceHoldActive = NO;
|
||||
self.backspaceChunkModeActive = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
self.backspaceHasLastTouchPoint = NO;
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
if (shouldClear) {
|
||||
[self kb_clearAllInput];
|
||||
}
|
||||
}
|
||||
|
||||
#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 = @"立刻清空";
|
||||
label.textAlignment = NSTextAlignmentCenter;
|
||||
label.font = [UIFont systemFontOfSize:12 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) {
|
||||
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
|
||||
}
|
||||
self.backspaceClearToken += 1;
|
||||
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;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
NSInteger count = before.length;
|
||||
NSInteger batch = 0;
|
||||
NSInteger nextEmptyRounds = emptyRounds;
|
||||
BOOL hitBoundary = NO;
|
||||
if (count > 0) {
|
||||
batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary];
|
||||
nextEmptyRounds = 0;
|
||||
} else {
|
||||
batch = kKBBackspaceClearBatchSize;
|
||||
nextEmptyRounds = emptyRounds + 1;
|
||||
}
|
||||
if (batch <= 0) { batch = 1; }
|
||||
|
||||
if (guard >= kKBBackspaceClearMaxDeletes ||
|
||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (NSInteger i = 0; i < batch; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
|
||||
NSInteger nextGuard = guard + batch;
|
||||
BOOL shouldContinue = NO;
|
||||
if (count > 0 && !hitBoundary) {
|
||||
if (count > batch) {
|
||||
shouldContinue = YES;
|
||||
} else if ([proxy hasText]) {
|
||||
shouldContinue = YES;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldContinue) { return; }
|
||||
__weak typeof(self) weakSelf = self;
|
||||
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;
|
||||
}
|
||||
|
||||
@end
|
||||
29
CustomKeyboard/Utils/KBBackspaceUndoManager.h
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput)
|
||||
- (void)recordClearWithContext:(NSString *)context;
|
||||
|
||||
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||
|
||||
/// 非清空行为触发时,清理撤销状态
|
||||
- (void)registerNonClearAction;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
170
CustomKeyboard/Utils/KBBackspaceUndoManager.m
Normal file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// KBBackspaceUndoManager.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBResponderUtils.h"
|
||||
|
||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
||||
|
||||
@interface KBBackspaceUndoManager ()
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
|
||||
@property (nonatomic, assign) BOOL lastActionWasClear;
|
||||
@property (nonatomic, assign) BOOL hasUndo;
|
||||
@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]) {
|
||||
_segments = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)recordClearWithContext:(NSString *)context {
|
||||
if (context.length == 0) { return; }
|
||||
NSString *segment = [self kb_segmentForClearFromContext:context];
|
||||
if (segment.length == 0) { return; }
|
||||
|
||||
if (!self.lastActionWasClear) {
|
||||
[self.segments removeAllObjects];
|
||||
}
|
||||
[self.segments addObject:segment];
|
||||
self.lastActionWasClear = YES;
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||
if (self.segments.count == 0) { return; }
|
||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *text = [self kb_buildUndoText];
|
||||
if (text.length == 0) { return; }
|
||||
[proxy insertText:text];
|
||||
|
||||
[self.segments removeAllObjects];
|
||||
self.lastActionWasClear = NO;
|
||||
[self kb_updateHasUndo:NO];
|
||||
}
|
||||
|
||||
- (void)registerNonClearAction {
|
||||
self.lastActionWasClear = NO;
|
||||
if (self.segments.count == 0) { return; }
|
||||
[self.segments 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_segmentForClearFromContext:(NSString *)context {
|
||||
NSInteger length = context.length;
|
||||
if (length == 0) { return @""; }
|
||||
|
||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
NSInteger start = (boundaryIndex == NSNotFound) ? 0 : (boundaryIndex + 1);
|
||||
if (start >= length) { return @""; }
|
||||
return [context substringFromIndex:start];
|
||||
}
|
||||
|
||||
- (NSString *)kb_buildUndoText {
|
||||
if (self.segments.count == 0) { return @""; }
|
||||
NSArray<NSString *> *ordered = [[self.segments reverseObjectEnumerator] allObjects];
|
||||
NSMutableString *result = [NSMutableString string];
|
||||
for (NSInteger i = 0; i < ordered.count; i++) {
|
||||
NSString *segment = ordered[i] ?: @"";
|
||||
if (segment.length == 0) { continue; }
|
||||
if (i < ordered.count - 1) {
|
||||
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
|
||||
}
|
||||
[result appendString:segment];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
|
||||
if (segment.length == 0) { return segment; }
|
||||
|
||||
static NSCharacterSet *boundarySet = nil;
|
||||
static NSCharacterSet *englishBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
NSInteger idx = segment.length - 1;
|
||||
while (idx >= 0) {
|
||||
unichar ch = [segment characterAtIndex:idx];
|
||||
if ([whitespaceSet characterIsMember:ch]) {
|
||||
idx -= 1;
|
||||
continue;
|
||||
}
|
||||
if (![boundarySet characterIsMember:ch]) {
|
||||
return segment;
|
||||
}
|
||||
NSString *comma = [englishBoundarySet characterIsMember:ch] ? @"," : @",";
|
||||
NSMutableString *mutable = [segment mutableCopy];
|
||||
NSRange r = NSMakeRange(idx, 1);
|
||||
[mutable replaceCharactersInRange:r withString:comma];
|
||||
return mutable;
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -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
|
||||
13
CustomKeyboard/View/EmojiView/KBEmojiBottomBarView.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBEmojiBottomBarView : UIView
|
||||
|
||||
@property (nonatomic, strong, readonly) UIScrollView *tabScrollView;
|
||||
@property (nonatomic, strong, readonly) UIStackView *tabStackView;
|
||||
@property (nonatomic, strong, readonly) UIButton *deleteButton;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
63
CustomKeyboard/View/EmojiView/KBEmojiBottomBarView.m
Normal file
@@ -0,0 +1,63 @@
|
||||
#import "KBEmojiBottomBarView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBEmojiBottomBarView ()
|
||||
|
||||
@property (nonatomic, strong, readwrite) UIScrollView *tabScrollView;
|
||||
@property (nonatomic, strong, readwrite) UIStackView *tabStackView;
|
||||
@property (nonatomic, strong, readwrite) UIButton *deleteButton;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBEmojiBottomBarView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
self.tabScrollView = [[UIScrollView alloc] init];
|
||||
self.tabScrollView.showsHorizontalScrollIndicator = NO;
|
||||
self.tabScrollView.backgroundColor = [UIColor clearColor];
|
||||
[self addSubview:self.tabScrollView];
|
||||
|
||||
self.tabStackView = [[UIStackView alloc] init];
|
||||
self.tabStackView.axis = UILayoutConstraintAxisHorizontal;
|
||||
self.tabStackView.spacing = 8;
|
||||
self.tabStackView.alignment = UIStackViewAlignmentFill;
|
||||
[self.tabScrollView addSubview:self.tabStackView];
|
||||
|
||||
self.deleteButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
self.deleteButton.layer.cornerRadius = 16;
|
||||
self.deleteButton.layer.masksToBounds = YES;
|
||||
// self.deleteButton.titleLabel.font = [UIFont systemFontOfSize:24 weight:UIFontWeightSemibold];
|
||||
// [self.deleteButton setTitle:@"⌫" forState:UIControlStateNormal];
|
||||
[self.deleteButton setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal];
|
||||
[self addSubview:self.deleteButton];
|
||||
|
||||
[self.tabScrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.deleteButton.mas_left).offset(-12);
|
||||
make.top.equalTo(self.mas_top).offset(4);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-4);
|
||||
}];
|
||||
|
||||
[self.tabStackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.tabScrollView);
|
||||
make.height.equalTo(self.tabScrollView);
|
||||
}];
|
||||
|
||||
[self.deleteButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.centerY.equalTo(self);
|
||||
make.width.mas_equalTo(44);
|
||||
make.height.equalTo(self.tabScrollView);
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
17
CustomKeyboard/View/EmojiView/KBEmojiCollectionCell.h
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// KBEmojiCollectionCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/12/15.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBEmojiCollectionCell : UICollectionViewCell
|
||||
@property (nonatomic, strong) UILabel *emojiLabel;
|
||||
- (void)configureWithEmoji:(NSString *)emoji;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
38
CustomKeyboard/View/EmojiView/KBEmojiCollectionCell.m
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// KBEmojiCollectionCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/12/15.
|
||||
//
|
||||
|
||||
#import "KBEmojiCollectionCell.h"
|
||||
|
||||
@implementation KBEmojiCollectionCell
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
_emojiLabel = [[UILabel alloc] init];
|
||||
_emojiLabel.font = [UIFont systemFontOfSize:32];
|
||||
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_emojiLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.contentView addSubview:_emojiLabel];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_emojiLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor],
|
||||
[_emojiLabel.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor],
|
||||
[_emojiLabel.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor],
|
||||
[_emojiLabel.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor],
|
||||
]];
|
||||
self.contentView.layer.cornerRadius = 10;
|
||||
self.contentView.layer.masksToBounds = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
self.emojiLabel.text = @"";
|
||||
}
|
||||
|
||||
- (void)configureWithEmoji:(NSString *)emoji {
|
||||
self.emojiLabel.text = emoji ?: @"";
|
||||
}
|
||||
@end
|
||||
35
CustomKeyboard/View/EmojiView/KBEmojiPanelView.h
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// KBEmojiPanelView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBEmojiPanelView, KBSkinTheme;
|
||||
|
||||
@protocol KBEmojiPanelViewDelegate <NSObject>
|
||||
- (void)emojiPanelView:(KBEmojiPanelView *)panel didSelectEmoji:(NSString *)emoji;
|
||||
- (void)emojiPanelViewDidRequestClose:(KBEmojiPanelView *)panel;
|
||||
- (void)emojiPanelViewDidTapSearch:(KBEmojiPanelView *)panel;
|
||||
@optional
|
||||
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel;
|
||||
@end
|
||||
|
||||
@interface KBEmojiPanelView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBEmojiPanelViewDelegate> delegate;
|
||||
|
||||
/// 刷新数据(包括常用分类)。
|
||||
- (void)reloadData;
|
||||
|
||||
/// 应用当前主题色
|
||||
- (void)applyTheme:(KBSkinTheme *)theme;
|
||||
|
||||
/// 高亮指定分类
|
||||
- (void)selectCategoryAtIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
439
CustomKeyboard/View/EmojiView/KBEmojiPanelView.m
Normal file
@@ -0,0 +1,439 @@
|
||||
//
|
||||
// KBEmojiPanelView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBEmojiPanelView.h"
|
||||
#import "KBEmojiDataProvider.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBLocalizationManager.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBEmojiCollectionCell.h"
|
||||
#import "KBEmojiBottomBarView.h"
|
||||
|
||||
|
||||
@interface KBEmojiPanelView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
|
||||
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UIButton *backButton;
|
||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||
@property (nonatomic, strong) KBEmojiBottomBarView *bottomBar;
|
||||
//@property (nonatomic, strong) UIButton *searchButton;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *tabButtons;
|
||||
@property (nonatomic, strong) KBEmojiDataProvider *dataProvider;
|
||||
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categories;
|
||||
@property (nonatomic, assign) NSInteger currentIndex;
|
||||
@property (nonatomic, strong) UIView *magnifierView;
|
||||
@property (nonatomic, strong) UILabel *magnifierLabel;
|
||||
@property (nonatomic, strong) UIColor *tabNormalColor;
|
||||
@property (nonatomic, strong) UIColor *tabSelectedColor;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBEmojiPanelView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
_dataProvider = [KBEmojiDataProvider shared];
|
||||
_currentIndex = 1;
|
||||
[self setupUI];
|
||||
[self registerNotifications];
|
||||
[self reloadData];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark - Setup
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.08 alpha:1.0];
|
||||
|
||||
UIView *topBar = [[UIView alloc] init];
|
||||
topBar.backgroundColor = [UIColor clearColor];
|
||||
[self addSubview:topBar];
|
||||
|
||||
self.backButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
// self.backButton.titleLabel.font = [UIFont systemFontOfSize:30 weight:UIFontWeightSemibold];
|
||||
// [self.backButton setTitle:@"⌨︎" forState:UIControlStateNormal];
|
||||
[self.backButton setImage:[UIImage imageNamed:@"back_keybord_icon"] forState:UIControlStateNormal];
|
||||
[self.backButton addTarget:self action:@selector(onBack) forControlEvents:UIControlEventTouchUpInside];
|
||||
[topBar addSubview:self.backButton];
|
||||
|
||||
self.titleLabel = [[UILabel alloc] init];
|
||||
self.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold];
|
||||
self.titleLabel.textColor = [UIColor whiteColor];
|
||||
[topBar addSubview:self.titleLabel];
|
||||
|
||||
[topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(4);
|
||||
make.height.mas_equalTo(40);
|
||||
}];
|
||||
[self.backButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(topBar.mas_left).offset(12);
|
||||
make.centerY.equalTo(topBar);
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self);
|
||||
make.centerY.equalTo(topBar);
|
||||
// make.right.lessThanOrEqualTo(topBar.mas_right).offset(-12);
|
||||
}];
|
||||
|
||||
UICollectionViewFlowLayout *layout = [UICollectionViewFlowLayout new];
|
||||
layout.scrollDirection = UICollectionViewScrollDirectionVertical;
|
||||
layout.minimumInteritemSpacing = 8;
|
||||
layout.minimumLineSpacing = 12;
|
||||
self.collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
|
||||
self.collectionView.backgroundColor = [UIColor clearColor];
|
||||
self.collectionView.dataSource = self;
|
||||
self.collectionView.delegate = self;
|
||||
self.collectionView.alwaysBounceVertical = YES;
|
||||
[self.collectionView registerClass:KBEmojiCollectionCell.class forCellWithReuseIdentifier:@"KBEmojiCollectionCell"];
|
||||
[self addSubview:self.collectionView];
|
||||
|
||||
self.bottomBar = [[KBEmojiBottomBarView alloc] init];
|
||||
[self addSubview:self.bottomBar];
|
||||
[self.bottomBar.deleteButton addTarget:self action:@selector(onDelete) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
// self.searchButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
// self.searchButton.layer.cornerRadius = 20;
|
||||
// self.searchButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightBold];
|
||||
// [self.searchButton setTitle:KBLocalized(@"Search") forState:UIControlStateNormal];
|
||||
// [self.searchButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
// [self.searchButton addTarget:self action:@selector(onSearch) forControlEvents:UIControlEventTouchUpInside];
|
||||
// [self.bottomBar addSubview:self.searchButton];
|
||||
|
||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.top.equalTo(topBar.mas_bottom).offset(0);
|
||||
make.bottom.equalTo(self.bottomBar.mas_top).offset(0);
|
||||
}];
|
||||
|
||||
[self.bottomBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.bottom.equalTo(self);
|
||||
make.height.mas_equalTo(40);
|
||||
}];
|
||||
|
||||
UISwipeGestureRecognizer *leftSwipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(onSwipe:)];
|
||||
leftSwipe.direction = UISwipeGestureRecognizerDirectionLeft;
|
||||
[self addGestureRecognizer:leftSwipe];
|
||||
UISwipeGestureRecognizer *rightSwipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(onSwipe:)];
|
||||
rightSwipe.direction = UISwipeGestureRecognizerDirectionRight;
|
||||
[self addGestureRecognizer:rightSwipe];
|
||||
|
||||
[self applyTheme:[KBSkinManager shared].current];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
[self updateTabButtonCornerRadii];
|
||||
}
|
||||
|
||||
- (void)updateTabButtonCornerRadii {
|
||||
for (UIButton *btn in self.tabButtons) {
|
||||
CGFloat radius = MIN(CGRectGetHeight(btn.bounds), CGRectGetWidth(btn.bounds)) / 2.0;
|
||||
if (radius <= 0) { continue; }
|
||||
btn.layer.cornerRadius = radius;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
btn.layer.cornerCurve = kCACornerCurveContinuous;
|
||||
}
|
||||
}
|
||||
UIButton *deleteButton = self.bottomBar.deleteButton;
|
||||
if (deleteButton) {
|
||||
CGFloat radius = MIN(CGRectGetHeight(deleteButton.bounds), CGRectGetWidth(deleteButton.bounds)) / 2.0;
|
||||
if (radius > 0) {
|
||||
deleteButton.layer.cornerRadius = radius;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
deleteButton.layer.cornerCurve = kCACornerCurveContinuous;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)registerNotifications {
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(onEmojiDataChanged)
|
||||
name:KBEmojiRecentsDidChangeNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(onLocalizationChanged)
|
||||
name:KBLocalizationDidChangeNotification
|
||||
object:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Data
|
||||
|
||||
- (void)reloadData {
|
||||
self.categories = self.dataProvider.categories;
|
||||
if (self.categories.count == 0) {
|
||||
self.currentIndex = NSNotFound;
|
||||
[self.collectionView reloadData];
|
||||
self.titleLabel.text = @"";
|
||||
return;
|
||||
}
|
||||
NSInteger preserved = self.currentIndex;
|
||||
if (preserved < 0 || preserved >= self.categories.count) {
|
||||
preserved = 0;
|
||||
}
|
||||
[self rebuildTabButtons];
|
||||
[self updateSelectionToIndex:preserved];
|
||||
}
|
||||
|
||||
- (void)rebuildTabButtons {
|
||||
UIStackView *stackView = self.bottomBar.tabStackView;
|
||||
if (!stackView) { return; }
|
||||
for (UIView *v in stackView.arrangedSubviews) {
|
||||
[stackView removeArrangedSubview:v];
|
||||
[v removeFromSuperview];
|
||||
}
|
||||
NSMutableArray<UIButton *> *buttons = [NSMutableArray arrayWithCapacity:self.categories.count];
|
||||
[self.categories enumerateObjectsUsingBlock:^(KBEmojiCategory * _Nonnull cat, NSUInteger idx, BOOL * _Nonnull stop) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = idx;
|
||||
btn.layer.cornerRadius = 16;
|
||||
btn.layer.masksToBounds = YES;
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:18];
|
||||
[btn setTitle:cat.iconSymbol ?: @"●" forState:UIControlStateNormal];
|
||||
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
[btn addTarget:self action:@selector(onTabTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
btn.contentEdgeInsets = UIEdgeInsetsMake(0, 12, 0, 12);
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
// [btn.heightAnchor constraintEqualTo:self.bottomBar.tabScrollView.heightAnchor].active = YES;
|
||||
[stackView addArrangedSubview:btn];
|
||||
[buttons addObject:btn];
|
||||
}];
|
||||
self.tabButtons = buttons.copy;
|
||||
[self setNeedsLayout];
|
||||
}
|
||||
|
||||
- (void)updateSelectionToIndex:(NSInteger)index {
|
||||
if (self.categories.count == 0) {
|
||||
self.currentIndex = NSNotFound;
|
||||
[self.collectionView reloadData];
|
||||
self.titleLabel.text = @"";
|
||||
return;
|
||||
}
|
||||
if (index < 0) { index = 0; }
|
||||
if (index >= self.categories.count) { index = self.categories.count - 1; }
|
||||
self.currentIndex = index;
|
||||
KBEmojiCategory *cat = self.categories[index];
|
||||
self.titleLabel.text = cat.displayTitle;
|
||||
[self.collectionView reloadData];
|
||||
[self updateTabHighlightStates];
|
||||
[self scrollTabToVisible:index];
|
||||
}
|
||||
|
||||
- (void)selectCategoryAtIndex:(NSInteger)index {
|
||||
[self updateSelectionToIndex:index];
|
||||
}
|
||||
|
||||
- (void)updateTabHighlightStates {
|
||||
[self.tabButtons enumerateObjectsUsingBlock:^(UIButton * _Nonnull btn, NSUInteger idx, BOOL * _Nonnull stop) {
|
||||
BOOL selected = (idx == self.currentIndex);
|
||||
btn.backgroundColor = selected ? self.tabSelectedColor : self.tabNormalColor;
|
||||
btn.alpha = selected ? 1.0 : 0.6;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)scrollTabToVisible:(NSInteger)index {
|
||||
if (index < 0 || index >= self.tabButtons.count) return;
|
||||
UIScrollView *scrollView = self.bottomBar.tabScrollView;
|
||||
UIStackView *stackView = self.bottomBar.tabStackView;
|
||||
if (!scrollView || !stackView) { return; }
|
||||
UIButton *btn = self.tabButtons[index];
|
||||
CGRect rect = [scrollView convertRect:btn.frame fromView:stackView];
|
||||
rect = CGRectInset(rect, -12, 0);
|
||||
[scrollView scrollRectToVisible:rect animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onBack {
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidRequestClose:)]) {
|
||||
[self.delegate emojiPanelViewDidRequestClose:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onSearch {
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapSearch:)]) {
|
||||
[self.delegate emojiPanelViewDidTapSearch:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onDelete {
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapDelete:)]) {
|
||||
[self.delegate emojiPanelViewDidTapDelete:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onTabTapped:(UIButton *)sender {
|
||||
[self updateSelectionToIndex:sender.tag];
|
||||
}
|
||||
|
||||
- (void)onSwipe:(UISwipeGestureRecognizer *)gesture {
|
||||
if (self.categories.count == 0) return;
|
||||
if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) {
|
||||
if (self.currentIndex + 1 < self.categories.count) {
|
||||
[self updateSelectionToIndex:self.currentIndex + 1];
|
||||
}
|
||||
} else if (gesture.direction == UISwipeGestureRecognizerDirectionRight) {
|
||||
if (self.currentIndex - 1 >= 0) {
|
||||
[self updateSelectionToIndex:self.currentIndex - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onEmojiDataChanged {
|
||||
[self reloadData];
|
||||
}
|
||||
|
||||
- (void)onLocalizationChanged {
|
||||
// [self.searchButton setTitle:KBLocalized(@"Search") forState:UIControlStateNormal];
|
||||
[self reloadData];
|
||||
}
|
||||
|
||||
#pragma mark - Theme
|
||||
|
||||
- (void)applyTheme:(KBSkinTheme *)theme {
|
||||
UIColor *bg = theme.keyboardBackground ?: [UIColor colorWithWhite:0.08 alpha:1.0];
|
||||
self.backgroundColor = bg;
|
||||
self.collectionView.backgroundColor = [UIColor clearColor];
|
||||
self.titleLabel.textColor = theme.keyTextColor ?: [UIColor whiteColor];
|
||||
UIColor *searchColor = theme.accentColor ?: [UIColor colorWithRed:0.35 green:0.35 blue:0.95 alpha:1];
|
||||
// self.searchButton.backgroundColor = searchColor;
|
||||
self.tabNormalColor = [UIColor colorWithWhite:1 alpha:0.08];
|
||||
self.tabSelectedColor = theme.accentColor ?: [UIColor colorWithWhite:1 alpha:0.25];
|
||||
[self updateTabHighlightStates];
|
||||
if (self.bottomBar.deleteButton) {
|
||||
self.bottomBar.deleteButton.backgroundColor = self.tabNormalColor;
|
||||
UIColor *deleteTitleColor = theme.keyTextColor ?: [UIColor whiteColor];
|
||||
[self.bottomBar.deleteButton setTitleColor:deleteTitleColor forState:UIControlStateNormal];
|
||||
}
|
||||
if (self.magnifierView) {
|
||||
self.magnifierView.backgroundColor = theme.keyBackground ?: [UIColor colorWithWhite:1 alpha:0.9];
|
||||
}
|
||||
if (self.magnifierLabel) {
|
||||
self.magnifierLabel.textColor = theme.keyTextColor ?: [UIColor blackColor];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Magnifier
|
||||
|
||||
- (void)showMagnifierForEmoji:(NSString *)emoji fromRect:(CGRect)rect {
|
||||
if (!self.magnifierView) {
|
||||
self.magnifierView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 68, 68)];
|
||||
self.magnifierView.layer.cornerRadius = 12;
|
||||
self.magnifierView.layer.masksToBounds = YES;
|
||||
self.magnifierView.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.3].CGColor;
|
||||
self.magnifierView.layer.shadowOpacity = 0.6;
|
||||
self.magnifierView.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
self.magnifierView.layer.shadowRadius = 3;
|
||||
self.magnifierLabel = [[UILabel alloc] initWithFrame:self.magnifierView.bounds];
|
||||
self.magnifierLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.magnifierLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.magnifierLabel.font = [UIFont systemFontOfSize:40];
|
||||
[self.magnifierView addSubview:self.magnifierLabel];
|
||||
self.magnifierView.alpha = 0;
|
||||
[self addSubview:self.magnifierView];
|
||||
}
|
||||
self.magnifierLabel.text = emoji;
|
||||
CGRect converted = [self convertRect:rect fromView:self.collectionView];
|
||||
CGFloat targetX = CGRectGetMidX(converted);
|
||||
CGFloat targetY = CGRectGetMinY(converted) - CGRectGetHeight(self.magnifierView.bounds)/2 - 8;
|
||||
targetX = MAX(CGRectGetWidth(self.magnifierView.bounds)/2 + 8, targetX);
|
||||
targetX = MIN(CGRectGetWidth(self.bounds) - CGRectGetWidth(self.magnifierView.bounds)/2 - 8, targetX);
|
||||
if (targetY < CGRectGetHeight(self.magnifierView.bounds)/2 + 10) {
|
||||
targetY = CGRectGetHeight(self.magnifierView.bounds)/2 + 10;
|
||||
}
|
||||
self.magnifierView.center = CGPointMake(targetX, targetY);
|
||||
self.magnifierView.hidden = NO;
|
||||
[UIView animateWithDuration:0.08 animations:^{
|
||||
self.magnifierView.alpha = 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)hideMagnifier {
|
||||
if (!self.magnifierView) return;
|
||||
[UIView animateWithDuration:0.08 animations:^{
|
||||
self.magnifierView.alpha = 0.0;
|
||||
} completion:^(BOOL finished) {
|
||||
self.magnifierView.hidden = YES;
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionViewDataSource
|
||||
|
||||
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
|
||||
if (self.currentIndex == NSNotFound || self.currentIndex >= self.categories.count) {
|
||||
return 0;
|
||||
}
|
||||
KBEmojiCategory *cat = self.categories[self.currentIndex];
|
||||
return cat.items.count;
|
||||
}
|
||||
|
||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBEmojiCollectionCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBEmojiCollectionCell" forIndexPath:indexPath];
|
||||
KBEmojiCategory *cat = self.categories[self.currentIndex];
|
||||
if (indexPath.item < cat.items.count) {
|
||||
KBEmojiItem *item = cat.items[indexPath.item];
|
||||
[cell configureWithEmoji:item.value];
|
||||
} else {
|
||||
[cell configureWithEmoji:@""];
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionViewDelegate
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (self.currentIndex == NSNotFound || self.currentIndex >= self.categories.count) return;
|
||||
KBEmojiCategory *cat = self.categories[self.currentIndex];
|
||||
if (indexPath.item >= cat.items.count) return;
|
||||
KBEmojiItem *item = cat.items[indexPath.item];
|
||||
if (item.value.length == 0) return;
|
||||
[self.dataProvider recordEmojiSelection:item.value];
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelView:didSelectEmoji:)]) {
|
||||
[self.delegate emojiPanelView:self didSelectEmoji:item.value];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didHighlightItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBEmojiCategory *cat = (self.currentIndex < self.categories.count) ? self.categories[self.currentIndex] : nil;
|
||||
if (indexPath.item >= cat.items.count) return;
|
||||
KBEmojiItem *item = cat.items[indexPath.item];
|
||||
UICollectionViewCell *cell = [collectionView cellForItemAtIndexPath:indexPath];
|
||||
if (!cell) return;
|
||||
[self showMagnifierForEmoji:item.value fromRect:cell.frame];
|
||||
}
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
[self hideMagnifier];
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionViewDelegateFlowLayout
|
||||
|
||||
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
CGFloat availableWidth = collectionView.bounds.size.width;
|
||||
NSInteger columns = 8;
|
||||
CGFloat spacing = 8;
|
||||
CGFloat totalSpacing = spacing * (columns - 1);
|
||||
CGFloat width = floor((availableWidth - totalSpacing) / columns);
|
||||
if (width < 32) { width = 32; }
|
||||
return CGSizeMake(width, width);
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
|
||||
return 12;
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section {
|
||||
return 8;
|
||||
}
|
||||
|
||||
@end
|
||||
30
CustomKeyboard/View/Function/KBFunctionTagListView.h
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// KBFunctionTagListView.h
|
||||
// 封装标签列表(UICollectionView)
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "KBTagItemModel.h"
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBFunctionTagListView;
|
||||
|
||||
@protocol KBFunctionTagListViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title;
|
||||
@end
|
||||
|
||||
@interface KBFunctionTagListView : UIView
|
||||
|
||||
@property (nonatomic, weak, nullable) id<KBFunctionTagListViewDelegate> delegate;
|
||||
@property (nonatomic, strong, readonly) UICollectionView *collectionView;
|
||||
|
||||
//- (void)setItems:(NSArray<NSString *> *)items;
|
||||
- (void)setItems:(NSArray<KBTagItemModel *> *)items;
|
||||
|
||||
/// 在指定 index 上显示/隐藏加载指示(若 cell 不可见,内部会记录状态,待出现时应用)
|
||||
- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
86
CustomKeyboard/View/Function/KBFunctionTagListView.m
Normal file
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// KBFunctionTagListView.m
|
||||
//
|
||||
|
||||
#import "KBFunctionTagListView.h"
|
||||
#import "KBFunctionTagCell.h"
|
||||
|
||||
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
static CGFloat const kKBItemSpace = 4;
|
||||
|
||||
@interface KBFunctionTagListView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
|
||||
@property (nonatomic, strong) UICollectionView *collectionViewInternal;
|
||||
@property (nonatomic, copy) NSArray<KBTagItemModel *> *items;
|
||||
@property (nonatomic, strong) NSMutableSet<NSNumber *> *loadingIndexes; // 记录需要展示loading的index
|
||||
@end
|
||||
|
||||
@implementation KBFunctionTagListView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
UICollectionViewFlowLayout *layout = [UICollectionViewFlowLayout new];
|
||||
_collectionViewInternal = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
|
||||
_collectionViewInternal.backgroundColor = [UIColor clearColor];
|
||||
_collectionViewInternal.dataSource = self;
|
||||
_collectionViewInternal.delegate = self;
|
||||
[_collectionViewInternal registerClass:[KBFunctionTagCell class] forCellWithReuseIdentifier:kKBFunctionTagCellId2];
|
||||
[self addSubview:_collectionViewInternal];
|
||||
_collectionViewInternal.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_collectionViewInternal.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[_collectionViewInternal.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[_collectionViewInternal.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[_collectionViewInternal.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
_items = @[];
|
||||
_loadingIndexes = [NSMutableSet set];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
//- (void)setItems:(NSArray<NSString *> *)items { _items = [items copy]; [self.collectionViewInternal reloadData]; }
|
||||
- (void)setItems:(NSArray<KBTagItemModel *> *)items{ _items = [items copy]; [self.collectionViewInternal reloadData]; }
|
||||
|
||||
- (UICollectionView *)collectionView { return self.collectionViewInternal; }
|
||||
|
||||
#pragma mark - UICollectionView
|
||||
|
||||
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return self.items.count; }
|
||||
|
||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBFunctionTagCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBFunctionTagCellId2 forIndexPath:indexPath];
|
||||
KBTagItemModel *itemModel = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
cell.itemModel = itemModel;
|
||||
BOOL loading = [self.loadingIndexes containsObject:@(indexPath.item)];
|
||||
[cell setLoading:loading];
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
CGFloat totalW = collectionView.bounds.size.width; CGFloat space = kKBItemSpace; NSInteger columns = 3;
|
||||
CGFloat width = floor((totalW - space * (columns - 1)) / columns);
|
||||
return CGSizeMake(width, 41);
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index {
|
||||
NSNumber *key = @(index);
|
||||
if (loading) { [self.loadingIndexes addObject:key]; }
|
||||
else { [self.loadingIndexes removeObject:key]; }
|
||||
NSIndexPath *ip = [NSIndexPath indexPathForItem:index inSection:0];
|
||||
if (ip && ip.item < [self.collectionViewInternal numberOfItemsInSection:0]) {
|
||||
KBFunctionTagCell *cell = (KBFunctionTagCell *)[self.collectionViewInternal cellForItemAtIndexPath:ip];
|
||||
if ([cell isKindOfClass:[KBFunctionTagCell class]]) { [cell setLoading:loading]; }
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
28
CustomKeyboard/View/Function/KBStreamOverlayView.h
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// KBStreamOverlayView.h
|
||||
// 自带关闭按钮的流式展示层,内部持有 KBStreamTextView。
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBStreamTextView, KBStreamOverlayView;
|
||||
|
||||
@protocol KBStreamOverlayViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)streamOverlayDidTapClose:(KBStreamOverlayView *)overlay;
|
||||
@end
|
||||
|
||||
@interface KBStreamOverlayView : UIView
|
||||
|
||||
@property (nonatomic, strong, readonly) KBStreamTextView *textView;
|
||||
@property (nonatomic, weak, nullable) id<KBStreamOverlayViewDelegate> delegate;
|
||||
|
||||
- (void)appendChunk:(NSString *)text;
|
||||
- (void)finish;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
150
CustomKeyboard/View/Function/KBStreamOverlayView.m
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// KBStreamOverlayView.m
|
||||
//
|
||||
|
||||
#import "KBStreamOverlayView.h"
|
||||
#import "KBStreamTextView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBStreamOverlayView ()
|
||||
@property (nonatomic, strong) KBStreamTextView *textViewInternal;
|
||||
@property (nonatomic, strong) UIButton *closeButton;
|
||||
|
||||
// 新增:流式打字机用的缓冲 & 定时器
|
||||
@property (nonatomic, strong) NSMutableString *pendingText;
|
||||
@property (nonatomic, strong) NSTimer *streamTimer;
|
||||
@property (nonatomic, assign) NSInteger charsPerTick; // 每次“跳”几个字符
|
||||
// 新增:标记 SSE 已经收到 done
|
||||
@property (nonatomic, assign) BOOL streamDidReceiveDone;
|
||||
@end
|
||||
|
||||
@implementation KBStreamOverlayView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.92];
|
||||
self.layer.cornerRadius = 12.0; self.layer.masksToBounds = YES;
|
||||
|
||||
[self addSubview:self.textViewInternal];
|
||||
[self addSubview:self.closeButton];
|
||||
|
||||
[self.textViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(0);
|
||||
make.right.equalTo(self.mas_right).offset(0);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(0);
|
||||
make.top.equalTo(self.mas_top).offset(0);
|
||||
}];
|
||||
|
||||
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.mas_top).offset(8);
|
||||
make.right.equalTo(self.mas_right).offset(-8);
|
||||
make.height.mas_equalTo(28);
|
||||
make.width.mas_greaterThanOrEqualTo(56);
|
||||
}];
|
||||
_pendingText = [NSMutableString string];
|
||||
_charsPerTick = 2; // 每次输出 1~2 个字符,可以自己调
|
||||
_streamDidReceiveDone = NO;
|
||||
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (KBStreamTextView *)textViewInternal {
|
||||
if (!_textViewInternal) {
|
||||
_textViewInternal = [[KBStreamTextView alloc] init];
|
||||
}
|
||||
return _textViewInternal;
|
||||
}
|
||||
|
||||
- (UIButton *)closeButton {
|
||||
if (!_closeButton) {
|
||||
UIButton *del = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
del.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.35];
|
||||
del.layer.cornerRadius = 14; del.layer.masksToBounds = YES;
|
||||
del.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
[del setTitle:KBLocalized(@"common_back") forState:UIControlStateNormal];
|
||||
[del setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
[del addTarget:self action:@selector(onTapClose) forControlEvents:UIControlEventTouchUpInside];
|
||||
_closeButton = del;
|
||||
}
|
||||
return _closeButton;
|
||||
}
|
||||
|
||||
- (void)onTapClose {
|
||||
if ([self.delegate respondsToSelector:@selector(streamOverlayDidTapClose:)]) {
|
||||
[self.delegate streamOverlayDidTapClose:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)appendChunk:(NSString *)text {
|
||||
if (text.length == 0) return;
|
||||
if (![NSThread isMainThread]) {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[weakSelf appendChunk:text];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self.pendingText appendString:text];
|
||||
[self startStreamTimerIfNeeded];
|
||||
}
|
||||
|
||||
- (void)startStreamTimerIfNeeded {
|
||||
if (self.streamTimer) return;
|
||||
self.streamTimer = [NSTimer scheduledTimerWithTimeInterval:0.02
|
||||
target:self
|
||||
selector:@selector(handleStreamTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
}
|
||||
|
||||
- (void)stopStreamTimer {
|
||||
[self.streamTimer invalidate];
|
||||
self.streamTimer = nil;
|
||||
}
|
||||
|
||||
- (void)handleStreamTick {
|
||||
if (self.pendingText.length == 0) {
|
||||
// 如果已经收到 done 并且没有待播内容了,这里再真正 finish
|
||||
if (self.streamDidReceiveDone) {
|
||||
[self.textViewInternal finishStreaming];
|
||||
}
|
||||
[self stopStreamTimer];
|
||||
return;
|
||||
}
|
||||
|
||||
NSInteger len = MIN(self.charsPerTick, self.pendingText.length);
|
||||
NSString *slice = [self.pendingText substringToIndex:len];
|
||||
[self.pendingText deleteCharactersInRange:NSMakeRange(0, len)];
|
||||
|
||||
[self.textViewInternal appendStreamText:slice];
|
||||
}
|
||||
|
||||
|
||||
- (void)finish {
|
||||
if (![NSThread isMainThread]) {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[weakSelf finish];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 只标记“流已结束”
|
||||
self.streamDidReceiveDone = YES;
|
||||
|
||||
// 如果此时已经没有待播内容了,可以立即结束
|
||||
if (self.pendingText.length == 0) {
|
||||
[self stopStreamTimer];
|
||||
[self.textViewInternal finishStreaming];
|
||||
}
|
||||
// 否则等 handleStreamTick 把 pendingText 慢慢播完,
|
||||
// 它看到 pendingText == 0 且 streamDidReceiveDone == YES 时会自动调用 finishStreaming
|
||||
}
|
||||
|
||||
|
||||
|
||||
- (KBStreamTextView *)textView { return self.textViewInternal; }
|
||||
|
||||
@end
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
#import "KBFullAccessGuideView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||
#import "KBHUD.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
|
||||
@interface KBFullAccessGuideView ()
|
||||
@property (nonatomic, strong) UIControl *backdrop;
|
||||
@property (nonatomic, strong) UIView *card;
|
||||
// 预先保存当前键盘控制器,避免运行时通过响应链找不到
|
||||
@property (nonatomic, weak) UIInputViewController *ivc;
|
||||
@end
|
||||
|
||||
@implementation KBFullAccessGuideView
|
||||
@@ -42,7 +47,7 @@
|
||||
}];
|
||||
|
||||
UILabel *title = [UILabel new];
|
||||
title.text = @"开启【允许完全访问】,体验完整功能";
|
||||
title.text = KBLocalized(@"Turn on Allow Full Access to experience all features");
|
||||
title.font = [UIFont boldSystemFontOfSize:16];
|
||||
title.textColor = [UIColor blackColor];
|
||||
title.textAlignment = NSTextAlignmentCenter;
|
||||
@@ -64,8 +69,8 @@
|
||||
make.height.mas_equalTo(100);
|
||||
}];
|
||||
|
||||
UILabel *row1 = [UILabel new]; row1.text = @"恋爱键盘"; row1.textColor = [UIColor blackColor];
|
||||
UILabel *row2 = [UILabel new]; row2.text = @"允许完全访问"; row2.textColor = [UIColor blackColor];
|
||||
UILabel *row1 = [UILabel new]; row1.text = AppName; row1.textColor = [UIColor blackColor];
|
||||
UILabel *row2 = [UILabel new]; row2.text = KBLocalized(@"Allow Full Access"); row2.textColor = [UIColor blackColor];
|
||||
[box addSubview:row1]; [box addSubview:row2];
|
||||
[row1 mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(box).offset(16); make.top.equalTo(box).offset(14); }];
|
||||
UIView *line = [UIView new]; line.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
@@ -93,7 +98,7 @@
|
||||
|
||||
UIButton *go = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
go.backgroundColor = [UIColor blackColor];
|
||||
[go setTitle:@"去开启" forState:UIControlStateNormal];
|
||||
[go setTitle:KBLocalized(@"Go enable") forState:UIControlStateNormal];
|
||||
[go setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
go.titleLabel.font = [UIFont boldSystemFontOfSize:18];
|
||||
go.layer.cornerRadius = 12;
|
||||
@@ -109,7 +114,8 @@
|
||||
}
|
||||
|
||||
- (void)presentIn:(UIView *)parent {
|
||||
UIView *container = parent.window ?: parent;
|
||||
if (!parent) return;
|
||||
UIView *container = parent; // 关键:加到键盘视图树中,而不是 window
|
||||
self.frame = container.bounds;
|
||||
self.alpha = 0;
|
||||
[container addSubview:self];
|
||||
@@ -125,15 +131,19 @@
|
||||
|
||||
+ (void)showInView:(UIView *)parent {
|
||||
if (!parent) return;
|
||||
// 避免重复
|
||||
for (UIView *v in (parent.window ?: parent).subviews) {
|
||||
// 避免重复(仅在 parent 层级检查)
|
||||
for (UIView *v in parent.subviews) {
|
||||
if ([v isKindOfClass:[KBFullAccessGuideView class]]) return;
|
||||
}
|
||||
[[KBFullAccessGuideView build] presentIn:parent];
|
||||
KBFullAccessGuideView *view = [KBFullAccessGuideView build];
|
||||
// 预取 ivc
|
||||
view.ivc = KBFindInputViewController(parent);
|
||||
[view presentIn:parent];
|
||||
}
|
||||
|
||||
+ (void)dismissFromView:(UIView *)parent {
|
||||
UIView *container = parent.window ?: parent;
|
||||
UIView *container = parent;
|
||||
if (!container) return;
|
||||
for (UIView *v in container.subviews) {
|
||||
if ([v isKindOfClass:[KBFullAccessGuideView class]]) {
|
||||
[(KBFullAccessGuideView *)v dismiss];
|
||||
@@ -146,40 +156,21 @@
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (UIInputViewController *)kb_findInputController {
|
||||
UIResponder *res = self;
|
||||
while (res) {
|
||||
if ([res isKindOfClass:[UIInputViewController class]]) {
|
||||
return (UIInputViewController *)res;
|
||||
}
|
||||
res = res.nextResponder;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// 工具方法已提取到 KBResponderUtils.h
|
||||
// 打开主 App,引导用户去系统设置开启完全访问:通过宿主 UIApplication + 自定义 Scheme 拉起。
|
||||
- (void)onTapGoEnable {
|
||||
// 在扩展中无法使用 UIApplication。改为委托宿主打开链接:
|
||||
// 方案:优先拉起主 App 并由主 App 打开设置页,避免宿主拦截。
|
||||
UIInputViewController *ivc = [self kb_findInputController];
|
||||
if (!ivc) { [self dismiss]; return; }
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
// 找不到键盘控制器也可以尝试从自身 responder 链出发
|
||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||
|
||||
// 先尝试 Universal Link(如未配置可改为你的域名),失败再用自定义 scheme。
|
||||
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=kb_extension", KB_UL_SETTINGS]];
|
||||
void (^fallback)(void) = ^{
|
||||
NSURL *scheme = [NSURL URLWithString:@"kbkeyboard://settings?src=kb_extension"]; // 主App在 openURL 中处理
|
||||
[ivc.extensionContext openURL:scheme completionHandler:^(__unused BOOL ok2) {
|
||||
// 无论成功与否,都收起当前提示层,避免遮挡
|
||||
[self dismiss];
|
||||
}];
|
||||
};
|
||||
|
||||
if (ul) {
|
||||
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
|
||||
if (ok) { [self dismiss]; }
|
||||
else { fallback(); }
|
||||
}];
|
||||
// 自定义 Scheme(AppDelegate 中处理 kbkeyboardAppExtension://settings)
|
||||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//settings?src=kb_extension", KB_APP_SCHEME]];
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
||||
if (ok) {
|
||||
[self dismiss];
|
||||
} else {
|
||||
fallback();
|
||||
NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings → General → Keyboard → Keyboards → %@ → Allow Full Access"),AppName];
|
||||
[KBHUD showInfo:showInfo];
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 功能区顶部的Bar:左侧4个按钮,右侧3个按钮
|
||||
/// 功能区顶部的 Bar:
|
||||
/// 左侧:App 图标按钮(点击可回到主 App 或打开更多功能)
|
||||
/// 右侧:升级 VIP 按钮
|
||||
@class KBFunctionBarView;
|
||||
|
||||
@protocol KBFunctionBarViewDelegate <NSObject>
|
||||
@@ -24,15 +26,15 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@property (nonatomic, weak, nullable) id<KBFunctionBarViewDelegate> delegate;
|
||||
|
||||
/// 左侧4个按钮(懒加载创建,等宽水平排布)
|
||||
/// 左侧按钮(当前只有一个:App 图标)
|
||||
@property (nonatomic, strong, readonly) NSArray<UIButton *> *leftButtons;
|
||||
|
||||
/// 右侧3个按钮(懒加载创建,等宽水平排布,靠右)
|
||||
/// 右侧按钮(当前只有一个:升级 VIP)
|
||||
@property (nonatomic, strong, readonly) NSArray<UIButton *> *rightButtons;
|
||||
|
||||
/// 配置按钮标题(可选)
|
||||
@property (nonatomic, copy) NSArray<NSString *> *leftTitles; // 默认 @[@"帮回", @"会说", @"话术", @"更多"]
|
||||
@property (nonatomic, copy) NSArray<NSString *> *rightTitles; // 默认 @[@"❤", @"收藏", @"宫格"]
|
||||
/// 预留的标题配置(目前按钮主要以图片为主,可选)
|
||||
@property (nonatomic, copy) NSArray<NSString *> *leftTitles;
|
||||
@property (nonatomic, copy) NSArray<NSString *> *rightTitles;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
|
||||
#import "KBFunctionBarView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBResponderUtils.h" // 查找 UIInputViewController,用于系统切换输入法
|
||||
|
||||
@interface KBFunctionBarView ()
|
||||
@property (nonatomic, strong) UIView *leftContainer; // 左侧按钮容器
|
||||
@property (nonatomic, strong) UIView *rightContainer; // 右侧按钮容器
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *rightButtonsInternal;
|
||||
@property (nonatomic, strong) UIButton *globeButtonInternal; // 可选:系统“切换输入法”键
|
||||
@end
|
||||
|
||||
@implementation KBFunctionBarView
|
||||
@@ -20,8 +22,9 @@
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_leftTitles = @[@"ABC"];
|
||||
_rightTitles = @[@"Upgrade VIP"];
|
||||
// 标题字段暂时不用,预留给后续可能的文案按钮
|
||||
_leftTitles = @[];
|
||||
_rightTitles = @[];
|
||||
[self buildUI];
|
||||
}
|
||||
return self;
|
||||
@@ -36,83 +39,63 @@
|
||||
#pragma mark - UI
|
||||
|
||||
- (void)buildUI {
|
||||
// 左右两个容器,方便分别布局
|
||||
// 左右容器 + 可选地球键
|
||||
[self addSubview:self.globeButtonInternal];
|
||||
[self addSubview:self.leftContainer];
|
||||
[self addSubview:self.rightContainer];
|
||||
|
||||
// 右侧:升级 VIP 按钮(使用资源图 upgrad_vip_icon,设计尺寸 115x35)
|
||||
[self.rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.right.equalTo(self.mas_right).offset(-6);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.height.mas_equalTo(36);
|
||||
make.width.mas_equalTo(115);
|
||||
make.height.mas_equalTo(35);
|
||||
}];
|
||||
|
||||
[self.leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
UIButton *vipButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
vipButton.tag = 200; // 右侧 index = 0
|
||||
UIImage *vipImage = [UIImage imageNamed:@"upgrad_vip_icon"];
|
||||
[vipButton setImage:vipImage forState:UIControlStateNormal];
|
||||
vipButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
vipButton.adjustsImageWhenHighlighted = YES;
|
||||
[vipButton addTarget:self action:@selector(onRightTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.rightContainer addSubview:vipButton];
|
||||
[vipButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.rightContainer);
|
||||
}];
|
||||
self.rightButtonsInternal = @[vipButton];
|
||||
|
||||
// 左侧地球键(按需显示,由 kb_refreshGlobeVisibility 控制是否展示)
|
||||
[self.globeButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.rightContainer.mas_left).offset(-12);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
// 左侧:App 图标按钮(使用资源图 App_icon,图标 34x34,按钮容器 36x36)
|
||||
[self.leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.width.mas_equalTo(36);
|
||||
make.height.mas_equalTo(36);
|
||||
make.left.equalTo(self.mas_left).offset(12); // 具体偏移在 kb_refreshGlobeVisibility 中会根据地球键重新设置
|
||||
}];
|
||||
|
||||
// 左侧4个等宽按钮
|
||||
NSMutableArray<UIButton *> *leftBtns = [NSMutableArray arrayWithCapacity:4];
|
||||
UIView *prev = nil;
|
||||
for (NSInteger i = 0; i < self.leftTitles.count; i++) {
|
||||
UIButton *btn = [self buildButtonWithTitle:(i < self.leftTitles.count ? self.leftTitles[i] : [NSString stringWithFormat:@"L%ld", (long)i])];
|
||||
btn.tag = 100 + i;
|
||||
[btn addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.leftContainer addSubview:btn];
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (prev) {
|
||||
make.left.equalTo(prev.mas_right).offset(8);
|
||||
make.width.equalTo(prev);
|
||||
} else {
|
||||
make.left.equalTo(self.leftContainer.mas_left);
|
||||
}
|
||||
make.top.bottom.equalTo(self.leftContainer);
|
||||
}];
|
||||
prev = btn;
|
||||
[leftBtns addObject:btn];
|
||||
}
|
||||
[prev mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.leftContainer.mas_right);
|
||||
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
appButton.tag = 100; // 左侧 index = 0
|
||||
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
||||
[appButton setImage:appImage forState:UIControlStateNormal];
|
||||
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
appButton.adjustsImageWhenHighlighted = YES;
|
||||
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.leftContainer addSubview:appButton];
|
||||
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.leftContainer);
|
||||
make.width.height.mas_equalTo(34); // 设计图尺寸
|
||||
}];
|
||||
self.leftButtonsInternal = leftBtns.copy;
|
||||
self.leftButtonsInternal = @[appButton];
|
||||
|
||||
// 右侧N个按钮(靠右、两两等宽)
|
||||
NSMutableArray<UIButton *> *rightBtns = [NSMutableArray arrayWithCapacity:3];
|
||||
for (NSInteger i = 0; i < self.rightTitles.count; i++) {
|
||||
UIButton *btn = [self buildButtonWithTitle:(i < self.rightTitles.count ? self.rightTitles[i] : [NSString stringWithFormat:@"R%ld", (long)i])];
|
||||
btn.tag = 200 + i;
|
||||
[self.rightContainer addSubview:btn];
|
||||
[btn addTarget:self action:@selector(onRightTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[rightBtns addObject:btn];
|
||||
}
|
||||
|
||||
// 从右往左链式布局,保证整体靠右;支持 1/2/3... 任意数量
|
||||
UIView *prevRight = nil; // 指向右侧已布局的按钮
|
||||
for (NSInteger i = rightBtns.count - 1; i >= 0; i--) {
|
||||
UIButton *btn = rightBtns[i];
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (!prevRight) {
|
||||
// 最右侧按钮贴右
|
||||
make.right.equalTo(self.rightContainer.mas_right);
|
||||
} else {
|
||||
// 其余按钮紧挨左侧兄弟,且与其等宽
|
||||
make.right.equalTo(prevRight.mas_left).offset(-8);
|
||||
make.width.equalTo(prevRight);
|
||||
}
|
||||
make.top.bottom.equalTo(self.rightContainer);
|
||||
}];
|
||||
prevRight = btn;
|
||||
}
|
||||
// 最左侧一个不超出容器左边(允许根据内容自然宽度收缩)
|
||||
if (prevRight) {
|
||||
[prevRight mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.greaterThanOrEqualTo(self.rightContainer.mas_left);
|
||||
}];
|
||||
}
|
||||
|
||||
self.rightButtonsInternal = rightBtns.copy;
|
||||
// 初始刷新地球键可见性与事件绑定
|
||||
[self kb_refreshGlobeVisibility];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
@@ -131,17 +114,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (UIButton *)buildButtonWithTitle:(NSString *)title {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
btn.layer.cornerRadius = 18;
|
||||
btn.layer.masksToBounds = YES;
|
||||
btn.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
[btn setTitle:title forState:UIControlStateNormal];
|
||||
[btn setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
return btn;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIView *)leftContainer {
|
||||
@@ -158,4 +130,56 @@
|
||||
return _rightContainer;
|
||||
}
|
||||
|
||||
- (UIButton *)globeButtonInternal {
|
||||
if (!_globeButtonInternal) {
|
||||
_globeButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_globeButtonInternal.layer.cornerRadius = 16;
|
||||
_globeButtonInternal.layer.masksToBounds = YES;
|
||||
_globeButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
[_globeButtonInternal setTitle:@"🌐" forState:UIControlStateNormal];
|
||||
[_globeButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
}
|
||||
return _globeButtonInternal;
|
||||
}
|
||||
|
||||
#pragma mark - Globe (Input Mode Switch)
|
||||
|
||||
- (void)kb_refreshGlobeVisibility {
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
BOOL needSwitchKey = YES;
|
||||
if (ivc && [ivc respondsToSelector:@selector(needsInputModeSwitchKey)]) {
|
||||
needSwitchKey = ivc.needsInputModeSwitchKey;
|
||||
}
|
||||
|
||||
self.globeButtonInternal.hidden = !needSwitchKey;
|
||||
|
||||
// 左容器左约束:根据是否显示地球键动态调整
|
||||
[self.leftContainer mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
if (needSwitchKey) {
|
||||
make.left.equalTo(self.globeButtonInternal.mas_right).offset(8);
|
||||
} else {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
}
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.width.mas_equalTo(36);
|
||||
make.height.mas_equalTo(36);
|
||||
}];
|
||||
|
||||
// 绑定系统输入法切换事件
|
||||
[self.globeButtonInternal removeTarget:nil action:NULL forControlEvents:UIControlEventAllEvents];
|
||||
if (needSwitchKey && ivc) {
|
||||
SEL sel = NSSelectorFromString(@"handleInputModeListFromView:withEvent:");
|
||||
if ([ivc respondsToSelector:sel]) {
|
||||
[self.globeButtonInternal addTarget:ivc action:sel forControlEvents:UIControlEventAllTouchEvents];
|
||||
} else {
|
||||
[self.globeButtonInternal addTarget:ivc action:@selector(advanceToNextInputMode) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
[self kb_refreshGlobeVisibility];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -11,13 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 粘贴提示输入框区域(左侧图标+占位文案,圆角白底)
|
||||
@interface KBFunctionPasteView : UIView
|
||||
|
||||
/// 左侧图标
|
||||
@property (nonatomic, strong, readonly) UIImageView *iconView;
|
||||
|
||||
/// 提示文案,例如:点击粘贴TA的话
|
||||
@property (nonatomic, strong, readonly) UILabel *placeholderLabel;
|
||||
|
||||
@property (nonatomic, strong) UIButton *pasBtn;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
#import "KBFunctionPasteView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBFont.h"
|
||||
|
||||
@interface KBFunctionPasteView ()
|
||||
@property (nonatomic, strong) UIImageView *iconViewInternal;
|
||||
@property (nonatomic, strong) UILabel *placeholderLabelInternal;
|
||||
//@property (nonatomic, strong) UIImageView *iconViewInternal;
|
||||
//@property (nonatomic, strong) UILabel *placeholderLabelInternal;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBFunctionPasteView
|
||||
@@ -22,54 +24,64 @@
|
||||
self.layer.cornerRadius = 12.0;
|
||||
self.layer.masksToBounds = YES;
|
||||
|
||||
[self addSubview:self.iconViewInternal];
|
||||
[self addSubview:self.placeholderLabelInternal];
|
||||
// [self addSubview:self.iconViewInternal];
|
||||
// [self addSubview:self.placeholderLabelInternal];
|
||||
[self addSubview:self.pasBtn];
|
||||
[self.pasBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
// [self.iconViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.left.equalTo(self.mas_left).offset(12);
|
||||
// make.centerY.equalTo(self.mas_centerY);
|
||||
// make.width.height.mas_equalTo(20);
|
||||
// }];
|
||||
// [self.placeholderLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.left.equalTo(self.iconViewInternal.mas_right).offset(8);
|
||||
// make.right.equalTo(self.mas_right).offset(-12);
|
||||
// make.centerY.equalTo(self.mas_centerY);
|
||||
// }];
|
||||
|
||||
[self.iconViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.width.height.mas_equalTo(20);
|
||||
}];
|
||||
[self.placeholderLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.iconViewInternal.mas_right).offset(8);
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
//
|
||||
//- (UIImageView *)iconViewInternal {
|
||||
// if (!_iconViewInternal) {
|
||||
// _iconViewInternal = [[UIImageView alloc] init];
|
||||
// _iconViewInternal.image = [UIImage imageNamed:@"kb_zt_icon"];
|
||||
// }
|
||||
// return _iconViewInternal;
|
||||
//}
|
||||
//
|
||||
//- (UILabel *)placeholderLabelInternal {
|
||||
// if (!_placeholderLabelInternal) {
|
||||
// _placeholderLabelInternal = [[UILabel alloc] init];
|
||||
// // 文案改为更贴近设计稿
|
||||
// _placeholderLabelInternal.text = KBLocalized(@"Paste Ta's Words");
|
||||
// _placeholderLabelInternal.textColor = [UIColor colorWithRed:0.20 green:0.64 blue:0.54 alpha:1.0];
|
||||
// _placeholderLabelInternal.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
// }
|
||||
// return _placeholderLabelInternal;
|
||||
//}
|
||||
|
||||
- (UIImageView *)iconViewInternal {
|
||||
if (!_iconViewInternal) {
|
||||
_iconViewInternal = [[UIImageView alloc] init];
|
||||
// 用简单的系统表情代替资源图(项目可替换成实际图片)
|
||||
UILabel *emoji = [[UILabel alloc] init];
|
||||
emoji.text = @"📋"; // 粘贴/剪贴板含义
|
||||
emoji.font = [UIFont systemFontOfSize:18];
|
||||
emoji.textAlignment = NSTextAlignmentCenter;
|
||||
[_iconViewInternal addSubview:emoji];
|
||||
[emoji mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_iconViewInternal);
|
||||
}];
|
||||
- (UIButton *)pasBtn{
|
||||
if (!_pasBtn) {
|
||||
_pasBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[_pasBtn setImage:[UIImage imageNamed:@"kb_zt_icon"] forState:UIControlStateNormal];
|
||||
[_pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal];
|
||||
[_pasBtn setTitleColor:[UIColor colorWithHex:0x02BEAC] forState:UIControlStateNormal];
|
||||
_pasBtn.titleLabel.font = [KBFont medium:13];
|
||||
_pasBtn.backgroundColor = [UIColor whiteColor];
|
||||
}
|
||||
return _iconViewInternal;
|
||||
}
|
||||
|
||||
- (UILabel *)placeholderLabelInternal {
|
||||
if (!_placeholderLabelInternal) {
|
||||
_placeholderLabelInternal = [[UILabel alloc] init];
|
||||
_placeholderLabelInternal.text = @"点击粘贴TA的话";
|
||||
_placeholderLabelInternal.textColor = [UIColor colorWithRed:0.20 green:0.64 blue:0.54 alpha:1.0];
|
||||
_placeholderLabelInternal.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
}
|
||||
return _placeholderLabelInternal;
|
||||
return _pasBtn;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UIImageView *)iconView { return self.iconViewInternal; }
|
||||
- (UILabel *)placeholderLabel { return self.placeholderLabelInternal; }
|
||||
//- (UIImageView *)iconView { return self.iconViewInternal; }
|
||||
//- (UILabel *)placeholderLabel { return self.placeholderLabelInternal; }
|
||||
|
||||
@end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// 话术标签Cell:左图标+右标题,圆角灰白底
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#import "KBTagItemModel.h"
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBFunctionTagCell : UICollectionViewCell
|
||||
@@ -17,7 +17,13 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 头像/图标
|
||||
@property (nonatomic, strong, readonly) UIImageView *iconView;
|
||||
|
||||
|
||||
@property (nonatomic, strong) KBTagItemModel *itemModel;
|
||||
|
||||
|
||||
/// 显示/隐藏加载指示(小菊花)
|
||||
- (void)setLoading:(BOOL)loading;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBFunctionTagCell ()
|
||||
@property (nonatomic, strong) UILabel *emojiLabel;
|
||||
@property (nonatomic, strong) UILabel *titleLabelInternal;
|
||||
@property (nonatomic, strong) UIImageView *iconViewInternal;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingView;
|
||||
@end
|
||||
|
||||
@implementation KBFunctionTagCell
|
||||
@@ -21,53 +22,102 @@
|
||||
self.contentView.layer.cornerRadius = 12;
|
||||
self.contentView.layer.masksToBounds = YES;
|
||||
|
||||
[self.contentView addSubview:self.iconViewInternal];
|
||||
[self.contentView addSubview:self.titleLabelInternal];
|
||||
// 小菊花:默认隐藏,放在整体内容右侧偏内的位置
|
||||
[self.contentView addSubview:self.loadingView];
|
||||
[self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.contentView);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
|
||||
[self.iconViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.contentView.mas_left).offset(10);
|
||||
// 中心容器:将 icon + title 组合整体水平居中
|
||||
UIView *centerContainer = [[UIView alloc] init];
|
||||
centerContainer.backgroundColor = [UIColor clearColor];
|
||||
[self.contentView addSubview:centerContainer];
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.contentView.mas_centerX);
|
||||
make.centerY.equalTo(self.contentView.mas_centerY);
|
||||
make.left.greaterThanOrEqualTo(self.contentView.mas_left).offset(6);
|
||||
make.right.lessThanOrEqualTo(self.contentView).offset(-6);
|
||||
}];
|
||||
|
||||
[centerContainer addSubview:self.emojiLabel];
|
||||
[centerContainer addSubview:self.titleLabelInternal];
|
||||
|
||||
[self.emojiLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(centerContainer.mas_left);
|
||||
make.centerY.equalTo(centerContainer.mas_centerY);
|
||||
// 留出一点余量,避免 emoji 字形在右侧被裁剪
|
||||
make.width.height.mas_equalTo(24);
|
||||
}];
|
||||
[self.titleLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.iconViewInternal.mas_right).offset(6);
|
||||
make.right.equalTo(self.contentView.mas_right).offset(-10);
|
||||
make.centerY.equalTo(self.contentView.mas_centerY);
|
||||
make.left.equalTo(self.emojiLabel.mas_right).offset(3);
|
||||
make.top.equalTo(centerContainer.mas_top);
|
||||
make.bottom.equalTo(centerContainer.mas_bottom);
|
||||
make.right.equalTo(centerContainer.mas_right);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setItemModel:(KBTagItemModel *)itemModel{
|
||||
_itemModel = itemModel;
|
||||
self.emojiLabel.text = itemModel.emoji;
|
||||
self.titleLabelInternal.text = itemModel.characterName;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)iconViewInternal {
|
||||
if (!_iconViewInternal) {
|
||||
_iconViewInternal = [[UIImageView alloc] init];
|
||||
UILabel *emoji = [[UILabel alloc] init];
|
||||
emoji.text = @"🙂"; // 占位图标
|
||||
emoji.textAlignment = NSTextAlignmentCenter;
|
||||
emoji.font = [UIFont systemFontOfSize:20];
|
||||
[_iconViewInternal addSubview:emoji];
|
||||
[emoji mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_iconViewInternal);
|
||||
}];
|
||||
- (UILabel *)emojiLabel {
|
||||
if (!_emojiLabel) {
|
||||
_emojiLabel = [[UILabel alloc] init];
|
||||
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_emojiLabel.font = [KBFont medium:20];
|
||||
_emojiLabel.adjustsFontSizeToFitWidth = YES;
|
||||
|
||||
}
|
||||
return _iconViewInternal;
|
||||
return _emojiLabel;
|
||||
}
|
||||
|
||||
- (UILabel *)titleLabelInternal {
|
||||
if (!_titleLabelInternal) {
|
||||
_titleLabelInternal = [[UILabel alloc] init];
|
||||
_titleLabelInternal.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
_titleLabelInternal.textColor = [UIColor blackColor];
|
||||
_titleLabelInternal.font = [KBFont medium:10];
|
||||
_titleLabelInternal.textColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
// 最多两行,文本过长时末尾截断
|
||||
_titleLabelInternal.numberOfLines = 2;
|
||||
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
}
|
||||
return _titleLabelInternal;
|
||||
}
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleMedium; }
|
||||
#else
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleGray; }
|
||||
#endif
|
||||
|
||||
- (UIActivityIndicatorView *)loadingView {
|
||||
if (!_loadingView) {
|
||||
_loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
||||
_loadingView.hidesWhenStopped = YES;
|
||||
_loadingView.color = [UIColor grayColor];
|
||||
_loadingView.hidden = YES;
|
||||
}
|
||||
return _loadingView;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UILabel *)titleLabel { return self.titleLabelInternal; }
|
||||
- (UIImageView *)iconView { return self.iconViewInternal; }
|
||||
|
||||
- (void)setLoading:(BOOL)loading {
|
||||
if (loading) {
|
||||
self.loadingView.hidden = NO;
|
||||
[self.loadingView startAnimating];
|
||||
} else {
|
||||
[self.loadingView stopAnimating];
|
||||
self.loadingView.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
@protocol KBFunctionViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *_Nullable)functionView;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
@@ -33,6 +36,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@property (nonatomic, strong, readonly) UIButton *clearButton; // 右侧-清空
|
||||
@property (nonatomic, strong, readonly) UIButton *sendButton; // 右侧-发送
|
||||
|
||||
/// 应用当前皮肤(更新背景/强调色)
|
||||
- (void)kb_applyTheme;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||
#import "KBFunctionBarView.h"
|
||||
#import "KBFunctionPasteView.h"
|
||||
#import "KBFunctionTagCell.h"
|
||||
@@ -13,46 +14,121 @@
|
||||
#import <MBProgressHUD.h>
|
||||
#import "KBFullAccessGuideView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBAuthManager.h" // 登录态判断(共享钥匙串)
|
||||
#import "KBULBridgeNotification.h" // Darwin 通知常量(UL 已处理)
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBStreamTextView.h" // 流式文本视图
|
||||
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
||||
#import "KBFunctionTagListView.h"
|
||||
#import "WJXEventSource.h"
|
||||
#import "KBTagItemModel.h"
|
||||
#import <MJExtension/MJExtension.h>
|
||||
#import "KBBizCode.h"
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
|
||||
static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
|
||||
@interface KBFunctionView () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
|
||||
// UI
|
||||
@property (nonatomic, strong) KBFunctionBarView *barViewInternal;
|
||||
@property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
|
||||
@property (nonatomic, strong) UICollectionView *collectionViewInternal;
|
||||
@property (nonatomic, strong) KBFunctionTagListView *tagListView;
|
||||
@property (nonatomic, strong) UIView *rightButtonContainer; // 右侧竖排按钮容器
|
||||
@property (nonatomic, strong) UIButton *pasteButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *deleteButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *clearButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *sendButtonInternal;
|
||||
|
||||
// 叠层:流式文本视图 + 关闭按钮
|
||||
@property (nonatomic, strong, nullable) KBStreamOverlayView *streamOverlay;
|
||||
|
||||
// 网络流式(封装)
|
||||
@property (nonatomic, strong, nullable) WJXEventSource *eventSource;
|
||||
@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
|
||||
@property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index
|
||||
@property (nonatomic, copy, nullable) NSString *loadingTagTitle;
|
||||
@property (nonatomic, assign) BOOL eventSourceDidReceiveDone;
|
||||
@property (nonatomic, copy, nullable) NSString *eventSourceSplitPrefix;
|
||||
|
||||
// Data
|
||||
@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
|
||||
//@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
|
||||
@property (nonatomic, strong) NSMutableArray<KBTagItemModel *> *modelArray;
|
||||
|
||||
// 剪贴板自动检测
|
||||
@property (nonatomic, strong) NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程)
|
||||
@property (nonatomic, assign) NSInteger lastHandledPBCount; // 上次处理过的 changeCount,避免重复弹窗
|
||||
|
||||
// UL 双路兜底
|
||||
@property (nonatomic, assign) NSUInteger kb_ulSeq; // 当前 UL 发起序号
|
||||
@property (nonatomic, assign) BOOL kb_ulHandledFlag; // 主 App 已确认处理 UL
|
||||
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
||||
@end
|
||||
|
||||
@implementation KBFunctionView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
// 整体绿色背景(接近截图效果,项目可自行替换素材)
|
||||
self.backgroundColor = [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0];
|
||||
// 背景使用当前主题强调色
|
||||
[self kb_applyTheme];
|
||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||
|
||||
[self setupUI];
|
||||
[self reloadDemoData];
|
||||
// [self reloadDemoData];
|
||||
[self kb_reloadTagsFromSharedDefaults];
|
||||
|
||||
|
||||
// 初始化剪贴板监控状态
|
||||
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
|
||||
|
||||
// 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发 TCC/XPC 错误日志
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil];
|
||||
|
||||
// 监听主 App 的 Darwin 确认(UL 已处理)
|
||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
KBULDarwinCallback,
|
||||
(__bridge CFStringRef)KBDarwinULHandled,
|
||||
NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Data
|
||||
|
||||
/// 从 App Group 的 NSUserDefaults 中读取真实 JSON,解析为 model + 标签文案
|
||||
- (void)kb_reloadTagsFromSharedDefaults {
|
||||
NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
NSDictionary *jsonDict = [sharedDefaults objectForKey:AppGroup_MyKbJson];
|
||||
if (jsonDict != nil) {
|
||||
id dataObj = jsonDict[@"data"];
|
||||
NSArray<KBTagItemModel *> *modelList = [KBTagItemModel mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
||||
if (modelList.count > 0) {
|
||||
self.modelArray = [NSMutableArray array];
|
||||
[self.modelArray addObjectsFromArray:modelList];
|
||||
// [self.collectionView reloadData];
|
||||
[self.tagListView setItems:self.modelArray];
|
||||
}
|
||||
}else{
|
||||
NSLog(@"json❎");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Theme
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
// KBSkinManager *mgr = [KBSkinManager shared];
|
||||
// UIColor *accent = mgr.current.accentColor ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0];
|
||||
// BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
||||
self.backgroundColor = [UIColor colorWithHex:0xD0D3DA];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopPasteboardMonitor];
|
||||
[self kb_stopNetworkStreaming];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled, NULL);
|
||||
}
|
||||
|
||||
#pragma mark - UI
|
||||
@@ -60,19 +136,24 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
- (void)setupUI {
|
||||
// 1. 顶部 Bar
|
||||
[self addSubview:self.barViewInternal];
|
||||
CGFloat barTopInset = KBFit(6.0f);
|
||||
CGFloat barHeight = KBFit(52.0f);
|
||||
[self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(6);
|
||||
make.height.mas_equalTo(48);
|
||||
make.top.equalTo(self.mas_top).offset(barTopInset);
|
||||
make.height.mas_equalTo(barHeight);
|
||||
}];
|
||||
|
||||
// 右侧竖排按钮容器
|
||||
[self addSubview:self.rightButtonContainer];
|
||||
CGFloat rightInset = KBFit(4.0f);
|
||||
CGFloat containerBottomInset = KBFit(10.0f);
|
||||
CGFloat containerWidth = KBFit(60.0f);
|
||||
[self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-10);
|
||||
make.width.mas_equalTo(72);
|
||||
make.right.equalTo(self.mas_right).offset(-rightInset);
|
||||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(0);
|
||||
make.width.mas_equalTo(containerWidth);
|
||||
}];
|
||||
|
||||
// 右侧四个按钮
|
||||
@@ -81,14 +162,12 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
[self.rightButtonContainer addSubview:self.clearButtonInternal];
|
||||
[self.rightButtonContainer addSubview:self.sendButtonInternal];
|
||||
|
||||
// 竖向排布:粘贴、删除、清空为等高,发送略高
|
||||
CGFloat smallH = 44;
|
||||
CGFloat bigH = 56;
|
||||
CGFloat vSpace = 10;
|
||||
// 竖向排布:容器内四个按钮等高分配,间距为 8px(按设计稿等比缩放)
|
||||
CGFloat smallH = KBFit(41.0f);
|
||||
CGFloat vSpace = KBFit(8.0f);
|
||||
[self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.rightButtonContainer.mas_top);
|
||||
make.left.right.equalTo(self.rightButtonContainer);
|
||||
make.height.mas_equalTo(smallH);
|
||||
}];
|
||||
[self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace);
|
||||
@@ -103,92 +182,497 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
[self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace);
|
||||
make.left.right.equalTo(self.rightButtonContainer);
|
||||
make.height.mas_equalTo(bigH);
|
||||
make.bottom.lessThanOrEqualTo(self.rightButtonContainer.mas_bottom); // 底部可伸缩
|
||||
make.height.equalTo(self.pasteButtonInternal);
|
||||
make.bottom.equalTo(self.rightButtonContainer.mas_bottom);
|
||||
}];
|
||||
|
||||
// 2. 粘贴区(位于右侧按钮左侧)
|
||||
[self addSubview:self.pasteViewInternal];
|
||||
[self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12);
|
||||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8);
|
||||
make.height.mas_equalTo(48);
|
||||
make.left.equalTo(self.mas_left).offset(vSpace);
|
||||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
|
||||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
|
||||
make.height.mas_equalTo(smallH);
|
||||
}];
|
||||
// 点击整个粘贴卡片按钮,行为与右侧「Paste」按钮保持一致
|
||||
[self.pasteViewInternal.pasBtn addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
// 3. CollectionView
|
||||
[self addSubview:self.collectionViewInternal];
|
||||
[self.collectionViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12);
|
||||
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-10);
|
||||
// 3. Tag List View
|
||||
[self addSubview:self.tagListView];
|
||||
[self.tagListView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.pasteViewInternal);
|
||||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
|
||||
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(vSpace);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(0);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Data
|
||||
|
||||
- (void)reloadDemoData {
|
||||
// 演示数据(可由外部替换)
|
||||
self.itemsInternal = @[@"高情商", @"暖味拉扯", @"风趣幽默", @"撩女生", @"社交惬匿", @"情场高手", @"一枚暖男", @"聊天搭子", @"表达爱意", @"更多话术"];
|
||||
[self.collectionViewInternal reloadData];
|
||||
//- (void)reloadDemoData {
|
||||
// // 演示数据(可由外部替换)
|
||||
// self.itemsInternal = @[KBLocalized(@"Warm hearted man"),
|
||||
// KBLocalized(@"Warm2 hearted man"),
|
||||
// KBLocalized(@"Warm3 hearted man"),
|
||||
// KBLocalized(@"撩女生啊u发顺丰大师傅"),
|
||||
// KBLocalized(@"Warm = man"),
|
||||
// KBLocalized(@"Warm hearted man"),
|
||||
// KBLocalized(@"一枚暖男发放"),
|
||||
// KBLocalized(@"聊天搭子"),
|
||||
// KBLocalized(@"表达爱意"),
|
||||
// KBLocalized(@"更多话术")];
|
||||
// [self.tagListView setItems:self.itemsInternal];
|
||||
//}
|
||||
|
||||
// UICollectionView 逻辑已下沉至 KBFunctionTagListView
|
||||
|
||||
- (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title {
|
||||
// 已有则不重复创建
|
||||
if (self.streamOverlay.superview) { return; }
|
||||
|
||||
// 隐藏标签列表,使用同一区域展示流式文本
|
||||
self.tagListView.hidden = YES;
|
||||
|
||||
KBStreamOverlayView *overlay = [[KBStreamOverlayView alloc] init];
|
||||
overlay.delegate = (id)self;
|
||||
[self addSubview:overlay];
|
||||
[overlay mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// 在原标签区域内展示流式文本,右侧继续保留竖排按钮栏
|
||||
CGFloat vSpace = KBFit(4.0f);
|
||||
CGFloat overlayTopInset = KBFit(10.0f);
|
||||
CGFloat overlayBottomInset = KBFit(10.0f);
|
||||
make.left.equalTo(self.pasteViewInternal);
|
||||
make.right.equalTo(self).offset(-vSpace);
|
||||
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(overlayTopInset);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-overlayBottomInset);
|
||||
}];
|
||||
// 仅隐藏删除/清空/发送按钮,保留“Paste”按钮可用
|
||||
self.pasteButtonInternal.hidden = NO;
|
||||
self.deleteButtonInternal.hidden = YES;
|
||||
self.clearButtonInternal.hidden = YES;
|
||||
self.sendButtonInternal.hidden = YES;
|
||||
// 适当缩小内部左右留白,进一步提升可用宽度
|
||||
overlay.textView.contentHorizontalPadding = 8.0;
|
||||
self.streamOverlay = overlay;
|
||||
|
||||
// 只创建 UI;网络在点击 cell 时启动,避免重复 start 导致首包重复
|
||||
}
|
||||
|
||||
#pragma mark - UICollectionView
|
||||
|
||||
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
|
||||
return self.itemsInternal.count;
|
||||
- (void)kb_onTapStreamDelete {
|
||||
// 关闭并销毁流式视图,恢复标签列表
|
||||
[self kb_stopNetworkStreaming];
|
||||
[self.streamOverlay removeFromSuperview];
|
||||
self.streamOverlay = nil;
|
||||
self.tagListView.hidden = NO;
|
||||
// 恢复右侧按钮栏的全部按钮
|
||||
self.pasteButtonInternal.hidden = NO;
|
||||
self.deleteButtonInternal.hidden = NO;
|
||||
self.clearButtonInternal.hidden = NO;
|
||||
self.sendButtonInternal.hidden = NO;
|
||||
}
|
||||
|
||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBFunctionTagCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBFunctionTagCellId forIndexPath:indexPath];
|
||||
cell.titleLabel.text = self.itemsInternal[indexPath.item];
|
||||
return cell;
|
||||
// 叠层关闭回调
|
||||
- (void)streamOverlayDidTapClose:(KBStreamOverlayView *)overlay {
|
||||
[self kb_onTapStreamDelete];
|
||||
}
|
||||
|
||||
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
// 三列等宽
|
||||
CGFloat totalW = collectionView.bounds.size.width;
|
||||
CGFloat space = 10.0;
|
||||
NSInteger columns = 3;
|
||||
CGFloat width = floor((totalW - space * (columns - 1)) / columns);
|
||||
return CGSizeMake(width, 48);
|
||||
|
||||
#pragma mark - Network Streaming (WJXEventSource)
|
||||
|
||||
- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle {
|
||||
[self kb_stopNetworkStreaming];
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) { return; }
|
||||
|
||||
NSString *apiUrl = [NSString stringWithFormat:@"%@%@", KB_BASE_URL, API_AI_TALK];
|
||||
NSURL *url = [NSURL URLWithString:apiUrl];
|
||||
if (!url) { return; }
|
||||
|
||||
NSInteger characterId = 0;
|
||||
if (self.loadingTagIndex != nil) {
|
||||
NSInteger idx = self.loadingTagIndex.integerValue;
|
||||
if (idx >= 0 && idx < self.modelArray.count) {
|
||||
KBTagItemModel *model = self.modelArray[idx];
|
||||
characterId = model.characterId;
|
||||
}
|
||||
}
|
||||
NSInteger resolvedCharacterId = (characterId > 0) ? characterId : 75;
|
||||
NSString *message = seedTitle.length > 0 ? seedTitle : @"aliqua non cupidatat";
|
||||
// message = [NSString stringWithFormat:@"%@%d",message,arc4random() % 10000];
|
||||
NSDictionary *payload = @{
|
||||
@"characterId": @(resolvedCharacterId),
|
||||
@"message": message
|
||||
};
|
||||
NSLog(@"[KBFunction] request payload: %@", payload);
|
||||
NSError *bodyError = nil;
|
||||
NSData *bodyData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&bodyError];
|
||||
if (bodyError || bodyData.length == 0) {
|
||||
NSLog(@"[KBFunction] build body failed: %@", bodyError);
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60];
|
||||
request.HTTPMethod = @"POST";
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
||||
if (token.length > 0) {
|
||||
[request setValue:token forHTTPHeaderField:@"auth-token"];
|
||||
}
|
||||
request.HTTPBody = bodyData;
|
||||
|
||||
self.streamHasOutput = NO;
|
||||
self.eventSourceSplitPrefix = nil;
|
||||
self.eventSourceDidReceiveDone = NO;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
WJXEventSource *source = [[WJXEventSource alloc] initWithRquest:request];
|
||||
source.ignoreRetryAction = YES;
|
||||
[source addListener:^(WJXEvent * _Nonnull event) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||||
[self kb_handleEventSourceMessage:event];
|
||||
} forEvent:WJXEventNameMessage queue:NSOperationQueue.mainQueue];
|
||||
[source addListener:^(WJXEvent * _Nonnull event) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||||
[self kb_handleEventSourceError:event.error];
|
||||
} forEvent:WJXEventNameError queue:NSOperationQueue.mainQueue];
|
||||
self.eventSource = source;
|
||||
[self.eventSource open];
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section {
|
||||
return 10.0;
|
||||
- (void)kb_stopNetworkStreaming {
|
||||
[self.eventSource close];
|
||||
self.eventSource = nil;
|
||||
self.eventSourceSplitPrefix = nil;
|
||||
self.eventSourceDidReceiveDone = NO;
|
||||
self.streamHasOutput = NO;
|
||||
}
|
||||
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
|
||||
return 12.0;
|
||||
- (void)kb_handleEventSourceMessage:(WJXEvent *)event {
|
||||
if (event.data.length == 0) { return; }
|
||||
NSLog(@"[KBStream] SSE raw payload: %@", event.data);
|
||||
NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!jsonData) { return; }
|
||||
NSError *error = nil;
|
||||
NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
|
||||
if (error || ![payload isKindOfClass:[NSDictionary class]]) { return; }
|
||||
if ([self kb_handleBizErrorIfNeeded:payload]) { return; }
|
||||
NSString *type = payload[@"type"];
|
||||
if (![type isKindOfClass:[NSString class]]) { return; }
|
||||
|
||||
if ([type isEqualToString:@"llm_chunk"]) {
|
||||
NSString *chunk = [self kb_normalizedLLMChunkString:payload[@"data"]];
|
||||
if (chunk.length > 0) {
|
||||
[self kb_appendChunkToStreamView:chunk];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ([type isEqualToString:@"search_result"]) {
|
||||
NSString *text = [self kb_formattedSearchResultString:payload[@"data"]];
|
||||
if (text.length > 0) {
|
||||
[self kb_appendChunkToStreamView:text];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ([type isEqualToString:@"done"]) {
|
||||
self.eventSourceDidReceiveDone = YES;
|
||||
[self kb_finishEventSourceWithError:nil];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问
|
||||
- (void)kb_handleEventSourceError:(NSError * _Nullable)error {
|
||||
if (self.eventSourceDidReceiveDone) { return; }
|
||||
[self kb_finishEventSourceWithError:error];
|
||||
}
|
||||
|
||||
- (void)kb_finishEventSourceWithError:(NSError * _Nullable)error {
|
||||
[self.eventSource close];
|
||||
self.eventSource = nil;
|
||||
if (!self.streamHasOutput && self.loadingTagIndex) {
|
||||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||||
self.loadingTagIndex = nil;
|
||||
self.loadingTagTitle = nil;
|
||||
}
|
||||
BOOL shouldShowError = (error != nil);
|
||||
if (shouldShowError) {
|
||||
[KBHUD showInfo:error.localizedDescription ?: KBLocalized(@"拉取失败")];
|
||||
}
|
||||
if (self.streamOverlay) {
|
||||
[self.streamOverlay finish];
|
||||
}
|
||||
self.eventSourceSplitPrefix = nil;
|
||||
self.eventSourceDidReceiveDone = NO;
|
||||
}
|
||||
|
||||
- (BOOL)kb_handleBizErrorIfNeeded:(NSDictionary *)payload {
|
||||
NSInteger code = KBBizCodeFromJSONObject(payload);
|
||||
if (code == NSNotFound || code == KBBizCodeSuccess) {
|
||||
return NO;
|
||||
}
|
||||
BOOL needSubscriptionGuide = (code == KBBizCodeQuotaExhausted);
|
||||
NSString *msg = KBBizMessageFromJSONObject(payload);
|
||||
if (msg.length == 0) {
|
||||
msg = KBLocalized(@"拉取失败");
|
||||
}
|
||||
NSError *bizError = [NSError errorWithDomain:@"KBStreamBizError"
|
||||
code:code
|
||||
userInfo:@{NSLocalizedDescriptionKey: msg}];
|
||||
[self kb_finishEventSourceWithError:bizError];
|
||||
if (needSubscriptionGuide) {
|
||||
[self kb_requestSubscriptionGuide];
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)kb_requestSubscriptionGuide {
|
||||
if ([self.delegate respondsToSelector:@selector(functionViewDidRequestSubscription:)]) {
|
||||
[self.delegate functionViewDidRequestSubscription:self];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Event Parsing
|
||||
|
||||
- (NSString *)kb_normalizedLLMChunkString:(id)dataValue {
|
||||
if (![dataValue isKindOfClass:[NSString class]]) { return @""; }
|
||||
NSString *text = (NSString *)dataValue;
|
||||
|
||||
// 1. 处理上一个包遗留的 <SPLIT> 前缀(比如 "<SP" + "LIT>")
|
||||
if (self.eventSourceSplitPrefix.length > 0) {
|
||||
text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""];
|
||||
self.eventSourceSplitPrefix = nil;
|
||||
}
|
||||
if (text.length == 0) { return @""; }
|
||||
|
||||
// 2. 去掉开头多余换行(避免一开始就空一大块)
|
||||
while (text.length > 0) {
|
||||
unichar c0 = [text characterAtIndex:0];
|
||||
if (c0 == '\n' || c0 == '\r') {
|
||||
text = [text substringFromIndex:1];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (text.length == 0) { return @""; }
|
||||
|
||||
// 3. 处理结尾可能是不完整的 "<SPLIT" 之类,先截掉,放到下一个包里拼
|
||||
NSString *suffix = [self kb_pendingSplitSuffixForString:text];
|
||||
if (suffix.length > 0) {
|
||||
self.eventSourceSplitPrefix = suffix;
|
||||
text = [text substringToIndex:text.length - suffix.length];
|
||||
}
|
||||
if (text.length == 0) { return @""; }
|
||||
|
||||
// 4. 处理完整的 <SPLIT>,变成段落分隔符 \t
|
||||
text = [text stringByReplacingOccurrencesOfString:@"<SPLIT>" withString:@"\t"];
|
||||
|
||||
// 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
- (NSString *)kb_formattedSearchResultString:(id)dataValue {
|
||||
// data 不是数组就直接返回空串
|
||||
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
|
||||
NSArray *list = (NSArray *)dataValue;
|
||||
|
||||
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
||||
|
||||
[list enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
||||
NSString *payload = nil;
|
||||
|
||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||||
id val = obj[@"payload"];
|
||||
if ([val isKindOfClass:[NSString class]]) {
|
||||
payload = (NSString *)val;
|
||||
}
|
||||
} else if ([obj isKindOfClass:[NSString class]]) {
|
||||
// 兼容后端直接给字符串数组的情况
|
||||
payload = (NSString *)obj;
|
||||
}
|
||||
|
||||
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (payload.length > 0) {
|
||||
// 每一个 payload 就是一段
|
||||
[segments addObject:payload];
|
||||
}
|
||||
}];
|
||||
|
||||
if (segments.count == 0) { return @""; }
|
||||
|
||||
// 用 \t 拼起来,KBStreamTextView 会按 \t 拆成多个 label
|
||||
NSMutableString *result = [NSMutableString string];
|
||||
|
||||
[segments enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
||||
// 每段前面加一个 \t,保证是新的一段
|
||||
[result appendFormat:@"\t%@", obj];
|
||||
}];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
- (NSString *)kb_pendingSplitSuffixForString:(NSString *)text {
|
||||
static NSString * const token = @"<SPLIT>";
|
||||
if (text.length == 0) { return @""; }
|
||||
NSUInteger tokenLen = token.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 = [token substringToIndex:len];
|
||||
if ([suffix isEqualToString:prefix]) {
|
||||
return suffix;
|
||||
}
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
/// 统一处理需要输出到 KBStreamTextView 的分片:
|
||||
/// - 已将 `<SPLIT>` 转换为 `\t` 并去掉多余换行
|
||||
/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动
|
||||
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
|
||||
if (chunk.length == 0) return;
|
||||
// 第一次有数据才创建 overlay,并取消 cell 上的小菊花
|
||||
if (!self.streamOverlay) {
|
||||
[self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""];
|
||||
if (self.loadingTagIndex) {
|
||||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||||
self.loadingTagIndex = nil; self.loadingTagTitle = nil;
|
||||
}
|
||||
}
|
||||
if (!self.streamOverlay) return;
|
||||
[self.streamOverlay appendChunk:chunk];
|
||||
self.streamHasOutput = YES;
|
||||
}
|
||||
|
||||
/// 统一更新左侧粘贴按钮的展示:
|
||||
/// - 有粘贴文本:只显示文字,不再展示左侧图标;
|
||||
/// - 无粘贴文本:恢复原始图标 + 占位文案。
|
||||
- (void)kb_updatePasteButtonWithDisplayText:(NSString * _Nullable)text {
|
||||
if (text.length > 0) {
|
||||
NSString *displayText = text;
|
||||
if (displayText.length > 30) {
|
||||
displayText = [[displayText substringToIndex:30] stringByAppendingString:@"…"];
|
||||
}
|
||||
[self.pasteView.pasBtn setImage:nil forState:UIControlStateNormal];
|
||||
[self.pasteView.pasBtn setTitle:displayText forState:UIControlStateNormal];
|
||||
} else {
|
||||
UIImage *img = [UIImage imageNamed:@"kb_zt_icon"];
|
||||
[self.pasteView.pasBtn setImage:img forState:UIControlStateNormal];
|
||||
[self.pasteView.pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - KBFunctionTagListViewDelegate
|
||||
|
||||
- (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
|
||||
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view];
|
||||
return;
|
||||
// if (!ivc) return;
|
||||
// NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
||||
// NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)index, encodedTitle]];
|
||||
// if (!ul) return;
|
||||
// // 发起 UL,不依赖 ok 结果
|
||||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
// [ivc.extensionContext openURL:ul completionHandler:^(__unused BOOL ok) {}];
|
||||
// });
|
||||
// // 双路兜底:500ms 内未收到主 App 确认,则回退到自定义 Scheme(通过宿主 UIApplication 打开)
|
||||
// self.kb_ulHandledFlag = NO;
|
||||
// NSUInteger token = ++self.kb_ulSeq;
|
||||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
// if (token != self.kb_ulSeq) return; // 已有新请求覆盖
|
||||
// if (self.kb_ulHandledFlag) return; // 主 App 已确认处理
|
||||
// NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)index, encodedTitle]];
|
||||
// if (!scheme) return;
|
||||
// UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||
// // 让键盘失去焦点
|
||||
// [ivc dismissKeyboard];
|
||||
// BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
||||
// if (!ok) {
|
||||
// [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")];
|
||||
// }else{
|
||||
//
|
||||
// }
|
||||
// });
|
||||
// return;
|
||||
}
|
||||
BOOL hasPasteText = ![self.pasteView.pasBtn.currentTitle isEqualToString:KBLocalized(@" Paste Ta's Words")];
|
||||
// BOOL hasPasteText = (self.pasteView.pasBtn.imageView.image == nil);
|
||||
if (!hasPasteText) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please copy the text first")];
|
||||
return;
|
||||
}
|
||||
NSString *copyTitle = self.pasteView.pasBtn.currentTitle;
|
||||
// 3) 已登录:开始业务逻辑(展示加载并拉取流式内容)
|
||||
[self.tagListView setLoading:YES atIndex:index];
|
||||
self.loadingTagIndex = @(index);
|
||||
self.loadingTagTitle = title ?: @"";
|
||||
[self kb_startNetworkStreamingWithSeed:copyTitle];
|
||||
return;
|
||||
}
|
||||
|
||||
// Darwin 回调:主 App 已处理 UL
|
||||
static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
|
||||
KBFunctionView *self_ = (__bridge KBFunctionView *)observer;
|
||||
if (!self_) return;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ self_.kb_ulHandledFlag = YES; });
|
||||
}
|
||||
|
||||
// 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。
|
||||
// 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView,并在其右上角提供删除按钮关闭。
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||||
if ([[KBFullAccessManager shared] hasFullAccess]) {
|
||||
KBTagItemModel *selModel = self.modelArray[indexPath.item];
|
||||
[self kb_showStreamTextViewIfNeededWithTitle:selModel.characterName];
|
||||
return;
|
||||
}
|
||||
|
||||
[KBHUD showInfo:@"处理中…"];
|
||||
// return;
|
||||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
|
||||
UIInputViewController *ivc = [self findInputViewController];
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
if (!ivc) return;
|
||||
|
||||
NSString *title = (indexPath.item < self.itemsInternal.count) ? self.itemsInternal[indexPath.item] : @"";
|
||||
NSString *title = self.modelArray[indexPath.item].characterName;
|
||||
NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
||||
|
||||
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]];
|
||||
if (!ul) return;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
// 先尝试通过 extensionContext 打开 UL
|
||||
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
|
||||
if (ok) return; // Universal Link 成功
|
||||
|
||||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"kbkeyboard://login?src=functionView&index=%ld&title=%@", (long)indexPath.item, encodedTitle]];
|
||||
[ivc.extensionContext openURL:scheme completionHandler:^(BOOL ok2) {
|
||||
if (!ok2) {
|
||||
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self]; });
|
||||
}
|
||||
}];
|
||||
if (ok) {
|
||||
return;
|
||||
}
|
||||
// UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底
|
||||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
|
||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||
BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
||||
if (!ok2) {
|
||||
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||||
});
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -201,18 +685,31 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
// - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗;
|
||||
// - iOS15 及以下不会弹窗,直接返回内容;
|
||||
// 注意:不要在非用户触发的时机主动读取(如 viewDidLoad),否则会造成“立刻弹窗”的体验。
|
||||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||||
return;
|
||||
}
|
||||
UIPasteboard *pb = [UIPasteboard generalPasteboard];
|
||||
NSString *text = pb.string; // 读取纯文本(可能触发系统粘贴权限弹窗)
|
||||
|
||||
if (text.length > 0) {
|
||||
// 将粘贴内容展示到左侧“粘贴区”的占位文案上
|
||||
self.pasteView.placeholderLabel.text = text;
|
||||
// 如果需要多行展示,可按需放开(高度由外部约束决定,默认一行会截断)
|
||||
// self.pasteView.placeholderLabel.numberOfLines = 0;
|
||||
} else {
|
||||
if (text.length <= 0) {
|
||||
// 无可用文本或用户拒绝了粘贴权限;保持占位文案不变
|
||||
NSLog(@"粘贴板无可用文本或未授权粘贴");
|
||||
[KBHUD showInfo:KBLocalized(@"Clipboard is empty")];
|
||||
return;
|
||||
}
|
||||
|
||||
// 1)把内容真正「粘贴」到当前输入框
|
||||
// UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
// if (ivc) {
|
||||
// id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
// [proxy insertText:text];
|
||||
// }
|
||||
|
||||
// 2)顺便把最新的剪贴板内容展示在左侧粘贴区按钮上,便于用户确认
|
||||
[self kb_updatePasteButtonWithDisplayText:text];
|
||||
}
|
||||
|
||||
#pragma mark - 自动监控剪贴板(复制即弹窗)
|
||||
@@ -224,8 +721,10 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
// - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。
|
||||
|
||||
- (void)startPasteboardMonitor {
|
||||
// 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) return;
|
||||
if (self.pasteboardTimer) return;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
KBWeakSelf
|
||||
self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||||
UIPasteboard *pb = [UIPasteboard generalPasteboard];
|
||||
@@ -235,9 +734,8 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
// 实际读取触发系统弹窗(iOS16+)
|
||||
NSString *text = pb.string;
|
||||
if (text.length > 0) {
|
||||
self.pasteView.placeholderLabel.text = text;
|
||||
}
|
||||
// 有文字 -> 仅展示文字;无文字/非文本 -> 恢复图标 + 原占位文案
|
||||
[self kb_updatePasteButtonWithDisplayText:text];
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -248,51 +746,46 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
if (self.window && !self.isHidden) {
|
||||
[self startPasteboardMonitor];
|
||||
} else {
|
||||
[self stopPasteboardMonitor];
|
||||
}
|
||||
[self kb_refreshPasteboardMonitor];
|
||||
}
|
||||
|
||||
- (void)setHidden:(BOOL)hidden {
|
||||
BOOL wasHidden = self.isHidden;
|
||||
[super setHidden:hidden];
|
||||
if (wasHidden != hidden) {
|
||||
if (!hidden && self.window) {
|
||||
[self startPasteboardMonitor];
|
||||
} else {
|
||||
[self stopPasteboardMonitor];
|
||||
}
|
||||
[self kb_refreshPasteboardMonitor];
|
||||
}
|
||||
}
|
||||
- (void)onTapDelete {
|
||||
|
||||
// 根据窗口可见性与完全访问状态,统一启停粘贴板监控
|
||||
- (void)kb_refreshPasteboardMonitor {
|
||||
BOOL visible = (self.window && !self.isHidden);
|
||||
if (visible && [[KBFullAccessManager shared] hasFullAccess]) {
|
||||
[self startPasteboardMonitor];
|
||||
} else {
|
||||
[self stopPasteboardMonitor];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_fullAccessChanged {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshPasteboardMonitor]; });
|
||||
}
|
||||
- (void)onTapDelete {
|
||||
NSLog(@"点击:删除");
|
||||
UIInputViewController *ivc = [self findInputViewController];
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
- (void)onTapClear {
|
||||
NSLog(@"点击:清空");
|
||||
// 连续删除:仅清空光标之前的输入(不改动 pasteView 的内容)
|
||||
UIInputViewController *ivc = [self findInputViewController];
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
// 逐批读取 documentContextBeforeInput 并删除,避免 50 字符窗口限制带来的残留
|
||||
NSInteger guard = 0; // 上限保护,避免极端情况下长时间阻塞
|
||||
while (guard < 10000) {
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
NSInteger count = before.length;
|
||||
if (count <= 0) { break; } // 光标前已无内容
|
||||
for (NSInteger i = 0; i < count; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
guard += count;
|
||||
}
|
||||
[self.backspaceHandler performClearAction];
|
||||
}
|
||||
- (void)onTapSend {
|
||||
- (void)onTapSend {
|
||||
NSLog(@"点击:发送");
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
|
||||
UIInputViewController *ivc = [self findInputViewController];
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[proxy insertText:@"\n"];
|
||||
}
|
||||
@@ -318,6 +811,9 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
- (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index {
|
||||
// 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展)
|
||||
if ([self.delegate respondsToSelector:@selector(functionView:didRightTapToolActionAtIndex:)]) {
|
||||
[self.delegate functionView:self didRightTapToolActionAtIndex:index];
|
||||
}
|
||||
}
|
||||
|
||||
- (KBFunctionPasteView *)pasteViewInternal {
|
||||
@@ -327,17 +823,12 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
return _pasteViewInternal;
|
||||
}
|
||||
|
||||
- (UICollectionView *)collectionViewInternal {
|
||||
if (!_collectionViewInternal) {
|
||||
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
|
||||
layout.sectionInset = UIEdgeInsetsZero; // 外边距交由约束控制
|
||||
_collectionViewInternal = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
|
||||
_collectionViewInternal.backgroundColor = [UIColor clearColor];
|
||||
_collectionViewInternal.dataSource = self;
|
||||
_collectionViewInternal.delegate = self;
|
||||
[_collectionViewInternal registerClass:[KBFunctionTagCell class] forCellWithReuseIdentifier:kKBFunctionTagCellId];
|
||||
- (KBFunctionTagListView *)tagListView {
|
||||
if (!_tagListView) {
|
||||
_tagListView = [[KBFunctionTagListView alloc] init];
|
||||
_tagListView.delegate = (id)self;
|
||||
}
|
||||
return _collectionViewInternal;
|
||||
return _tagListView;
|
||||
}
|
||||
|
||||
- (UIView *)rightButtonContainer {
|
||||
@@ -349,11 +840,11 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
}
|
||||
|
||||
- (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.backgroundColor = color;
|
||||
btn.layer.cornerRadius = 12.0;
|
||||
btn.layer.cornerRadius = 8.0;
|
||||
btn.layer.masksToBounds = YES;
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
btn.titleLabel.font = [KBFont medium:13];
|
||||
[btn setTitle:title forState:UIControlStateNormal];
|
||||
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
return btn;
|
||||
@@ -361,7 +852,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
- (UIButton *)pasteButtonInternal {
|
||||
if (!_pasteButtonInternal) {
|
||||
_pasteButtonInternal = [self buildRightButtonWithTitle:@"粘贴" color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
|
||||
_pasteButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Paste") color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
|
||||
[_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _pasteButtonInternal;
|
||||
@@ -369,27 +860,26 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
- (UIButton *)deleteButtonInternal {
|
||||
if (!_deleteButtonInternal) {
|
||||
// 浅灰底深色文字,更接近截图里“删除”样式
|
||||
_deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_deleteButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0];
|
||||
_deleteButtonInternal.layer.cornerRadius = 12.0;
|
||||
_deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
_deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
||||
_deleteButtonInternal.layer.cornerRadius = 8.0;
|
||||
_deleteButtonInternal.layer.masksToBounds = YES;
|
||||
_deleteButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
[_deleteButtonInternal setTitle:@"删除" forState:UIControlStateNormal];
|
||||
[_deleteButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
[_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal];
|
||||
|
||||
[_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.backspaceHandler bindDeleteButton:_deleteButtonInternal showClearLabel:NO];
|
||||
}
|
||||
return _deleteButtonInternal;
|
||||
}
|
||||
|
||||
- (UIButton *)clearButtonInternal {
|
||||
if (!_clearButtonInternal) {
|
||||
_clearButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_clearButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0];
|
||||
_clearButtonInternal.layer.cornerRadius = 12.0;
|
||||
_clearButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
_clearButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
||||
_clearButtonInternal.layer.cornerRadius = 8.0;
|
||||
_clearButtonInternal.layer.masksToBounds = YES;
|
||||
_clearButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
[_clearButtonInternal setTitle:@"清空" forState:UIControlStateNormal];
|
||||
_clearButtonInternal.titleLabel.font = [KBFont medium:13];
|
||||
[_clearButtonInternal setTitle:KBLocalized(@"Clear") forState:UIControlStateNormal];
|
||||
[_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
[_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
@@ -398,16 +888,17 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
- (UIButton *)sendButtonInternal {
|
||||
if (!_sendButtonInternal) {
|
||||
_sendButtonInternal = [self buildRightButtonWithTitle:@"发送" color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
|
||||
_sendButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Send") color:[UIColor colorWithHex:0x02BEAC]];
|
||||
[_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _sendButtonInternal;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UICollectionView *)collectionView { return self.collectionViewInternal; }
|
||||
- (NSArray<NSString *> *)items { return self.itemsInternal; }
|
||||
- (UICollectionView *)collectionView { return self.tagListView.collectionView; }
|
||||
//- (NSArray<NSString *> *)items { return self.itemsInternal; }
|
||||
- (KBFunctionBarView *)barView { return self.barViewInternal; }
|
||||
- (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; }
|
||||
- (UIButton *)pasteButton { return self.pasteButtonInternal; }
|
||||
@@ -417,16 +908,6 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
#pragma mark - Find Owner Controller
|
||||
|
||||
// 在视图的响应链中查找宿主 UIInputViewController(KeyboardViewController)
|
||||
- (UIInputViewController *)findInputViewController {
|
||||
UIResponder *responder = self;
|
||||
while (responder) {
|
||||
if ([responder isKindOfClass:[UIInputViewController class]]) {
|
||||
return (UIInputViewController *)responder;
|
||||
}
|
||||
responder = responder.nextResponder;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
// 工具方法已提取到 KBResponderUtils.h
|
||||
|
||||
@end
|
||||
|
||||
@@ -23,11 +23,22 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 点击了右侧设置按钮
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
/// 点击了撤销删除按钮
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
|
||||
/// emoji 视图里选择了一个表情
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji;
|
||||
|
||||
/// emoji 面板点击搜索
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
@end
|
||||
|
||||
@interface KBKeyBoardMainView : UIView
|
||||
@property (nonatomic, weak) id<KBKeyBoardMainViewDelegate> delegate;
|
||||
|
||||
/// 应用当前皮肤(会触发键区重载以应用按键颜色)
|
||||
- (void)kb_applyTheme;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -10,44 +10,95 @@
|
||||
#import "KBKeyboardView.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBEmojiPanelView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBSkinManager.h"
|
||||
|
||||
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate>
|
||||
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate>
|
||||
@property (nonatomic, strong) KBToolBar *topBar;
|
||||
@property (nonatomic, strong) KBKeyboardView *keyboardView;
|
||||
@property (nonatomic, strong) KBEmojiPanelView *emojiView;
|
||||
@property (nonatomic, assign) BOOL emojiPanelVisible;
|
||||
// 注意:功能面板的展示/隐藏由外部控制器决定,此处不再直接管理显隐
|
||||
@end
|
||||
@implementation KBKeyBoardMainView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
|
||||
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
||||
|
||||
// 顶部栏
|
||||
self.topBar = [[KBToolBar alloc] init];
|
||||
self.topBar.delegate = self;
|
||||
[self addSubview:self.topBar];
|
||||
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(6);
|
||||
make.height.mas_equalTo(40);
|
||||
}];
|
||||
// 键盘区域(高度按照设计值做等比缩放,避免不同机型上按键被压缩/拉伸)
|
||||
CGFloat keyboardAreaHeight = KBFit(200.0f);
|
||||
CGFloat bottomInset = KBFit(4.0f);
|
||||
CGFloat barSpacing = KBFit(6.0f);
|
||||
|
||||
// 键盘区域
|
||||
self.keyboardView = [[KBKeyboardView alloc] init];
|
||||
self.keyboardView.delegate = self;
|
||||
[self addSubview:self.keyboardView];
|
||||
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.topBar.mas_bottom).offset(4);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-4);
|
||||
make.height.mas_equalTo(keyboardAreaHeight);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-bottomInset);
|
||||
}];
|
||||
|
||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||
self.emojiView = [[KBEmojiPanelView alloc] init];
|
||||
self.emojiView.hidden = YES;
|
||||
self.emojiView.alpha = 0.0;
|
||||
self.emojiView.delegate = self;
|
||||
[self addSubview:self.emojiView];
|
||||
[self.emojiView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(0);
|
||||
make.bottom.equalTo(self.keyboardView.mas_top).offset(0);
|
||||
}];
|
||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setEmojiPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (self.emojiPanelVisible == visible) return;
|
||||
self.emojiPanelVisible = visible;
|
||||
if (visible) {
|
||||
[self.emojiView reloadData];
|
||||
self.emojiView.hidden = NO;
|
||||
[self bringSubviewToFront:self.emojiView];
|
||||
} else {
|
||||
self.keyboardView.hidden = NO;
|
||||
self.topBar.hidden = NO;
|
||||
}
|
||||
|
||||
void (^changes)(void) = ^{
|
||||
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
||||
self.keyboardView.alpha = visible ? 0.0 : 1.0;
|
||||
self.topBar.alpha = visible ? 0.0 : 1.0;
|
||||
};
|
||||
void (^completion)(BOOL) = ^(BOOL finished) {
|
||||
self.emojiView.hidden = !visible;
|
||||
self.keyboardView.hidden = visible;
|
||||
self.topBar.hidden = visible;
|
||||
};
|
||||
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:changes completion:completion];
|
||||
} else {
|
||||
changes();
|
||||
completion(YES);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleEmojiPanel {
|
||||
[self setEmojiPanelVisible:!self.emojiPanelVisible animated:YES];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - KBToolBarDelegate
|
||||
|
||||
@@ -64,6 +115,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapUndo:)]) {
|
||||
[self.delegate keyBoardMainViewDidTapUndo:self];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardViewDelegate
|
||||
|
||||
- (void)keyboardView:(KBKeyboardView *)keyboard didTapKey:(KBKey *)key {
|
||||
@@ -99,12 +156,15 @@
|
||||
[self.delegate keyBoardMainView:self didTapKey:key];
|
||||
}
|
||||
break;
|
||||
case KBKeyTypeCustom:
|
||||
// 自定义占位:切换语言或其它操作
|
||||
case KBKeyTypeCustom: {
|
||||
if ([key.identifier isEqualToString:KBKeyIdentifierEmojiPanel]) {
|
||||
[self toggleEmojiPanel];
|
||||
break;
|
||||
}
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didTapKey:)]) {
|
||||
[self.delegate keyBoardMainView:self didTapKey:key];
|
||||
}
|
||||
break;
|
||||
} break;
|
||||
case KBKeyTypeShift:
|
||||
// Shift 已在 KBKeyboardView 内部处理
|
||||
break;
|
||||
@@ -114,5 +174,47 @@
|
||||
// 切换功能面板交由外部控制器处理(此处不再实现)
|
||||
|
||||
// 设置页展示改由 KeyboardViewController 统一处理
|
||||
#pragma mark - KBEmojiPanelViewDelegate
|
||||
|
||||
- (void)emojiPanelView:(KBEmojiPanelView *)panel didSelectEmoji:(NSString *)emoji {
|
||||
if (emoji.length == 0) return;
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didSelectEmoji:)]) {
|
||||
[self.delegate keyBoardMainView:self didSelectEmoji:emoji];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)emojiPanelViewDidRequestClose:(KBEmojiPanelView *)panel {
|
||||
[self setEmojiPanelVisible:NO animated:YES];
|
||||
}
|
||||
|
||||
- (void)emojiPanelViewDidTapSearch:(KBEmojiPanelView *)panel {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapEmojiSearch:)]) {
|
||||
[self.delegate keyBoardMainViewDidTapEmojiSearch:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didTapKey:)]) {
|
||||
KBKey *backspace = [KBKey keyWithTitle:@"" type:KBKeyTypeBackspace];
|
||||
[self.delegate keyBoardMainView:self didTapKey:backspace];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Theme
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
KBSkinManager *mgr = [KBSkinManager shared];
|
||||
BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
||||
UIColor *bg = mgr.current.keyboardBackground;
|
||||
self.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
||||
self.keyboardView.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
||||
if ([self.topBar respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
[self.topBar kb_applyTheme];
|
||||
}
|
||||
[self.keyboardView reloadKeys];
|
||||
if (self.emojiView) {
|
||||
[self.emojiView applyTheme:mgr.current];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@interface KBKeyButton : UIButton
|
||||
|
||||
@property (nonatomic, strong) KBKey *key;
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
|
||||
/// 配置基础样式(背景、圆角等)。创建按钮时调用。
|
||||
- (void)applyDefaultStyle;
|
||||
@@ -17,4 +18,7 @@
|
||||
/// 根据选中/高亮等状态刷新外观
|
||||
- (void)refreshStateAppearance;
|
||||
|
||||
/// 根据当前皮肤与按键标识,应用图标和文字显隐等细节
|
||||
- (void)applyThemeForCurrentKey;
|
||||
|
||||
@end
|
||||
|
||||
@@ -5,37 +5,114 @@
|
||||
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBSkinManager.h"
|
||||
|
||||
@interface KBKeyButton ()
|
||||
// 内部缓存:便于从按钮查找到所属的 KBKeyboardView
|
||||
@property (nonatomic, weak, readonly) UIView *kb_keyboardContainer;
|
||||
@property (nonatomic, strong) UIImageView *normalImageView; /// 没有皮肤的时候展示
|
||||
@property (nonatomic, strong) UIColor *baseBackgroundColor; /// 无按下状态下,由皮肤/主题决定的底色(由 normalImageView 展示)
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyButton
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
[self addSubview:self.normalImageView];
|
||||
self.normalImageView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.normalImageView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[self.normalImageView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[self.normalImageView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
||||
[self.normalImageView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
||||
]];
|
||||
[self applyDefaultStyle];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)applyDefaultStyle {
|
||||
self.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; // 字体样式
|
||||
[self setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
[self setTitleColor:[UIColor blackColor] forState:UIControlStateHighlighted];
|
||||
self.backgroundColor = [UIColor whiteColor];
|
||||
self.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold];
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
[self setTitleColor:t.keyTextColor forState:UIControlStateNormal];
|
||||
[self setTitleColor:t.keyTextColor forState:UIControlStateHighlighted];
|
||||
// 颜色由 normalImageView 控制,按钮本身保持透明,避免叠加背景影响视觉
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.layer.cornerRadius = 6.0; // 圆角
|
||||
self.layer.masksToBounds = NO;
|
||||
self.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.1].CGColor; // 阴影效果
|
||||
self.layer.shadowOpacity = 1.0;
|
||||
self.layer.shadowOffset = CGSizeMake(0, 1);
|
||||
self.layer.shadowRadius = 1.5;
|
||||
|
||||
// 初始状态下根据主题设置底色(给没有皮肤图的按键使用)
|
||||
[self refreshStateAppearance];
|
||||
|
||||
// 懒创建图标视图,用于后续皮肤按键小图标展示
|
||||
if (!self.iconView) {
|
||||
UIImageView *iv = [[UIImageView alloc] initWithFrame:CGRectZero];
|
||||
// 作为按键的整块皮肤背景,铺满整个按钮区域
|
||||
iv.contentMode = UIViewContentModeScaleToFill;
|
||||
iv.clipsToBounds = YES;
|
||||
iv.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:iv];
|
||||
// 让皮肤图片撑满整个按钮
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[iv.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[iv.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[iv.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
||||
[iv.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
||||
]];
|
||||
self.iconView = iv;
|
||||
|
||||
// 文字保持居中;若需要显示文字,则覆盖在皮肤图片之上
|
||||
self.titleEdgeInsets = UIEdgeInsetsZero;
|
||||
[self bringSubviewToFront:self.titleLabel];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setKey:(KBKey *)key {
|
||||
_key = key;
|
||||
}
|
||||
|
||||
- (void)setHighlighted:(BOOL)highlighted {
|
||||
[super setHighlighted:highlighted];
|
||||
// 简单按压反馈:选中态不改变透明度,避免和高亮态冲突
|
||||
if (self.isSelected) {
|
||||
self.alpha = 1.0;
|
||||
} else {
|
||||
self.alpha = highlighted ? 0.2 : 1.0;
|
||||
// 按下时整体做一个等比缩放动画,不改背景色和透明度。
|
||||
// 这样无论是纯文字键还是整块皮肤图,都有统一的“按下”视觉反馈。
|
||||
CGFloat scale = highlighted ? 0.9 : 1.0; // 可根据手感微调 0.9~0.95
|
||||
|
||||
// 没有皮肤图片时,normalImageView 做灰度按下效果
|
||||
BOOL hasIcon = (self.iconView.image != nil);
|
||||
UIColor *normalBgColor = self.baseBackgroundColor ?: [UIColor whiteColor];
|
||||
UIColor *highlightBgColor = [self kb_darkerColorForColor:normalBgColor];
|
||||
|
||||
[UIView animateWithDuration:0.08
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
if (!hasIcon && !self.normalImageView.hidden) {
|
||||
self.normalImageView.backgroundColor = highlighted ? highlightBgColor : normalBgColor;
|
||||
}
|
||||
self.transform = CGAffineTransformMakeScale(scale, scale);
|
||||
}
|
||||
completion:nil];
|
||||
|
||||
// 将“按下/抬起”事件转发给键盘视图,用于显示/隐藏顶部预览气泡。
|
||||
UIView *container = self.kb_keyboardContainer;
|
||||
if ([container respondsToSelector:@selector(showPreviewForButton:)] &&
|
||||
[container respondsToSelector:@selector(hidePreview)]) {
|
||||
if (highlighted) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[container performSelector:@selector(showPreviewForButton:) withObject:self];
|
||||
#pragma clang diagnostic pop
|
||||
} else {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[container performSelector:@selector(hidePreview)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +123,109 @@
|
||||
|
||||
- (void)refreshStateAppearance {
|
||||
// 选中态用于 Shift/CapsLock 等特殊按键的高亮显示
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIColor *base = nil;
|
||||
if (self.isSelected) {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.85 alpha:1.0];
|
||||
base = t.keyHighlightBackground ?: t.keyBackground;
|
||||
} else {
|
||||
self.backgroundColor = [UIColor whiteColor];
|
||||
base = t.keyBackground;
|
||||
}
|
||||
if (!base) {
|
||||
base = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
self.baseBackgroundColor = base;
|
||||
// 按键背景统一由 normalImageView 控制,按钮本身透明
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
// 有皮肤图时仅展示 icon,不再显示普通背景色
|
||||
if (self.iconView.image != nil || self.normalImageView.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.normalImageView.backgroundColor = base;
|
||||
}
|
||||
|
||||
- (void)applyThemeForCurrentKey {
|
||||
// 根据皮肤映射加载图标(若有),支持大小写变体:
|
||||
// - identifier: 逻辑按键标识(如 letter_q)
|
||||
// - caseVariant: 0/1/2 => 无变体/小写/大写
|
||||
NSString *identifier = self.key.identifier;
|
||||
NSInteger variant = (NSInteger)self.key.caseVariant;
|
||||
KBSkinManager *skinManager = [KBSkinManager shared];
|
||||
UIImage *iconImg = [skinManager iconImageForKeyIdentifier:identifier caseVariant:variant];
|
||||
|
||||
if (!iconImg && [identifier isEqualToString:@"ai"]) {
|
||||
NSString *skinId = skinManager.current.skinId ?: @"";
|
||||
BOOL usingDefaultSkin = (skinId.length == 0 || [skinId isEqualToString:@"default"]);
|
||||
if (usingDefaultSkin) {
|
||||
iconImg = [UIImage imageNamed:@"ai_key_icon"];
|
||||
}
|
||||
}
|
||||
|
||||
// 设置整块按键背景图(若有)
|
||||
self.iconView.image = iconImg;
|
||||
self.iconView.hidden = (iconImg == nil);
|
||||
|
||||
BOOL hasIcon = (iconImg != nil);
|
||||
self.normalImageView.hidden = hasIcon;
|
||||
if (hasIcon) {
|
||||
// 有图标:仅显示图片,完全隐藏文字
|
||||
[self setTitle:@"" forState:UIControlStateNormal];
|
||||
[self setTitle:@"" forState:UIControlStateHighlighted];
|
||||
[self setTitle:@"" forState:UIControlStateSelected];
|
||||
self.titleLabel.hidden = YES;
|
||||
self.normalImageView.backgroundColor = [UIColor clearColor];
|
||||
} else {
|
||||
// 无图标:按键标题正常显示(使用 key.title),并根据 hidden_keys 决定要不要隐藏
|
||||
[self setTitle:self.key.title forState:UIControlStateNormal];
|
||||
BOOL hideTextBySkin = [[KBSkinManager shared] shouldHideKeyTextForIdentifier:identifier];
|
||||
self.titleLabel.hidden = hideTextBySkin;
|
||||
}
|
||||
}
|
||||
|
||||
- (UIImageView *)normalImageView{
|
||||
if (!_normalImageView) {
|
||||
_normalImageView = [[UIImageView alloc] init];
|
||||
// 初始给一个默认色,后续会由 refreshStateAppearance / 皮肤统一覆盖
|
||||
_normalImageView.backgroundColor = [UIColor whiteColor];
|
||||
_normalImageView.layer.cornerRadius = 6;
|
||||
_normalImageView.layer.masksToBounds = true;
|
||||
}
|
||||
return _normalImageView;
|
||||
}
|
||||
|
||||
/// 在当前底色的基础上略微变暗,作为按下时的“灰度”效果
|
||||
- (UIColor *)kb_darkerColorForColor:(UIColor *)color {
|
||||
if (!color) return [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
|
||||
CGFloat h = 0, s = 0, b = 0, a = 0;
|
||||
if ([color getHue:&h saturation:&s brightness:&b alpha:&a]) {
|
||||
return [UIColor colorWithHue:h saturation:s brightness:MAX(b * 0.9, 0.0) alpha:a];
|
||||
}
|
||||
|
||||
CGFloat white = 0;
|
||||
if ([color getWhite:&white alpha:&a]) {
|
||||
return [UIColor colorWithWhite:MAX(white * 0.9, 0.0) alpha:a];
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyButton (KBKeyboardContainer)
|
||||
|
||||
- (UIView *)kb_keyboardContainer {
|
||||
UIView *v = self.superview;
|
||||
while (v) {
|
||||
// KBKeyboardView 是当前容器类型,这里用类名字符串避免直接引用头文件死循环
|
||||
if ([NSStringFromClass(v.class) isEqualToString:@"KBKeyboardView"]) {
|
||||
return v;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
21
CustomKeyboard/View/KBKeyPreviewView.h
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// KBKeyPreviewView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBKey;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 按键按下时显示的气泡预览视图(类似系统键盘上方弹出的放大字母)。
|
||||
@interface KBKeyPreviewView : UIView
|
||||
|
||||
/// 配置预览内容:字符与可选图标。
|
||||
- (void)configureWithKey:(KBKey *)key icon:(nullable UIImage *)icon;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||