Compare commits

317 Commits

Author SHA1 Message Date
9cdd024ce2 修改UI 2025-12-19 22:00:52 +08:00
d612346db5 删除立即充值按钮 2025-12-19 21:36:11 +08:00
2cacaab974 更换AI皮肤逻辑图片 + 更换placeholdimage 2025-12-19 21:29:11 +08:00
200b1ab9f8 修复键盘功能键 发送按钮距离底部很远 2025-12-19 20:40:53 +08:00
8d0939cd78 1 2025-12-19 20:25:22 +08:00
df51620ca9 Emoji 默认选中第一个笑脸 2025-12-19 20:13:57 +08:00
70520fb7d9 AI键移除 放在顶部KBToolBar 2025-12-19 20:08:13 +08:00
7587fe6714 KBFunctionView 长按删除不弹出立刻清空按钮 2025-12-19 19:56:39 +08:00
1c8834caf6 键盘添加撤销操作 2025-12-19 19:21:08 +08:00
68306aa07f 1 2025-12-19 18:58:29 +08:00
9544ad75ff 重构键盘长按删除 2025-12-19 18:45:14 +08:00
d90e080981 修改立刻清空出现延迟时间 2025-12-19 18:14:28 +08:00
fc65052583 1 2025-12-19 18:11:09 +08:00
e0d5ae0257 新增键盘删除长按删除 2025-12-19 18:08:51 +08:00
639ce7eafd 1 2025-12-19 17:06:01 +08:00
e0379d3717 添加长按多文字删除 2025-12-19 16:24:47 +08:00
c108077178 刷新详情头部 2025-12-19 15:40:39 +08:00
182e5b9da1 未登录处理 2025-12-19 13:34:58 +08:00
ea4ecc05b4 添加右滑返回 2025-12-18 14:57:28 +08:00
ae05127292 1 2025-12-18 13:46:14 +08:00
38a3d2879e 优化未登录 键盘点击充值去登录 2025-12-18 13:20:49 +08:00
10ba4cd80f 添加反馈接口 2025-12-17 20:52:23 +08:00
857822c49c 2 2025-12-17 20:43:16 +08:00
85012eab78 2 2025-12-17 20:37:53 +08:00
8aa43d723a 1 2025-12-17 20:33:14 +08:00
1ecb7d60e5 新增搜索 2025-12-17 20:30:01 +08:00
904a6c932a 1 2025-12-17 19:56:22 +08:00
886de394d0 1 2025-12-17 19:45:39 +08:00
8bad475288 1 2025-12-17 19:08:44 +08:00
4a26419e67 1 2025-12-17 18:22:37 +08:00
1e04e7c39a 2 2025-12-17 17:01:49 +08:00
dde8716262 添加键盘里的充值界面 2025-12-17 16:22:41 +08:00
c6b4444589 处理网络请求token为空的问题 2025-12-17 15:39:45 +08:00
5bd20a911f 修改UI 2025-12-16 21:43:00 +08:00
b43567748c 1 2025-12-16 20:20:57 +08:00
59297eac77 重构 2025-12-16 20:05:33 +08:00
9f7d805a52 修改时间 2025-12-16 19:04:21 +08:00
6800864866 新增恢复购买 2025-12-16 16:25:50 +08:00
e8a980ff5b 2 2025-12-16 16:09:14 +08:00
dfbd5efe69 2 2025-12-16 16:03:14 +08:00
4a474e9b44 2 2025-12-16 16:00:00 +08:00
30f2e4f24f 1 2025-12-16 15:47:12 +08:00
c898d16688 删除storkit 1 2025-12-16 14:29:47 +08:00
f10ddd9a31 1 2025-12-16 14:14:49 +08:00
1651258eec 处理storkit2 2025-12-16 13:49:08 +08:00
fd0ddfd45a 1 2025-12-16 13:10:50 +08:00
444877fb73 2 2025-12-15 21:02:48 +08:00
1436464eca 1 2025-12-15 18:56:29 +08:00
05b9a0b823 添加删除按钮 2025-12-15 18:32:54 +08:00
053001170a 1 2025-12-15 18:02:58 +08:00
9cafb0f70e 引导UI修改 2025-12-15 17:56:06 +08:00
a399af53b5 1 2025-12-15 16:52:16 +08:00
a7574cd286 1 2025-12-15 16:41:53 +08:00
06dee39566 1 2025-12-15 15:47:16 +08:00
d5b4ef2b59 2 2025-12-15 15:40:25 +08:00
2620bd6845 1 2025-12-15 15:22:27 +08:00
1f0bdb1bd4 2 2025-12-15 15:11:16 +08:00
b21a2a8193 1 2025-12-15 15:03:35 +08:00
1f07120289 处理点击一个label,再次点击一个label崩溃了 2025-12-15 14:21:42 +08:00
64e0218add 添加emoji的map图标对应key 2025-12-15 13:45:14 +08:00
7972188ac3 修改emoji 2025-12-15 13:32:47 +08:00
6963c8016f 添加emoji1 2025-12-15 13:24:43 +08:00
633e6a9123 1 2025-12-12 20:08:34 +08:00
437f796a08 1 2025-12-12 16:09:14 +08:00
2b08dd44ee 2 2025-12-12 14:54:45 +08:00
1eeeef266b 1 2025-12-12 14:46:38 +08:00
3813974eae 1 2025-12-12 14:16:48 +08:00
6a5bda44e6 1 2025-12-11 22:02:18 +08:00
704553cd4e 1 2025-12-11 21:00:43 +08:00
35597f89ca 2 2025-12-11 20:40:49 +08:00
577b749198 2 2025-12-11 19:43:55 +08:00
cccced6afa 1 2025-12-11 18:36:14 +08:00
14637a21ad 1 2025-12-11 17:59:25 +08:00
111fe42782 1 2025-12-11 17:51:00 +08:00
58da905ade 1 2025-12-11 17:36:16 +08:00
f338a54e41 3 2025-12-11 16:59:14 +08:00
526ac1a7df 2 2025-12-11 16:45:39 +08:00
7f90240731 1 2025-12-11 16:39:22 +08:00
e4442afe72 1 2025-12-11 16:18:00 +08:00
be1d1ad70d 1 2025-12-11 15:39:33 +08:00
4fd0a52a36 2 2025-12-11 15:19:23 +08:00
04c7d19c37 1 2025-12-11 15:00:58 +08:00
94269209e0 2 2025-12-11 14:03:13 +08:00
2b4123741a 1 2025-12-11 13:51:04 +08:00
45695364e9 1 2025-12-11 13:40:32 +08:00
d348b35984 1 2025-12-11 13:16:06 +08:00
e39104c431 处理流逝返回
处理粘贴
2025-12-09 16:12:54 +08:00
7b86b739eb 处理searchresult 2025-12-09 15:19:10 +08:00
ade23e7a20 1 2025-12-09 14:32:21 +08:00
1b2b0c1143 1 2025-12-09 13:59:32 +08:00
0400d2020b 1 2025-12-08 20:46:24 +08:00
2cc93e0b48 1 2025-12-08 19:48:13 +08:00
fd8c08316b 1 2025-12-08 16:39:47 +08:00
0a1c30f669 处理AI回复问题 2025-12-05 21:54:10 +08:00
ca3ea0630e 2 2025-12-05 21:23:54 +08:00
a26b6b58a9 1 2025-12-05 21:15:48 +08:00
6fd4a86a7e 扩展添加token 2025-12-05 20:22:53 +08:00
fa999f502f 1 2025-12-05 20:18:18 +08:00
ad18a47d21 一次性配置内购服务 2025-12-05 13:49:12 +08:00
d2258883df 1 2025-12-04 21:53:47 +08:00
f7d11c5f8b 1 2025-12-04 21:10:32 +08:00
40d9b5aad4 1 2025-12-04 20:57:39 +08:00
6ac6514f89 1 2025-12-04 20:34:23 +08:00
eb7ad1a9f1 1 2025-12-04 20:27:26 +08:00
17ce91d40a 1 2025-12-04 20:04:02 +08:00
515487e748 1 2025-12-04 19:44:54 +08:00
7a25a6a5fa 1 2025-12-04 19:24:42 +08:00
64887054e0 1 2025-12-04 19:12:34 +08:00
8f63741d8c 1 2025-12-04 16:59:59 +08:00
f593ef0b4a 1 2025-12-04 16:41:33 +08:00
231f7f8c13 1 2025-12-04 16:18:43 +08:00
c9863cd353 11 2025-12-04 15:44:43 +08:00
ce4c4f0531 1 2025-12-04 15:35:23 +08:00
af2bcc42fd 1 2025-12-04 15:28:11 +08:00
1596aac717 2 2025-12-04 15:00:15 +08:00
d13bb734b1 1 2025-12-04 14:53:25 +08:00
2665b5ad1f 2 2025-12-04 14:44:56 +08:00
cffd77eeb5 1 2025-12-04 14:26:22 +08:00
b8f8d2e6b0 1 2025-12-04 14:17:47 +08:00
279255a14c 1 2025-12-04 14:07:12 +08:00
b216ddaa61 1 2025-12-04 13:37:11 +08:00
f770f8055e 1 2025-12-03 20:41:24 +08:00
819d74cc8d 2 2025-12-03 20:31:33 +08:00
9651ae7ad7 1 2025-12-03 20:14:14 +08:00
eca168957d 1 2025-12-03 20:02:37 +08:00
f026b9f9fd 1 2025-12-03 19:50:23 +08:00
b368ba0159 2 2025-12-03 19:43:45 +08:00
43e8b85656 1 2025-12-03 19:38:55 +08:00
716f91bdd0 3 2025-12-03 19:19:20 +08:00
b00283cd96 2 2025-12-03 19:05:53 +08:00
25edf2d817 1 2025-12-03 19:00:55 +08:00
1d6371c37e 1 2025-12-03 18:53:15 +08:00
82123fc232 1 2025-12-03 18:49:18 +08:00
6f02bc7cf5 2 2025-12-03 18:19:51 +08:00
a5ff2ce51b 1 2025-12-03 18:02:20 +08:00
4deccb76dc 1 2025-12-03 17:46:28 +08:00
93556ddb9c 6 2025-12-03 16:54:23 +08:00
681fced59d 2 2025-12-03 16:51:46 +08:00
91e2b047eb 5 2025-12-03 16:48:25 +08:00
c1c4c85bd2 2 2025-12-03 16:45:58 +08:00
d06e0499bc 1 2025-12-03 16:38:20 +08:00
49f730b609 3 2025-12-03 16:26:49 +08:00
04a392e7c7 4 2025-12-03 16:05:00 +08:00
22e393e588 2 2025-12-03 15:53:26 +08:00
6556689c8f 1 2025-12-03 15:46:26 +08:00
a50d18b486 1 2025-12-03 15:19:03 +08:00
599a5de3bc 5 2025-12-03 14:30:02 +08:00
c1eb6a3458 4 2025-12-03 13:54:57 +08:00
b87998549c 3 2025-12-03 13:31:02 +08:00
27aa723e7d 1 2025-12-03 12:55:51 +08:00
6be90ebb10 1 2025-12-02 21:32:49 +08:00
2f55e7bfa1 3 2025-12-02 20:33:17 +08:00
c56655c728 1 2025-12-02 19:39:37 +08:00
5e4c16c577 2 2025-12-02 19:19:25 +08:00
8245e7b3d1 1 2025-12-02 18:29:04 +08:00
cafde48f4a 修改UI逻辑 2025-11-28 16:55:26 +08:00
c897111855 1 2025-11-28 16:19:06 +08:00
a2bb61408b 2 2025-11-28 16:06:34 +08:00
9268a21eb8 处理bug 2025-11-28 14:25:13 +08:00
d4c553f072 处理视频退到后台进前台没有继续播放 2025-11-28 13:56:29 +08:00
73802b6e80 1 2025-11-28 13:20:50 +08:00
1a0a444a99 1 2025-11-28 13:07:35 +08:00
c37038f163 2 2025-11-27 21:39:03 +08:00
3144315de5 1 2025-11-27 20:05:39 +08:00
8f16250cbe 1 2025-11-27 19:20:20 +08:00
95d5e2b972 更改显示 2025-11-27 15:36:56 +08:00
2760a070a3 添加guard 蒙层 2025-11-27 15:34:33 +08:00
2435d760e8 添加键盘功能viewUI改动 2025-11-26 21:16:56 +08:00
80e4db86e4 1 2025-11-26 19:46:23 +08:00
4ab8a61a3c 处理因为网络下载失败导致之前的皮肤不在的bug 2025-11-25 22:00:13 +08:00
73c83153f9 缺少键盘图
修改网络环境下载图
2025-11-25 21:50:07 +08:00
1b67998f6a 2 2025-11-25 20:35:08 +08:00
b8cc38aa61 3 2025-11-25 18:54:53 +08:00
c4398a689b 2 2025-11-25 16:53:38 +08:00
b660eb19f4 1 2025-11-25 16:10:08 +08:00
1eb73f5257 封装KBFont,适配字体 2025-11-25 15:36:16 +08:00
71423df1c0 添加苹果登录的测试 2025-11-24 20:47:36 +08:00
709e0f4453 添加local文本 2025-11-24 20:31:23 +08:00
18df76a2b4 新增页面 2025-11-24 20:15:41 +08:00
15e37841bb 处理ios18从其他app用自己键盘 拉起主app的bug
其他问题
2025-11-24 19:58:19 +08:00
8e93f8f86f 动态化高度比例 2025-11-21 21:50:40 +08:00
fd35c5c993 1 2025-11-21 20:59:39 +08:00
fd7b3a7f75 优化键盘弹出宽度
优化键盘按钮视觉效果
2025-11-21 19:40:57 +08:00
af8fff5b13 添加无网络去设置,优化多语言 2025-11-21 18:36:00 +08:00
fc87c545a0 封装跨应用拉起, 2025-11-21 18:26:02 +08:00
0f4ca89060 固定键盘高度250
优化kbkeyboardview
优化ui
2025-11-21 16:22:00 +08:00
c371c7224e 优化键盘 预览宽度固定 2025-11-21 13:48:22 +08:00
31bb72c8f4 按钮之间无间距,按钮里的图片设置间距 2025-11-20 21:53:46 +08:00
faa05e2a10 添加按钮文字预览提示 2025-11-20 21:11:27 +08:00
6bdd111a3a 添加按下去动画 2025-11-20 20:36:31 +08:00
8296ac12b6 修改键盘UI 2025-11-20 20:17:19 +08:00
b2994adc1c UI 2025-11-20 19:57:11 +08:00
75d2e4072a 1 2025-11-20 18:23:56 +08:00
b23927968f 2 2025-11-20 15:35:22 +08:00
c27fd099f6 2 2025-11-20 15:10:22 +08:00
bc1264e28f 2 2025-11-20 14:56:15 +08:00
799b0f3989 添加扩展键盘本地皮肤 2025-11-20 14:27:57 +08:00
b3ce856ad4 添加启动图 2025-11-19 21:58:54 +08:00
f51fe1fac9 恢复默认皮肤 2025-11-19 20:30:30 +08:00
8dbaa9dcf6 配置化json strings 2025-11-19 20:16:19 +08:00
0196128008 1 2025-11-19 19:15:28 +08:00
4108aed4e0 处理键盘图片和自定义文字同时存在的bug 2025-11-19 16:13:30 +08:00
cc55bb107a 添加皮肤 2025-11-19 15:39:47 +08:00
7518a29d2f 2 2025-11-19 15:07:24 +08:00
37e131eb09 1 2025-11-19 14:54:45 +08:00
3dcc4932c3 3 2025-11-18 20:53:47 +08:00
254e65906a 添加app groups 2025-11-18 14:41:35 +08:00
ced0b88ca4 1 2025-11-18 13:48:22 +08:00
b2021dcb3c 6 2025-11-17 21:35:25 +08:00
0ef7b7d1d8 2 2025-11-17 21:08:25 +08:00
7254e2dbd9 2 2025-11-17 20:55:11 +08:00
26ef29ac4e 1 2025-11-17 20:26:39 +08:00
005e3c7581 2 2025-11-17 20:07:39 +08:00
ee433db4ad 66 2025-11-17 18:51:06 +08:00
ea4b8168b7 1 2025-11-17 16:42:32 +08:00
f366a4aa6c 1 2025-11-17 16:16:38 +08:00
d849b201ca 3 2025-11-17 15:39:03 +08:00
dc813fcabc 2 2025-11-17 15:06:05 +08:00
1d215ffdb3 1 2025-11-17 14:53:23 +08:00
d9bfc30c88 1 2025-11-17 13:30:01 +08:00
9305acb69b 1 2025-11-15 14:27:41 +08:00
f9a8955384 1 2025-11-15 00:33:29 +08:00
1f9dbba39d 1 2025-11-14 23:09:04 +08:00
dace0a9309 1 2025-11-14 19:48:15 +08:00
4f2e80e482 1 2025-11-14 18:43:08 +08:00
b27b9f9ee1 1 2025-11-14 18:24:38 +08:00
66a1ddef66 1 2025-11-14 16:34:01 +08:00
eacac8425c 1 2025-11-14 14:07:04 +08:00
d164514fcf 添加pay 2025-11-13 21:22:10 +08:00
ae79d1b1ba 更新UI 2025-11-13 19:20:57 +08:00
50163d02a7 3 2025-11-13 19:07:59 +08:00
5ec950cc61 统一api 2025-11-13 18:03:26 +08:00
a61b5fa2fd 1 2025-11-13 16:23:46 +08:00
f406416698 1 2025-11-13 15:34:56 +08:00
debbe2777b 1 2025-11-13 14:11:44 +08:00
bc261661ae 2 2025-11-12 21:23:31 +08:00
0aead49816 删除无关代码
退出登录
2025-11-12 19:46:07 +08:00
66b7a9218e 处理\n 2025-11-12 17:55:59 +08:00
2f4205ad1a 3 2025-11-12 16:49:19 +08:00
fea22aecab 重构了KBFunctionView 2025-11-12 16:03:30 +08:00
62f3ddae4a 删除测试数据 2025-11-12 15:40:30 +08:00
c317afc0fe 2 2025-11-12 15:31:22 +08:00
1dbe04cdf9 2 2025-11-12 14:36:15 +08:00
afc44cb471 1 2025-11-12 14:18:56 +08:00
39d8b3d547 1 2025-11-12 13:43:48 +08:00
f387b95d0d 测试假数据 2025-11-11 21:48:26 +08:00
1d064c1f31 1 2025-11-11 20:24:13 +08:00
3440cc4773 1 2025-11-11 19:39:33 +08:00
20b13bcffa 1 2025-11-11 17:36:12 +08:00
105e2ddf9b 1 2025-11-11 16:46:05 +08:00
a1a38d821c 添加复制 2025-11-11 15:59:19 +08:00
83987db5ac fix ui 2025-11-11 15:55:52 +08:00
d10114572e name pop 2025-11-11 15:28:22 +08:00
e34288ae56 fix ui 2025-11-11 15:13:43 +08:00
17b8bf2bfd fix 2025-11-11 14:56:57 +08:00
57bd4ba109 添加弹窗 2025-11-11 14:38:38 +08:00
e4ba237a00 1 2025-11-11 14:02:36 +08:00
9059a24637 1 2025-11-10 21:33:00 +08:00
dc0c55c495 2 2025-11-10 20:40:11 +08:00
a007a77db9 fix color 2025-11-10 19:55:50 +08:00
3eb3a86376 2 2025-11-10 19:51:23 +08:00
1dc9560a1f 1 2025-11-10 19:22:31 +08:00
8069b08fab Merge branch 'dev_st'
# Conflicts:
#	keyBoard.xcodeproj/project.pbxproj
解决冲突
2025-11-10 16:12:38 +08:00
2c8142c0d2 增加webview页面 2025-11-10 16:09:47 +08:00
9f4110b24a 添加弹窗 2025-11-10 15:55:36 +08:00
1cdc17b710 1 2025-11-10 15:38:30 +08:00
97316c7989 添加底部view 2025-11-10 15:29:21 +08:00
fac5e7657c Merge branch 'dev_st' 2025-11-10 13:27:44 +08:00
50dcb78417 提交 2025-11-10 13:27:26 +08:00
998fa7aa67 修改项目配置,锦支持iPhone 2025-11-10 13:25:39 +08:00
5e1a1f540e 1 2025-11-09 21:41:35 +08:00
2415e97c97 fix 2025-11-09 21:05:03 +08:00
aa71cc3c4f 1 2025-11-09 20:54:30 +08:00
e5ddcc4308 添加LYEmptyView '~> 0.3.10' 2025-11-09 20:54:14 +08:00
883b222254 fix 2025-11-09 18:07:47 +08:00
dc9ee10023 1 2025-11-09 17:07:43 +08:00
2c4a4329ff 2 2025-11-09 16:05:42 +08:00
553238de0c 1 2025-11-09 15:59:18 +08:00
80b6102673 1 2025-11-09 14:58:44 +08:00
705b0f374e 1 2025-11-09 14:44:31 +08:00
5bdc7ddec0 1 2025-11-09 14:26:02 +08:00
5d2a3de2f4 fix 2025-11-09 13:56:13 +08:00
675a9f6d64 3 2025-11-08 22:25:57 +08:00
41b14ceea4 3 2025-11-08 21:44:41 +08:00
a729396401 3 2025-11-08 20:49:05 +08:00
3b0beb52da 2 2025-11-08 20:04:50 +08:00
faeb930fe3 1 2025-11-08 11:48:06 +08:00
9a39c29e88 2 2025-11-07 22:22:41 +08:00
b23c9a678b 处理所有UI 2025-11-07 21:57:42 +08:00
96cd32ed99 添加箭头 2025-11-07 21:37:31 +08:00
50dd53b0c0 1 2025-11-07 21:05:25 +08:00
48a12f0919 1 2025-11-07 20:58:14 +08:00
91d754b389 1 2025-11-07 19:55:11 +08:00
450798c8bd 2 2025-11-07 19:33:54 +08:00
c3acc11f6a 1 2025-11-07 19:32:02 +08:00
d592c9f12e 1 2025-11-07 16:58:33 +08:00
26e39ce416 1 2025-11-07 16:46:08 +08:00
32521208a0 2 2025-11-07 16:29:15 +08:00
6e969648c6 1 2025-11-07 15:03:45 +08:00
074596ebcb 添加homeheadView 2025-11-07 14:21:03 +08:00
f0542c11c8 修改三方库 JXCategoryIndicatorCell self.contentView.layer.cornerRadius = 4; 2025-11-07 00:12:28 +08:00
0fa3d10284 9 2025-11-06 21:38:58 +08:00
0d13192723 优化ui 2025-11-06 19:51:50 +08:00
a72aae84ef 处理tabbar
处理HomeRankContentVC去除弹性效果 - 适配HWPanModal下拉效果
2025-11-06 19:29:52 +08:00
6ba1339c0b 修改UI 2025-11-06 19:19:12 +08:00
a75afbe4c1 创建nav宏
去除tabbar透明
2025-11-06 16:57:28 +08:00
1f45564539 5 2025-11-06 16:05:28 +08:00
41aec6b89e 4 2025-11-06 15:16:03 +08:00
a1db745b6c 3 2025-11-06 14:59:00 +08:00
15fc9621cd 2 2025-11-06 14:02:22 +08:00
d7874829d9 1 2025-11-06 13:18:27 +08:00
abf32e8457 添加HWPanModal和FLAnimatedImage 2025-11-05 22:04:56 +08:00
efdcf60ed1 在非刘海添加地球 2025-11-05 20:11:10 +08:00
7a1b17d060 处理键盘不能拉起主app的问题 2025-11-05 18:10:56 +08:00
f43f94b94d 添加键盘背景 2025-11-04 21:01:46 +08:00
3e2dc4bcb6 1 2025-11-04 16:37:24 +08:00
6fb9e56720 修改项目方向设置 2025-11-04 13:55:45 +08:00
1080 changed files with 101942 additions and 3162 deletions

View File

@@ -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.

View File

@@ -2,10 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>keychain-access-groups</key> <key>com.apple.developer.associated-domains</key>
<array> <array>
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string> <string>applinks:app.tknb.net</string>
</array> </array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.loveKey.nyx</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kbkeyboardAppExtension</string>
</array>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionAttributes</key> <key>NSExtensionAttributes</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View 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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -14,14 +14,42 @@
#import "Masonry.h" #import "Masonry.h"
#import "KBAuthManager.h" #import "KBAuthManager.h"
#import "KBFullAccessManager.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) UIButton *nextKeyboardButton; //
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 0 @property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 0
@property (nonatomic, strong) KBFunctionView *functionView; // 0 @property (nonatomic, strong) KBFunctionView *functionView; // 0
@property (nonatomic, strong) KBSettingView *settingView; // @property (nonatomic, strong) KBSettingView *settingView; //
@property (nonatomic, strong) UIImageView *bgImageView; //
@property (nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
@end @end
@implementation KeyboardViewController @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) { __unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
// 访 UI // 访 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 { - (void)setupUI {
// self.view.translatesAutoresizingMaskIntoConstraints = NO;
[self.view.heightAnchor constraintEqualToConstant:KEYBOARDHEIGHT].active = YES;
// /
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.functionView.hidden = YES;
[self.view addSubview:self.functionView]; [self.view addSubview:self.functionView];
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) { [self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view); make.left.right.equalTo(self.view);
make.top.equalTo(self.view).offset(4); make.top.equalTo(self.view).offset(0);
make.bottom.equalTo(self.view.mas_bottom).offset(-4); make.bottom.equalTo(self.view).offset(0);
}]; }];
[self.view addSubview:self.keyBoardMainView]; [self.view addSubview:self.keyBoardMainView];
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view); make.left.right.equalTo(self.view);
make.top.equalTo(self.view).offset(4); make.top.equalTo(self.view).offset(0);
make.bottom.equalTo(self.view.mas_bottom).offset(-4); make.bottom.equalTo(self.view.mas_bottom).offset(-0);
}]; }];
} }
@@ -73,6 +143,10 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
self.functionView.hidden = !show; self.functionView.hidden = !show;
self.keyBoardMainView.hidden = show; self.keyBoardMainView.hidden = show;
if (show) {
[self hideSubscriptionPanel];
}
// //
if (show) { if (show) {
[self.view bringSubviewToFront:self.functionView]; [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 // MARK: - KBKeyBoardMainViewDelegate
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key { - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
if (key.type != KBKeyTypeShift && key.type != KBKeyTypeModeChange) {
[[KBBackspaceUndoManager shared] registerNonClearAction];
}
switch (key.type) { switch (key.type) {
case KBKeyTypeCharacter: case KBKeyTypeCharacter:
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break; [self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
@@ -134,7 +265,7 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
case KBKeyTypeGlobe: case KBKeyTypeGlobe:
[self advanceToNextInputMode]; break; [self advanceToNextInputMode]; break;
case KBKeyTypeCustom: case KBKeyTypeCustom:
// AI //
[self showFunctionPanel:YES]; [self showFunctionPanel:YES];
break; break;
case KBKeyTypeModeChange: case KBKeyTypeModeChange:
@@ -147,15 +278,29 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index { - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index {
if (index == 0) { if (index == 0) {
[self showFunctionPanel:YES]; [self showFunctionPanel:YES];
} else { return;
[self showFunctionPanel:NO];
} }
[self showFunctionPanel:NO];
} }
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { - (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
[self showSettingView:YES]; [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 // MARK: - KBFunctionViewDelegate
- (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index { - (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index {
// index == 0 // index == 0
@@ -163,6 +308,36 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
[self showFunctionPanel:NO]; [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 #pragma mark - lazy
- (KBKeyBoardMainView *)keyBoardMainView{ - (KBKeyBoardMainView *)keyBoardMainView{
@@ -188,33 +363,135 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20;
return _settingView; 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 #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 { - (void)onTapSettingsBack {
[self showSettingView:NO]; [self showSettingView:NO];
} }
- (void)dealloc {
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
NULL);
}
// App App // App App
- (void)viewDidAppear:(BOOL)animated { - (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated]; [super viewDidAppear:animated];
if (!_kb_didTriggerLoginDeepLinkOnce) { // if (!_kb_didTriggerLoginDeepLinkOnce) {
_kb_didTriggerLoginDeepLinkOnce = YES; // _kb_didTriggerLoginDeepLinkOnce = YES;
// App // // App
if (!KBAuthManager.shared.isLoggedIn) { // if (!KBAuthManager.shared.isLoggedIn) {
[self kb_tryOpenContainerForLoginIfNeeded]; // [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 { - (void)kb_consumePendingShopSkin {
NSURL *url = [NSURL URLWithString:@"kbkeyboard://login?src=keyboard"]; KBWeakSelf
if (!url) return; [KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle
__weak typeof(self) weakSelf = self; completion:^(BOOL success, NSError * _Nullable error) {
[self.extensionContext openURL:url completionHandler:^(__unused BOOL success) { if (!success) {
// 使 if (error) {
__unused typeof(weakSelf) selfStrong = weakSelf; 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 @end

View 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

View 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

View File

@@ -16,17 +16,35 @@ typedef NS_ENUM(NSInteger, KBKeyType) {
KBKeyTypeSpace, // 空格 KBKeyTypeSpace, // 空格
KBKeyTypeReturn, // 回车/发送 KBKeyTypeReturn, // 回车/发送
KBKeyTypeGlobe, // 系统地球键 KBKeyTypeGlobe, // 系统地球键
KBKeyTypeCustom, // 自定义功能占位 KBKeyTypeCustom, // 自定义功能占位(如 AI/Emoji
KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换 KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换
}; };
FOUNDATION_EXPORT NSString * const KBKeyIdentifierEmojiPanel;
/// 字母键的大小写变体标记(非字母键使用 KBKeyCaseVariantNone
typedef NS_ENUM(NSInteger, KBKeyCaseVariant) {
KBKeyCaseVariantNone = 0,
KBKeyCaseVariantLower = 1,
KBKeyCaseVariantUpper = 2,
};
@interface KBKey : NSObject @interface KBKey : NSObject
@property (nonatomic, assign) KBKeyType type; @property (nonatomic, assign) KBKeyType type;
@property (nonatomic, copy) NSString *title; // 显示标题 @property (nonatomic, copy) NSString *title; // 显示标题
@property (nonatomic, copy) NSString *output; // 字符键插入的文本 @property (nonatomic, copy) NSString *output; // 字符键插入的文本
/// 逻辑按键标识,用于皮肤映射(如 @"letter_q" @"space" @"backspace"
@property (nonatomic, copy, nullable) NSString *identifier;
/// 字母键的大小写变体(便于皮肤为大小写准备不同图)
@property (nonatomic, assign) KBKeyCaseVariant caseVariant;
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output; + (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output;
+ (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type; + (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type;
/// 通用构造方法:用于指定 identifier便于皮肤做精细控制
+ (instancetype)keyWithIdentifier:(nullable NSString *)identifier
title:(NSString *)title
output:(NSString *)output
type:(KBKeyType)type;
@end @end

View File

@@ -5,6 +5,8 @@
#import "KBKey.h" #import "KBKey.h"
NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
@implementation KBKey @implementation KBKey
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output { + (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output {
@@ -12,6 +14,7 @@
k.type = KBKeyTypeCharacter; k.type = KBKeyTypeCharacter;
k.title = title ?: @""; k.title = title ?: @"";
k.output = output ?: title ?: @""; k.output = output ?: title ?: @"";
k.caseVariant = KBKeyCaseVariantNone;
return k; return k;
} }
@@ -20,8 +23,21 @@
k.type = type; k.type = type;
k.title = title ?: @""; k.title = title ?: @"";
k.output = @""; k.output = @"";
k.caseVariant = KBKeyCaseVariantNone;
return k;
}
+ (instancetype)keyWithIdentifier:(NSString *)identifier
title:(NSString *)title
output:(NSString *)output
type:(KBKeyType)type {
KBKey *k = [[KBKey alloc] init];
k.type = type;
k.identifier = identifier;
k.title = title ?: @"";
k.output = output ?: @"";
k.caseVariant = KBKeyCaseVariantNone;
return k; return k;
} }
@end @end

View 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

View 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

View File

@@ -19,8 +19,15 @@ typedef NS_ERROR_ENUM(KBNetworkErrorDomain, KBNetworkError) {
KBNetworkErrorDecodeFailed = 4, KBNetworkErrorDecodeFailed = 4,
}; };
/// 简单的 JSON 回调json 为 NSDictionary/NSArray 或者在非 JSON 情况下返回 NSData /// JSON 回调(扩展侧目前很少使用 JSON可按需扩展
typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error); 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 @interface KBNetworkManager : NSObject
@@ -45,6 +52,12 @@ typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nul
headers:(nullable NSDictionary<NSString *, NSString *> *)headers headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkCompletion)completion; completion:(KBNetworkCompletion)completion;
/// GET 原始二进制数据(不做 JSON 解析)
- (nullable NSURLSessionDataTask *)GETData:(NSString *)path
parameters:(nullable NSDictionary *)parameters
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkDataCompletion)completion;
/// POST JSON 请求jsonBody 会以 application/json 发送 /// POST JSON 请求jsonBody 会以 application/json 发送
- (nullable NSURLSessionDataTask *)POST:(NSString *)path - (nullable NSURLSessionDataTask *)POST:(NSString *)path
jsonBody:(nullable id)jsonBody jsonBody:(nullable id)jsonBody
@@ -54,4 +67,3 @@ typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nul
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -6,7 +6,8 @@
#import "KBNetworkManager.h" #import "KBNetworkManager.h"
#import "AFNetworking.h" #import "AFNetworking.h"
#import "KBAuthManager.h" #import "KBAuthManager.h"
//#import "KBUserSessionManager.h"
#import "KBSignUtils.h"
NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network"; NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
@interface KBNetworkManager () @interface KBNetworkManager ()
@@ -26,37 +27,87 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
if (self = [super init]) { if (self = [super init]) {
_enabled = NO; // _enabled = NO; //
_timeout = 10.0; _timeout = 10.0;
_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]; _baseURL = [NSURL URLWithString:KB_BASE_URL];
} }
return self; return self;
} }
- (void)getSignWithParare:(NSDictionary *)bodyParams{
NSString *appId = @"loveKeyboard";
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; //
NSString *timestamp = [KBSignUtils currentTimestamp];
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
// 1.
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
signParams[@"appId"] = appId;
signParams[@"timestamp"] = timestamp;
signParams[@"nonce"] = nonce;
// body
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
if ([obj isKindOfClass:[NSString class]]) {
signParams[key] = obj;
} else {
signParams[key] = [obj description];
}
}];
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
//
NSMutableDictionary<NSString *, NSString *> *headers =
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
if (sign.length > 0) {
headers[@"X-Sign"] = sign;
}
headers[@"X-App-Id"] = appId;
headers[@"X-Timestamp"] = timestamp;
headers[@"X-Nonce"] = nonce;
// copy
self.defaultHeaders = headers;
}
#pragma mark - Public #pragma mark - Public
- (NSURLSessionDataTask *)GET:(NSString *)path - (NSURLSessionDataTask *)GET:(NSString *)path
parameters:(NSDictionary *)parameters parameters:(NSDictionary *)parameters
headers:(NSDictionary<NSString *,NSString *> *)headers headers:(NSDictionary<NSString *,NSString *> *)headers
completion:(KBNetworkCompletion)completion { completion:(KBNetworkCompletion)completion {
[self getSignWithParare:parameters];
if (![self ensureEnabled:completion]) return nil; if (![self ensureEnabled:completion]) return nil;
NSString *urlString = [self buildURLStringWithPath:path]; NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
// 使 AFHTTPRequestSerializer // 使 AFHTTPRequestSerializer
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
serializer.timeoutInterval = self.timeout; serializer.timeoutInterval = self.timeout;
NSError *serror = nil;
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET" NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
URLString:urlString URLString:urlString
parameters:parameters parameters:parameters
error: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]; [self applyHeaders:headers toMutableRequest:req contentType:nil];
return [self startAFTaskWithRequest:req completion:completion]; return [self startAFJSONTaskWithRequest:req completion:completion];
} }
- (NSURLSessionDataTask *)POST:(NSString *)path - (NSURLSessionDataTask *)POST:(NSString *)path
jsonBody:(id)jsonBody jsonBody:(id)jsonBody
headers:(NSDictionary<NSString *,NSString *> *)headers headers:(NSDictionary<NSString *,NSString *> *)headers
completion:(KBNetworkCompletion)completion { completion:(KBNetworkCompletion)completion {
[self getSignWithParare:jsonBody];
if (![self ensureEnabled:completion]) return nil; if (![self ensureEnabled:completion]) return nil;
NSString *urlString = [self buildURLStringWithPath:path]; NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
@@ -70,14 +121,49 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
error:&error]; error:&error];
if (error) { if (completion) completion(nil, nil, error); return nil; } if (error) { if (completion) completion(nil, nil, error); return nil; }
[self applyHeaders:headers toMutableRequest:req contentType:nil]; [self applyHeaders:headers toMutableRequest:req contentType:nil];
return [self 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 #pragma mark - Core
- (BOOL)ensureEnabled:(KBNetworkCompletion)completion { - (BOOL)ensureEnabled:(KBNetworkCompletion)completion {
if (!self.isEnabled) { if (!self.isEnabled) {
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: @"网络未启用(可能未开启完全访问)"}]; NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
if (completion) completion(nil, nil, e); if (completion) completion(nil, nil, e);
return NO; return NO;
} }
@@ -90,7 +176,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
return path; return path;
} }
if (self.baseURL) { 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 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 { - (void)applyHeaders:(NSDictionary<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
// //
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new]; NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
NSString *token = [KBAuthManager shared].current.accessToken;
if (token.length > 0) {
all[@"auth-token"] = token;
} else {
[all removeObjectForKey:@"auth-token"];
}
NSDictionary *auth = [[KBAuthManager shared] authorizationHeader]; NSDictionary *auth = [[KBAuthManager shared] authorizationHeader];
[auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }]; [auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }];
if (contentType) all[@"Content-Type"] = contentType; if (contentType) all[@"Content-Type"] = contentType;
@@ -105,42 +202,82 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
[all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }]; [all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }];
} }
- (NSURLSessionDataTask *)startAFTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion { - (NSURLSessionDataTask *)startAFJSONTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
// Content-Type JSON // Content-Type JSON
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer]; self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) { NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
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; NSData *data = (NSData *)responseObject;
if (![data isKindOfClass:[NSData class]]) { if (![data isKindOfClass:[NSData class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:@"无数据"}]); if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
return; return;
} }
NSString *ct = nil; NSString *ct = nil;
if ([response isKindOfClass:[NSHTTPURLResponse class]]) { if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"]; ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
} }
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) { if (looksJSON) {
NSError *jsonErr = nil; NSError *jsonErr = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr]; id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
if (jsonErr) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:@"JSON解析失败"}]); return; } if (jsonErr) {
if (completion) completion(json, response, nil); 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 { } else {
if (completion) completion(data, response, nil); if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
} }
}]; }];
[task resume]; [task resume];
return task; return task;
} }
- (NSURLSessionDataTask *)startAFDataTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkDataCompletion)completion {
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
if (error) {
if (completion) completion(nil, response, error);
return;
}
NSData *data = (NSData *)responseObject;
if (![data isKindOfClass:[NSData class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
return;
}
if (completion) completion(data, response, nil);
}];
[task resume];
return task;
}
#pragma mark - AFHTTPSessionManager #pragma mark - AFHTTPSessionManager
- (AFHTTPSessionManager *)manager { - (AFHTTPSessionManager *)manager {
if (!_manager) { if (!_manager) {
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
cfg.timeoutIntervalForRequest = self.timeout; // per-request serializer.timeoutInterval
cfg.timeoutIntervalForResource = MAX(self.timeout, 30.0);
if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; } if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; }
_manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg]; _manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg];
// 使 JSON // 使 JSON
@@ -152,12 +289,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
#pragma mark - Private helpers #pragma mark - Private helpers
- (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion { - (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion {
NSString *msg = @"网络错误"; NSString *msg = KBLocalized(@"Network error");
switch (code) { switch (code) {
case KBNetworkErrorDisabled: msg = @"网络未启用(可能未开启完全访问)"; break; case KBNetworkErrorDisabled: msg = KBLocalized(@"Network disabled (Full Access may be off)"); break;
case KBNetworkErrorInvalidURL: msg = @"无效的URL"; break; case KBNetworkErrorInvalidURL: msg = KBLocalized(@"Invalid URL"); break;
case KBNetworkErrorInvalidResponse: msg = @"无效的响应"; break; case KBNetworkErrorInvalidResponse: msg = KBLocalized(@"Invalid response"); break;
case KBNetworkErrorDecodeFailed: msg = @"解析失败"; break; case KBNetworkErrorDecodeFailed: msg = KBLocalized(@"Parse failed"); break;
default: break; default: break;
} }
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -17,15 +17,22 @@
// 公共配置 // 公共配置
#import "KBConfig.h" #import "KBConfig.h"
#import "KBAPI.h" // 接口路径宏(统一管理)
#import "Masonry.h" #import "Masonry.h"
#import "KBHUD.h" // 复用 App 内的 HUD 封装 #import "KBHUD.h" // 复用 App 内的 HUD 封装
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用) #import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
// 通用链接Universal Links统一配置 // 通用链接Universal Links统一配置
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。 // 配置好 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_LOGIN KB_UL_BASE @"/login"
#define KB_UL_SETTINGS KB_UL_BASE @"/settings" #define KB_UL_SETTINGS KB_UL_BASE @"/settings"
// 在扩展内,启用 URL Bridge仅在显式的用户点击动作中使用
// 这样即便宿主 App如备忘录拒绝 extensionContext 的 openURL仍可通过响应链兜底拉起容器 App。
#ifndef KB_URL_BRIDGE_ENABLE
#define KB_URL_BRIDGE_ENABLE 1
#endif
#endif /* PrefixHeader_pch */ #endif /* PrefixHeader_pch */

Binary file not shown.

Binary file not shown.

View 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";

File diff suppressed because it is too large Load Diff

Binary file not shown.

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,36 @@
//
// KBExtensionAppLauncher.h
// CustomKeyboard
//
// 封装:在键盘扩展中拉起主 AppScheme / 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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; // loadingindex
@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

View 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

View 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

View File

@@ -5,10 +5,15 @@
#import "KBFullAccessGuideView.h" #import "KBFullAccessGuideView.h"
#import "Masonry.h" #import "Masonry.h"
#import "KBResponderUtils.h" // UIInputViewController
#import "KBHUD.h"
#import "KBHostAppLauncher.h"
@interface KBFullAccessGuideView () @interface KBFullAccessGuideView ()
@property (nonatomic, strong) UIControl *backdrop; @property (nonatomic, strong) UIControl *backdrop;
@property (nonatomic, strong) UIView *card; @property (nonatomic, strong) UIView *card;
//
@property (nonatomic, weak) UIInputViewController *ivc;
@end @end
@implementation KBFullAccessGuideView @implementation KBFullAccessGuideView
@@ -42,7 +47,7 @@
}]; }];
UILabel *title = [UILabel new]; UILabel *title = [UILabel new];
title.text = @"开启【允许完全访问】,体验完整功能"; title.text = KBLocalized(@"Turn on Allow Full Access to experience all features");
title.font = [UIFont boldSystemFontOfSize:16]; title.font = [UIFont boldSystemFontOfSize:16];
title.textColor = [UIColor blackColor]; title.textColor = [UIColor blackColor];
title.textAlignment = NSTextAlignmentCenter; title.textAlignment = NSTextAlignmentCenter;
@@ -64,8 +69,8 @@
make.height.mas_equalTo(100); make.height.mas_equalTo(100);
}]; }];
UILabel *row1 = [UILabel new]; row1.text = @"恋爱键盘"; row1.textColor = [UIColor blackColor]; UILabel *row1 = [UILabel new]; row1.text = AppName; row1.textColor = [UIColor blackColor];
UILabel *row2 = [UILabel new]; row2.text = @"允许完全访问"; row2.textColor = [UIColor blackColor]; UILabel *row2 = [UILabel new]; row2.text = KBLocalized(@"Allow Full Access"); row2.textColor = [UIColor blackColor];
[box addSubview:row1]; [box addSubview:row2]; [box addSubview:row1]; [box addSubview:row2];
[row1 mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(box).offset(16); make.top.equalTo(box).offset(14); }]; [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]; UIView *line = [UIView new]; line.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
@@ -93,7 +98,7 @@
UIButton *go = [UIButton buttonWithType:UIButtonTypeSystem]; UIButton *go = [UIButton buttonWithType:UIButtonTypeSystem];
go.backgroundColor = [UIColor blackColor]; go.backgroundColor = [UIColor blackColor];
[go setTitle:@"去开启" forState:UIControlStateNormal]; [go setTitle:KBLocalized(@"Go enable") forState:UIControlStateNormal];
[go setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; [go setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
go.titleLabel.font = [UIFont boldSystemFontOfSize:18]; go.titleLabel.font = [UIFont boldSystemFontOfSize:18];
go.layer.cornerRadius = 12; go.layer.cornerRadius = 12;
@@ -109,7 +114,8 @@
} }
- (void)presentIn:(UIView *)parent { - (void)presentIn:(UIView *)parent {
UIView *container = parent.window ?: parent; if (!parent) return;
UIView *container = parent; // window
self.frame = container.bounds; self.frame = container.bounds;
self.alpha = 0; self.alpha = 0;
[container addSubview:self]; [container addSubview:self];
@@ -125,15 +131,19 @@
+ (void)showInView:(UIView *)parent { + (void)showInView:(UIView *)parent {
if (!parent) return; if (!parent) return;
// // parent
for (UIView *v in (parent.window ?: parent).subviews) { for (UIView *v in parent.subviews) {
if ([v isKindOfClass:[KBFullAccessGuideView class]]) return; 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 { + (void)dismissFromView:(UIView *)parent {
UIView *container = parent.window ?: parent; UIView *container = parent;
if (!container) return;
for (UIView *v in container.subviews) { for (UIView *v in container.subviews) {
if ([v isKindOfClass:[KBFullAccessGuideView class]]) { if ([v isKindOfClass:[KBFullAccessGuideView class]]) {
[(KBFullAccessGuideView *)v dismiss]; [(KBFullAccessGuideView *)v dismiss];
@@ -146,40 +156,21 @@
#pragma mark - Actions #pragma mark - Actions
- (UIInputViewController *)kb_findInputController { // KBResponderUtils.h
UIResponder *res = self; // App访宿 UIApplication + Scheme
while (res) {
if ([res isKindOfClass:[UIInputViewController class]]) {
return (UIInputViewController *)res;
}
res = res.nextResponder;
}
return nil;
}
- (void)onTapGoEnable { - (void)onTapGoEnable {
// 使 UIApplication宿 UIInputViewController *ivc = KBFindInputViewController(self);
// App App 宿 // responder
UIInputViewController *ivc = [self kb_findInputController]; UIResponder *start = ivc.view ?: (UIResponder *)self;
if (!ivc) { [self dismiss]; return; }
// Universal Link scheme // SchemeAppDelegate kbkeyboardAppExtension://settings
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=kb_extension", KB_UL_SETTINGS]]; NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//settings?src=kb_extension", KB_APP_SCHEME]];
void (^fallback)(void) = ^{ BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
NSURL *scheme = [NSURL URLWithString:@"kbkeyboard://settings?src=kb_extension"]; // App openURL if (ok) {
[ivc.extensionContext openURL:scheme completionHandler:^(__unused BOOL ok2) { [self dismiss];
//
[self dismiss];
}];
};
if (ul) {
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
if (ok) { [self dismiss]; }
else { fallback(); }
}];
} else { } else {
fallback(); NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings → General → Keyboard → Keyboards → %@ → Allow Full Access"),AppName];
[KBHUD showInfo:showInfo];
} }
} }
@end @end

View File

@@ -9,7 +9,9 @@
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
/// 功能区顶部的Bar左侧4个按钮右侧3个按钮 /// 功能区顶部的 Bar
/// 左侧App 图标按钮(点击可回到主 App 或打开更多功能)
/// 右侧:升级 VIP 按钮
@class KBFunctionBarView; @class KBFunctionBarView;
@protocol KBFunctionBarViewDelegate <NSObject> @protocol KBFunctionBarViewDelegate <NSObject>
@@ -24,15 +26,15 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, weak, nullable) id<KBFunctionBarViewDelegate> delegate; @property (nonatomic, weak, nullable) id<KBFunctionBarViewDelegate> delegate;
/// 左侧4个按钮(懒加载创建,等宽水平排布 /// 左侧按钮(当前只有一个App 图标
@property (nonatomic, strong, readonly) NSArray<UIButton *> *leftButtons; @property (nonatomic, strong, readonly) NSArray<UIButton *> *leftButtons;
/// 右侧3个按钮(懒加载创建,等宽水平排布,靠右 /// 右侧按钮(当前只有一个:升级 VIP
@property (nonatomic, strong, readonly) NSArray<UIButton *> *rightButtons; @property (nonatomic, strong, readonly) NSArray<UIButton *> *rightButtons;
/// 配置按钮标题(可选) /// 预留的标题配置(目前按钮主要以图片为主,可选)
@property (nonatomic, copy) NSArray<NSString *> *leftTitles; // 默认 @[@"帮回", @"会说", @"话术", @"更多"] @property (nonatomic, copy) NSArray<NSString *> *leftTitles;
@property (nonatomic, copy) NSArray<NSString *> *rightTitles; // 默认 @[@"❤", @"收藏", @"宫格"] @property (nonatomic, copy) NSArray<NSString *> *rightTitles;
@end @end

View File

@@ -7,12 +7,14 @@
#import "KBFunctionBarView.h" #import "KBFunctionBarView.h"
#import "Masonry.h" #import "Masonry.h"
#import "KBResponderUtils.h" // UIInputViewController
@interface KBFunctionBarView () @interface KBFunctionBarView ()
@property (nonatomic, strong) UIView *leftContainer; // @property (nonatomic, strong) UIView *leftContainer; //
@property (nonatomic, strong) UIView *rightContainer; // @property (nonatomic, strong) UIView *rightContainer; //
@property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal; @property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal;
@property (nonatomic, strong) NSArray<UIButton *> *rightButtonsInternal; @property (nonatomic, strong) NSArray<UIButton *> *rightButtonsInternal;
@property (nonatomic, strong) UIButton *globeButtonInternal; //
@end @end
@implementation KBFunctionBarView @implementation KBFunctionBarView
@@ -20,8 +22,9 @@
- (instancetype)initWithFrame:(CGRect)frame{ - (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) { if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor]; self.backgroundColor = [UIColor clearColor];
_leftTitles = @[@"ABC"]; //
_rightTitles = @[@"Upgrade VIP"]; _leftTitles = @[];
_rightTitles = @[];
[self buildUI]; [self buildUI];
} }
return self; return self;
@@ -36,83 +39,63 @@
#pragma mark - UI #pragma mark - UI
- (void)buildUI { - (void)buildUI {
// 便 // +
[self addSubview:self.globeButtonInternal];
[self addSubview:self.leftContainer]; [self addSubview:self.leftContainer];
[self addSubview:self.rightContainer]; [self addSubview:self.rightContainer];
// VIP 使 upgrad_vip_icon 115x35
[self.rightContainer mas_makeConstraints:^(MASConstraintMaker *make) { [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.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.left.equalTo(self.mas_left).offset(12);
make.right.equalTo(self.rightContainer.mas_left).offset(-12);
make.centerY.equalTo(self.mas_centerY); 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.height.mas_equalTo(36);
make.left.equalTo(self.mas_left).offset(12); // kb_refreshGlobeVisibility
}]; }];
// 4 UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
NSMutableArray<UIButton *> *leftBtns = [NSMutableArray arrayWithCapacity:4]; appButton.tag = 100; // index = 0
UIView *prev = nil; UIImage *appImage = [UIImage imageNamed:@"App_icon"];
for (NSInteger i = 0; i < self.leftTitles.count; i++) { [appButton setImage:appImage forState:UIControlStateNormal];
UIButton *btn = [self buildButtonWithTitle:(i < self.leftTitles.count ? self.leftTitles[i] : [NSString stringWithFormat:@"L%ld", (long)i])]; appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
btn.tag = 100 + i; appButton.adjustsImageWhenHighlighted = YES;
[btn addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside]; [appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
[self.leftContainer addSubview:btn]; [self.leftContainer addSubview:appButton];
[btn mas_makeConstraints:^(MASConstraintMaker *make) { [appButton mas_makeConstraints:^(MASConstraintMaker *make) {
if (prev) { make.center.equalTo(self.leftContainer);
make.left.equalTo(prev.mas_right).offset(8); make.width.height.mas_equalTo(34); //
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);
}]; }];
self.leftButtonsInternal = leftBtns.copy; self.leftButtonsInternal = @[appButton];
// N //
NSMutableArray<UIButton *> *rightBtns = [NSMutableArray arrayWithCapacity:3]; [self kb_refreshGlobeVisibility];
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;
} }
#pragma mark - Actions #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 #pragma mark - Lazy
- (UIView *)leftContainer { - (UIView *)leftContainer {
@@ -158,4 +130,56 @@
return _rightContainer; 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 @end

View File

@@ -11,13 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
/// 粘贴提示输入框区域(左侧图标+占位文案,圆角白底) /// 粘贴提示输入框区域(左侧图标+占位文案,圆角白底)
@interface KBFunctionPasteView : UIView @interface KBFunctionPasteView : UIView
@property (nonatomic, strong) UIButton *pasBtn;
/// 左侧图标
@property (nonatomic, strong, readonly) UIImageView *iconView;
/// 提示文案例如点击粘贴TA的话
@property (nonatomic, strong, readonly) UILabel *placeholderLabel;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -7,10 +7,12 @@
#import "KBFunctionPasteView.h" #import "KBFunctionPasteView.h"
#import "Masonry.h" #import "Masonry.h"
#import "KBFont.h"
@interface KBFunctionPasteView () @interface KBFunctionPasteView ()
@property (nonatomic, strong) UIImageView *iconViewInternal; //@property (nonatomic, strong) UIImageView *iconViewInternal;
@property (nonatomic, strong) UILabel *placeholderLabelInternal; //@property (nonatomic, strong) UILabel *placeholderLabelInternal;
@end @end
@implementation KBFunctionPasteView @implementation KBFunctionPasteView
@@ -22,54 +24,64 @@
self.layer.cornerRadius = 12.0; self.layer.cornerRadius = 12.0;
self.layer.masksToBounds = YES; self.layer.masksToBounds = YES;
[self addSubview:self.iconViewInternal]; // [self addSubview:self.iconViewInternal];
[self addSubview:self.placeholderLabelInternal]; // [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; return self;
} }
#pragma mark - Lazy #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 { - (UIButton *)pasBtn{
if (!_iconViewInternal) { if (!_pasBtn) {
_iconViewInternal = [[UIImageView alloc] init]; _pasBtn = [UIButton buttonWithType:UIButtonTypeCustom];
// [_pasBtn setImage:[UIImage imageNamed:@"kb_zt_icon"] forState:UIControlStateNormal];
UILabel *emoji = [[UILabel alloc] init]; [_pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal];
emoji.text = @"📋"; // / [_pasBtn setTitleColor:[UIColor colorWithHex:0x02BEAC] forState:UIControlStateNormal];
emoji.font = [UIFont systemFontOfSize:18]; _pasBtn.titleLabel.font = [KBFont medium:13];
emoji.textAlignment = NSTextAlignmentCenter; _pasBtn.backgroundColor = [UIColor whiteColor];
[_iconViewInternal addSubview:emoji];
[emoji mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(_iconViewInternal);
}];
} }
return _iconViewInternal; return _pasBtn;
}
- (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;
} }
#pragma mark - Expose #pragma mark - Expose
- (UIImageView *)iconView { return self.iconViewInternal; } //- (UIImageView *)iconView { return self.iconViewInternal; }
- (UILabel *)placeholderLabel { return self.placeholderLabelInternal; } //- (UILabel *)placeholderLabel { return self.placeholderLabelInternal; }
@end @end

View File

@@ -6,7 +6,7 @@
// 话术标签Cell左图标+右标题,圆角灰白底 // 话术标签Cell左图标+右标题,圆角灰白底
#import <UIKit/UIKit.h> #import <UIKit/UIKit.h>
#import "KBTagItemModel.h"
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
@interface KBFunctionTagCell : UICollectionViewCell @interface KBFunctionTagCell : UICollectionViewCell
@@ -17,7 +17,13 @@ NS_ASSUME_NONNULL_BEGIN
/// 头像/图标 /// 头像/图标
@property (nonatomic, strong, readonly) UIImageView *iconView; @property (nonatomic, strong, readonly) UIImageView *iconView;
@property (nonatomic, strong) KBTagItemModel *itemModel;
/// 显示/隐藏加载指示(小菊花)
- (void)setLoading:(BOOL)loading;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -9,8 +9,9 @@
#import "Masonry.h" #import "Masonry.h"
@interface KBFunctionTagCell () @interface KBFunctionTagCell ()
@property (nonatomic, strong) UILabel *emojiLabel;
@property (nonatomic, strong) UILabel *titleLabelInternal; @property (nonatomic, strong) UILabel *titleLabelInternal;
@property (nonatomic, strong) UIImageView *iconViewInternal; @property (nonatomic, strong) UIActivityIndicatorView *loadingView;
@end @end
@implementation KBFunctionTagCell @implementation KBFunctionTagCell
@@ -21,53 +22,102 @@
self.contentView.layer.cornerRadius = 12; self.contentView.layer.cornerRadius = 12;
self.contentView.layer.masksToBounds = YES; 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) { // icon + title
make.left.equalTo(self.contentView.mas_left).offset(10); 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.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); make.width.height.mas_equalTo(24);
}]; }];
[self.titleLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.titleLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.iconViewInternal.mas_right).offset(6); make.left.equalTo(self.emojiLabel.mas_right).offset(3);
make.right.equalTo(self.contentView.mas_right).offset(-10); make.top.equalTo(centerContainer.mas_top);
make.centerY.equalTo(self.contentView.mas_centerY); make.bottom.equalTo(centerContainer.mas_bottom);
make.right.equalTo(centerContainer.mas_right);
}]; }];
} }
return self; return self;
} }
- (void)setItemModel:(KBTagItemModel *)itemModel{
_itemModel = itemModel;
self.emojiLabel.text = itemModel.emoji;
self.titleLabelInternal.text = itemModel.characterName;
}
#pragma mark - Lazy #pragma mark - Lazy
- (UIImageView *)iconViewInternal { - (UILabel *)emojiLabel {
if (!_iconViewInternal) { if (!_emojiLabel) {
_iconViewInternal = [[UIImageView alloc] init]; _emojiLabel = [[UILabel alloc] init];
UILabel *emoji = [[UILabel alloc] init]; _emojiLabel.textAlignment = NSTextAlignmentCenter;
emoji.text = @"🙂"; // _emojiLabel.font = [KBFont medium:20];
emoji.textAlignment = NSTextAlignmentCenter; _emojiLabel.adjustsFontSizeToFitWidth = YES;
emoji.font = [UIFont systemFontOfSize:20];
[_iconViewInternal addSubview:emoji];
[emoji mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(_iconViewInternal);
}];
} }
return _iconViewInternal; return _emojiLabel;
} }
- (UILabel *)titleLabelInternal { - (UILabel *)titleLabelInternal {
if (!_titleLabelInternal) { if (!_titleLabelInternal) {
_titleLabelInternal = [[UILabel alloc] init]; _titleLabelInternal = [[UILabel alloc] init];
_titleLabelInternal.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; _titleLabelInternal.font = [KBFont medium:10];
_titleLabelInternal.textColor = [UIColor blackColor]; _titleLabelInternal.textColor = [UIColor colorWithHex:0x1B1F1A];
//
_titleLabelInternal.numberOfLines = 2;
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
} }
return _titleLabelInternal; 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 #pragma mark - Expose
- (UILabel *)titleLabel { return self.titleLabelInternal; } - (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 @end

View File

@@ -11,6 +11,9 @@
@protocol KBFunctionViewDelegate <NSObject> @protocol KBFunctionViewDelegate <NSObject>
@optional @optional
- (void)functionView:(KBFunctionView *_Nullable)functionView didTapToolActionAtIndex:(NSInteger)index; - (void)functionView:(KBFunctionView *_Nullable)functionView didTapToolActionAtIndex:(NSInteger)index;
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index;
- (void)functionViewDidRequestSubscription:(KBFunctionView *_Nullable)functionView;
@end @end
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
@@ -33,6 +36,9 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, strong, readonly) UIButton *clearButton; // 右侧-清空 @property (nonatomic, strong, readonly) UIButton *clearButton; // 右侧-清空
@property (nonatomic, strong, readonly) UIButton *sendButton; // 右侧-发送 @property (nonatomic, strong, readonly) UIButton *sendButton; // 右侧-发送
/// 应用当前皮肤(更新背景/强调色)
- (void)kb_applyTheme;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -6,6 +6,7 @@
// //
#import "KBFunctionView.h" #import "KBFunctionView.h"
#import "KBResponderUtils.h" // UIInputViewController
#import "KBFunctionBarView.h" #import "KBFunctionBarView.h"
#import "KBFunctionPasteView.h" #import "KBFunctionPasteView.h"
#import "KBFunctionTagCell.h" #import "KBFunctionTagCell.h"
@@ -13,46 +14,121 @@
#import <MBProgressHUD.h> #import <MBProgressHUD.h>
#import "KBFullAccessGuideView.h" #import "KBFullAccessGuideView.h"
#import "KBFullAccessManager.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 () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
// UI // UI
@property (nonatomic, strong) KBFunctionBarView *barViewInternal; @property (nonatomic, strong) KBFunctionBarView *barViewInternal;
@property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal; @property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
@property (nonatomic, strong) UICollectionView *collectionViewInternal; @property (nonatomic, strong) KBFunctionTagListView *tagListView;
@property (nonatomic, strong) UIView *rightButtonContainer; // @property (nonatomic, strong) UIView *rightButtonContainer; //
@property (nonatomic, strong) UIButton *pasteButtonInternal; @property (nonatomic, strong) UIButton *pasteButtonInternal;
@property (nonatomic, strong) UIButton *deleteButtonInternal; @property (nonatomic, strong) UIButton *deleteButtonInternal;
@property (nonatomic, strong) UIButton *clearButtonInternal; @property (nonatomic, strong) UIButton *clearButtonInternal;
@property (nonatomic, strong) UIButton *sendButtonInternal; @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; // loadingindex
@property (nonatomic, copy, nullable) NSString *loadingTagTitle;
@property (nonatomic, assign) BOOL eventSourceDidReceiveDone;
@property (nonatomic, copy, nullable) NSString *eventSourceSplitPrefix;
// Data // 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, strong) NSTimer *pasteboardTimer; // 线
@property (nonatomic, assign) NSInteger lastHandledPBCount; // changeCount @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 @end
@implementation KBFunctionView @implementation KBFunctionView
- (instancetype)initWithFrame:(CGRect)frame { - (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame: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 setupUI];
[self reloadDemoData]; // [self reloadDemoData];
[self kb_reloadTagsFromSharedDefaults];
// //
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount; _lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
// 访访 TCC/XPC
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil];
// App Darwin UL
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
KBULDarwinCallback,
(__bridge CFStringRef)KBDarwinULHandled,
NULL,
CFNotificationSuspensionBehaviorDeliverImmediately);
} }
return self; 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 { - (void)dealloc {
[self stopPasteboardMonitor]; [self stopPasteboardMonitor];
[self kb_stopNetworkStreaming];
[[NSNotificationCenter defaultCenter] removeObserver:self];
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled, NULL);
} }
#pragma mark - UI #pragma mark - UI
@@ -60,19 +136,24 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
- (void)setupUI { - (void)setupUI {
// 1. Bar // 1. Bar
[self addSubview:self.barViewInternal]; [self addSubview:self.barViewInternal];
CGFloat barTopInset = KBFit(6.0f);
CGFloat barHeight = KBFit(52.0f);
[self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self); make.left.right.equalTo(self);
make.top.equalTo(self.mas_top).offset(6); make.top.equalTo(self.mas_top).offset(barTopInset);
make.height.mas_equalTo(48); make.height.mas_equalTo(barHeight);
}]; }];
// //
[self addSubview:self.rightButtonContainer]; [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) { [self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.mas_right).offset(-12); make.right.equalTo(self.mas_right).offset(-rightInset);
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8); make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
make.bottom.equalTo(self.mas_bottom).offset(-10); make.bottom.equalTo(self.mas_bottom).offset(0);
make.width.mas_equalTo(72); make.width.mas_equalTo(containerWidth);
}]; }];
// //
@@ -81,14 +162,12 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
[self.rightButtonContainer addSubview:self.clearButtonInternal]; [self.rightButtonContainer addSubview:self.clearButtonInternal];
[self.rightButtonContainer addSubview:self.sendButtonInternal]; [self.rightButtonContainer addSubview:self.sendButtonInternal];
// // 8px稿
CGFloat smallH = 44; CGFloat smallH = KBFit(41.0f);
CGFloat bigH = 56; CGFloat vSpace = KBFit(8.0f);
CGFloat vSpace = 10;
[self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.rightButtonContainer.mas_top); make.top.equalTo(self.rightButtonContainer.mas_top);
make.left.right.equalTo(self.rightButtonContainer); make.left.right.equalTo(self.rightButtonContainer);
make.height.mas_equalTo(smallH);
}]; }];
[self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace); make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace);
@@ -103,92 +182,497 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
[self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace); make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace);
make.left.right.equalTo(self.rightButtonContainer); make.left.right.equalTo(self.rightButtonContainer);
make.height.mas_equalTo(bigH); make.height.equalTo(self.pasteButtonInternal);
make.bottom.lessThanOrEqualTo(self.rightButtonContainer.mas_bottom); // make.bottom.equalTo(self.rightButtonContainer.mas_bottom);
}]; }];
// 2. // 2.
[self addSubview:self.pasteViewInternal]; [self addSubview:self.pasteViewInternal];
[self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(12); make.left.equalTo(self.mas_left).offset(vSpace);
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12); make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8); make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
make.height.mas_equalTo(48); make.height.mas_equalTo(smallH);
}]; }];
// Paste
[self.pasteViewInternal.pasBtn addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
// 3. CollectionView // 3. Tag List View
[self addSubview:self.collectionViewInternal]; [self addSubview:self.tagListView];
[self.collectionViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { [self.tagListView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(12); make.left.equalTo(self.pasteViewInternal);
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12); make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10); make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(vSpace);
make.bottom.equalTo(self.mas_bottom).offset(-10); make.bottom.equalTo(self.mas_bottom).offset(0);
}]; }];
} }
#pragma mark - Data #pragma mark - Data
- (void)reloadDemoData { //- (void)reloadDemoData {
// // //
self.itemsInternal = @[@"高情商", @"暖味拉扯", @"风趣幽默", @"撩女生", @"社交惬匿", @"情场高手", @"一枚暖男", @"聊天搭子", @"表达爱意", @"更多话术"]; // self.itemsInternal = @[KBLocalized(@"Warm hearted man"),
[self.collectionViewInternal reloadData]; // 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 - (void)kb_onTapStreamDelete {
//
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { [self kb_stopNetworkStreaming];
return self.itemsInternal.count; [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]; - (void)streamOverlayDidTapClose:(KBStreamOverlayView *)overlay {
cell.titleLabel.text = self.itemsInternal[indexPath.item]; [self kb_onTapStreamDelete];
return cell;
} }
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
// #pragma mark - Network Streaming (WJXEventSource)
CGFloat totalW = collectionView.bounds.size.width;
CGFloat space = 10.0; - (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle {
NSInteger columns = 3; [self kb_stopNetworkStreaming];
CGFloat width = floor((totalW - space * (columns - 1)) / columns); if (![[KBFullAccessManager shared] hasFullAccess]) { return; }
return CGSizeMake(width, 48);
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 { - (void)kb_stopNetworkStreaming {
return 10.0; [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 { - (void)kb_handleEventSourceMessage:(WJXEvent *)event {
return 12.0; 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 { - (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:@"处理中…"]; [KBHUD showInfo:KBLocalized(@"处理中…")];
// return;
UIInputViewController *ivc = [self findInputViewController]; UIInputViewController *ivc = KBFindInputViewController(self);
if (!ivc) return; 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]] ?: @""; NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]]; NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]];
if (!ul) return; if (!ul) return;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// extensionContext UL
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) { [ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
if (ok) return; // Universal Link if (ok) {
return;
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"kbkeyboard://login?src=functionView&index=%ld&title=%@", (long)indexPath.item, encodedTitle]]; }
[ivc.extensionContext openURL:scheme completionHandler:^(BOOL ok2) { // UL 宿 UIApplication + Scheme
if (!ok2) { NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
// 访宿 Manager UIResponder *start = ivc.view ?: (UIResponder *)self;
dispatch_async(dispatch_get_main_queue(), ^{ [[KBFullAccessManager shared] ensureFullAccessOrGuideInView: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 // - iOS16+ App
// - iOS15 // - iOS15
// viewDidLoad // viewDidLoad
// + 访访
if (![[KBFullAccessManager shared] hasFullAccess]) {
// 访
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
return;
}
UIPasteboard *pb = [UIPasteboard generalPasteboard]; UIPasteboard *pb = [UIPasteboard generalPasteboard];
NSString *text = pb.string; // NSString *text = pb.string; //
if (text.length > 0) { if (text.length <= 0) {
//
self.pasteView.placeholderLabel.text = text;
//
// self.pasteView.placeholderLabel.numberOfLines = 0;
} else {
// //
NSLog(@"粘贴板无可用文本或未授权粘贴"); 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 - #pragma mark -
@@ -224,8 +721,10 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
// - / changeCount // - / changeCount
- (void)startPasteboardMonitor { - (void)startPasteboardMonitor {
// 访宿/
if (![[KBFullAccessManager shared] hasFullAccess]) return;
if (self.pasteboardTimer) return; if (self.pasteboardTimer) return;
__weak typeof(self) weakSelf = self; KBWeakSelf
self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) { self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return; __strong typeof(weakSelf) self = weakSelf; if (!self) return;
UIPasteboard *pb = [UIPasteboard generalPasteboard]; UIPasteboard *pb = [UIPasteboard generalPasteboard];
@@ -235,9 +734,8 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
// iOS16+ // iOS16+
NSString *text = pb.string; 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 { - (void)didMoveToWindow {
[super didMoveToWindow]; [super didMoveToWindow];
if (self.window && !self.isHidden) { [self kb_refreshPasteboardMonitor];
[self startPasteboardMonitor];
} else {
[self stopPasteboardMonitor];
}
} }
- (void)setHidden:(BOOL)hidden { - (void)setHidden:(BOOL)hidden {
BOOL wasHidden = self.isHidden; BOOL wasHidden = self.isHidden;
[super setHidden:hidden]; [super setHidden:hidden];
if (wasHidden != hidden) { if (wasHidden != hidden) {
if (!hidden && self.window) { [self kb_refreshPasteboardMonitor];
[self startPasteboardMonitor];
} else {
[self stopPasteboardMonitor];
}
} }
} }
- (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(@"点击:删除"); NSLog(@"点击:删除");
UIInputViewController *ivc = [self findInputViewController]; [[KBBackspaceUndoManager shared] registerNonClearAction];
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy; id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy deleteBackward]; [proxy deleteBackward];
} }
- (void)onTapClear { - (void)onTapClear {
NSLog(@"点击:清空"); NSLog(@"点击:清空");
// pasteView [self.backspaceHandler performClearAction];
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;
}
} }
- (void)onTapSend { - (void)onTapSend {
NSLog(@"点击:发送"); NSLog(@"点击:发送");
[[KBBackspaceUndoManager shared] registerNonClearAction];
// App // App
UIInputViewController *ivc = [self findInputViewController]; UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy; id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy insertText:@"\n"]; [proxy insertText:@"\n"];
} }
@@ -318,6 +811,9 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
- (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index { - (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index {
// / // /
if ([self.delegate respondsToSelector:@selector(functionView:didRightTapToolActionAtIndex:)]) {
[self.delegate functionView:self didRightTapToolActionAtIndex:index];
}
} }
- (KBFunctionPasteView *)pasteViewInternal { - (KBFunctionPasteView *)pasteViewInternal {
@@ -327,17 +823,12 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
return _pasteViewInternal; return _pasteViewInternal;
} }
- (UICollectionView *)collectionViewInternal { - (KBFunctionTagListView *)tagListView {
if (!_collectionViewInternal) { if (!_tagListView) {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; _tagListView = [[KBFunctionTagListView alloc] init];
layout.sectionInset = UIEdgeInsetsZero; // _tagListView.delegate = (id)self;
_collectionViewInternal = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
_collectionViewInternal.backgroundColor = [UIColor clearColor];
_collectionViewInternal.dataSource = self;
_collectionViewInternal.delegate = self;
[_collectionViewInternal registerClass:[KBFunctionTagCell class] forCellWithReuseIdentifier:kKBFunctionTagCellId];
} }
return _collectionViewInternal; return _tagListView;
} }
- (UIView *)rightButtonContainer { - (UIView *)rightButtonContainer {
@@ -349,11 +840,11 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
} }
- (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color { - (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem]; UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.backgroundColor = color; btn.backgroundColor = color;
btn.layer.cornerRadius = 12.0; btn.layer.cornerRadius = 8.0;
btn.layer.masksToBounds = YES; btn.layer.masksToBounds = YES;
btn.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; btn.titleLabel.font = [KBFont medium:13];
[btn setTitle:title forState:UIControlStateNormal]; [btn setTitle:title forState:UIControlStateNormal];
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; [btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
return btn; return btn;
@@ -361,7 +852,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
- (UIButton *)pasteButtonInternal { - (UIButton *)pasteButtonInternal {
if (!_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]; [_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
} }
return _pasteButtonInternal; return _pasteButtonInternal;
@@ -369,27 +860,26 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
- (UIButton *)deleteButtonInternal { - (UIButton *)deleteButtonInternal {
if (!_deleteButtonInternal) { if (!_deleteButtonInternal) {
// _deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
_deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem]; _deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
_deleteButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0]; _deleteButtonInternal.layer.cornerRadius = 8.0;
_deleteButtonInternal.layer.cornerRadius = 12.0;
_deleteButtonInternal.layer.masksToBounds = YES; _deleteButtonInternal.layer.masksToBounds = YES;
_deleteButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; [_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal];
[_deleteButtonInternal setTitle:@"删除" forState:UIControlStateNormal];
[_deleteButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside]; [_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside];
[self.backspaceHandler bindDeleteButton:_deleteButtonInternal showClearLabel:NO];
} }
return _deleteButtonInternal; return _deleteButtonInternal;
} }
- (UIButton *)clearButtonInternal { - (UIButton *)clearButtonInternal {
if (!_clearButtonInternal) { if (!_clearButtonInternal) {
_clearButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem]; _clearButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
_clearButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0]; _clearButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
_clearButtonInternal.layer.cornerRadius = 12.0; _clearButtonInternal.layer.cornerRadius = 8.0;
_clearButtonInternal.layer.masksToBounds = YES; _clearButtonInternal.layer.masksToBounds = YES;
_clearButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; _clearButtonInternal.titleLabel.font = [KBFont medium:13];
[_clearButtonInternal setTitle:@"清空" forState:UIControlStateNormal]; [_clearButtonInternal setTitle:KBLocalized(@"Clear") forState:UIControlStateNormal];
[_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; [_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside]; [_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside];
} }
@@ -398,16 +888,17 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
- (UIButton *)sendButtonInternal { - (UIButton *)sendButtonInternal {
if (!_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]; [_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside];
} }
return _sendButtonInternal; return _sendButtonInternal;
} }
#pragma mark - Expose #pragma mark - Expose
- (UICollectionView *)collectionView { return self.collectionViewInternal; } - (UICollectionView *)collectionView { return self.tagListView.collectionView; }
- (NSArray<NSString *> *)items { return self.itemsInternal; } //- (NSArray<NSString *> *)items { return self.itemsInternal; }
- (KBFunctionBarView *)barView { return self.barViewInternal; } - (KBFunctionBarView *)barView { return self.barViewInternal; }
- (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; } - (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; }
- (UIButton *)pasteButton { return self.pasteButtonInternal; } - (UIButton *)pasteButton { return self.pasteButtonInternal; }
@@ -417,16 +908,6 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
#pragma mark - Find Owner Controller #pragma mark - Find Owner Controller
// 宿 UIInputViewControllerKeyboardViewController // KBResponderUtils.h
- (UIInputViewController *)findInputViewController {
UIResponder *responder = self;
while (responder) {
if ([responder isKindOfClass:[UIInputViewController class]]) {
return (UIInputViewController *)responder;
}
responder = responder.nextResponder;
}
return nil;
}
@end @end

View File

@@ -23,11 +23,22 @@ NS_ASSUME_NONNULL_BEGIN
/// 点击了右侧设置按钮 /// 点击了右侧设置按钮
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView; - (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView;
/// 点击了撤销删除按钮
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView;
/// emoji 视图里选择了一个表情
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji;
/// emoji 面板点击搜索
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
@end @end
@interface KBKeyBoardMainView : UIView @interface KBKeyBoardMainView : UIView
@property (nonatomic, weak) id<KBKeyBoardMainViewDelegate> delegate; @property (nonatomic, weak) id<KBKeyBoardMainViewDelegate> delegate;
/// 应用当前皮肤(会触发键区重载以应用按键颜色)
- (void)kb_applyTheme;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -10,44 +10,95 @@
#import "KBKeyboardView.h" #import "KBKeyboardView.h"
#import "KBFunctionView.h" #import "KBFunctionView.h"
#import "KBKey.h" #import "KBKey.h"
#import "KBEmojiPanelView.h"
#import "Masonry.h" #import "Masonry.h"
#import "KBSkinManager.h"
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate> @interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate>
@property (nonatomic, strong) KBToolBar *topBar; @property (nonatomic, strong) KBToolBar *topBar;
@property (nonatomic, strong) KBKeyboardView *keyboardView; @property (nonatomic, strong) KBKeyboardView *keyboardView;
@property (nonatomic, strong) KBEmojiPanelView *emojiView;
@property (nonatomic, assign) BOOL emojiPanelVisible;
// / // /
@end @end
@implementation KBKeyBoardMainView @implementation KBKeyBoardMainView
- (instancetype)initWithFrame:(CGRect)frame { - (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) { if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0]; self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
// //
self.topBar = [[KBToolBar alloc] init]; self.topBar = [[KBToolBar alloc] init];
self.topBar.delegate = self; self.topBar.delegate = self;
[self addSubview:self.topBar]; [self addSubview:self.topBar];
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) { // /
make.left.right.equalTo(self); CGFloat keyboardAreaHeight = KBFit(200.0f);
make.top.equalTo(self.mas_top).offset(6); CGFloat bottomInset = KBFit(4.0f);
make.height.mas_equalTo(40); CGFloat barSpacing = KBFit(6.0f);
}];
//
self.keyboardView = [[KBKeyboardView alloc] init]; self.keyboardView = [[KBKeyboardView alloc] init];
self.keyboardView.delegate = self; self.keyboardView.delegate = self;
[self addSubview:self.keyboardView]; [self addSubview:self.keyboardView];
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) { [self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self); make.left.right.equalTo(self);
make.top.equalTo(self.topBar.mas_bottom).offset(4); make.height.mas_equalTo(keyboardAreaHeight);
make.bottom.equalTo(self.mas_bottom).offset(-4); 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; 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 #pragma mark - KBToolBarDelegate
@@ -64,6 +115,12 @@
} }
} }
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar {
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapUndo:)]) {
[self.delegate keyBoardMainViewDidTapUndo:self];
}
}
#pragma mark - KBKeyboardViewDelegate #pragma mark - KBKeyboardViewDelegate
- (void)keyboardView:(KBKeyboardView *)keyboard didTapKey:(KBKey *)key { - (void)keyboardView:(KBKeyboardView *)keyboard didTapKey:(KBKey *)key {
@@ -99,12 +156,15 @@
[self.delegate keyBoardMainView:self didTapKey:key]; [self.delegate keyBoardMainView:self didTapKey:key];
} }
break; break;
case KBKeyTypeCustom: case KBKeyTypeCustom: {
// if ([key.identifier isEqualToString:KBKeyIdentifierEmojiPanel]) {
[self toggleEmojiPanel];
break;
}
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didTapKey:)]) { if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didTapKey:)]) {
[self.delegate keyBoardMainView:self didTapKey:key]; [self.delegate keyBoardMainView:self didTapKey:key];
} }
break; } break;
case KBKeyTypeShift: case KBKeyTypeShift:
// Shift KBKeyboardView // Shift KBKeyboardView
break; break;
@@ -114,5 +174,47 @@
// //
// KeyboardViewController // 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 @end

View File

@@ -10,6 +10,7 @@
@interface KBKeyButton : UIButton @interface KBKeyButton : UIButton
@property (nonatomic, strong) KBKey *key; @property (nonatomic, strong) KBKey *key;
@property (nonatomic, strong) UIImageView *iconView;
/// 配置基础样式(背景、圆角等)。创建按钮时调用。 /// 配置基础样式(背景、圆角等)。创建按钮时调用。
- (void)applyDefaultStyle; - (void)applyDefaultStyle;
@@ -17,4 +18,7 @@
/// 根据选中/高亮等状态刷新外观 /// 根据选中/高亮等状态刷新外观
- (void)refreshStateAppearance; - (void)refreshStateAppearance;
/// 根据当前皮肤与按键标识,应用图标和文字显隐等细节
- (void)applyThemeForCurrentKey;
@end @end

View File

@@ -5,37 +5,114 @@
#import "KBKeyButton.h" #import "KBKeyButton.h"
#import "KBKey.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 @implementation KBKeyButton
- (instancetype)initWithFrame:(CGRect)frame { - (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame: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]; [self applyDefaultStyle];
} }
return self; return self;
} }
- (void)applyDefaultStyle { - (void)applyDefaultStyle {
self.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; // self.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold];
[self setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; KBSkinTheme *t = [KBSkinManager shared].current;
[self setTitleColor:[UIColor blackColor] forState:UIControlStateHighlighted]; [self setTitleColor:t.keyTextColor forState:UIControlStateNormal];
self.backgroundColor = [UIColor whiteColor]; [self setTitleColor:t.keyTextColor forState:UIControlStateHighlighted];
// normalImageView
self.backgroundColor = [UIColor clearColor];
self.layer.cornerRadius = 6.0; // self.layer.cornerRadius = 6.0; //
self.layer.masksToBounds = NO; self.layer.masksToBounds = NO;
self.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.1].CGColor; // self.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.1].CGColor; //
self.layer.shadowOpacity = 1.0; self.layer.shadowOpacity = 1.0;
self.layer.shadowOffset = CGSizeMake(0, 1); self.layer.shadowOffset = CGSizeMake(0, 1);
self.layer.shadowRadius = 1.5; self.layer.shadowRadius = 1.5;
// 使
[self refreshStateAppearance]; [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 { - (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted]; [super setHighlighted:highlighted];
// //
if (self.isSelected) { //
self.alpha = 1.0; CGFloat scale = highlighted ? 0.9 : 1.0; // 0.9~0.95
} else {
self.alpha = highlighted ? 0.2 : 1.0; // 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 { - (void)refreshStateAppearance {
// Shift/CapsLock // Shift/CapsLock
KBSkinTheme *t = [KBSkinManager shared].current;
UIColor *base = nil;
if (self.isSelected) { if (self.isSelected) {
self.backgroundColor = [UIColor colorWithWhite:0.85 alpha:1.0]; base = t.keyHighlightBackground ?: t.keyBackground;
} else { } 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 @end

View 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

Some files were not shown because too many files have changed in this diff Show More