Compare commits
761 Commits
v1.0.2
...
feat/js-di
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d98e8274 | |||
| 09a1d2821c | |||
|
|
7842b5a1ac | ||
|
|
079f574e2f | ||
|
|
b08f11f6ac | ||
|
|
cd925df125 | ||
|
|
8c87c4a906 | ||
|
|
c234a53518 | ||
| 49fd64358c | |||
| 3426d707fe | |||
| ebc106d45b | |||
| 0cda9a2921 | |||
| 0eb5d76687 | |||
| 29d25f7fcd | |||
| 7d60e78f27 | |||
|
|
e93b56a008 | ||
|
|
d10873a903 | ||
|
|
2d27f7d650 | ||
| e1fbdfbd50 | |||
| 0a5b70b161 | |||
|
|
5a76a10fb2 | ||
| 9173665afe | |||
|
|
f09e766a8a | ||
| e0ea449c17 | |||
| c438a84537 | |||
| 8c625e212a | |||
| ab786ed2ab | |||
| d9303aab2e | |||
|
|
b7f79476c8 | ||
|
|
44bcce4385 | ||
|
|
6ce6066de2 | ||
|
|
7fa48cec29 | ||
| e549a18dbf | |||
| c17c4abb5b | |||
| af57bc31b1 | |||
| 16449a1440 | |||
| a7c1983f35 | |||
| 4c257d7178 | |||
| 3a9d634edf | |||
|
|
e179c8f67f | ||
|
|
c4b85471c1 | ||
|
|
a898b57d96 | ||
| 50c6bec4cd | |||
|
|
8c44f83d6c | ||
|
|
103b6b2832 | ||
| 4129349c70 | |||
| 77a9aa5457 | |||
| 97940b9492 | |||
| 7945c0e54f | |||
| dfee65c3af | |||
| fa2dbd79f6 | |||
| 9a9f539906 | |||
| d7331f36e9 | |||
|
|
d0b76de465 | ||
| 894a922b8f | |||
| a91d7fff2d | |||
|
|
926a3a530e | ||
| d308c2ac60 | |||
| ac13807ef4 | |||
| 38a5b2b8cf | |||
| 3a7c8d5e38 | |||
|
|
ce0d10aeb2 | ||
|
|
0ac857ef9a | ||
| 3928f5afe7 | |||
| 8a61a4750b | |||
|
|
1bc3fef47b | ||
|
|
4dac132bee | ||
|
|
7c60c00962 | ||
| 9d8ade6fe0 | |||
| 6245399810 | |||
| c074e7f9d1 | |||
| f822e198ea | |||
| 7035f11eb5 | |||
| f2f5a4f573 | |||
| 2acf234f7d | |||
| 9ed8f351c7 | |||
| 7c35dc7cf7 | |||
|
|
17b8b9ea8f | ||
| ccb03343f4 | |||
|
|
951bcae603 | ||
|
|
0b9de68c86 | ||
|
|
81b27fd941 | ||
|
|
b9817ec030 | ||
|
|
5ebb554e54 | ||
|
|
d5d72911ed | ||
|
|
838d5c9c3e | ||
| 23ee79fe9d | |||
|
|
85baac657a | ||
|
|
cceca6b96f | ||
|
|
b5b0dc85e3 | ||
|
|
50044c4372 | ||
|
|
5fd7f1b880 | ||
|
|
058fde3f5a | ||
|
|
a2d46123dd | ||
|
|
01acc4f9de | ||
|
|
856aae0769 | ||
|
|
8eda8adcc8 | ||
| defd4b8624 | |||
| b2a164e066 | |||
| a46ceebf19 | |||
| cc08445f13 | |||
| 93f7f72d07 | |||
| 20f7ab4866 | |||
| 54363919cd | |||
| 182a821fc5 | |||
| 8868c6edb3 | |||
|
|
fffbb4ed23 | ||
|
|
b057be0311 | ||
|
|
fc5fed1707 | ||
|
|
8525f5318f | ||
|
|
d58cafc4a0 | ||
| 23afafd1d6 | |||
|
|
3b6e0adbbb | ||
| 20a57c7a36 | |||
| 665f50ed2a | |||
| 55733ef505 | |||
| 0c46214619 | |||
| 749a1a47fb | |||
| 76e9ef87d4 | |||
| dcd6466547 | |||
| ed70fdba93 | |||
| ded0068ea6 | |||
|
|
7dc6be622a | ||
|
|
88f093f7e5 | ||
| 8f357b3e6c | |||
| 9ee82975e8 | |||
|
|
9f048685e4 | ||
|
|
bc1f5e11b5 | ||
| 1f2147ef72 | |||
| fba365fd93 | |||
| a5e3fbaee5 | |||
| 190e645a12 | |||
|
|
8a83ff5367 | ||
| 6e14942dab | |||
| 146fc70143 | |||
| b37ea01aca | |||
| bf7b90313a | |||
| 929c1a9d91 | |||
| 9ff68d0701 | |||
| dfd15ed34a | |||
|
|
dfe2a0db6a | ||
| c6714f79b6 | |||
| 552a42fb27 | |||
| af456c52f1 | |||
| f38129133a | |||
| 17e2696ca4 | |||
| 9d6999af33 | |||
| ae5548918c | |||
| 92d22c977c | |||
| 8cc3702e1a | |||
| 3131ce52a7 | |||
| 62e4056f4a | |||
| a29a7cbaf3 | |||
| 7bdab7ade7 | |||
| ea99e87afb | |||
| 0d3fde9457 | |||
| aa9f4dae82 | |||
| 6877aa120f | |||
| d25d72a5f7 | |||
|
|
97768b4945 | ||
| 2481780ab3 | |||
|
|
49481bfa6a | ||
| 211850d73e | |||
| fcf0334d55 | |||
| aa8eec5792 | |||
| 6eb0060dd6 | |||
| c096f5a2d8 | |||
| 554b9f2a77 | |||
| f87afbe397 | |||
| 6ff30f8ac3 | |||
| 118941f239 | |||
| d91bca6913 | |||
| 463ad5b5bc | |||
| 971fc1da92 | |||
| 37af7e266a | |||
| 276e23354d | |||
| 3da00595b7 | |||
|
|
d3c115ee0c | ||
| dcc94c5b3d | |||
| a116b5b615 | |||
| 05fcb23a4d | |||
| daa6e8ce18 | |||
| 8665994572 | |||
| 90441af989 | |||
| 7631fab86b | |||
| cd9b07bb3e | |||
| 6c179ceb95 | |||
| ec48dbef57 | |||
| cd1cc1229e | |||
|
|
bda299e1f8 | ||
|
|
78ea129564 | ||
|
|
f3b4598bb6 | ||
|
|
7bc4c69a32 | ||
|
|
a8e55e0151 | ||
|
|
fddd959545 | ||
|
|
ebf6846bf1 | ||
| 0f2d0bb9f9 | |||
| 48338e4ef7 | |||
| 8d8e345d82 | |||
|
|
fcbf6a6277 | ||
| d83d679eb9 | |||
| d6087e5f59 | |||
| 37371bee6c | |||
| 45fe5f503a | |||
| d440ed6424 | |||
| d812332613 | |||
| dee8d17b1e | |||
|
|
c0d461ebd9 | ||
|
|
45e2a1142a | ||
|
|
533c2b2507 | ||
|
|
29b7e0d646 | ||
| b1870b65d6 | |||
| 1103076009 | |||
| 51739355c8 | |||
| 1b4f67b314 | |||
| d9b23dadf0 | |||
| ba8831caa6 | |||
| 2b1684b0fc | |||
| cd3f09efae | |||
| d05eaf8c7e | |||
| 03628f2afa | |||
|
|
9dae28e366 | ||
|
|
11e66328c4 | ||
|
|
73d4e28ed0 | ||
|
|
169676fd9e | ||
| 332497cf90 | |||
| 5f15c08eef | |||
| 3f6b3152b2 | |||
| f5b3b36acb | |||
| fd8607777e | |||
| fa951cac95 | |||
| 55ad652191 | |||
| 533497ead1 | |||
|
|
00cdc18ddd | ||
|
|
474d9aa6f1 | ||
| ffa0c8f887 | |||
| 0f3f3ea270 | |||
|
|
b752caa079 | ||
| 309df2143b | |||
| 8e964468ea | |||
| ca8f09807b | |||
| 68b214e295 | |||
| 00c0a64de0 | |||
|
|
dbc2c27db0 | ||
| fffb3dc973 | |||
| 0ca8a28639 | |||
| 6426ebaf16 | |||
| 316f61394d | |||
| 04ab75cf92 | |||
| 4828a57e1a | |||
| d089163220 | |||
| 7b5c13200d | |||
| 0f6874f8d7 | |||
| 4af15b9139 | |||
| 9fe49217dc | |||
| 76c56964a5 | |||
| e8afbca7b2 | |||
| 5843d7c919 | |||
|
|
de98dfaa1b | ||
|
|
30cbfb54ef | ||
| c633021963 | |||
|
|
4640831e69 | ||
| af7a7c220e | |||
| fd19f6bf7d | |||
| 96b4125613 | |||
|
|
587c5d8040 | ||
|
|
72730361c8 | ||
| 38d5563534 | |||
| 5a886f7504 | |||
| 1464b7d5e5 | |||
| 5645d805f5 | |||
| 7fe81ae418 | |||
| be0daddd82 | |||
|
|
3efc4794d0 | ||
|
|
4eff50dbed | ||
| f3c191f7f3 | |||
| a014587a94 | |||
| bf51cd5cee | |||
| 3f10473fb6 | |||
| fba49233c8 | |||
| 8adf61b54f | |||
|
|
e829f567e5 | ||
|
|
701573ee19 | ||
|
|
7b601058eb | ||
|
|
24b7319bb5 | ||
|
|
26adfc6c4f | ||
| 6db00eaf71 | |||
|
|
bbf31a4bbe | ||
| 36ab104c81 | |||
| a63d458707 | |||
| 011619340f | |||
| 40b9b5b329 | |||
| edc2cb066b | |||
| bd5d10e919 | |||
| 2b3c7a8564 | |||
|
|
a630771f0b | ||
| ee0da9a26a | |||
| a471e79ef2 | |||
| 26a1d68913 | |||
|
|
d0d27206cd | ||
|
|
90f0c9dab3 | ||
|
|
0c54a9be11 | ||
| 5fb0d2327d | |||
| d73e152cec | |||
| bd53416968 | |||
|
|
c28f4d40c2 | ||
|
|
7994ffb6a4 | ||
| b8e4cc5937 | |||
| 14837e2543 | |||
| afd3bfb7f5 | |||
| d004fcd944 | |||
| 3ff2f6aa36 | |||
| 5c162d2800 | |||
| 198966920e | |||
| 317e0f87e5 | |||
| 562ac9a95b | |||
| 0c7bc78541 | |||
| 94098eea77 | |||
| a2b113ca20 | |||
| c9b7ea97bf | |||
| 23f9763fe8 | |||
| e7aad5f0d1 | |||
| 22c01b4fd0 | |||
| 350bcf4ffc | |||
| d179b39b64 | |||
| ef2e621da2 | |||
| 193f5f73ff | |||
| 2333c6df85 | |||
|
|
455c6c1356 | ||
| bd24cfad46 | |||
| 985e46ff88 | |||
| 31e391ddae | |||
| fec1926774 | |||
|
|
7cd0a20785 | ||
| ed124d0419 | |||
| 14c3e9ea43 | |||
| d2aca7ce44 | |||
| 34194559f5 | |||
| 18c5d5d85a | |||
| 9b1bafcbe1 | |||
| dd7e2d6744 | |||
| 51c2bf0d6f | |||
| 53e5ebbbf6 | |||
| c600d99c58 | |||
| f4804faf52 | |||
| c7d72347a9 | |||
| a4e2d4f6e4 | |||
| 5c7cd7a304 | |||
| 9fb63e47ea | |||
| fc66e8ae2d | |||
| d04c872491 | |||
| 426936082e | |||
| 5129530e56 | |||
| 3735249de6 | |||
|
|
8868a02a7e | ||
|
|
e1b95c9e23 | ||
| 0b65b4ab53 | |||
| df4263f969 | |||
| 17ef17ca5b | |||
|
|
e55c45a589 | ||
| 591f2836d4 | |||
| 8ab4f7a34b | |||
| 614c01872b | |||
| 6be258092a | |||
| ce50812857 | |||
| f0b1135eb7 | |||
|
|
cc0f070df5 | ||
| 35429c132c | |||
| 998d4c31d3 | |||
| 0122bb8f28 | |||
| 33a9fa062b | |||
| 13081332f2 | |||
|
|
cdc6c95579 | ||
|
|
3aca3baafc | ||
| 58d6ccdde1 | |||
| 23404b86f6 | |||
|
|
965187e9de | ||
|
|
24155746f2 | ||
| 340496da30 | |||
| 28a56b4612 | |||
| 4e6f71ef36 | |||
| 739685f60f | |||
| 8c5dae1e59 | |||
| e2c69d882f | |||
| 0b9f0b7d35 | |||
| 9ea749a84a | |||
| d675af3fb4 | |||
| d99a30b7d8 | |||
|
|
3c3c07b6fb | ||
|
|
e688ab759a | ||
|
|
64a3ef352f | ||
| ef8dc9e8d4 | |||
|
|
19af2d79dd | ||
| 5a11168f98 | |||
| 1564156e28 | |||
| 2534c55ffb | |||
| ba4eff66db | |||
| b43d907763 | |||
| f5a814cfe4 | |||
| 24b9bcd86e | |||
| 812b36d1e9 | |||
| bab2578b65 | |||
| 5cf2f9f33a | |||
| 040a5d7ad2 | |||
| 69da66904a | |||
| 11e4d7a9f2 | |||
| 7bd0c2b82a | |||
| 6b0a5184b9 | |||
| 864980079b | |||
| de51b66d39 | |||
| 23205c518d | |||
| 3ae5c7c7f2 | |||
| 312e991935 | |||
| 5184130ff8 | |||
| e555779419 | |||
| 5ef973cbfb | |||
| 8e2520f8e8 | |||
| 87f0f5bb55 | |||
|
|
578c06fdc1 | ||
| 8645dda967 | |||
| ded9055363 | |||
| ff42c726fa | |||
| 53b033258a | |||
| 6ec4817dc1 | |||
| 283afbc6d4 | |||
| c3a09c8870 | |||
| f2388c81e0 | |||
| c334e4fa05 | |||
|
|
cc8277d462 | ||
| e6b7f5b014 | |||
| 1edf284709 | |||
| 6033a3cde9 | |||
| 27e7356721 | |||
| d88ae57320 | |||
| 7b7710b441 | |||
| 63346396e0 | |||
| 51b7df02e7 | |||
|
|
811fbb04dc | ||
|
|
eaf94363ae | ||
| 5e3ff48d35 | |||
| c6ec38632f | |||
| 1c1f418019 | |||
|
|
b6e5035509 | ||
| 52410bac03 | |||
| 0a187cca2e | |||
| dda8d98e85 | |||
| 1abf9c151e | |||
| d9084272e5 | |||
| 16512f2711 | |||
| 481bb97301 | |||
| 950690df48 | |||
| 825ef39605 | |||
| 5f36ef6ea3 | |||
| bfd115046d | |||
| 4c6e4373e9 | |||
| 6467a46e5c | |||
| 0011738820 | |||
| c640e6bfbf | |||
| 5d1d62e157 | |||
| 399b9abaee | |||
|
|
d874920c88 | ||
|
|
213c225e1e | ||
|
|
d55c0aa325 | ||
|
|
2d6e76a5a6 | ||
|
|
2968f1fa29 | ||
| 72228515f6 | |||
|
|
b56f8d7398 | ||
|
|
8375fb721e | ||
| 9876da85da | |||
| 4b19ab57d2 | |||
| 91ee48cc6c | |||
|
|
7495c11944 | ||
| 08e8a45236 | |||
| fb1b017bc9 | |||
| 99a3788f4a | |||
| a747179cc4 | |||
| 1ca8da1c83 | |||
| 8eddab5e13 | |||
| 030007159d | |||
| 43a054c12a | |||
| 51a6456dad | |||
| 3a320feda9 | |||
|
|
a88bbe9ea6 | ||
|
|
5be2dbcfd7 | ||
| 68a203a1c1 | |||
| c06709aeb7 | |||
| 95649ca9fe | |||
| 1e09d69507 | |||
|
|
a5c745f40d | ||
| d27efb180a | |||
| 1f5382ff8c | |||
| 2238fcc68f | |||
| df42cf320c | |||
| eb14f973e4 | |||
| 99454041d3 | |||
| 1ae33c43b1 | |||
| bed30d3cea | |||
| 06f953c1bc | |||
| 0b96d01afb | |||
| 6023e462d7 | |||
| 0e22574002 | |||
| e1b2f83c48 | |||
| e77424e00e | |||
| 9f67cd0d07 | |||
| 6a79f68909 | |||
| aa66111f2c | |||
| ddeaaf0856 | |||
| 18f450a0db | |||
| a217b86c08 | |||
|
|
79d2c91723 | ||
|
|
731510e11d | ||
|
|
b3d3c141f9 | ||
|
|
bea861a83c | ||
|
|
4a595a8aca | ||
|
|
bf634f8654 | ||
|
|
bda215ebb7 | ||
| a70b690d3c | |||
| 0b8ae2d377 | |||
| 24c5a1bb01 | |||
| ea973a2787 | |||
|
|
17bce96143 | ||
|
|
909c0014ac | ||
| eb1abfc02a | |||
|
|
788e41f584 | ||
| 929ec88e84 | |||
| abaeaf4f77 | |||
|
|
a614e83470 | ||
| 8b9fd0d03d | |||
|
|
1964c4c0d5 | ||
| 43d724dd27 | |||
| f9c42aef4b | |||
| 06a6e5156a | |||
|
|
be45a06981 | ||
| 4763b9c7b4 | |||
| 7e608be70f | |||
| 211e6ab8c8 | |||
|
|
100dc6458b | ||
|
|
8dab5f9e88 | ||
| d08383e14b | |||
| a55e4eff67 | |||
| ab3953292b | |||
| b49e0974ab | |||
|
|
b6cccb7749 | ||
|
|
dac07cfac4 | ||
|
|
da12b3bcca | ||
|
|
017f964705 | ||
|
|
bed0f78e81 | ||
|
|
092eb59c10 | ||
|
|
a5d3d160c8 | ||
|
|
d3c3748ce5 | ||
|
|
586874de15 | ||
| bda2c6c2e1 | |||
| e9aa6fcf30 | |||
| 60c6be08c5 | |||
| e4e2d264f5 | |||
| c2cfd066f6 | |||
| d7b91f6a50 | |||
| da025b16ff | |||
| 08e0082186 | |||
| 463805f5ed | |||
| 72b146a9bf | |||
| 1104d28f14 | |||
| cf7be85f29 | |||
| cab66619df | |||
| bdd0724788 | |||
| 617c452e07 | |||
| c8e6e1311c | |||
| 0bdb1299ca | |||
| af9835eb8f | |||
| 4801457e0e | |||
| 0c9f7126a2 | |||
| 3cf9228e2a | |||
| 07f8cd2455 | |||
| 659b211038 | |||
| 4e121748cd | |||
| 14fe901144 | |||
| 835b40860d | |||
| ef435dcaa5 | |||
| e999652a3e | |||
| 425cbed8a1 | |||
|
|
488299bcfb | ||
| b8bdda16c6 | |||
| 1a50b8bc27 | |||
| 546f619063 | |||
|
|
0e831468ee | ||
| a4cc0a3af2 | |||
| 80811bf12d | |||
| 21bf9d72c0 | |||
| 035a84380c | |||
| 5ddb6f47ca | |||
| c1672d01f8 | |||
|
|
66ebdb03b1 | ||
| df2ba6efd1 | |||
| 705c448cfe | |||
| a711335012 | |||
| 305ef9263d | |||
| f8b8811aaa | |||
| a868fe3fff | |||
| 873cbd779e | |||
| d56e3fd59f | |||
| d96b36414d | |||
| b30bd11d1a | |||
|
|
72507d907a | ||
| 9b821f1b46 | |||
| 867b2a4b64 | |||
| 8f07c8a2bb | |||
|
|
7aed61a65e | ||
| 674b5c9636 | |||
| 153f1a9dfe | |||
| 6c5df47663 | |||
| 24188b51c0 | |||
| 070c803f97 | |||
| b425eec561 | |||
|
|
95c98eeaed | ||
| 60f7b4d3b0 | |||
| 2ee2a01550 | |||
| a2f628001a | |||
| de4503a2de | |||
| 30b2aa2f99 | |||
| 2f4927f719 | |||
| 9fb3482474 | |||
| 2063eee82b | |||
| 91b765ffba | |||
| bbfe87fff2 | |||
|
|
430b6eeb3a | ||
|
|
06094fc5fc | ||
|
|
ce48a89cc1 | ||
| f155bed694 | |||
| 1500d2a1d2 | |||
| 2408096a7c | |||
| bf1930cea2 | |||
| 5d99b6ed99 | |||
| e2aceb857d | |||
| 4b32165aae | |||
| 5bc3ddaf26 | |||
| 904e4f1186 | |||
| 511a9fdc09 | |||
| c2b8760d86 | |||
|
|
a1474ca9c3 | ||
|
|
c3474b1dff | ||
|
|
2f290f0c86 | ||
|
|
8b1f13cd33 | ||
| f3aa0e9f27 | |||
| f4b9cb5abe | |||
| 4d55e6a72f | |||
| ad3f2fab45 | |||
| b1cdcc2a91 | |||
| 7fcb63c0cb | |||
| 454497fd65 | |||
|
|
c4aab2369f | ||
| ce175a2135 | |||
| 6aeaeadb10 | |||
|
|
8402c1c9f3 | ||
| ed67bc80ea | |||
|
|
eb3a7f9d52 | ||
|
|
0d77803e8c | ||
| 8db52c9db1 | |||
| ce6f65f912 | |||
| 689700f52a | |||
| 250f458029 | |||
| 1489e6c86d | |||
| b4921c8e14 | |||
| 800b67fb28 | |||
|
|
036474a5d2 | ||
| a1d1f504bd | |||
| 458bc261f3 | |||
| 00af5f1989 | |||
| 9988e76149 | |||
| 213179b8c2 | |||
| 708cf83a32 | |||
| 0ee99a8760 | |||
| 30a1c806cd | |||
|
|
7bc0aeb4af | ||
| 8513a739ec | |||
|
|
d749e7421e | ||
| 165e5f2850 | |||
| edff9c7a0c | |||
|
|
65b41b2873 | ||
|
|
f912e57bfd | ||
|
|
2ef03ad7ae | ||
|
|
47eb597d96 | ||
| 0ac9ee7061 | |||
| dd7154830b | |||
|
|
194abb82de | ||
| a8bc097541 | |||
| d34c7c3806 | |||
|
|
926437b967 | ||
|
|
856ad82c55 | ||
| 81baf53ad4 | |||
| 71b03d744a | |||
| 6f2bac52e4 | |||
| 9fcc306ee0 | |||
| 5d4e8f5b84 | |||
| 9bdcba1270 | |||
| 8e99e94620 | |||
|
|
00bcbaa2eb | ||
|
|
acb9c47657 | ||
| 1636c959d0 | |||
|
|
4ff1140bf6 | ||
|
|
057d6a2f54 | ||
|
|
601ef68ad3 | ||
|
|
c94438d7c4 | ||
|
|
5825f88e78 | ||
|
|
389403c11d | ||
|
|
abd9afad6b | ||
|
|
5119beb1fe | ||
| 9b98075153 | |||
| 775ab471f5 | |||
| 293040f374 | |||
| a427bcdf84 | |||
| c4f531a463 | |||
|
|
6c076bfc7a | ||
|
|
93bf99daa5 | ||
|
|
b3e95d7162 | ||
|
|
c35bf9fb7f | ||
|
|
189dfe5a43 | ||
|
|
53b9bc79dd | ||
|
|
bc4e0f79a5 | ||
| 05bbef0b8a | |||
| e1df69e785 | |||
| a0e3cc720a | |||
| 6ae3e50a5b | |||
|
|
7cf55fcb8e | ||
|
|
d875681c4b | ||
| 193ecdb765 | |||
| ea3cc8cc58 | |||
| f8eace4c31 | |||
| db2c2395de | |||
|
|
fe266dcade | ||
|
|
ecb657d20d | ||
| b8492b3adc | |||
|
|
0f37feb318 | ||
| 6e2c5c6e07 | |||
| 64d8bcba9a | |||
| 160d0df935 | |||
| 6a60194ffb | |||
| 93193bddc0 | |||
| aa415f201e | |||
| 4f4411fcc3 | |||
|
|
afd690ed07 | ||
|
|
a3936f64da | ||
|
|
7bf8cf569f | ||
|
|
856ec23586 | ||
|
|
d910b8a35d | ||
|
|
234bf218a9 | ||
|
|
0226477256 | ||
|
|
42ded1221a | ||
| a9a22ace14 | |||
| 99bbea80dc | |||
| 26fa41f503 | |||
| 082aa36316 | |||
| 5a14ea48c1 | |||
| 5d43f5c556 | |||
| e51a58ba4f | |||
| 5234de434a | |||
| 22f2ac99ad | |||
| b08b5d0abe | |||
|
|
96c6323c07 | ||
| ae80715db1 | |||
| 3d7f30af00 | |||
| f12cb55bbc |
29
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -7,6 +7,32 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a problem, please complete the title and fill in the following information.
|
||||
|
||||
感谢您的反馈,请填写完整标题并填写以下信息。
|
||||
|
||||
**Please do not report any issues related to config files.**
|
||||
|
||||
**请不要报告与配置文件相关的任何问题。**
|
||||
|
||||
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
|
||||
|
||||
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
|
||||
- type: dropdown
|
||||
id: bugType
|
||||
attributes:
|
||||
label: Bug type
|
||||
description: What type of bug are you reporting?
|
||||
options:
|
||||
- Crash
|
||||
- UI
|
||||
- Performance
|
||||
- Security
|
||||
- Reader
|
||||
- JS Engine
|
||||
- Comic Source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -19,7 +45,8 @@ body:
|
||||
attributes:
|
||||
label: Version
|
||||
description: |
|
||||
App version
|
||||
App version.
|
||||
|
||||
Please try to update if it is not the latest version
|
||||
validations:
|
||||
required: true
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
10
.github/ISSUE_TEMPLATE/enhancement.yaml
vendored
@@ -7,6 +7,16 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Welcome to make a feature request, please fill in the following information after completing the title.
|
||||
|
||||
欢迎提出功能建议,请填写完整标题后填写以下信息。
|
||||
|
||||
**Please do not report any issues related to config files.**
|
||||
|
||||
**请不要报告与配置文件相关的任何问题。**
|
||||
|
||||
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
|
||||
|
||||
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/other.yaml
vendored
@@ -1,9 +0,0 @@
|
||||
name: other
|
||||
description: Other contents
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Content
|
||||
validations:
|
||||
required: true
|
||||
19
.github/workflows/analyze.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: "analyze"
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: flutter pub get
|
||||
- uses: invertase/github-action-dart-analyzer@v1
|
||||
20
.github/workflows/fastlane.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Validate Fastlane metadata
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'fastlane/**'
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'fastlane/**'
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||
29
.github/workflows/issue_check.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Check Issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check Issue
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check Issue
|
||||
id: check
|
||||
uses: wgh136/gpt_issue_checker@v1.0.2
|
||||
with:
|
||||
api-url: ${{ secrets.API_URL }}
|
||||
api-key: ${{ secrets.API_KEY }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
prompt: "You are a repository issue checker. The project is a comic app that supports view local or network comics using config files. To view a comic source, user must add a config file. User should not report any issue related to config file to the project repository because there is another repository for managing config files. You are given an issue content and you need to decide whether to close the issue. If you decide to close the issue, you should also provide a comment explaining why you are closing the issue. If you decide not to close the issue, you should provide a comment which is a summary of the issue. You should response with a JSON object with the following keys: should_close, should_comment, comment."
|
||||
model: "gpt-4o"
|
||||
33
.github/workflows/linux.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Build Linux
|
||||
run-name: Build Linux
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
jobs:
|
||||
Build_Linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- run: python3 debian/build.py
|
||||
- run: dart run flutter_to_arch
|
||||
- run: |
|
||||
sudo rm -rf build/linux/arch/app.tar.gz
|
||||
sudo rm -rf build/linux/arch/pkg
|
||||
sudo rm -rf build/linux/arch/src
|
||||
sudo rm -rf build/linux/arch/PKGBUILD
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb_build
|
||||
path: build/linux/x64/release/debian
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: arch_build
|
||||
path: build/linux/arch/
|
||||
187
.github/workflows/main.yml
vendored
@@ -1,10 +1,13 @@
|
||||
name: Build IOS
|
||||
run-name: Build IOS
|
||||
name: Build ALL
|
||||
run-name: Build ALL
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
Build_MacOS:
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -12,7 +15,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
# Step 1: Decode and install the certificate
|
||||
- name: Decode and install certificate
|
||||
@@ -23,6 +26,9 @@ jobs:
|
||||
echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12
|
||||
security import signing_certificate.p12 -k ~/Library/Keychains/login.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
|
||||
- name: Check rust-toolchain.toml
|
||||
run: rustup show
|
||||
|
||||
# Step 2: Build the Flutter macOS app
|
||||
- name: Build Flutter macOS App
|
||||
run: flutter build macos --release
|
||||
@@ -36,14 +42,20 @@ jobs:
|
||||
ln -s /Applications dist/dmg_contents/Applications
|
||||
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg"
|
||||
|
||||
- name: Add version to filename
|
||||
run: |
|
||||
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
|
||||
mkdir -p result
|
||||
mv dist/venera.dmg result/venera-$APP_VERSION.dmg
|
||||
|
||||
# Step 4: Attach and upload artifacts (optional)
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: venera.dmg
|
||||
path: dist/venera.dmg
|
||||
name: macos_build
|
||||
path: result/
|
||||
Build_IOS:
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -51,7 +63,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign
|
||||
- run: |
|
||||
@@ -59,7 +71,160 @@ jobs:
|
||||
mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload
|
||||
cd /Users/runner/work/venera/venera/build/ios/iphoneos/
|
||||
zip -r venera-ios.ipa Payload
|
||||
- name: Add version to filename
|
||||
run: |
|
||||
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
|
||||
mkdir -p result
|
||||
mv build/ios/iphoneos/venera-ios.ipa result/venera-ios-$APP_VERSION.ipa
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-ios.ipa
|
||||
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
|
||||
name: ios_build
|
||||
path: result/
|
||||
Build_Android:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- name: Decode and install certificate
|
||||
env:
|
||||
STORE_FILE: ${{ secrets.ANDROID_KEYSTORE }}
|
||||
PROPERTY_FILE: ${{ secrets.ANDROID_KEY_PROPERTIES }}
|
||||
run: |
|
||||
echo "$STORE_FILE" | base64 --decode > android/keystore.jks
|
||||
echo "$PROPERTY_FILE" > android/key.properties
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
- name: Check rust-toolchain.toml
|
||||
run: rustup show
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apks
|
||||
path: build/app/outputs/apk/release
|
||||
Build_Windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: install dependencies
|
||||
run: |
|
||||
choco install yq -y
|
||||
pip install httpx
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- name: build
|
||||
run: |
|
||||
flutter pub get
|
||||
python windows/build.py
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows_build
|
||||
path: build/windows/Venera-*
|
||||
Build_Linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- run: python3 debian/build.py x64
|
||||
- run: dart run flutter_to_arch
|
||||
- run: |
|
||||
sudo rm -rf build/linux/arch/app.tar.gz
|
||||
sudo rm -rf build/linux/arch/pkg
|
||||
sudo rm -rf build/linux/arch/src
|
||||
sudo rm -rf build/linux/arch/PKGBUILD
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb_build
|
||||
path: build/linux/x64/release/debian
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: arch_build
|
||||
path: build/linux/arch/
|
||||
Build_Linux_ARM64:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'master'
|
||||
flutter-version-file: pubspec.yaml
|
||||
- run: |
|
||||
flutter pub get
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- name: "Patch font"
|
||||
run: |
|
||||
dart run patch/font.dart
|
||||
- run: python3 debian/build.py arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb_arm64_build
|
||||
path: build/linux/x64/release/debian # This is a bug related to flutter_to_debian, but it's not a big deal.
|
||||
|
||||
Release:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux, Build_Linux_ARM64]
|
||||
if: github.event_name == 'release' # 仅在 push 事件时执行
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: apks
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deb_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: arch_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deb_arm64_build
|
||||
path: outputs
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
outputs/*.ipa
|
||||
outputs/*.dmg
|
||||
outputs/*.apk
|
||||
outputs/*.zip
|
||||
outputs/*.exe
|
||||
outputs/*.deb
|
||||
outputs/*.zst
|
||||
outputs/*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}
|
||||
|
||||
87
.github/workflows/update_alt_store.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Update AltStore Source
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build ALL"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Record job start time
|
||||
id: job_start_time
|
||||
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update AltStore source
|
||||
id: update_source
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python update_alt_store.py
|
||||
git config --global user.name 'GitHub Action'
|
||||
git config --global user.email 'action@github.com'
|
||||
git add alt_store.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base master
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Calculate job duration
|
||||
id: duration
|
||||
if: always()
|
||||
run: |
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
|
||||
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create job summary
|
||||
run: |
|
||||
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
|
||||
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Changes Detected and Applied" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The alt_store.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The alt_store.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The next scheduled run will be tomorrow at midnight UTC." >> $GITHUB_STEP_SUMMARY
|
||||
6
.gitignore
vendored
@@ -15,6 +15,7 @@ migrate_working_dir/
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
@@ -42,4 +43,7 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
./add_translation.py
|
||||
add_translation.py
|
||||
|
||||
*/*/generated_*
|
||||
*/*/Generated*
|
||||
17
README.md
@@ -1,14 +1,16 @@
|
||||
# venera
|
||||
|
||||
[](https://flutter.dev/)
|
||||
[](https://flutter.dev/)
|
||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://t.me/venera_release)
|
||||
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://aur.archlinux.org/packages/venera-bin)
|
||||
[](https://f-droid.org/packages/com.github.wgh136.venera/)
|
||||
|
||||
A comic reader that support reading local and network comics.
|
||||
|
||||
## Features
|
||||
|
||||
- Read local comics
|
||||
- Use javascript to create comic sources
|
||||
- Read comics from network sources
|
||||
@@ -18,19 +20,20 @@ A comic reader that support reading local and network comics.
|
||||
- Login to comment, rate, and other operations if the source supports
|
||||
|
||||
## Build from source
|
||||
|
||||
1. Clone the repository
|
||||
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
|
||||
3. Install rust, see [rustup.rs](https://rustup.rs/)
|
||||
4. Build for your platform: e.g. `flutter build apk`
|
||||
|
||||
## Create a new comic source
|
||||
|
||||
See [venera-configs](https://github.com/venera-app/venera-configs)
|
||||
See [Comic Source](doc/comic_source.md)
|
||||
|
||||
## Thanks
|
||||
|
||||
### Tags Translation
|
||||
[](https://github.com/EhTagTranslation/Database)
|
||||
|
||||
## Headless Mode
|
||||
See [Headless Doc](doc/headless_doc.md)
|
||||
|
||||
The Chinese translation of the manga tags is from this project.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import re
|
||||
import json
|
||||
|
||||
path='./assets/translation.json'
|
||||
|
||||
with open(path, 'r',encoding='utf-8') as file:
|
||||
translations=json.load(file)
|
||||
|
||||
|
||||
while True:
|
||||
line=input()
|
||||
if line=="q":
|
||||
break
|
||||
words=line.split('-')
|
||||
if len(words)!=3:
|
||||
print("invalid entry:",line,"(len(words) != 3)"
|
||||
continue
|
||||
en=words[0]
|
||||
cn=words[1]
|
||||
tw=words[2]
|
||||
translations["zh_CN"][en]=cn
|
||||
translations["zh_TW"][en]=tw
|
||||
|
||||
|
||||
with open(path, 'w',encoding='utf-8') as file:
|
||||
json.dump(translations, file, indent=2,ensure_ascii=False)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
81
alt_store.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "Venera",
|
||||
"identifier": "com.github.wgh136.venera.source",
|
||||
"website": "https://github.com/venera-app/venera",
|
||||
"subtitle": "Venera official AltStore Source.",
|
||||
"description": "This is the official AltStore Source for Venera.\n\n A comic reader that supports reading local and network comics",
|
||||
"tintColor": "#0784FC",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"apps": [
|
||||
{
|
||||
"beta": false,
|
||||
"name": "Venera",
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.5.3",
|
||||
"versionDate": "2025-10-13",
|
||||
"versionDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 15047841,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
"com.apple.security.application-groups",
|
||||
"get-task-allow",
|
||||
"keychain-access-groups",
|
||||
"com.apple.developer.kernel.extended-virtual-addressing",
|
||||
"com.apple.developer.kernel.increased-memory-limit",
|
||||
"com.apple.developer.healthkit.background-delivery"
|
||||
],
|
||||
"privacy": {
|
||||
"NSFaceIDUsageDescription": "Face ID or Touch ID is used to protect your privacy when opening the app, ensuring secure access to your reading content.",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save comic images you've favorited or downloaded to your photo library for easy access and sharing.",
|
||||
"NSPhotoLibraryUsageDescription": "Used to select images from your photo library when needed, and to save comic images you've collected to your device."
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"date": "2025-10-13",
|
||||
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"size": 15047841
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
"localizedDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"size": 14960268
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"news": [
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-06-18T09:02:01Z",
|
||||
"identifier": "release-v1.4.5",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-10-13T12:47:27Z",
|
||||
"identifier": "release-v1.5.3",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.5.3 - Venera 13/10/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,7 @@ linter:
|
||||
rules:
|
||||
collection_methods_unrelated_type: false
|
||||
use_build_context_synchronously: false
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
avoid_print: false
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
|
||||
1
android/.gitignore
vendored
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
/app/.cxx/
|
||||
|
||||
@@ -5,6 +5,8 @@ plugins {
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
@@ -30,10 +32,18 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
android {
|
||||
namespace = "com.github.wgh136.venera"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion "25.1.8937393"
|
||||
ndkVersion "28.0.13004108"
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
}
|
||||
|
||||
splits{
|
||||
abi {
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
enable true
|
||||
universalApk true
|
||||
}
|
||||
@@ -63,7 +73,6 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.github.wgh136.venera"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
@@ -75,22 +84,47 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.release
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (abi != null) {
|
||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||
} else {
|
||||
outputFileName = "venera-${variant.versionName}.apk"
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (variant.buildType.name == "release") {
|
||||
if (abi != null) {
|
||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||
def abiVersionCode = project.ext.abiCodes.get(abi)
|
||||
if (abiVersionCode != null) {
|
||||
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
} else {
|
||||
outputFileName = "venera-${variant.versionName}.apk"
|
||||
versionCodeOverride = variant.versionCode * 10
|
||||
}
|
||||
} else if (variant.buildType.name == "debug") {
|
||||
versionCodeOverride = variant.versionCode * 10 + 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
@@ -98,6 +132,6 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.activity:activity-ktx:1.9.2"
|
||||
implementation "androidx.activity:activity-ktx:1.10.1"
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<application
|
||||
android:label="venera"
|
||||
android:name="${applicationName}"
|
||||
@@ -12,6 +16,7 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -43,6 +48,11 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_text">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -1,51 +1,107 @@
|
||||
package com.github.wgh136.venera
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import dev.flutter.packages.file_selector_android.FileUtils
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.lang.Exception
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
var volumeListen = VolumeListen()
|
||||
var listening = false
|
||||
|
||||
private val pickDirectoryCode = 1
|
||||
private val storageRequestCode = 0x10
|
||||
private var storagePermissionRequest: ((Boolean) -> Unit)? = null
|
||||
|
||||
private lateinit var result: MethodChannel.Result
|
||||
private val nextLocalRequestCode = AtomicInteger()
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == pickDirectoryCode) {
|
||||
if(resultCode != Activity.RESULT_OK) {
|
||||
result.success(null)
|
||||
return
|
||||
private val sharedTexts = ArrayList<String>()
|
||||
|
||||
private var textShareHandler: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent?.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null)
|
||||
handleSharedText(text)
|
||||
}
|
||||
val pickedDirectoryUri = data?.data
|
||||
if (pickedDirectoryUri == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
try {
|
||||
result.success(onPickedDirectory(pickedDirectoryUri))
|
||||
}
|
||||
catch (e: Exception) {
|
||||
result.error("Failed to Copy Files", e.toString(), null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null)
|
||||
handleSharedText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSharedText(text: String) {
|
||||
if (textShareHandler != null) {
|
||||
textShareHandler?.invoke(text)
|
||||
} else {
|
||||
sharedTexts.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <I, O> startContractForResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
input: I,
|
||||
callback: ActivityResultCallback<O>
|
||||
) {
|
||||
val key = "activity_rq_for_result#${nextLocalRequestCode.getAndIncrement()}"
|
||||
val registry = activityResultRegistry
|
||||
var launcher: ActivityResultLauncher<I>? = null
|
||||
val observer = object : LifecycleEventObserver {
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (Lifecycle.Event.ON_DESTROY == event) {
|
||||
launcher?.unregister()
|
||||
lifecycle.removeObserver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
val newCallback = ActivityResultCallback<O> {
|
||||
launcher?.unregister()
|
||||
lifecycle.removeObserver(observer)
|
||||
callback.onActivityResult(it)
|
||||
}
|
||||
launcher = registry.register(key, contract, newCallback)
|
||||
launcher.launch(input)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||
MethodChannel(
|
||||
@@ -63,12 +119,23 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
res.success(null)
|
||||
}
|
||||
|
||||
"getDirectoryPath" -> {
|
||||
this.result = res
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
startActivityForResult(intent, pickDirectoryCode)
|
||||
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent) { activityResult ->
|
||||
if (activityResult.resultCode != Activity.RESULT_OK) {
|
||||
res.success(null)
|
||||
return@startContractForResult
|
||||
}
|
||||
val pickedDirectoryUri = activityResult.data?.data
|
||||
if (pickedDirectoryUri == null)
|
||||
res.success(null)
|
||||
else
|
||||
onPickedDirectory(pickedDirectoryUri, res)
|
||||
}
|
||||
}
|
||||
|
||||
else -> res.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -85,10 +152,44 @@ class MainActivity : FlutterActivity() {
|
||||
events.success(2)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
listening = false
|
||||
}
|
||||
})
|
||||
|
||||
val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage")
|
||||
storageChannel.setMethodCallHandler { _, res ->
|
||||
requestStoragePermission { result ->
|
||||
res.success(result)
|
||||
}
|
||||
}
|
||||
|
||||
val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file")
|
||||
selectFileChannel.setMethodCallHandler { req, res ->
|
||||
val mimeType = req.arguments<String>()
|
||||
openFile(res, mimeType!!)
|
||||
}
|
||||
|
||||
val shareTextChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/text_share")
|
||||
shareTextChannel.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
textShareHandler = {text ->
|
||||
events.success(text)
|
||||
}
|
||||
if (sharedTexts.isNotEmpty()) {
|
||||
for (text in sharedTexts) {
|
||||
events.success(text)
|
||||
}
|
||||
sharedTexts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
textShareHandler = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun getProxy(): String {
|
||||
@@ -102,12 +203,13 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if(listening){
|
||||
if (listening) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
volumeListen.down()
|
||||
return true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
volumeListen.up()
|
||||
return true
|
||||
@@ -117,43 +219,199 @@ class MainActivity : FlutterActivity() {
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
/// copy the directory to tmp directory, return copied directory
|
||||
private fun onPickedDirectory(uri: Uri): String {
|
||||
val contentResolver = context.contentResolver
|
||||
var tmp = context.cacheDir
|
||||
tmp = File(tmp, "getDirectoryPathTemp")
|
||||
/// Ensure that the directory is accessible by dart:io
|
||||
private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) {
|
||||
if (hasStoragePermission()) {
|
||||
var plain = uri.toString()
|
||||
if(plain.contains("%3A")) {
|
||||
plain = Uri.decode(plain)
|
||||
}
|
||||
val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:";
|
||||
if(plain.startsWith(externalStoragePrefix)) {
|
||||
val path = plain.substring(externalStoragePrefix.length)
|
||||
result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path)
|
||||
}
|
||||
// The uri cannot be parsed to plain path, use copy method
|
||||
}
|
||||
// dart:io cannot access the directory without permission.
|
||||
// so we need to copy the directory to cache directory
|
||||
val contentResolver = contentResolver
|
||||
var tmp = cacheDir
|
||||
var dirName = DocumentFile.fromTreeUri(this, uri)?.name
|
||||
tmp = File(tmp, dirName!!)
|
||||
if(tmp.exists()) {
|
||||
tmp.deleteRecursively()
|
||||
}
|
||||
tmp.mkdir()
|
||||
copyDirectory(contentResolver, uri, tmp)
|
||||
Thread {
|
||||
try {
|
||||
copyDirectory(contentResolver, uri, tmp)
|
||||
result.success(tmp.absolutePath)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
result.error("copy error", e.message, null)
|
||||
}
|
||||
}.start()
|
||||
|
||||
return tmp.absolutePath
|
||||
}
|
||||
|
||||
private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) {
|
||||
val src = DocumentFile.fromTreeUri(context, srcUri) ?: return
|
||||
val src = DocumentFile.fromTreeUri(this, srcUri) ?: return
|
||||
for (file in src.listFiles()) {
|
||||
if(file.isDirectory) {
|
||||
if (file.isDirectory) {
|
||||
val newDir = File(destDir, file.name!!)
|
||||
newDir.mkdir()
|
||||
copyDirectory(resolver, file.uri, newDir)
|
||||
} else {
|
||||
val newFile = File(destDir, file.name!!)
|
||||
val inputStream = resolver.openInputStream(file.uri) ?: return
|
||||
val outputStream = FileOutputStream(newFile)
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
resolver.openInputStream(file.uri)?.use { input ->
|
||||
FileOutputStream(newFile).use { output ->
|
||||
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasStoragePermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
Environment.isExternalStorageManager()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestStoragePermission(result: (Boolean) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
val readPermission = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val writePermission = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (!readPermission || !writePermission) {
|
||||
storagePermissionRequest = result
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
),
|
||||
storageRequestCode
|
||||
)
|
||||
} else {
|
||||
result(true)
|
||||
}
|
||||
} else {
|
||||
if (!Environment.isExternalStorageManager()) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ _ ->
|
||||
result(Environment.isExternalStorageManager())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result(false)
|
||||
}
|
||||
} else {
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == storageRequestCode) {
|
||||
storagePermissionRequest?.invoke(grantResults.all {
|
||||
it == PackageManager.PERMISSION_GRANTED
|
||||
})
|
||||
storagePermissionRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFile(result: MethodChannel.Result, mimeType: String) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = mimeType
|
||||
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult ->
|
||||
if (activityResult.resultCode != Activity.RESULT_OK) {
|
||||
result.success(null)
|
||||
return@startContractForResult
|
||||
}
|
||||
val uri = activityResult.data?.data
|
||||
if (uri == null) {
|
||||
result.success(null)
|
||||
return@startContractForResult
|
||||
}
|
||||
val contentResolver = contentResolver
|
||||
val file = DocumentFile.fromSingleUri(this, uri)
|
||||
if (file == null) {
|
||||
result.success(null)
|
||||
return@startContractForResult
|
||||
}
|
||||
val fileName = file.name
|
||||
if (fileName == null) {
|
||||
result.success(null)
|
||||
return@startContractForResult
|
||||
}
|
||||
if(hasStoragePermission()) {
|
||||
try {
|
||||
val filePath = FileUtils.getPathFromUri(this, uri)
|
||||
result.success(filePath)
|
||||
return@startContractForResult
|
||||
}
|
||||
catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// use copy method
|
||||
val tmp = File(cacheDir, fileName)
|
||||
if(tmp.exists()) {
|
||||
tmp.delete()
|
||||
}
|
||||
Log.i("Venera", "copy file (${fileName}) to ${tmp.absolutePath}")
|
||||
Thread {
|
||||
try {
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(tmp).use { output ->
|
||||
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
result.success(tmp.absolutePath)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
result.error("copy error", e.message, null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VolumeListen{
|
||||
class VolumeListen {
|
||||
var onUp = fun() {}
|
||||
var onDown = fun() {}
|
||||
fun up(){
|
||||
fun up() {
|
||||
onUp()
|
||||
}
|
||||
fun down(){
|
||||
|
||||
fun down() {
|
||||
onDown()
|
||||
}
|
||||
}
|
||||
|
||||
4
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">搜索</string>
|
||||
</resources>
|
||||
4
android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">搜尋</string>
|
||||
</resources>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">Search</string>
|
||||
</resources>
|
||||
@@ -3,4 +3,4 @@ android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
android.nonFinalResIds=false
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.2.1' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||
id "com.android.application" version '8.9.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
319
assets/init.js
@@ -4,6 +4,25 @@ Venera JavaScript Library
|
||||
This library provides a set of APIs for interacting with the Venera app.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function sendMessage
|
||||
* @global
|
||||
* @param {Object} message
|
||||
* @returns {any}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set a timeout to execute a callback function after a specified delay.
|
||||
* @param callback {Function}
|
||||
* @param delay {number} - delay in milliseconds
|
||||
*/
|
||||
function setTimeout(callback, delay) {
|
||||
sendMessage({
|
||||
method: 'delay',
|
||||
time: delay,
|
||||
}).then(callback);
|
||||
}
|
||||
|
||||
/// encode, decode, hash, decrypt
|
||||
let Convert = {
|
||||
/**
|
||||
@@ -32,6 +51,32 @@ let Convert = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param str {string}
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
encodeGbk: (str) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "gbk",
|
||||
value: str,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param value {ArrayBuffer}
|
||||
* @returns {string}
|
||||
*/
|
||||
decodeGbk: (value) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "gbk",
|
||||
value: value,
|
||||
isEncode: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @returns {string}
|
||||
@@ -169,7 +214,7 @@ let Convert = {
|
||||
decryptAesCbc: (value, key, iv) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-ecb",
|
||||
type: "aes-cbc",
|
||||
value: value,
|
||||
key: key,
|
||||
iv: iv,
|
||||
@@ -486,6 +531,37 @@ let Network = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
|
||||
* @param url {string}
|
||||
* @param [options] {{method?: string, headers?: Object, body?: any}}
|
||||
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<ArrayBuffer>), text: (function(): Promise<string>), json: (function(): Promise<any>)}>}
|
||||
* @since 1.2.0
|
||||
*/
|
||||
async function fetch(url, options) {
|
||||
let method = 'GET';
|
||||
let headers = {};
|
||||
let data = null;
|
||||
|
||||
if (options) {
|
||||
method = options.method || method;
|
||||
headers = options.headers || headers;
|
||||
data = options.body || data;
|
||||
}
|
||||
|
||||
let result = await Network.fetchBytes(method, url, headers, data);
|
||||
|
||||
return {
|
||||
ok: result.status >= 200 && result.status < 300,
|
||||
status: result.status,
|
||||
statusText: '',
|
||||
headers: result.headers,
|
||||
arrayBuffer: async () => result.body,
|
||||
text: async () => Convert.decodeUtf8(result.body),
|
||||
json: async () => JSON.parse(Convert.decodeUtf8(result.body)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HtmlDocument class for parsing HTML and querying elements.
|
||||
*/
|
||||
@@ -699,7 +775,7 @@ class HtmlElement {
|
||||
doc: this.doc,
|
||||
})
|
||||
if(k == null) return null;
|
||||
return new HtmlElement(k);
|
||||
return new HtmlElement(k, this.doc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -850,6 +926,7 @@ let console = {
|
||||
* @param id {string}
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param tags {string[]}
|
||||
* @param description {string}
|
||||
@@ -859,10 +936,11 @@ let console = {
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @constructor
|
||||
*/
|
||||
function Comic({id, title, subtitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
|
||||
function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.subTitle = subTitle;
|
||||
this.cover = cover;
|
||||
this.tags = tags;
|
||||
this.description = description;
|
||||
@@ -875,11 +953,13 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
|
||||
/**
|
||||
* Create a comic details object
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param description {string?}
|
||||
* @param tags {Map<string, string[]> | {} | null | undefined}
|
||||
* @param chapters {Map<string, string> | {} | null | undefined}} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined}} - favorite status. If the comic source supports multiple folders, this field should be null
|
||||
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined} - favorite status.
|
||||
* @param subId {string?} - a param which is passed to comments api
|
||||
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
|
||||
* @param recommend {Comic[]?} - related comics
|
||||
@@ -892,10 +972,12 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
|
||||
* @param url {string?}
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @param maxPage {number?}
|
||||
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
|
||||
* @constructor
|
||||
*/
|
||||
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage}) {
|
||||
function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle ?? subTitle;
|
||||
this.cover = cover;
|
||||
this.description = description;
|
||||
this.tags = tags;
|
||||
@@ -913,6 +995,7 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su
|
||||
this.url = url;
|
||||
this.stars = stars;
|
||||
this.maxPage = maxPage;
|
||||
this.comments = comments;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -940,6 +1023,33 @@ function Comment({userName, avatar, content, time, replyCount, id, isLiked, scor
|
||||
this.voteStatus = voteStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image loading config
|
||||
* @param url {string?}
|
||||
* @param method {string?} - http method, uppercase
|
||||
* @param data {any} - request data, may be null
|
||||
* @param headers {Object?} - request headers
|
||||
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
|
||||
* @param modifyImage {string?}
|
||||
* A js script string.
|
||||
* The script will be executed in a new Isolate.
|
||||
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
|
||||
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
|
||||
* @constructor
|
||||
* @since 1.0.5
|
||||
*
|
||||
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
|
||||
*/
|
||||
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
|
||||
this.url = url;
|
||||
this.method = method;
|
||||
this.data = data;
|
||||
this.headers = headers;
|
||||
this.onResponse = onResponse;
|
||||
this.modifyImage = modifyImage;
|
||||
this.onLoadFailed = onLoadFailed;
|
||||
}
|
||||
|
||||
class ComicSource {
|
||||
name = ""
|
||||
|
||||
@@ -1014,6 +1124,19 @@ class ComicSource {
|
||||
});
|
||||
}
|
||||
|
||||
translation = {}
|
||||
|
||||
/**
|
||||
* Translate given string with the current locale using the translation object.
|
||||
* @param key {string}
|
||||
* @returns {string}
|
||||
* @since 1.2.5
|
||||
*/
|
||||
translate(key) {
|
||||
let locale = APP.locale;
|
||||
return this.translation[locale]?.[key] ?? key;
|
||||
}
|
||||
|
||||
init() { }
|
||||
|
||||
static sources = {}
|
||||
@@ -1132,3 +1255,187 @@ class Image {
|
||||
return new Image(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI related apis
|
||||
* @since 1.2.0
|
||||
*/
|
||||
let UI = {
|
||||
/**
|
||||
* Show a message
|
||||
* @param message {string}
|
||||
*/
|
||||
showMessage: (message) => {
|
||||
sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showMessage',
|
||||
message: message,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a dialog. Any action will close the dialog.
|
||||
* @param title {string}
|
||||
* @param content {string}
|
||||
* @param actions {{text:string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved.
|
||||
* @returns {Promise<void>} - Resolved when the dialog is closed.
|
||||
* @since 1.2.1
|
||||
*/
|
||||
showDialog: (title, content, actions) => {
|
||||
sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showDialog',
|
||||
title: title,
|
||||
content: content,
|
||||
actions: actions,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open [url] in external browser
|
||||
* @param url {string}
|
||||
*/
|
||||
launchUrl: (url) => {
|
||||
sendMessage({
|
||||
method: 'UI',
|
||||
function: 'launchUrl',
|
||||
url: url,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a loading dialog.
|
||||
* @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user.
|
||||
* @returns {number} - A number that can be used to cancel the loading dialog.
|
||||
* @since 1.2.1
|
||||
*/
|
||||
showLoading: (onCancel) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showLoading',
|
||||
onCancel: onCancel
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel a loading dialog.
|
||||
* @param id {number} - returned by [showLoading]
|
||||
* @since 1.2.1
|
||||
*/
|
||||
cancelLoading: (id) => {
|
||||
sendMessage({
|
||||
method: 'UI',
|
||||
function: 'cancelLoading',
|
||||
id: id
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an input dialog
|
||||
* @param title {string}
|
||||
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
||||
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator, image) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showInputDialog',
|
||||
title: title,
|
||||
image: image,
|
||||
validator: validator
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a select dialog
|
||||
* @param title {string}
|
||||
* @param options {string[]}
|
||||
* @param initialIndex {number?}
|
||||
* @returns {Promise<number | null>} - The selected index. If the dialog is canceled, return null.
|
||||
*/
|
||||
showSelectDialog: (title, options, initialIndex) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showSelectDialog',
|
||||
title: title,
|
||||
options: options,
|
||||
initialIndex: initialIndex
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App related apis
|
||||
* @since 1.2.1
|
||||
*/
|
||||
let APP = {
|
||||
/**
|
||||
* Get the app version
|
||||
* @returns {string} - The app version
|
||||
*/
|
||||
get version() {
|
||||
return appVersion // defined in the engine
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current app locale
|
||||
* @returns {string} - The app locale, in the format of [languageCode]_[countryCode]
|
||||
*/
|
||||
get locale() {
|
||||
return sendMessage({
|
||||
method: 'getLocale'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current running platform
|
||||
* @returns {string} - The platform name, "android", "ios", "windows", "macos", "linux"
|
||||
*/
|
||||
get platform() {
|
||||
return sendMessage({
|
||||
method: 'getPlatform'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set clipboard text
|
||||
* @param text {string}
|
||||
* @returns {Promise<void>}
|
||||
*
|
||||
* @since 1.3.4
|
||||
*/
|
||||
function setClipboard(text) {
|
||||
return sendMessage({
|
||||
method: 'setClipboard',
|
||||
text: text
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clipboard text
|
||||
* @returns {Promise<string>}
|
||||
*
|
||||
* @since 1.3.4
|
||||
*/
|
||||
function getClipboard() {
|
||||
return sendMessage({
|
||||
method: 'getClipboard'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
|
||||
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
|
||||
* @param args {any[]} - The arguments to pass to the function.
|
||||
* @returns {Promise<any>} - The result of the function.
|
||||
* @since 1.5.0
|
||||
*/
|
||||
function compute(func, ...args) {
|
||||
return sendMessage({
|
||||
method: 'compute',
|
||||
function: func,
|
||||
args: args
|
||||
})
|
||||
}
|
||||
3982
assets/opencc.txt
Normal file
@@ -17,7 +17,8 @@
|
||||
"Multiple Comics": "多个漫画",
|
||||
"help": "帮助",
|
||||
"Select": "选择",
|
||||
"Imported @a comics": "已导入 @a 部漫画",
|
||||
"Selected @a comics": "已选择 @a 部漫画",
|
||||
"Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
|
||||
"Downloading": "下载中",
|
||||
"Back": "后退",
|
||||
"Delete": "删除",
|
||||
@@ -40,11 +41,19 @@
|
||||
"Select a folder": "选择一个文件夹",
|
||||
"Folder": "文件夹",
|
||||
"Confirm": "确认",
|
||||
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?",
|
||||
"Add comic source": "添加漫画来源",
|
||||
"Reversed successfully": "反转成功",
|
||||
"Remove comic from favorite?": "从收藏中移除漫画?",
|
||||
"Move": "移动",
|
||||
"Move to folder": "移动到文件夹",
|
||||
"Copy to folder": "复制到文件夹",
|
||||
"Delete Comic": "删除漫画",
|
||||
"Delete @c comics?": "删除 @c 本漫画?",
|
||||
"Add comic source": "添加漫画源",
|
||||
"Delete comic source '@n' ?": "删除漫画源 '@n' ?",
|
||||
"Select file": "选择文件",
|
||||
"View list": "查看列表",
|
||||
"Open help": "打开帮助",
|
||||
"Open in Browser": "打开网页",
|
||||
"Check updates": "检查更新",
|
||||
"Edit": "编辑",
|
||||
"Update": "更新",
|
||||
@@ -74,7 +83,10 @@
|
||||
"New Folder": "新建文件夹",
|
||||
"Reading": "阅读中",
|
||||
"Appearance": "外观",
|
||||
"Network Favorites": "网络收藏",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
|
||||
"Auto close favorite panel after operation": "自动关闭收藏面板",
|
||||
"APP": "应用",
|
||||
"About": "关于",
|
||||
"Display mode of comic tile": "漫画缩略图的显示模式",
|
||||
@@ -97,10 +109,12 @@
|
||||
"Continuous (Right to Left)": "连续(从右到左)",
|
||||
"Continuous (Top to Bottom)": "连续(从上到下)",
|
||||
"Auto page turning interval": "自动翻页间隔",
|
||||
"The number of pic in screen for landscape (Only Gallery Mode)": "横屏同屏幕图片数量(仅画廊模式)",
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)": "竖屏同屏幕图片数量(仅画廊模式)",
|
||||
"Theme Mode": "主题模式",
|
||||
"System": "系统",
|
||||
"Light": "明亮",
|
||||
"Dark": "黑暗",
|
||||
"Light": "浅色",
|
||||
"Dark": "深色",
|
||||
"Theme Color": "主题颜色",
|
||||
"Red": "红色",
|
||||
"Pink": "粉色",
|
||||
@@ -129,22 +143,18 @@
|
||||
"Block": "屏蔽",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "阅读后移动收藏",
|
||||
"Are you sure you want to delete this folder?" : "确定要删除这个收藏夹吗?",
|
||||
"Delete folder?": "删除文件夹?",
|
||||
"Delete folder '@f' ?": "删除文件夹 '@f' ?",
|
||||
"Import from file": "从文件导入",
|
||||
"Failed to import": "导入失败",
|
||||
"Cache Limit": "缓存限制",
|
||||
"Set Cache Limit": "设置缓存限制",
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
|
||||
"Select a directory which contains the comic directories.": "选择一个包含漫画文件夹的目录",
|
||||
"Help": "帮助",
|
||||
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
|
||||
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
|
||||
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
|
||||
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。",
|
||||
"Export as cbz": "导出为cbz",
|
||||
"Select a cbz file." : "选择一个cbz文件",
|
||||
"A cbz file" : "一个cbz文件",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)": "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file": "一个归档文件",
|
||||
"Fullscreen": "全屏",
|
||||
"Exit": "退出",
|
||||
"View more": "查看更多",
|
||||
@@ -169,7 +179,242 @@
|
||||
"minAppVersion @version is required": "需要最低App版本 @version",
|
||||
"Remove": "移除",
|
||||
"Long press to zoom": "长按缩放",
|
||||
"Updates Available": "更新可用"
|
||||
"Updates Available": "更新可用",
|
||||
"Unselected": "未选择",
|
||||
"Long press and drag to reorder.": "长按并拖动以重新排序。",
|
||||
"Limit image width": "限制图片宽度",
|
||||
"When using Continuous(Top to Bottom) mode": "当使用连续(从上到下)模式",
|
||||
"Open link": "打开链接",
|
||||
"Open comic": "打开漫画",
|
||||
"Move To First": "移动到最前",
|
||||
"Cancel": "取消",
|
||||
"Paused": "已暂停",
|
||||
"Pause": "暂停",
|
||||
"Operation": "操作",
|
||||
"Upload": "上传",
|
||||
"Saved": "已保存",
|
||||
"Saved Failed": "保存失败",
|
||||
"Sync Data": "同步数据",
|
||||
"Syncing Data": "正在同步数据",
|
||||
"Data Sync": "数据同步",
|
||||
"Quick Favorite": "快速收藏",
|
||||
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
|
||||
"Added": "已添加",
|
||||
"Turn page by volume keys": "使用音量键翻页",
|
||||
"Display time & battery info in reader": "在阅读器中显示时间和电量信息",
|
||||
"EhViewer downloads": "EhViewer下载",
|
||||
"Select an EhViewer database and a download folder.": "选择EhViewer的下载数据(导出的db文件)与存放下载内容的目录",
|
||||
"(EhViewer)Default": "(EhViewer)默认",
|
||||
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画,程序将会按其中的下载标签自动创建收藏文件夹。",
|
||||
"Multi-Select": "进入多选模式",
|
||||
"Exit Multi-Select": "退出多选模式",
|
||||
"Selected @c comics": "已选择 @c 本漫画",
|
||||
"Select All": "全选",
|
||||
"Deselect": "取消选择",
|
||||
"Invert Selection": "反选",
|
||||
"Select in range": "区间选择",
|
||||
"Finished": "已完成",
|
||||
"Updating": "更新中",
|
||||
"Update Comics Info": "更新漫画信息",
|
||||
"Create Folder": "新建文件夹",
|
||||
"Select an image on screen": "选择屏幕上的图片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列",
|
||||
"Authorization Required": "需要身份验证",
|
||||
"Sync": "同步",
|
||||
"The folder is Linked to @source": "文件夹已关联到 @source",
|
||||
"Source Folder": "源文件夹",
|
||||
"Use a config file": "使用配置文件",
|
||||
"Comic Source list": "漫画源列表",
|
||||
"View": "查看",
|
||||
"Copy": "复制",
|
||||
"Copied": "已复制",
|
||||
"Search History": "搜索历史",
|
||||
"Clear Search History": "清除搜索历史",
|
||||
"Search in": "搜索于",
|
||||
"Clear History": "清除历史",
|
||||
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
|
||||
"No Explore Pages": "没有探索页面",
|
||||
"Please add some sources": "请添加一些源",
|
||||
"Please check your settings": "请检查您的设置",
|
||||
"No Category Pages": "没有分类页面",
|
||||
"Group @group": "第 @group 组",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 页",
|
||||
"Remove local favorite and history": "删除本地收藏和历史记录",
|
||||
"Also remove files on disk": "同时删除磁盘上的文件",
|
||||
"Copy to app local path": "将漫画复制到本地存储目录中",
|
||||
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
||||
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?": "有新版本可用。您要现在更新吗?",
|
||||
"No new version available": "没有新版本可用",
|
||||
"Export as pdf": "导出为pdf",
|
||||
"Export as epub": "导出为epub",
|
||||
"Aggregated Search": "聚合搜索",
|
||||
"Local comic collection is not supported at present": "本地收藏暂不支持",
|
||||
"The cover cannot be uncollected here": "封面不能在此取消收藏",
|
||||
"Uncollected the image": "取消收藏图片",
|
||||
"Successfully collected": "收藏成功",
|
||||
"Collect the image": "收藏图片",
|
||||
"Quick collect image": "快速收藏图片",
|
||||
"Not enable": "不启用",
|
||||
"Double Tap": "双击",
|
||||
"Swipe": "滑动",
|
||||
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在图片浏览页面, 你可以根据你的阅读模式横滑或者竖滑快速收藏图片",
|
||||
"Calculate your favorite from @a comics and @b images": "从 @a 本漫画和 @b 张图片中, 计算你最喜欢的",
|
||||
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括号后是图片数量或图片数比漫画页数",
|
||||
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫画的章节顺序可能发生了变化, 暂不支持收藏此章节",
|
||||
"Author: ": "作者: ",
|
||||
"Tags: ": "标签: ",
|
||||
"Comics(number): ": "漫画(数量): ",
|
||||
"Comics(percentage): ": "漫画(比例): ",
|
||||
"Time Filter": "时间筛选",
|
||||
"Image Favorites Greater Than": "图片收藏数大于",
|
||||
"Collection time": "收藏时间",
|
||||
"favoritesCompareComicPages": "收藏数与漫画页数比较",
|
||||
"Cover": "封面",
|
||||
"Page @a": "第 @a 页",
|
||||
"Time Asc": "时间升序",
|
||||
"Time Desc": "时间降序",
|
||||
"Favorite Num": "收藏数",
|
||||
"Favorite Num Compare Comic Pages": "收藏数比漫画页数",
|
||||
"All": "全部",
|
||||
"Last Week": "上周",
|
||||
"Last Month": "上月",
|
||||
"Last Half Year": "半年",
|
||||
"Last Year": "一年",
|
||||
"Filter": "筛选",
|
||||
"Image Favorites": "图片收藏",
|
||||
"Title": "标题",
|
||||
"@a Cover": "@a 封面",
|
||||
"Photo View": "图片浏览",
|
||||
"Delete @a images": "删除 @a 张图片",
|
||||
"Update the page number by the latest collection": "按最新收藏更新页数",
|
||||
"Copy the title successfully": "复制标题成功",
|
||||
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
|
||||
"No search results found": "未找到搜索结果",
|
||||
"Added @c comics to download queue.": "已添加 @c 本漫画到下载队列",
|
||||
"Download started": "下载已开始",
|
||||
"Click favorite": "点击收藏",
|
||||
"End": "末尾",
|
||||
"None": "无",
|
||||
"View Detail": "查看详情",
|
||||
"Select a directory which contains multiple archive files.": "选择一个包含多个归档文件的目录",
|
||||
"Multiple archive files": "多个归档文件",
|
||||
"No valid comics found": "未找到有效的漫画",
|
||||
"Enable DNS Overrides": "启用DNS覆写",
|
||||
"DNS Overrides": "DNS覆写",
|
||||
"Custom Image Processing": "自定义图片处理",
|
||||
"Enable": "启用",
|
||||
"Aggregated": "聚合",
|
||||
"Default Search Target": "默认搜索目标",
|
||||
"Auto Language Filters": "自动语言筛选",
|
||||
"Check for updates on startup": "启动时检查更新",
|
||||
"Start Time": "开始时间",
|
||||
"End Time": "结束时间",
|
||||
"Custom": "自定义",
|
||||
"Reset": "重置",
|
||||
"Tags": "标签",
|
||||
"Authors": "作者",
|
||||
"Comics": "漫画",
|
||||
"Imported @a comics": "已导入 @a 本漫画",
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 项更新",
|
||||
"No updates": "无更新",
|
||||
"Set comic source list url": "设置漫画源列表URL",
|
||||
"Deselect All": "取消全选",
|
||||
"Add keyword": "添加关键词",
|
||||
"Keyword": "关键词",
|
||||
"Manage": "管理",
|
||||
"Verify": "验证",
|
||||
"Cloudflare verification required": "需要Cloudflare验证",
|
||||
"Success": "成功",
|
||||
"Compressing": "压缩中",
|
||||
"Exporting": "导出中",
|
||||
"Search Sources": "搜索源",
|
||||
"Removed": "已移除",
|
||||
"Added to favorites": "已添加到收藏",
|
||||
"Not added": "未添加",
|
||||
"Create a folder": "新建收藏夹",
|
||||
"Created successfully": "创建成功",
|
||||
"name": "名称",
|
||||
"Reverse tap to turn Pages": "反转点击翻页",
|
||||
"Show all": "显示全部",
|
||||
"Number of images preloaded": "预加载图片数量",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Last Reading": "上次阅读",
|
||||
"Replies": "回复",
|
||||
"Follow Updates": "追更",
|
||||
"Not Configured": "未配置",
|
||||
"Choose a folder to follow updates.": "选择一个文件夹以追更",
|
||||
"Choose Folder": "选择文件夹",
|
||||
"No folders available": "没有可用的文件夹",
|
||||
"Updating comics...": "更新漫画中...",
|
||||
"Automatic update checking enabled.": "已启用自动更新检查",
|
||||
"The app will check for updates at most once a day.": "APP将每天最多检查一次更新",
|
||||
"Change Folder": "更改文件夹",
|
||||
"Check Now": "立即检查",
|
||||
"Updates": "更新",
|
||||
"No updates found": "未找到更新",
|
||||
"All Comics": "全部漫画",
|
||||
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
|
||||
"Disable": "禁用",
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
|
||||
"Cache cleared": "缓存已清除",
|
||||
"Disabled": "已禁用",
|
||||
"Auto Sync Data": "自动同步数据",
|
||||
"Mark all as read": "全部标记为已读",
|
||||
"Do you want to mark all as read?": "您要全部标记为已读吗?",
|
||||
"Swipe down for previous chapter": "向下滑动查看上一章",
|
||||
"Swipe up for next chapter": "向上滑动查看下一章",
|
||||
"Initial Page": "初始页面",
|
||||
"Home Page": "主页",
|
||||
"Favorites Page": "收藏页面",
|
||||
"Explore Page": "探索页面",
|
||||
"Categories Page": "分类页面",
|
||||
"Convert to local": "转换为本地",
|
||||
"Refresh": "刷新",
|
||||
"Paging": "分页",
|
||||
"Continuous": "连续",
|
||||
"Display mode of comic list": "漫画列表的显示模式",
|
||||
"Show Page Number": "显示页码",
|
||||
"Jump to page": "跳转到页面",
|
||||
"Page": "页面",
|
||||
"Jump": "跳转",
|
||||
"Copy Image": "复制图片",
|
||||
"A valid WebDav directory URL": "有效的WebDav目录URL",
|
||||
"Shut Down": "关闭",
|
||||
"Uploading data...": "正在上传数据...",
|
||||
"Pages": "页数",
|
||||
"Long press zoom position": "长按缩放位置",
|
||||
"Press position": "按压位置",
|
||||
"Screen center": "屏幕中心",
|
||||
"Suggestions": "建议",
|
||||
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
|
||||
"Show single image on first page": "在首页显示单张图片",
|
||||
"Show system status bar": "显示系统状态栏",
|
||||
"Click to select an image": "点击选择一张图片",
|
||||
"Repo URL": "仓库地址",
|
||||
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
|
||||
"Double tap to zoom": "双击缩放",
|
||||
"Clear Unfavorited": "清除未收藏",
|
||||
"Reverse": "反转",
|
||||
"Delete Chapters": "删除章节",
|
||||
"Open Folder": "打开文件夹",
|
||||
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||
"Reverse default chapter order": "反转默认章节顺序",
|
||||
"Reload Configs": "重新加载配置文件",
|
||||
"Reload": "重载",
|
||||
"Disable Length Limitation": "禁用长度限制",
|
||||
"Only valid for this run": "仅对本次运行有效",
|
||||
"Logs": "日志",
|
||||
"Export logs": "导出日志",
|
||||
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
|
||||
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
|
||||
"Enable comic specific settings": "启用此漫画特定设置",
|
||||
"Ignore Certificate Errors": "忽略证书错误",
|
||||
"Mouse scroll speed": "鼠标滚动速度"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -179,7 +424,7 @@
|
||||
"Settings": "設定",
|
||||
"Search": "搜尋",
|
||||
"History": "歷史",
|
||||
"Local": "本地",
|
||||
"Local": "本機",
|
||||
"Import": "匯入",
|
||||
"Comic Source": "漫畫源",
|
||||
"Accounts": "帳戶",
|
||||
@@ -190,14 +435,15 @@
|
||||
"Multiple Comics": "多部漫畫",
|
||||
"help": "幫助",
|
||||
"Select": "選擇",
|
||||
"Imported @a comics": "已匯入 @a 部漫畫",
|
||||
"Selected @a comics": "已選擇 @a 部漫畫",
|
||||
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載入 @b 頁, 接收到 @c 部漫畫",
|
||||
"Downloading": "下載中",
|
||||
"Back": "後退",
|
||||
"Delete": "刪除",
|
||||
"Full Screen": "全螢幕",
|
||||
"Auto Page Turning": "自動翻頁",
|
||||
"Chapters": "章節",
|
||||
"Save Image": "保存圖片",
|
||||
"Save Image": "儲存圖片",
|
||||
"Share": "分享",
|
||||
"Details": "詳情",
|
||||
"Description": "描述",
|
||||
@@ -205,60 +451,70 @@
|
||||
"Add to favorites": "加入收藏",
|
||||
"Error": "錯誤",
|
||||
"Retry": "重試",
|
||||
"Folders": "文件夾",
|
||||
"Delete Folder": "刪除文件夾",
|
||||
"Folders": "資料夾",
|
||||
"Delete Folder": "刪除資料夾",
|
||||
"Rename": "重新命名",
|
||||
"Reorder": "重新排序",
|
||||
"Network": "網路",
|
||||
"more": "更多",
|
||||
"Select a folder": "選擇一個文件夾",
|
||||
"Folder": "文件夾",
|
||||
"Select a folder": "選擇一個資料夾",
|
||||
"Folder": "資料夾",
|
||||
"Confirm": "確認",
|
||||
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?",
|
||||
"Add comic source": "添加漫畫來源",
|
||||
"Remove comic from favorite?": "從收藏中移除漫畫?",
|
||||
"Move": "移動",
|
||||
"Move to folder": "移動到資料夾",
|
||||
"Copy to folder": "複製到資料夾",
|
||||
"Delete Comic": "刪除漫畫",
|
||||
"Delete @c comics?": "刪除 @c 本漫畫?",
|
||||
"Add comic source": "添加漫畫源",
|
||||
"Delete comic source '@n' ?": "刪除漫畫源 '@n' ?",
|
||||
"Select file": "選擇文件",
|
||||
"View list": "查看列表",
|
||||
"Open help": "打開幫助",
|
||||
"Open in Browser": "打開網頁",
|
||||
"Check updates": "檢查更新",
|
||||
"Edit": "編輯",
|
||||
"Update": "更新",
|
||||
"Log in": "登錄",
|
||||
"Log in": "登入",
|
||||
"Log out": "登出",
|
||||
"Re-login": "重新登錄",
|
||||
"Click if login expired": "點擊此處如果登錄已過期",
|
||||
"Login": "登錄",
|
||||
"Username": "用戶名",
|
||||
"Re-login": "重新登入",
|
||||
"Click if login expired": "點擊此處如果登入已過期",
|
||||
"Login": "登入",
|
||||
"Username": "使用者名稱",
|
||||
"Password": "密碼",
|
||||
"Continue": "繼續",
|
||||
"Create Account": "創建帳戶",
|
||||
"Create Account": "建立帳戶",
|
||||
"Next": "前進",
|
||||
"Login with webview": "通過網頁登錄",
|
||||
"Login with webview": "透過網頁登入",
|
||||
"Read": "閱讀",
|
||||
"Download": "下載",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "評論",
|
||||
"Information": "信息",
|
||||
"Information": "資訊",
|
||||
"Uploader": "上傳者",
|
||||
"Upload Time": "上傳時間",
|
||||
"Preview": "預覽",
|
||||
"Comment": "評論",
|
||||
"Submit": "提交",
|
||||
"Add": "添加",
|
||||
"New Folder": "新建文件夾",
|
||||
"New Folder": "建立資料夾",
|
||||
"Reading": "閱讀中",
|
||||
"Appearance": "外觀",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Network Favorites": "網路收藏",
|
||||
"Local Favorites": "本機收藏",
|
||||
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
|
||||
"Auto close favorite panel after operation": "自動關閉收藏面板",
|
||||
"APP": "應用",
|
||||
"About": "關於",
|
||||
"Display mode of comic tile": "漫畫縮略圖的顯示模式",
|
||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||
"Detailed": "詳細",
|
||||
"Brief": "簡潔",
|
||||
"Size of comic tile": "漫畫縮略圖的大小",
|
||||
"Size of comic tile": "漫畫縮圖的大小",
|
||||
"Explore Pages": "探索頁面",
|
||||
"Category Pages": "分類頁面",
|
||||
"Show favorite status on comic tile": "在漫畫縮略圖上顯示收藏狀態",
|
||||
"Show history on comic tile": "在漫畫縮略圖上顯示歷史記錄",
|
||||
"Keyword blocking": "關鍵詞屏蔽",
|
||||
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
|
||||
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
|
||||
"Keyword blocking": "關鍵字封鎖",
|
||||
"Tap to turn Pages": "點擊翻頁",
|
||||
"Page animation": "頁面動畫",
|
||||
"Reading mode": "閱讀模式",
|
||||
@@ -269,10 +525,12 @@
|
||||
"Continuous (Right to Left)": "連續(從右到左)",
|
||||
"Continuous (Top to Bottom)": "連續(從上到下)",
|
||||
"Auto page turning interval": "自動翻頁間隔",
|
||||
"The number of pic in screen for landscape (Only Gallery Mode)": "橫向同螢幕圖片數量(僅畫廊模式)",
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)": "直向同螢幕圖片數量(僅畫廊模式)",
|
||||
"Theme Mode": "主題模式",
|
||||
"System": "系統",
|
||||
"Light": "明亮",
|
||||
"Dark": "黑暗",
|
||||
"Light": "淺色",
|
||||
"Dark": "深色",
|
||||
"Theme Color": "主題顏色",
|
||||
"Red": "紅色",
|
||||
"Pink": "粉色",
|
||||
@@ -281,42 +539,38 @@
|
||||
"Orange": "橙色",
|
||||
"Blue": "藍色",
|
||||
"App": "應用",
|
||||
"Data": "數據",
|
||||
"Storage Path for local comics": "本地漫畫的存儲路徑",
|
||||
"Set New Storage Path": "設置新的存儲路徑",
|
||||
"Set": "設置",
|
||||
"Cache Size": "緩存大小",
|
||||
"Clear Cache": "清除緩存",
|
||||
"Data": "資料",
|
||||
"Storage Path for local comics": "本機漫畫的儲存路徑",
|
||||
"Set New Storage Path": "設定新的儲存路徑",
|
||||
"Set": "設定",
|
||||
"Cache Size": "快取大小",
|
||||
"Clear Cache": "清除快取",
|
||||
"Clear": "清除",
|
||||
"Log": "日誌",
|
||||
"Open Log": "打開日誌",
|
||||
"Open": "打開",
|
||||
"User": "用戶",
|
||||
"User": "使用者",
|
||||
"Language": "語言",
|
||||
"Proxy": "代理",
|
||||
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
|
||||
"Check for updates": "檢查更新",
|
||||
"Check": "檢查",
|
||||
"Network Favorite Pages": "網路收藏頁面",
|
||||
"Block": "屏蔽",
|
||||
"Block": "封鎖",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "閱讀後移動收藏",
|
||||
"Are you sure you want to delete this folder?" : "確定要刪除這個收藏夾嗎?",
|
||||
"Delete folder?": "刪除資料夾?",
|
||||
"Delete folder '@f' ?": "刪除資料夾 '@f' ?",
|
||||
"Import from file": "從文件匯入",
|
||||
"Failed to import": "匯入失敗",
|
||||
"Cache Limit": "緩存限制",
|
||||
"Set Cache Limit": "設置緩存限制",
|
||||
"Cache Limit": "快取限制",
|
||||
"Set Cache Limit": "設定快取限制",
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
|
||||
"Select a directory which contains the comic directories.": "選擇一個包含漫畫資料夾的目錄",
|
||||
"Help": "幫助",
|
||||
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
|
||||
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
|
||||
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
|
||||
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。",
|
||||
"Export as cbz": "匯出為cbz",
|
||||
"Select a cbz file." : "選擇一個cbz文件",
|
||||
"A cbz file" : "一個cbz文件",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file": "一個歸檔文件",
|
||||
"Fullscreen": "全螢幕",
|
||||
"Exit": "退出",
|
||||
"View more": "查看更多",
|
||||
@@ -325,15 +579,16 @@
|
||||
"Date": "日期",
|
||||
"Date Desc": "日期降序",
|
||||
"Start": "開始",
|
||||
"Export App Data": "匯出應用數據",
|
||||
"Import App Data": "匯入應用數據",
|
||||
"Reversed successfully": "反轉成功",
|
||||
"Export App Data": "匯出應用資料",
|
||||
"Import App Data": "匯入應用資料",
|
||||
"Export": "匯出",
|
||||
"Download Threads": "下載線程數",
|
||||
"Download Threads": "下載執行緒數",
|
||||
"Update Time": "更新時間",
|
||||
"Copy ID": "複製ID",
|
||||
"Copy URL": "複製URL",
|
||||
"Create": "創建",
|
||||
"Folder Name": "文件夾名稱",
|
||||
"Create": "建立",
|
||||
"Folder Name": "資料夾名稱",
|
||||
"Ranking": "排行",
|
||||
"Download Selected": "下載選中",
|
||||
"Download All": "下載全部",
|
||||
@@ -341,6 +596,241 @@
|
||||
"minAppVersion @version is required": "需要最低App版本 @version",
|
||||
"Remove": "移除",
|
||||
"Long press to zoom": "長按縮放",
|
||||
"Updates Available": "更新可用"
|
||||
"Updates Available": "更新可用",
|
||||
"Unselected": "未選擇",
|
||||
"Long press and drag to reorder.": "長按並拖動以重新排序。",
|
||||
"Limit image width": "限製圖片寬度",
|
||||
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
|
||||
"Open link": "打開連結",
|
||||
"Open comic": "打開漫畫",
|
||||
"Move To First": "移動到最前",
|
||||
"Cancel": "取消",
|
||||
"Paused": "已暫停",
|
||||
"Pause": "暫停",
|
||||
"Operation": "操作",
|
||||
"Upload": "上傳",
|
||||
"Saved": "已儲存",
|
||||
"Saved Failed": "儲存失敗",
|
||||
"Sync Data": "同步資料",
|
||||
"Syncing Data": "正在同步資料",
|
||||
"Data Sync": "資料同步",
|
||||
"Quick Favorite": "快速收藏",
|
||||
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
|
||||
"Added": "已添加",
|
||||
"Turn page by volume keys": "使用音量鍵翻頁",
|
||||
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
|
||||
"EhViewer downloads": "EhViewer下載",
|
||||
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料(匯出的db檔案)與存放下載內容的目錄",
|
||||
"(EhViewer)Default": "(EhViewer)預設",
|
||||
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若透過EhViewer資料庫匯入漫畫,程式將會按其中的下載標籤自動建立收藏資料夾。",
|
||||
"Multi-Select": "進入多選模式",
|
||||
"Exit Multi-Select": "退出多選模式",
|
||||
"Selected @c comics": "已選擇 @c 本漫畫",
|
||||
"Select All": "全選",
|
||||
"Deselect": "取消選擇",
|
||||
"Invert Selection": "反選",
|
||||
"Select in range": "區間選擇",
|
||||
"Finished": "已完成",
|
||||
"Updating": "更新中",
|
||||
"Update Comics Info": "更新漫畫資訊",
|
||||
"Create Folder": "建立資料夾",
|
||||
"Select an image on screen": "選擇螢幕上的圖片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載佇列",
|
||||
"Authorization Required": "需要身份驗證",
|
||||
"Sync": "同步",
|
||||
"The folder is Linked to @source": "資料夾已關聯到 @source",
|
||||
"Source Folder": "來源資料夾",
|
||||
"Use a config file": "使用設定檔",
|
||||
"Comic Source list": "漫畫源列表",
|
||||
"View": "查看",
|
||||
"Copy": "複製",
|
||||
"Copied": "已複製",
|
||||
"Search History": "搜尋歷史",
|
||||
"Clear Search History": "清除搜尋歷史",
|
||||
"Search in": "搜尋於",
|
||||
"Clear History": "清除歷史",
|
||||
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
|
||||
"No Explore Pages": "沒有探索頁面",
|
||||
"Please add some sources": "請添加一些源",
|
||||
"Please check your settings": "請檢查您的設定",
|
||||
"No Category Pages": "沒有分類頁面",
|
||||
"Group @group": "第 @group 組",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 頁",
|
||||
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
|
||||
"Also remove files on disk": "同時刪除磁碟上的文件",
|
||||
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
|
||||
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
|
||||
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?": "有新版本可用。您要現在更新嗎?",
|
||||
"No new version available": "沒有新版本可用",
|
||||
"Export as pdf": "匯出為pdf",
|
||||
"Export as epub": "匯出為epub",
|
||||
"Aggregated Search": "聚合搜尋",
|
||||
"No search results found": "未找到搜尋結果",
|
||||
"Added @c comics to download queue.": "已添加 @c 本漫畫到下載佇列",
|
||||
"Download started": "下載已開始",
|
||||
"Click favorite": "點擊收藏",
|
||||
"Local comic collection is not supported at present": "本機收藏暫不支援",
|
||||
"The cover cannot be uncollected here": "封面不能在此取消收藏",
|
||||
"Uncollected the image": "取消收藏圖片",
|
||||
"Successfully collected": "收藏成功",
|
||||
"Collect the image": "收藏圖片",
|
||||
"Quick collect image": "快速收藏圖片",
|
||||
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
|
||||
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
|
||||
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
|
||||
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支援收藏此章節",
|
||||
"Author: ": "作者: ",
|
||||
"Tags: ": "標籤: ",
|
||||
"Comics(number): ": "漫畫(數量): ",
|
||||
"Comics(percentage): ": "漫畫(比例): ",
|
||||
"Time Filter": "時間篩選",
|
||||
"Image Favorites Greater Than": "圖片收藏數大於",
|
||||
"Collection time": "收藏時間",
|
||||
"Not enable": "不啟用",
|
||||
"Double Tap": "雙擊",
|
||||
"Swipe": "滑動",
|
||||
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
|
||||
"Cover": "封面",
|
||||
"Page @a": "第 @a 頁",
|
||||
"Time Asc": "時間升序",
|
||||
"Time Desc": "時間降序",
|
||||
"Favorite Num": "收藏數",
|
||||
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
|
||||
"All": "全部",
|
||||
"Last Week": "上週",
|
||||
"Last Month": "上月",
|
||||
"Last Half Year": "半年",
|
||||
"Last Year": "一年",
|
||||
"Filter": "篩選",
|
||||
"Image Favorites": "圖片收藏",
|
||||
"Title": "標題",
|
||||
"@a Cover": "@a 封面",
|
||||
"Photo View": "圖片瀏覽",
|
||||
"Delete @a images": "刪除 @a 張圖片",
|
||||
"Update the page number by the latest collection": "按最新收藏更新頁數",
|
||||
"Copy the title successfully": "複製標題成功",
|
||||
"The comic is invalid, please long press to delete, you can double click the title to copy": "該漫畫已失效, 請長按刪除, 可以雙擊標題進行複製",
|
||||
"End": "末尾",
|
||||
"None": "無",
|
||||
"View Detail": "查看詳情",
|
||||
"Select a directory which contains multiple archive files.": "選擇一個包含多個歸檔文件的目錄",
|
||||
"Multiple archive files": "多個歸檔文件",
|
||||
"No valid comics found": "未找到有效的漫畫",
|
||||
"Enable DNS Overrides": "啟用DNS覆寫",
|
||||
"DNS Overrides": "DNS覆寫",
|
||||
"Custom Image Processing": "自訂圖片處理",
|
||||
"Enable": "啟用",
|
||||
"Aggregated": "聚合",
|
||||
"Default Search Target": "預設搜尋目標",
|
||||
"Auto Language Filters": "自動語言篩選",
|
||||
"Check for updates on startup": "啟動時檢查更新",
|
||||
"Start Time": "開始時間",
|
||||
"End Time": "結束時間",
|
||||
"Custom": "自訂",
|
||||
"Reset": "重設",
|
||||
"Tags": "標籤",
|
||||
"Authors": "作者",
|
||||
"Comics": "漫畫",
|
||||
"Imported @a comics": "已匯入 @a 部漫畫",
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 項更新",
|
||||
"No updates": "無更新",
|
||||
"Set comic source list url": "設定漫畫源列表URL",
|
||||
"Deselect All": "取消全選",
|
||||
"Add keyword": "添加關鍵字",
|
||||
"Keyword": "關鍵字",
|
||||
"Manage": "管理",
|
||||
"Verify": "驗證",
|
||||
"Cloudflare verification required": "需要Cloudflare驗證",
|
||||
"Success": "成功",
|
||||
"Compressing": "壓縮中",
|
||||
"Exporting": "匯出中",
|
||||
"Search Sources": "搜尋源",
|
||||
"Removed": "已移除",
|
||||
"Added to favorites": "已添加到收藏",
|
||||
"Not added": "未添加",
|
||||
"Create a folder": "建立收藏夾",
|
||||
"Created successfully": "建立成功",
|
||||
"name": "名稱",
|
||||
"Reverse tap to turn Pages": "反轉點擊翻頁",
|
||||
"Show all": "顯示全部",
|
||||
"Number of images preloaded": "預載入圖片數量",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Last Reading": "上次閱讀",
|
||||
"Replies": "回覆",
|
||||
"Follow Updates": "追更",
|
||||
"Not Configured": "未配置",
|
||||
"Choose a folder to follow updates.": "選擇一個資料夾以追更",
|
||||
"Choose Folder": "選擇資料夾",
|
||||
"No folders available": "沒有可用的資料夾",
|
||||
"Updating comics...": "更新漫畫中...",
|
||||
"Automatic update checking enabled.": "已啟用自動更新檢查",
|
||||
"The app will check for updates at most once a day.": "APP將每天最多檢查一次更新",
|
||||
"Change Folder": "更改資料夾",
|
||||
"Check Now": "立即檢查",
|
||||
"Updates": "更新",
|
||||
"No updates found": "未找到更新",
|
||||
"All Comics": "全部漫畫",
|
||||
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
|
||||
"Disable": "停用",
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與伺服器同步資料",
|
||||
"Cache cleared": "快取已清除",
|
||||
"Disabled": "已停用",
|
||||
"Auto Sync Data": "自動同步資料",
|
||||
"Mark all as read": "全部標記為已讀",
|
||||
"Do you want to mark all as read?": "您要全部標記為已讀嗎?",
|
||||
"Swipe down for previous chapter": "向下滑動查看上一章",
|
||||
"Swipe up for next chapter": "向上滑動查看下一章",
|
||||
"Initial Page": "初始頁面",
|
||||
"Home Page": "首頁",
|
||||
"Favorites Page": "收藏頁面",
|
||||
"Explore Page": "探索頁面",
|
||||
"Categories Page": "分類頁面",
|
||||
"Convert to local": "轉換為本地",
|
||||
"Refresh": "刷新",
|
||||
"Paging": "分頁",
|
||||
"Continuous": "連續",
|
||||
"Display mode of comic list": "漫畫列表的顯示模式",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"Jump to page": "跳轉到頁面",
|
||||
"Page": "頁面",
|
||||
"Jump": "跳轉",
|
||||
"Copy Image": "複製圖片",
|
||||
"A valid WebDav directory URL": "有效的WebDav目錄URL",
|
||||
"Shut Down": "關閉",
|
||||
"Uploading data...": "正在上傳數據...",
|
||||
"Pages": "頁數",
|
||||
"Long press zoom position": "長按縮放位置",
|
||||
"Press position": "按壓位置",
|
||||
"Screen center": "螢幕中心",
|
||||
"Suggestions": "建議",
|
||||
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
|
||||
"Show single image on first page": "在首頁顯示單張圖片",
|
||||
"Show system status bar": "顯示系統狀態欄",
|
||||
"Click to select an image": "點擊選擇一張圖片",
|
||||
"Repo URL": "倉庫地址",
|
||||
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
|
||||
"Double tap to zoom": "雙擊縮放",
|
||||
"Clear Unfavorited": "清除未收藏",
|
||||
"Reverse": "反轉",
|
||||
"Delete Chapters": "刪除章節",
|
||||
"Open Folder": "打開資料夾",
|
||||
"Path copied to clipboard": "路徑已複製到剪貼簿",
|
||||
"Reverse default chapter order": "反轉預設章節順序",
|
||||
"Reload Configs": "重新載入設定檔",
|
||||
"Reload": "重載",
|
||||
"Disable Length Limitation": "禁用長度限制",
|
||||
"Only valid for this run": "僅對本次運行有效",
|
||||
"Logs": "日誌",
|
||||
"Export logs": "匯出日誌",
|
||||
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
|
||||
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
|
||||
"Enable comic specific settings": "啟用此漫畫特定設定",
|
||||
"Ignore Certificate Errors": "忽略證書錯誤",
|
||||
"Mouse scroll speed": "滑鼠滾動速度"
|
||||
}
|
||||
}
|
||||
11
debian/build.py
vendored
@@ -1,5 +1,7 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
arch = sys.argv[1]
|
||||
debianContent = ''
|
||||
desktopContent = ''
|
||||
version = ''
|
||||
@@ -12,7 +14,14 @@ with open('pubspec.yaml', 'r') as f:
|
||||
version = str.split(str.split(f.read(), 'version: ')[1], '+')[0]
|
||||
|
||||
with open('debian/debian.yaml', 'w') as f:
|
||||
f.write(debianContent.replace('{{Version}}', version))
|
||||
content = debianContent.replace('{{Version}}', version)
|
||||
if arch == 'x64':
|
||||
content = content.replace('{{Arch}}', 'x64')
|
||||
content = content.replace('{{Architecture}}', 'amd64')
|
||||
elif arch == 'arm64':
|
||||
content = content.replace('{{Arch}}', 'arm64')
|
||||
content = content.replace('{{Architecture}}', 'arm64')
|
||||
f.write(content)
|
||||
with open('debian/gui/venera.desktop', 'w') as f:
|
||||
f.write(desktopContent.replace('{{Version}}', version))
|
||||
|
||||
|
||||
6
debian/debian.yaml
vendored
@@ -1,13 +1,13 @@
|
||||
flutter_app:
|
||||
command: venera
|
||||
arch: x64
|
||||
arch: {{Arch}}
|
||||
parent: /usr/local/lib
|
||||
nonInteractive: false
|
||||
nonInteractive: true
|
||||
|
||||
control:
|
||||
Package: venera
|
||||
Version: {{Version}}
|
||||
Architecture: amd64
|
||||
Architecture: {{Architecture}}
|
||||
Priority: optional
|
||||
Depends: libwebkit2gtk-4.1-0, libgtk-3-0
|
||||
Maintainer: nyne
|
||||
|
||||
4
debian/gui/venera.desktop
vendored
@@ -1,9 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Version={{Version}}
|
||||
Name=Venera
|
||||
GenericName=Venera
|
||||
Comment=venera
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility
|
||||
Keywords=Flutter;comic;images;
|
||||
Keywords=Flutter;comic;images;
|
||||
Icon=venera
|
||||
BIN
debian/gui/venera.png
vendored
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 64 KiB |
694
doc/comic_source.md
Normal file
@@ -0,0 +1,694 @@
|
||||
# Comic Source
|
||||
|
||||
## Introduction
|
||||
|
||||
Venera is a comic reader that can read comics from various sources.
|
||||
|
||||
All comic sources are written in javascript.
|
||||
Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine which is forked from [ekibun](https://github.com/ekibun/flutter_qjs).
|
||||
|
||||
This document will describe how to write a comic source for Venera.
|
||||
|
||||
## Comic Source List
|
||||
|
||||
Venera can display a list of comic sources in the app.
|
||||
|
||||
You can use the following repo url:
|
||||
```
|
||||
https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json
|
||||
```
|
||||
The repo is maintained by the Venera team.
|
||||
|
||||
> The link is a mirror of the original repo. To contribute your comic source, please visit the [original repo](https://github.com/venera-app/venera-configs)
|
||||
|
||||
You should provide a repository url to let the app load the comic source list.
|
||||
The url should point to a JSON file that contains the list of comic sources.
|
||||
|
||||
The JSON file should have the following format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Source Name",
|
||||
"url": "https://example.com/source.js",
|
||||
"filename": "Relative path to the source file",
|
||||
"version": "1.0.0",
|
||||
"description": "A brief description of the source"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Only one of `url` and `filename` should be provided.
|
||||
The description field is optional.
|
||||
|
||||
## Create a Comic Source
|
||||
|
||||
### Preparation
|
||||
|
||||
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
|
||||
- An editor that supports javascript.
|
||||
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
|
||||
|
||||
### Start Writing
|
||||
|
||||
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
|
||||
|
||||
Here is a brief introduction to the template:
|
||||
|
||||
> Note: Javascript api document is [here](js_api.md).
|
||||
|
||||
#### Write basic information
|
||||
|
||||
```javascript
|
||||
class NewComicSource extends ComicSource {
|
||||
// Note: The fields which are marked as [Optional] should be removed if not used
|
||||
|
||||
// name of the source
|
||||
name = ""
|
||||
|
||||
// unique id of the source
|
||||
key = ""
|
||||
|
||||
version = "1.0.0"
|
||||
|
||||
minAppVersion = "1.0.0"
|
||||
|
||||
// update url
|
||||
url = ""
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
In this part, you need to do the following:
|
||||
- Change the class name to your source name.
|
||||
- Fill in the name, key, version, minAppVersion, and url fields.
|
||||
|
||||
#### init function
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* [Optional] init function
|
||||
*/
|
||||
init() {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The function will be called when the source is initialized. You can do some initialization work here.
|
||||
|
||||
Remove this function if not used.
|
||||
|
||||
#### Account
|
||||
|
||||
```javascript
|
||||
// [Optional] account related
|
||||
account = {
|
||||
/**
|
||||
* [Optional] login with account and password, return any value to indicate success
|
||||
* @param account {string}
|
||||
* @param pwd {string}
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
login: async (account, pwd) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* [Optional] login with webview
|
||||
*/
|
||||
loginWithWebview: {
|
||||
url: "",
|
||||
/**
|
||||
* check login status
|
||||
* @param url {string} - current url
|
||||
* @param title {string} - current title
|
||||
* @returns {boolean} - return true if login success
|
||||
*/
|
||||
checkStatus: (url, title) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] Callback when login success
|
||||
*/
|
||||
onLoginSuccess: () => {
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* [Optional] login with cookies
|
||||
* Note: If `this.account.login` is implemented, this will be ignored
|
||||
*/
|
||||
loginWithCookies: {
|
||||
fields: [
|
||||
"ipb_member_id",
|
||||
"ipb_pass_hash",
|
||||
"igneous",
|
||||
"star",
|
||||
],
|
||||
/**
|
||||
* Validate cookies, return false if cookies are invalid.
|
||||
*
|
||||
* Use `Network.setCookies` to set cookies before validate.
|
||||
* @param values {string[]} - same order as `fields`
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
validate: async (values) => {
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* logout function, clear account related data
|
||||
*/
|
||||
logout: () => {
|
||||
|
||||
},
|
||||
|
||||
// {string?} - register url
|
||||
registerWebsite: null
|
||||
}
|
||||
```
|
||||
|
||||
In this part, you can implement login, logout, and register functions.
|
||||
|
||||
Remove this part if not used.
|
||||
|
||||
#### Explore page
|
||||
|
||||
```javascript
|
||||
// explore page list
|
||||
explore = [
|
||||
{
|
||||
// title of the page.
|
||||
// title is used to identify the page, it should be unique
|
||||
title: "",
|
||||
|
||||
/// multiPartPage or multiPageComicList or mixed
|
||||
type: "multiPartPage",
|
||||
|
||||
/**
|
||||
* load function
|
||||
* @param page {number | null} - page number, null for `singlePageWithMultiPart` type
|
||||
* @returns {{}}
|
||||
* - for `multiPartPage` type, return {title: string, comics: Comic[], viewMore: string?}[]
|
||||
* - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number}
|
||||
* - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?}
|
||||
*/
|
||||
load: async (page) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Only use for `multiPageComicList` type.
|
||||
* `loadNext` would be ignored if `load` function is implemented.
|
||||
* @param next {string | null} - next page token, null if first page
|
||||
* @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page.
|
||||
*/
|
||||
loadNext(next) {},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In this part, you can implement the explore page.
|
||||
|
||||
A comic source can have multiple explore pages.
|
||||
|
||||
There are three types of explore pages:
|
||||
- multiPartPage: An explore page contains multiple parts, each part contains multiple comics.
|
||||
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
|
||||
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
|
||||
|
||||
#### Category Page
|
||||
|
||||
```javascript
|
||||
// categories
|
||||
category = {
|
||||
/// title of the category page, used to identify the page, it should be unique
|
||||
title: "",
|
||||
parts: [
|
||||
{
|
||||
// title of the part
|
||||
name: "Theme",
|
||||
|
||||
// fixed or random
|
||||
// if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time
|
||||
type: "fixed",
|
||||
|
||||
// number of comics to display at the same time
|
||||
// randomNumber: 5,
|
||||
|
||||
categories: ["All", "Adventure", "School"],
|
||||
|
||||
// category or search
|
||||
// if `category`, use categoryComics.load to load comics
|
||||
// if `search`, use search.load to load comics
|
||||
itemType: "category",
|
||||
|
||||
// [Optional] {string[]?} must have same length as categories, used to provide loading param for each category
|
||||
categoryParams: ["all", "adventure", "school"],
|
||||
|
||||
// [Optional] {string} cannot be used with `categoryParams`, set all category params to this value
|
||||
groupParam: null,
|
||||
}
|
||||
],
|
||||
// enable ranking page
|
||||
enableRankingPage: false,
|
||||
}
|
||||
```
|
||||
|
||||
Category page is a static page that contains multiple parts, each part contains multiple categories.
|
||||
|
||||
A comic source can only have one category page.
|
||||
|
||||
#### Category Comics Page
|
||||
|
||||
```javascript
|
||||
/// category comic loading related
|
||||
categoryComics = {
|
||||
/**
|
||||
* load comics of a category
|
||||
* @param category {string} - category name
|
||||
* @param param {string?} - category param
|
||||
* @param options {string[]} - options from optionList
|
||||
* @param page {number} - page number
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (category, param, options, page) => {
|
||||
|
||||
},
|
||||
// provide options for category comic loading
|
||||
optionList: [
|
||||
{
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"newToOld-New to Old",
|
||||
"oldToNew-Old to New"
|
||||
],
|
||||
// [Optional] {string[]} - show this option only when the value not in the list
|
||||
notShowWhen: null,
|
||||
// [Optional] {string[]} - show this option only when the value in the list
|
||||
showWhen: null
|
||||
}
|
||||
],
|
||||
ranking: {
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"day-Day",
|
||||
"week-Week"
|
||||
],
|
||||
/**
|
||||
* load ranking comics
|
||||
* @param option {string} - option from optionList
|
||||
* @param page {number} - page number
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (option, page) => {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When user clicks on a category, the category comics page will be displayed.
|
||||
|
||||
This part is used to load comics of a category.
|
||||
|
||||
#### Search
|
||||
|
||||
```javascript
|
||||
/// search related
|
||||
search = {
|
||||
/**
|
||||
* load search result
|
||||
* @param keyword {string}
|
||||
* @param options {(string | null)[]} - options from optionList
|
||||
* @param page {number}
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (keyword, options, page) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* load search result with next page token.
|
||||
* The field will be ignored if `load` function is implemented.
|
||||
* @param keyword {string}
|
||||
* @param options {(string)[]} - options from optionList
|
||||
* @param next {string | null}
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
loadNext: async (keyword, options, next) => {
|
||||
|
||||
},
|
||||
|
||||
// provide options for search
|
||||
optionList: [
|
||||
{
|
||||
// [Optional] default is `select`
|
||||
// type: select, multi-select, dropdown
|
||||
// For select, there is only one selected value
|
||||
// For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values
|
||||
// For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null
|
||||
type: "select",
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"0-time",
|
||||
"1-popular"
|
||||
],
|
||||
// option label
|
||||
label: "sort",
|
||||
// default selected options
|
||||
default: null,
|
||||
}
|
||||
],
|
||||
|
||||
// enable tags suggestions
|
||||
enableTagsSuggestions: false,
|
||||
// [Optional] handle tag suggestion click
|
||||
onTagSuggestionSelected: (namespace, tag) => {
|
||||
// return the text to insert into search box
|
||||
return `${namespace}:${tag}`
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to load search results.
|
||||
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
#### Favorites
|
||||
|
||||
```javascript
|
||||
// favorite related
|
||||
favorites = {
|
||||
// whether support multi folders
|
||||
multiFolder: false,
|
||||
/**
|
||||
* add or delete favorite.
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite
|
||||
* @param comicId {string}
|
||||
* @param folderId {string}
|
||||
* @param isAdding {boolean} - true for add, false for delete
|
||||
* @param favoriteId {string?} - [Comic.favoriteId]
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load favorite folders.
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
|
||||
* if comicId is not null, return favorite folders which contains the comic.
|
||||
* @param comicId {string?}
|
||||
* @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic
|
||||
*/
|
||||
loadFolders: async (comicId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* add a folder
|
||||
* @param name {string}
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
addFolder: async (name) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* delete a folder
|
||||
* @param folderId {string}
|
||||
* @returns {Promise<void>} - return any value to indicate success
|
||||
*/
|
||||
deleteFolder: async (folderId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load comics in a folder
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
|
||||
* @param page {number}
|
||||
* @param folder {string?} - folder id, null for non-multi-folder
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
loadComics: async (page, folder) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load comics with next page token
|
||||
* @param next {string | null} - next page token, null for first page
|
||||
* @param folder {string}
|
||||
* @returns {Promise<{comics: Comic[], next: string?}>}
|
||||
*/
|
||||
loadNext: async (next, folder) => {
|
||||
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to manage network favorites of the source.
|
||||
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
#### Comic Details
|
||||
|
||||
```javascript
|
||||
/// single comic related
|
||||
comic = {
|
||||
/**
|
||||
* load comic info
|
||||
* @param id {string}
|
||||
* @returns {Promise<ComicDetails>}
|
||||
*/
|
||||
loadInfo: async (id) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load thumbnails of a comic
|
||||
*
|
||||
* To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}`
|
||||
* - If width is not provided, use full width
|
||||
* - If height is not provided, use full height
|
||||
* @param id {string}
|
||||
* @param next {string?} - next page token, null for first page
|
||||
* @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more
|
||||
*/
|
||||
loadThumbnails: async (id, next) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* rate a comic
|
||||
* @param id
|
||||
* @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars,
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
starRating: async (id, rating) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* load images of a chapter
|
||||
* @param comicId {string}
|
||||
* @param epId {string?}
|
||||
* @returns {Promise<{images: string[]}>}
|
||||
*/
|
||||
loadEp: async (comicId, epId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] provide configs for an image loading
|
||||
* @param url
|
||||
* @param comicId
|
||||
* @param epId
|
||||
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
|
||||
*/
|
||||
onImageLoad: (url, comicId, epId) => {
|
||||
return {}
|
||||
},
|
||||
/**
|
||||
* [Optional] provide configs for a thumbnail loading
|
||||
* @param url {string}
|
||||
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
|
||||
*
|
||||
* `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored.
|
||||
* They are not supported for thumbnails.
|
||||
*/
|
||||
onThumbnailLoad: (url) => {
|
||||
return {}
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comic
|
||||
* @param id {string}
|
||||
* @param isLike {boolean} - true for like, false for unlike
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
likeComic: async (id, isLike) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load comments
|
||||
*
|
||||
* Since app version 1.0.6, rich text is supported in comments.
|
||||
* Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img'].
|
||||
* span tag supports style attribute, but only support font-weight, font-style, text-decoration.
|
||||
* All images will be placed at the end of the comment.
|
||||
* Auto link detection is enabled, but only http/https links are supported.
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param page {number}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
|
||||
*/
|
||||
loadComments: async (comicId, subId, page, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] send a comment, return any value to indicate success
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param content {string}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sendComment: async (comicId, subId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comment
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param commentId {string}
|
||||
* @param isLike {boolean} - true for like, false for unlike
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
likeComment: async (comicId, subId, commentId, isLike) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] vote a comment
|
||||
* @param id {string} - comicId
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param commentId {string} - commentId
|
||||
* @param isUp {boolean} - true for up, false for down
|
||||
* @param isCancel {boolean} - true for cancel, false for vote
|
||||
* @returns {Promise<number>} - new score
|
||||
*/
|
||||
voteComment: async (id, subId, commentId, isUp, isCancel) => {
|
||||
|
||||
},
|
||||
// {string?} - regex string, used to identify comic id from user input
|
||||
idMatch: null,
|
||||
/**
|
||||
* [Optional] Handle tag click event
|
||||
* @param namespace {string}
|
||||
* @param tag {string}
|
||||
* @returns {{action: string, keyword: string, param: string?}}
|
||||
*/
|
||||
onClickTag: (namespace, tag) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] Handle links
|
||||
*/
|
||||
link: {
|
||||
/**
|
||||
* set accepted domains
|
||||
*/
|
||||
domains: [
|
||||
'example.com'
|
||||
],
|
||||
/**
|
||||
* parse url to comic id
|
||||
* @param url {string}
|
||||
* @returns {string | null}
|
||||
*/
|
||||
linkToId: (url) => {
|
||||
|
||||
}
|
||||
},
|
||||
// enable tags translate
|
||||
enableTagsTranslate: false,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
This part is used to load comic details.
|
||||
|
||||
#### Settings
|
||||
|
||||
```javascript
|
||||
/*
|
||||
[Optional] settings related
|
||||
Use this.loadSetting to load setting
|
||||
```
|
||||
let setting1Value = this.loadSetting('setting1')
|
||||
console.log(setting1Value)
|
||||
```
|
||||
*/
|
||||
settings = {
|
||||
setting1: {
|
||||
// title
|
||||
title: "Setting1",
|
||||
// type: input, select, switch
|
||||
type: "select",
|
||||
// options
|
||||
options: [
|
||||
{
|
||||
// value
|
||||
value: 'o1',
|
||||
// [Optional] text, if not set, use value as text
|
||||
text: 'Option 1',
|
||||
},
|
||||
],
|
||||
default: 'o1',
|
||||
},
|
||||
setting2: {
|
||||
title: "Setting2",
|
||||
type: "switch",
|
||||
default: true,
|
||||
},
|
||||
setting3: {
|
||||
title: "Setting3",
|
||||
type: "input",
|
||||
validator: null, // string | null, regex string
|
||||
default: '',
|
||||
},
|
||||
setting4: {
|
||||
title: "Setting4",
|
||||
type: "callback",
|
||||
buttonText: "Click me",
|
||||
/**
|
||||
* callback function
|
||||
*
|
||||
* If the callback function returns a Promise, the button will show a loading indicator until the promise is resolved.
|
||||
* @returns {void | Promise<any>}
|
||||
*/
|
||||
callback: () => {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to provide settings for the source.
|
||||
|
||||
|
||||
#### Translations
|
||||
|
||||
```javascript
|
||||
// [Optional] translations for the strings in this config
|
||||
translation = {
|
||||
'zh_CN': {
|
||||
'Setting1': '设置1',
|
||||
'Setting2': '设置2',
|
||||
'Setting3': '设置3',
|
||||
},
|
||||
'zh_TW': {},
|
||||
'en': {}
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to provide translations for the source.
|
||||
|
||||
> Note: strings in the UI api will not be translated automatically. You need to translate them manually.
|
||||
180
doc/headless_doc.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Venera Headless Mode
|
||||
|
||||
Venera's headless mode allows you to run key features from the command line, making it easy to automate tasks and integrate with other tools. This document outlines the available commands and their usage.
|
||||
|
||||
## How to Use
|
||||
|
||||
To activate headless mode, use the `--headless` flag when running the Venera executable, followed by the desired command.
|
||||
|
||||
```bash
|
||||
venera --headless <command> [subcommand] [options]
|
||||
```
|
||||
|
||||
## Global Options
|
||||
|
||||
- **`--ignore-disheadless-log`**: Suppresses log output, providing a cleaner output for scripting.
|
||||
|
||||
## Commands
|
||||
|
||||
### `webdav`
|
||||
|
||||
Manage WebDAV data synchronization.
|
||||
|
||||
- **`webdav up`**: Uploads your local configuration to the WebDAV server.
|
||||
- **`webdav down`**: Downloads and applies the remote configuration from the WebDAV server.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
venera --headless webdav up
|
||||
```
|
||||
|
||||
### `updatescript`
|
||||
|
||||
Update comic source scripts.
|
||||
|
||||
- **`updatescript all`**: Checks for and applies all available updates for your comic source scripts.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
venera --headless updatescript all
|
||||
```
|
||||
|
||||
**Output Format:**
|
||||
|
||||
The `updatescript` command provides detailed progress and a final summary.
|
||||
|
||||
**Progress Logs:**
|
||||
|
||||
- **`Progress`**: Indicates a successful update for a single script.
|
||||
- **`ProgressError`**: Indicates a failure during a script update.
|
||||
|
||||
**Example `Progress` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "Progress",
|
||||
"data": {
|
||||
"current": 1,
|
||||
"total": 5,
|
||||
"source": {
|
||||
"key": "source-key",
|
||||
"name": "Source Name",
|
||||
"version": "1.0.0",
|
||||
"url": "https://example.com/source.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Final Summary:**
|
||||
|
||||
A summary is provided at the end, detailing the total number of scripts, how many were updated, and how many failed.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "All scripts updated.",
|
||||
"data": {
|
||||
"total": 5,
|
||||
"updated": 4,
|
||||
"errors": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `updatesubscribe`
|
||||
|
||||
Update your subscribed comics and retrieve a list of updated comics.
|
||||
|
||||
- **`updatesubscribe`**: Checks all subscribed comics for updates.
|
||||
- **`updatesubscribe --update-comic-by-id-type <id> <type>`**: Updates a single comic specified by its `id` and `type`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Update all subscriptions
|
||||
venera --headless updatesubscribe
|
||||
|
||||
# Update a single comic
|
||||
venera --headless updatesubscribe --update-comic-by-id-type "comic-id" "source-key"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
All headless commands output JSON objects prefixed with `[CLI PRINT]`. This structured format allows for easy parsing in automated scripts. The JSON object always contains a `status` and a `message`. For commands that return data, a `data` field will also be present.
|
||||
|
||||
### `updatesubscribe` Output
|
||||
|
||||
The `updatesubscribe` command provides detailed progress and final results in JSON format.
|
||||
|
||||
**Progress Logs:**
|
||||
|
||||
During an update, you will receive `Progress` or `ProgressError` messages.
|
||||
|
||||
- **`Progress`**: Indicates a successful step in the update process.
|
||||
- **`ProgressError`**: Indicates an error occurred while updating a specific comic.
|
||||
|
||||
**Example `Progress` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "Progress",
|
||||
"data": {
|
||||
"current": 1,
|
||||
"total": 10,
|
||||
"comic": {
|
||||
"id": "some-comic-id",
|
||||
"name": "Some Comic Name",
|
||||
"coverUrl": "https://example.com/cover.jpg",
|
||||
"author": "Author Name",
|
||||
"type": "source-key",
|
||||
"updateTime": "2023-10-27T12:00:00Z",
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example `ProgressError` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "ProgressError",
|
||||
"data": {
|
||||
"current": 2,
|
||||
"total": 10,
|
||||
"comic": {
|
||||
"id": "another-comic-id",
|
||||
"name": "Another Comic Name",
|
||||
...
|
||||
},
|
||||
"error": "Error message here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Final Output:**
|
||||
|
||||
Once the update process is complete, a final JSON object is returned with a list of all comics that have been updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Updated comics list.",
|
||||
"data": [
|
||||
{
|
||||
"id": "some-comic-id",
|
||||
"name": "Some Comic Name",
|
||||
"coverUrl": "https://example.com/cover.jpg",
|
||||
"author": "Author Name",
|
||||
"type": "source-key",
|
||||
"updateTime": "2023-10-27T12:00:00Z",
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
61
doc/import_comic.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Import Comic
|
||||
|
||||
## Introduction
|
||||
|
||||
Venera supports importing comics from local files.
|
||||
However, the comic files must be in a specific format.
|
||||
|
||||
## Comic Directory
|
||||
|
||||
A directory considered as a comic directory only if it follows one of the following two types of structure:
|
||||
|
||||
**Without Chapter**
|
||||
|
||||
```
|
||||
comic_directory
|
||||
├── cover.[ext]
|
||||
├── img1.[ext]
|
||||
├── img2.[ext]
|
||||
├── img3.[ext]
|
||||
├── ...
|
||||
```
|
||||
|
||||
**With Chapter**
|
||||
|
||||
```
|
||||
comic_directory
|
||||
├── cover.[ext]
|
||||
├── chapter1
|
||||
│ ├── img1.[ext]
|
||||
│ ├── img2.[ext]
|
||||
│ ├── img3.[ext]
|
||||
│ ├── ...
|
||||
├── chapter2
|
||||
│ ├── img1.[ext]
|
||||
│ ├── img2.[ext]
|
||||
│ ├── img3.[ext]
|
||||
│ ├── ...
|
||||
├── ...
|
||||
```
|
||||
|
||||
The file name can be anything, but the extension must be a valid image extension.
|
||||
|
||||
The page order is determined by the file name. App will sort the files by name and display them in that order.
|
||||
|
||||
Cover image is optional.
|
||||
If there is a file named `cover.[ext]` in the directory, it will be considered as the cover image.
|
||||
Otherwise, the first image will be considered as the cover image.
|
||||
|
||||
The name of directory will be used as comic title. And the name of chapter directory will be used as chapter title.
|
||||
|
||||
## Archive
|
||||
|
||||
Venera supports importing comics from archive files.
|
||||
|
||||
The archive file must follow [Comic Book Archive](https://en.wikipedia.org/wiki/Comic_book_archive_file) format.
|
||||
|
||||
Currently, Venera supports the following archive formats:
|
||||
- `.cbz`
|
||||
- `.cb7`
|
||||
- `.zip`
|
||||
- `.7z`
|
||||
513
doc/js_api.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Javascript API
|
||||
|
||||
## Overview
|
||||
|
||||
The Javascript API is a set of functions that used to interact application.
|
||||
|
||||
There are following parts in the API:
|
||||
- [Convert](#Convert)
|
||||
- [Network](#Network)
|
||||
- [Html](#Html)
|
||||
- [UI](#UI)
|
||||
- [Utils](#Utils)
|
||||
- [Types](#Types)
|
||||
|
||||
|
||||
## Convert
|
||||
|
||||
Convert is a set of functions that used to convert data between different types.
|
||||
|
||||
### `Convert.encodeUtf8(str: string): ArrayBuffer`
|
||||
|
||||
Convert a string to an ArrayBuffer.
|
||||
|
||||
### `Convert.decodeUtf8(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a string.
|
||||
|
||||
### `Convert.encodeBase64(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a base64 string.
|
||||
|
||||
### `Convert.decodeBase64(value: string): ArrayBuffer`
|
||||
|
||||
Convert a base64 string to an ArrayBuffer.
|
||||
|
||||
### `Convert.md5(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the md5 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha1(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha1 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha256(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha256 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha512(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha512 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.hmac(key: ArrayBuffer, value: ArrayBuffer, hash: string): ArrayBuffer`
|
||||
|
||||
Calculate the hmac hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.hmacString(key: ArrayBuffer, value: ArrayBuffer, hash: string): string`
|
||||
|
||||
Calculate the hmac hash of an ArrayBuffer and return a string.
|
||||
|
||||
### `Convert.decryptAesEcb(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES ECB mode.
|
||||
|
||||
### `Convert.decryptAesCbc(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES CBC mode.
|
||||
|
||||
### `Convert.decryptAesCfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES CFB mode.
|
||||
|
||||
### `Convert.decryptAesOfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES OFB mode.
|
||||
|
||||
### `Convert.decryptRsa(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with RSA.
|
||||
|
||||
### `Convert.hexEncode(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a hex string.
|
||||
|
||||
## Network
|
||||
|
||||
Network is a set of functions that used to send network requests and manage network resources.
|
||||
|
||||
### `Network.fetchBytes(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: ArrayBuffer}>`
|
||||
|
||||
Send a network request and return the response as an ArrayBuffer.
|
||||
|
||||
### `Network.sendRequest(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a network request and return the response as a string.
|
||||
|
||||
### `Network.get(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a GET request and return the response as a string.
|
||||
|
||||
### `Network.post(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a POST request and return the response as a string.
|
||||
|
||||
### `Network.put(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a PUT request and return the response as a string.
|
||||
|
||||
### `Network.delete(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a DELETE request and return the response as a string.
|
||||
|
||||
### `Network.patch(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a PATCH request and return the response as a string.
|
||||
|
||||
### `Network.setCookies(url: string, cookies: Cookie[]): void`
|
||||
|
||||
Set cookies for a specific url.
|
||||
|
||||
### `Network.getCookies(url: string): Cookie[]`
|
||||
|
||||
Get cookies for a specific url.
|
||||
|
||||
### `Network.deleteCookies(url: string): void`
|
||||
|
||||
Delete cookies for a specific url.
|
||||
|
||||
### `fetch`
|
||||
|
||||
The fetch function is a wrapper of the `Network.fetchBytes` function. Same as the `fetch` function in the browser.
|
||||
|
||||
## Html
|
||||
|
||||
Api for parsing HTML.
|
||||
|
||||
### `new HtmlDocument(html: string): HtmlDocument`
|
||||
|
||||
Create a HtmlDocument object from a html string.
|
||||
|
||||
### `HtmlDocument.querySelector(selector: string): HtmlElement`
|
||||
|
||||
Find the first element that matches the selector.
|
||||
|
||||
### `HtmlDocument.querySelectorAll(selector: string): HtmlElement[]`
|
||||
|
||||
Find all elements that match the selector.
|
||||
|
||||
### `HtmlDocument.getElementById(id: string): HtmlElement`
|
||||
|
||||
Find the element with the id.
|
||||
|
||||
### `HtmlDocument.dispose(): void`
|
||||
|
||||
Dispose the HtmlDocument object.
|
||||
|
||||
### `HtmlElement.querySelector(selector: string): HtmlElement`
|
||||
|
||||
Find the first element that matches the selector.
|
||||
|
||||
### `HtmlElement.querySelectorAll(selector: string): HtmlElement[]`
|
||||
|
||||
Find all elements that match the selector.
|
||||
|
||||
### `HtmlElement.getElementById(id: string): HtmlElement`
|
||||
|
||||
Find the element with the id.
|
||||
|
||||
### `get HtmlElement.text(): string`
|
||||
|
||||
Get the text content of the element.
|
||||
|
||||
### `get HtmlElement.attributes(): object`
|
||||
|
||||
Get the attributes of the element.
|
||||
|
||||
### `get HtmlElement.children(): HtmlElement[]`
|
||||
|
||||
Get the children
|
||||
|
||||
### `get HtmlElement.nodes(): HtmlNode[]`
|
||||
|
||||
Get the child nodes
|
||||
|
||||
### `get HtmlElement.parent(): HtmlElement | null`
|
||||
|
||||
Get the parent element
|
||||
|
||||
### `get HtmlElement.innerHtml(): string`
|
||||
|
||||
Get the inner html
|
||||
|
||||
### `get HtmlElement.classNames(): string[]`
|
||||
|
||||
Get the class names
|
||||
|
||||
### `get HtmlElement.id(): string | null`
|
||||
|
||||
Get the id
|
||||
|
||||
### `get HtmlElement.localName(): string`
|
||||
|
||||
Get the local name
|
||||
|
||||
### `get HtmlElement.previousSibling(): HtmlElement | null`
|
||||
|
||||
Get the previous sibling
|
||||
|
||||
### `get HtmlElement.nextSibling(): HtmlElement | null`
|
||||
|
||||
Get the next sibling
|
||||
|
||||
### `get HtmlNode.type(): string`
|
||||
|
||||
Get the node type ("text", "element", "comment", "document", "unknown")
|
||||
|
||||
### `HtmlNode.toElement(): HtmlElement | null`
|
||||
|
||||
Convert the node to an element
|
||||
|
||||
### `get HtmlNode.text(): string`
|
||||
|
||||
Get the text content of the node
|
||||
|
||||
## UI
|
||||
|
||||
### `UI.showMessage(message: string): void`
|
||||
|
||||
Show a message.
|
||||
|
||||
### `UI.showDialog(title: string, content: string, actions: {text: string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]): void`
|
||||
|
||||
Show a dialog. Any action will close the dialog.
|
||||
|
||||
### `UI.launchUrl(url: string): void`
|
||||
|
||||
Open a url in external browser.
|
||||
|
||||
### `UI.showLoading(onCancel: () => void | null | undefined): number`
|
||||
|
||||
Show a loading dialog.
|
||||
|
||||
### `UI.cancelLoading(id: number): void`
|
||||
|
||||
Cancel a loading dialog.
|
||||
|
||||
### `UI.showInputDialog(title: string, validator: (string) => string | null | undefined): string | null`
|
||||
|
||||
Show an input dialog.
|
||||
|
||||
### `UI.showSelectDialog(title: string, options: string[], initialIndex?: number): number | null`
|
||||
|
||||
Show a select dialog.
|
||||
|
||||
## Utils
|
||||
|
||||
### `createUuid(): string`
|
||||
|
||||
create a time-based uuid.
|
||||
|
||||
### `randomInt(min: number, max: number): number`
|
||||
|
||||
Generate a random integer between min and max.
|
||||
|
||||
### `randomDouble(min: number, max: number): number`
|
||||
|
||||
Generate a random double between min and max.
|
||||
|
||||
### console
|
||||
|
||||
Send log to application console. Same api as the browser console.
|
||||
|
||||
## Types
|
||||
|
||||
### `Cookie`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Create a cookie object.
|
||||
* @param name {string}
|
||||
* @param value {string}
|
||||
* @param domain {string}
|
||||
* @constructor
|
||||
*/
|
||||
function Cookie({name, value, domain}) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.domain = domain;
|
||||
}
|
||||
```
|
||||
|
||||
### `Comic`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Create a comic object
|
||||
* @param id {string}
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param tags {string[]}
|
||||
* @param description {string}
|
||||
* @param maxPage {number?}
|
||||
* @param language {string?}
|
||||
* @param favoriteId {string?} - Only set this field if the comic is from favorites page
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @constructor
|
||||
*/
|
||||
function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.subTitle = subTitle;
|
||||
this.cover = cover;
|
||||
this.tags = tags;
|
||||
this.description = description;
|
||||
this.maxPage = maxPage;
|
||||
this.language = language;
|
||||
this.favoriteId = favoriteId;
|
||||
this.stars = stars;
|
||||
}
|
||||
```
|
||||
|
||||
### `ComicDetails`
|
||||
```javascript
|
||||
/**
|
||||
* Create a comic details object
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param description {string?}
|
||||
* @param tags {Map<string, string[]> | {} | null | undefined}
|
||||
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
|
||||
* @param subId {string?} - a param which is passed to comments api
|
||||
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
|
||||
* @param recommend {Comic[]?} - related comics
|
||||
* @param commentCount {number?}
|
||||
* @param likesCount {number?}
|
||||
* @param isLiked {boolean?}
|
||||
* @param uploader {string?}
|
||||
* @param updateTime {string?}
|
||||
* @param uploadTime {string?}
|
||||
* @param url {string?}
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @param maxPage {number?}
|
||||
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
|
||||
* @constructor
|
||||
*/
|
||||
function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle ?? subTitle;
|
||||
this.cover = cover;
|
||||
this.description = description;
|
||||
this.tags = tags;
|
||||
this.chapters = chapters;
|
||||
this.isFavorite = isFavorite;
|
||||
this.subId = subId;
|
||||
this.thumbnails = thumbnails;
|
||||
this.recommend = recommend;
|
||||
this.commentCount = commentCount;
|
||||
this.likesCount = likesCount;
|
||||
this.isLiked = isLiked;
|
||||
this.uploader = uploader;
|
||||
this.updateTime = updateTime;
|
||||
this.uploadTime = uploadTime;
|
||||
this.url = url;
|
||||
this.stars = stars;
|
||||
this.maxPage = maxPage;
|
||||
this.comments = comments;
|
||||
}
|
||||
```
|
||||
|
||||
### `Comment`
|
||||
```javascript
|
||||
/**
|
||||
* Create a comment object
|
||||
* @param userName {string}
|
||||
* @param avatar {string?}
|
||||
* @param content {string}
|
||||
* @param time {string?}
|
||||
* @param replyCount {number?}
|
||||
* @param id {string?}
|
||||
* @param isLiked {boolean?}
|
||||
* @param score {number?}
|
||||
* @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none
|
||||
* @constructor
|
||||
*/
|
||||
function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) {
|
||||
this.userName = userName;
|
||||
this.avatar = avatar;
|
||||
this.content = content;
|
||||
this.time = time;
|
||||
this.replyCount = replyCount;
|
||||
this.id = id;
|
||||
this.isLiked = isLiked;
|
||||
this.score = score;
|
||||
this.voteStatus = voteStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### `ImageLoadingConfig`
|
||||
```javascript
|
||||
/**
|
||||
* Create image loading config
|
||||
* @param url {string?}
|
||||
* @param method {string?} - http method, uppercase
|
||||
* @param data {any} - request data, may be null
|
||||
* @param headers {Object?} - request headers
|
||||
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
|
||||
* @param modifyImage {string?}
|
||||
* A js script string.
|
||||
* The script will be executed in a new Isolate.
|
||||
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
|
||||
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
|
||||
* @constructor
|
||||
* @since 1.0.5
|
||||
*
|
||||
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
|
||||
*/
|
||||
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
|
||||
this.url = url;
|
||||
this.method = method;
|
||||
this.data = data;
|
||||
this.headers = headers;
|
||||
this.onResponse = onResponse;
|
||||
this.modifyImage = modifyImage;
|
||||
this.onLoadFailed = onLoadFailed;
|
||||
}
|
||||
```
|
||||
|
||||
### `ComicSource`
|
||||
```javascript
|
||||
class ComicSource {
|
||||
name = ""
|
||||
|
||||
key = ""
|
||||
|
||||
version = ""
|
||||
|
||||
minAppVersion = ""
|
||||
|
||||
url = ""
|
||||
|
||||
/**
|
||||
* load data with its key
|
||||
* @param {string} dataKey
|
||||
* @returns {any}
|
||||
*/
|
||||
loadData(dataKey) {
|
||||
return sendMessage({
|
||||
method: 'load_data',
|
||||
key: this.key,
|
||||
data_key: dataKey
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* load a setting with its key
|
||||
* @param key {string}
|
||||
* @returns {any}
|
||||
*/
|
||||
loadSetting(key) {
|
||||
return sendMessage({
|
||||
method: 'load_setting',
|
||||
key: this.key,
|
||||
setting_key: key
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* save data
|
||||
* @param {string} dataKey
|
||||
* @param data
|
||||
*/
|
||||
saveData(dataKey, data) {
|
||||
return sendMessage({
|
||||
method: 'save_data',
|
||||
key: this.key,
|
||||
data_key: dataKey,
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* delete data
|
||||
* @param {string} dataKey
|
||||
*/
|
||||
deleteData(dataKey) {
|
||||
return sendMessage({
|
||||
method: 'delete_data',
|
||||
key: this.key,
|
||||
data_key: dataKey,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isLogged() {
|
||||
return sendMessage({
|
||||
method: 'isLogged',
|
||||
key: this.key,
|
||||
});
|
||||
}
|
||||
|
||||
init() { }
|
||||
|
||||
static sources = {}
|
||||
}
|
||||
```
|
||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
<p>A comic reader that support reading local and network comics.</p>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Read local comics</li>
|
||||
<li>Use javascript to create comic sources</li>
|
||||
<li>Read comics from network sources</li>
|
||||
<li>Manage favorite comics</li>
|
||||
<li>Download comics</li>
|
||||
<li>View comments, tags, and other information of comics if the source supports</li>
|
||||
<li>Login to comment, rate, and other operations if the source supports</li>
|
||||
</ul>
|
||||
<h3>Thanks</h3>
|
||||
<h4>Tags Translation</h4>
|
||||
<li><a href="https://github.com/EhTagTranslation/Database">github.com/EhTagTranslation/Database</a></li>
|
||||
<p>The Chinese translation of the manga tags is from this project.</p>
|
||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
A comic reader that support reading local and network comics.
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
venera
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '14.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -59,6 +60,7 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryPicker.swift; sourceTree = "<group>"; };
|
||||
C22B8A9F3177D4A68EB8F66B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -133,6 +135,7 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -144,7 +147,6 @@
|
||||
730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */,
|
||||
29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -336,6 +338,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import Foundation // 添加此行
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate {
|
||||
var flutterResult: FlutterResult?
|
||||
var directoryPath: URL!
|
||||
|
||||
// 定义插件通道名称
|
||||
private var directoryPicker: DirectoryPicker?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -42,6 +46,9 @@ import UniformTypeIdentifiers
|
||||
self.directoryPath?.stopAccessingSecurityScopedResource()
|
||||
self.directoryPath = nil
|
||||
result(nil)
|
||||
} else if call.method == "selectDirectory" {
|
||||
self.directoryPicker = DirectoryPicker()
|
||||
self.directoryPicker?.selectDirectory(result: result)
|
||||
} else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
36
ios/Runner/DirectoryPicker.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
|
||||
class DirectoryPicker: NSObject, UIDocumentPickerDelegate {
|
||||
private var result: FlutterResult?
|
||||
|
||||
// 初始化选择目录方法
|
||||
func selectDirectory(result: @escaping FlutterResult) {
|
||||
self.result = result
|
||||
|
||||
// 配置 UIDocumentPicker 为目录选择模式
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = false
|
||||
|
||||
// 获取根视图控制器并显示选择器
|
||||
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
|
||||
rootViewController.present(documentPicker, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理选择完成后的结果
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
// 获取选中的路径
|
||||
if let url = urls.first {
|
||||
result?(url.path)
|
||||
} else {
|
||||
result?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理取消选择情况
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
result?(nil)
|
||||
}
|
||||
}
|
||||
@@ -47,5 +47,13 @@
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Choose images</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Ensure that the operation is being performed by the user themselves.</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.books</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const Appbar(
|
||||
{required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
super.key});
|
||||
const Appbar({
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
this.style = AppbarStyle.blur,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
|
||||
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final AppbarStyle style;
|
||||
|
||||
@override
|
||||
State<Appbar> createState() => _AppbarState();
|
||||
|
||||
@@ -76,7 +80,7 @@ class _AppbarState extends State<Appbar> {
|
||||
var content = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor ??
|
||||
context.colorScheme.surface.withOpacity(0.72),
|
||||
context.colorScheme.surface.toOpacity(0.86),
|
||||
),
|
||||
height: _kAppBarHeight + context.padding.top,
|
||||
child: Row(
|
||||
@@ -108,13 +112,26 @@ class _AppbarState extends State<Appbar> {
|
||||
],
|
||||
).paddingTop(context.padding.top),
|
||||
);
|
||||
return BlurEffect(
|
||||
blur: _scrolledUnder ? 15 : 0,
|
||||
child: content,
|
||||
);
|
||||
if (widget.style == AppbarStyle.shadow) {
|
||||
return Material(
|
||||
color: context.colorScheme.surface,
|
||||
elevation: _scrolledUnder ? 2 : 0,
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
return BlurEffect(
|
||||
blur: _scrolledUnder ? 15 : 0,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppbarStyle {
|
||||
blur,
|
||||
shadow,
|
||||
}
|
||||
|
||||
class SliverAppbar extends StatelessWidget {
|
||||
const SliverAppbar({
|
||||
super.key,
|
||||
@@ -122,6 +139,7 @@ class SliverAppbar extends StatelessWidget {
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.radius = 0,
|
||||
this.style = AppbarStyle.blur,
|
||||
});
|
||||
|
||||
final Widget? leading;
|
||||
@@ -132,6 +150,8 @@ class SliverAppbar extends StatelessWidget {
|
||||
|
||||
final double radius;
|
||||
|
||||
final AppbarStyle style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPersistentHeader(
|
||||
@@ -142,6 +162,7 @@ class SliverAppbar extends StatelessWidget {
|
||||
actions: actions,
|
||||
topPadding: MediaQuery.of(context).padding.top,
|
||||
radius: radius,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -160,57 +181,73 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
|
||||
final double radius;
|
||||
|
||||
_MySliverAppBarDelegate(
|
||||
{this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
required this.topPadding,
|
||||
this.radius = 0});
|
||||
final AppbarStyle style;
|
||||
|
||||
_MySliverAppBarDelegate({
|
||||
this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
required this.topPadding,
|
||||
this.radius = 0,
|
||||
this.style = AppbarStyle.blur,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return SizedBox.expand(
|
||||
child: BlurEffect(
|
||||
blur: 15,
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.withOpacity(0.72),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding),
|
||||
var body = Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding);
|
||||
|
||||
if (style == AppbarStyle.blur) {
|
||||
return SizedBox.expand(
|
||||
child: BlurEffect(
|
||||
blur: 15,
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.86),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
color: context.colorScheme.surface,
|
||||
elevation: shrinkOffset == 0 ? 0 : 2,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -224,22 +261,35 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
return oldDelegate is! _MySliverAppBarDelegate ||
|
||||
leading != oldDelegate.leading ||
|
||||
title != oldDelegate.title ||
|
||||
actions != oldDelegate.actions;
|
||||
actions != oldDelegate.actions ||
|
||||
topPadding != oldDelegate.topPadding ||
|
||||
radius != oldDelegate.radius ||
|
||||
style != oldDelegate.style;
|
||||
}
|
||||
}
|
||||
|
||||
class FilledTabBar extends StatefulWidget {
|
||||
const FilledTabBar({super.key, this.controller, required this.tabs});
|
||||
class AppTabBar extends StatefulWidget {
|
||||
const AppTabBar({
|
||||
super.key,
|
||||
this.controller,
|
||||
required this.tabs,
|
||||
this.actionButton,
|
||||
this.withUnderLine = true,
|
||||
});
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
final List<Tab> tabs;
|
||||
|
||||
final Widget? actionButton;
|
||||
|
||||
final bool withUnderLine;
|
||||
|
||||
@override
|
||||
State<FilledTabBar> createState() => _FilledTabBarState();
|
||||
State<AppTabBar> createState() => _AppTabBarState();
|
||||
}
|
||||
|
||||
class _FilledTabBarState extends State<FilledTabBar> {
|
||||
class _AppTabBarState extends State<AppTabBar> {
|
||||
late TabController _controller;
|
||||
|
||||
late List<GlobalKey> keys;
|
||||
@@ -269,16 +319,25 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
PageStorageBucket get bucket => PageStorage.of(context);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
initPainter();
|
||||
super.didChangeDependencies();
|
||||
var prevIndex = bucket.readState(context) as int?;
|
||||
if (prevIndex != null &&
|
||||
prevIndex != _controller.index &&
|
||||
prevIndex >= 0 &&
|
||||
prevIndex < widget.tabs.length) {
|
||||
_controller.index = prevIndex;
|
||||
}
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilledTabBar oldWidget) {
|
||||
void didUpdateWidget(covariant AppTabBar oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
@@ -303,7 +362,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
animation: _controller.animation ?? _controller,
|
||||
builder: buildTabBar,
|
||||
);
|
||||
}
|
||||
@@ -318,6 +377,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
controller: scrollController,
|
||||
builder: (context, controller, physics) {
|
||||
return SingleChildScrollView(
|
||||
key: const PageStorageKey('scroll'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
controller: controller,
|
||||
@@ -328,25 +388,29 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
callback: _tabLayoutCallback,
|
||||
children: List.generate(widget.tabs.length, buildTab),
|
||||
children: List.generate(widget.tabs.length, buildTab)
|
||||
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
|
||||
),
|
||||
).paddingHorizontal(4),
|
||||
);
|
||||
},
|
||||
);
|
||||
return Container(
|
||||
key: tabBarKey,
|
||||
height: _kTabHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child);
|
||||
key: tabBarKey,
|
||||
height: _kTabHeight,
|
||||
width: double.infinity,
|
||||
decoration: widget.withUnderLine
|
||||
? BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child,
|
||||
);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
@@ -358,6 +422,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
}
|
||||
updateScrollOffset(i);
|
||||
previousIndex = i;
|
||||
bucket.writeState(context, i);
|
||||
}
|
||||
|
||||
void updateScrollOffset(int i) {
|
||||
@@ -369,10 +434,14 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
final double tabWidth = tabRight - tabLeft;
|
||||
final double tabCenter = tabLeft + tabWidth / 2;
|
||||
final double tabBarWidth = tabBarBox.size.width;
|
||||
final double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
if (scrollOffset == scrollController.offset) {
|
||||
return;
|
||||
}
|
||||
scrollOffset = scrollOffset.clamp(
|
||||
0.0,
|
||||
scrollController.position.maxScrollExtent,
|
||||
);
|
||||
scrollController.animateTo(
|
||||
scrollOffset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -394,7 +463,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: i == _controller.index
|
||||
color: i == _controller.animation?.value.round()
|
||||
? context.colorScheme.primary
|
||||
: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -501,7 +570,7 @@ class _IndicatorPainter extends CustomPainter {
|
||||
|
||||
var rect = Rect.fromLTWH(
|
||||
tabLeft + padding.left + horizontalPadding,
|
||||
_FilledTabBarState._kTabHeight - 3.6,
|
||||
_AppTabBarState._kTabHeight - 3.6,
|
||||
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
|
||||
3,
|
||||
);
|
||||
@@ -534,6 +603,51 @@ class _IndicatorPainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
class TabViewBody extends StatefulWidget {
|
||||
/// Create a tab view body, which will show the child at the current tab index.
|
||||
const TabViewBody({super.key, required this.children, this.controller});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
@override
|
||||
State<TabViewBody> createState() => _TabViewBodyState();
|
||||
}
|
||||
|
||||
class _TabViewBodyState extends State<TabViewBody> {
|
||||
late TabController _controller;
|
||||
|
||||
int _currentIndex = 0;
|
||||
|
||||
void updateIndex() {
|
||||
if (_controller.index != _currentIndex) {
|
||||
setState(() {
|
||||
_currentIndex = _controller.index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_currentIndex = _controller.index;
|
||||
_controller.addListener(updateIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_controller.removeListener(updateIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.children[_currentIndex];
|
||||
}
|
||||
}
|
||||
|
||||
class SearchBarController {
|
||||
_SearchBarMixin? _state;
|
||||
|
||||
@@ -691,6 +805,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
editingController.clear();
|
||||
onChanged?.call("");
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -805,3 +920,42 @@ class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TabActionButton extends StatelessWidget {
|
||||
const TabActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Icon icon;
|
||||
|
||||
final String text;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
static const _kTabHeight = 46.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: _kTabHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(size: 20, color: context.colorScheme.primary),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Text(text, style: ts.withColor(context.colorScheme.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ class _ButtonState extends State<Button> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var padding = widget.padding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4);
|
||||
const EdgeInsets.symmetric(horizontal: 16);
|
||||
var width = widget.width;
|
||||
if (width != null) {
|
||||
width = width - padding.horizontal;
|
||||
@@ -206,6 +206,7 @@ class _ButtonState extends State<Button> {
|
||||
padding: padding,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 76,
|
||||
minHeight: 32,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
@@ -213,7 +214,7 @@ class _ButtonState extends State<Button> {
|
||||
boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal))
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.toOpacity(0.1),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
@@ -247,7 +248,7 @@ class _ButtonState extends State<Button> {
|
||||
if (widget.type == ButtonType.filled) {
|
||||
var color = widget.color ?? context.colorScheme.primary;
|
||||
if (isHover) {
|
||||
return color.withOpacity(0.9);
|
||||
return color.toOpacity(0.9);
|
||||
} else {
|
||||
return color;
|
||||
}
|
||||
@@ -255,13 +256,13 @@ class _ButtonState extends State<Button> {
|
||||
if (widget.type == ButtonType.normal) {
|
||||
var color = widget.color ?? context.colorScheme.surfaceContainer;
|
||||
if (isHover) {
|
||||
return color.withOpacity(0.9);
|
||||
return color.toOpacity(0.9);
|
||||
} else {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
if (isHover) {
|
||||
return context.colorScheme.outline.withOpacity(0.2);
|
||||
return context.colorScheme.outline.toOpacity(0.2);
|
||||
}
|
||||
return Colors.transparent;
|
||||
}
|
||||
@@ -344,7 +345,7 @@ class _IconButtonState extends State<_IconButton> {
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.4)
|
||||
.toOpacity(0.4)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular((iconSize + 12) / 2),
|
||||
),
|
||||
|
||||
383
lib/components/code.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class CodeEditor extends StatefulWidget {
|
||||
const CodeEditor({super.key, this.initialValue, this.onChanged});
|
||||
|
||||
final String? initialValue;
|
||||
|
||||
final void Function(String value)? onChanged;
|
||||
|
||||
@override
|
||||
State<CodeEditor> createState() => _CodeEditorState();
|
||||
}
|
||||
|
||||
class _CodeEditorState extends State<CodeEditor> {
|
||||
late _CodeTextEditingController _controller;
|
||||
late FocusNode _focusNode;
|
||||
var horizontalScrollController = ScrollController();
|
||||
var verticalScrollController = ScrollController();
|
||||
int lineCount = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = _CodeTextEditingController(text: widget.initialValue);
|
||||
_focusNode = FocusNode()
|
||||
..onKeyEvent = (node, event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
||||
if (event is KeyDownEvent) {
|
||||
handleTab();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
lineCount = calculateLineCount(widget.initialValue ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
future = _controller.init(context.brightness);
|
||||
}
|
||||
|
||||
void handleTab() {
|
||||
var text = _controller.text;
|
||||
var start = _controller.selection.start;
|
||||
var end = _controller.selection.end;
|
||||
_controller.text = '${text.substring(0, start)} ${text.substring(end)}';
|
||||
_controller.selection = TextSelection.collapsed(offset: start + 4);
|
||||
}
|
||||
|
||||
int calculateLineCount(String text) {
|
||||
return text.split('\n').length;
|
||||
}
|
||||
|
||||
Widget buildLineNumbers() {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 1; i <= lineCount; i++)
|
||||
SizedBox(
|
||||
height: 14 * 1.5,
|
||||
child: Center(
|
||||
child: Text(
|
||||
i.toString(),
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.outline,
|
||||
fontSize: 13,
|
||||
height: 1.0,
|
||||
fontFamily: 'Consolas',
|
||||
fontFamilyFallback: ['Courier New', 'monospace'],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingVertical(8);
|
||||
}
|
||||
|
||||
late Future future;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, value) {
|
||||
if (value.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_controller.selection = TextSelection.collapsed(
|
||||
offset: _controller.text.length,
|
||||
);
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: verticalScrollController,
|
||||
notificationPredicate: (notif) =>
|
||||
notif.metrics.axis == Axis.vertical,
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: horizontalScrollController,
|
||||
notificationPredicate: (notif) =>
|
||||
notif.metrics.axis == Axis.horizontal,
|
||||
child: SizedBox.expand(
|
||||
child: ScrollConfiguration(
|
||||
behavior: _CustomScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: horizontalScrollController,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: verticalScrollController,
|
||||
child: Row(
|
||||
children: [
|
||||
buildLineNumbers(),
|
||||
IntrinsicWidth(
|
||||
stepWidth: 100,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
maxLines: null,
|
||||
cursorHeight: 1.5 * 14,
|
||||
style: TextStyle(height: 1.5, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(8),
|
||||
),
|
||||
onChanged: (value) {
|
||||
widget.onChanged?.call(value);
|
||||
if (lineCount != calculateLineCount(value)) {
|
||||
setState(() {
|
||||
lineCount = calculateLineCount(value);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomScrollBehavior extends MaterialScrollBehavior {
|
||||
const _CustomScrollBehavior();
|
||||
@override
|
||||
Widget buildScrollbar(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeTextEditingController extends TextEditingController {
|
||||
_CodeTextEditingController({super.text});
|
||||
|
||||
HighlighterTheme? _theme;
|
||||
|
||||
Future<void> init(Brightness brightness) async {
|
||||
Highlighter.addLanguage('js', _jsGrammer);
|
||||
_theme = await HighlighterTheme.loadForBrightness(brightness);
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan(
|
||||
{required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing}) {
|
||||
var highlighter = Highlighter(
|
||||
language: 'js',
|
||||
theme: _theme!,
|
||||
);
|
||||
var result = highlighter.highlight(text);
|
||||
style = TextStyle(
|
||||
height: 1.5,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas',
|
||||
fontFamilyFallback: ['Courier New', 'Roboto Mono', 'monospace'],
|
||||
);
|
||||
|
||||
return mergeTextStyle(result, style);
|
||||
}
|
||||
|
||||
TextSpan mergeTextStyle(TextSpan span, TextStyle style) {
|
||||
var result = TextSpan(
|
||||
style: style.merge(span.style),
|
||||
children: span.children
|
||||
?.whereType()
|
||||
.map((e) => mergeTextStyle(e, style))
|
||||
.toList(),
|
||||
text: span.text,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const _jsGrammer = r'''
|
||||
{
|
||||
"name": "JavaScript",
|
||||
"version": "1.0.0",
|
||||
"fileTypes": ["js", "mjs", "cjs"],
|
||||
"scopeName": "source.js",
|
||||
|
||||
"foldingStartMarker": "\\{\\s*$",
|
||||
"foldingStopMarker": "^\\s*\\}",
|
||||
|
||||
"patterns": [
|
||||
{
|
||||
"name": "meta.preprocessor.script.js",
|
||||
"match": "^(#!.*)$"
|
||||
},
|
||||
{
|
||||
"name": "meta.import-export.js",
|
||||
"begin": "\\b(import|export)\\b",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "keyword.control.import.js"
|
||||
}
|
||||
},
|
||||
"end": ";",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.terminator.js"
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#strings"
|
||||
},
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"name": "keyword.control.import.js",
|
||||
"match": "\\b(as|from)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"include": "#keywords"
|
||||
},
|
||||
{
|
||||
"include": "#constants-and-special-vars"
|
||||
},
|
||||
{
|
||||
"include": "#operators"
|
||||
},
|
||||
{
|
||||
"include": "#strings"
|
||||
}
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"comments": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "comment.block.js",
|
||||
"begin": "/\\*",
|
||||
"end": "\\*/"
|
||||
},
|
||||
{
|
||||
"name": "comment.line.double-slash.js",
|
||||
"match": "//.*$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keywords": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.control.js",
|
||||
"match": "\\b(if|else|for|while|do|switch|case|default|break|continue|return|throw|try|catch|finally)\\b"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.js",
|
||||
"match": "\\b(instanceof|typeof|new|delete|in|void)\\b"
|
||||
},
|
||||
{
|
||||
"name": "storage.type.js",
|
||||
"match": "\\b(var|let|const|function|class|extends)\\b"
|
||||
},
|
||||
{
|
||||
"name": "keyword.declaration.js",
|
||||
"match": "\\b(export|import|default)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"constants-and-special-vars": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "constant.language.js",
|
||||
"match": "\\b(true|false|null|undefined|NaN|Infinity)\\b"
|
||||
},
|
||||
{
|
||||
"name": "constant.numeric.js",
|
||||
"match": "\\b(0x[0-9A-Fa-f]+|[0-9]+\\.?[0-9]*(e[+-]?[0-9]+)?)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operators": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.operator.assignment.js",
|
||||
"match": "(=|\\+=|-=|\\*=|/=|%=|\\|=|&=|\\^=|<<=|>>=|>>>=)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.comparison.js",
|
||||
"match": "(==|!=|===|!==|<|<=|>|>=)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.logical.js",
|
||||
"match": "(&&|\\|\\||!)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.arithmetic.js",
|
||||
"match": "(-|\\+|\\*|/|%)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.bitwise.js",
|
||||
"match": "(\\||&|\\^|~|<<|>>|>>>)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strings": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "string.quoted.double.js",
|
||||
"begin": "\"",
|
||||
"end": "\"",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "string.quoted.single.js",
|
||||
"begin": "'",
|
||||
"end": "'",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "string.template.js",
|
||||
"begin": "`",
|
||||
"end": "`",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"string-interpolation": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "variable.parameter.js",
|
||||
"begin": "\\$\\{",
|
||||
"end": "\\}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -1,7 +1,6 @@
|
||||
library components;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
@@ -10,6 +9,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:syntax_highlight/syntax_highlight.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/app_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
@@ -19,10 +19,13 @@ import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/image_provider/history_image_provider.dart';
|
||||
import 'package:venera/foundation/image_provider/local_comic_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/cloudflare.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
@@ -45,4 +48,5 @@ part 'select.dart';
|
||||
part 'side_bar.dart';
|
||||
part 'comic.dart';
|
||||
part 'effects.dart';
|
||||
part 'gesture.dart';
|
||||
part 'gesture.dart';
|
||||
part 'code.dart';
|
||||
225
lib/components/custom_slider.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
||||
/// patched slider.dart with RtL support
|
||||
class _SliderDefaultsM3 extends SliderThemeData {
|
||||
_SliderDefaultsM3(this.context)
|
||||
: super(trackHeight: 4.0);
|
||||
|
||||
final BuildContext context;
|
||||
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
||||
|
||||
@override
|
||||
Color? get activeTrackColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
|
||||
|
||||
@override
|
||||
Color? get secondaryActiveTrackColor => _colors.primary.toOpacity(0.54);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTrackColor => _colors.onSurface.toOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTrackColor => _colors.onSurface.toOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.toOpacity(0.12);
|
||||
|
||||
@override
|
||||
Color? get activeTickMarkColor => _colors.onPrimary.toOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.toOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledActiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get disabledInactiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
|
||||
|
||||
@override
|
||||
Color? get thumbColor => _colors.primary;
|
||||
|
||||
@override
|
||||
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.toOpacity(0.38), _colors.surface);
|
||||
|
||||
@override
|
||||
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.dragged)) {
|
||||
return _colors.primary.toOpacity(0.1);
|
||||
}
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return _colors.primary.toOpacity(0.08);
|
||||
}
|
||||
if (states.contains(WidgetState.focused)) {
|
||||
return _colors.primary.toOpacity(0.1);
|
||||
}
|
||||
|
||||
return Colors.transparent;
|
||||
});
|
||||
|
||||
@override
|
||||
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
|
||||
color: _colors.onPrimary,
|
||||
);
|
||||
|
||||
@override
|
||||
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
|
||||
}
|
||||
|
||||
class CustomSlider extends StatefulWidget {
|
||||
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, required this.focusNode, this.reversed = false, super.key});
|
||||
|
||||
final double min;
|
||||
|
||||
final double max;
|
||||
|
||||
final double value;
|
||||
|
||||
final int divisions;
|
||||
|
||||
final void Function(double) onChanged;
|
||||
|
||||
final FocusNode? focusNode;
|
||||
|
||||
final bool reversed;
|
||||
|
||||
@override
|
||||
State<CustomSlider> createState() => _CustomSliderState();
|
||||
}
|
||||
|
||||
class _CustomSliderState extends State<CustomSlider> {
|
||||
late double value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
value = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomSlider oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
setState(() {
|
||||
value = widget.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final theme = _SliderDefaultsM3(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
child: widget.max - widget.min > 0 ? LayoutBuilder(
|
||||
builder: (context, constraints) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapDown: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(widget.reversed){
|
||||
dx = constraints.maxWidth - dx;
|
||||
}
|
||||
var gap = constraints.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
onVerticalDragUpdate: (details){
|
||||
var dx = details.localPosition.dx;
|
||||
if(dx > constraints.maxWidth || dx < 0) return;
|
||||
if(widget.reversed){
|
||||
dx = constraints.maxWidth - dx;
|
||||
}
|
||||
var gap = constraints.maxWidth / widget.divisions;
|
||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.inactiveTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if(constraints.maxWidth / widget.divisions > 10)
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: (){
|
||||
var res = <Widget>[];
|
||||
for(int i = 0; i<widget.divisions-1; i++){
|
||||
res.add(const Spacer());
|
||||
res.add(Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withRed(10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
));
|
||||
}
|
||||
res.add(const Spacer());
|
||||
return res;
|
||||
}.call(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : 0,
|
||||
right: widget.reversed ? 0 : null,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
right: !widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.activeTrackColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ class Flyout extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<Flyout> createState() => FlyoutState();
|
||||
|
||||
static FlyoutState of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<FlyoutState>()!;
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutState extends State<Flyout> {
|
||||
@@ -137,7 +141,7 @@ class FlyoutState extends State<Flyout> {
|
||||
animation: animation,
|
||||
builder: (context, builder) {
|
||||
return ColoredBox(
|
||||
color: Colors.black.withOpacity(0.3 * animation.value),
|
||||
color: Colors.black.toOpacity(0.3 * animation.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -181,12 +185,18 @@ class FlyoutContent extends StatelessWidget {
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
type: MaterialType.card,
|
||||
color: context.colorScheme.surface.withOpacity(0.82),
|
||||
color: context.colorScheme.surface.toOpacity(0.82),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: minFlyoutWidth,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: context.brightness == ui.Brightness.dark
|
||||
? Border.all(color: context.colorScheme.outlineVariant)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -211,108 +221,3 @@ class FlyoutContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutTextButton extends StatefulWidget {
|
||||
const FlyoutTextButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutTextButton> createState() => _FlyoutTextButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutTextButtonState extends State<FlyoutTextButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutIconButton extends StatefulWidget {
|
||||
const FlyoutIconButton(
|
||||
{super.key,
|
||||
required this.icon,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutIconButton> createState() => _FlyoutIconButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutIconButtonState extends State<FlyoutIconButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
icon: widget.icon,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutFilledButton extends StatefulWidget {
|
||||
const FlyoutFilledButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.flyoutBuilder,
|
||||
this.navigator});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final WidgetBuilder flyoutBuilder;
|
||||
|
||||
final NavigatorState? navigator;
|
||||
|
||||
@override
|
||||
State<FlyoutFilledButton> createState() => _FlyoutFilledButtonState();
|
||||
}
|
||||
|
||||
class _FlyoutFilledButtonState extends State<FlyoutFilledButton> {
|
||||
final FlyoutController _controller = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flyout(
|
||||
controller: _controller,
|
||||
flyoutBuilder: widget.flyoutBuilder,
|
||||
navigator: widget.navigator,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
_controller.show();
|
||||
},
|
||||
child: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class MouseBackDetector extends StatelessWidget {
|
||||
const MouseBackDetector({super.key, required this.onTapDown, required this.child});
|
||||
const MouseBackDetector(
|
||||
{super.key, required this.onTapDown, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@@ -20,3 +21,52 @@ class MouseBackDetector extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedTapRegion extends StatefulWidget {
|
||||
const AnimatedTapRegion({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onTap,
|
||||
this.borderRadius = 0,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final void Function() onTap;
|
||||
|
||||
final double borderRadius;
|
||||
|
||||
@override
|
||||
State<AnimatedTapRegion> createState() => _AnimatedTapRegionState();
|
||||
}
|
||||
|
||||
class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
|
||||
bool isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) {
|
||||
setState(() {
|
||||
isHovered = true;
|
||||
});
|
||||
},
|
||||
onExit: (_) {
|
||||
setState(() {
|
||||
isHovered = false;
|
||||
});
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedPhysicalModel(
|
||||
duration: _fastAnimationDuration,
|
||||
elevation: isHovered ? 3 : 1,
|
||||
color: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class AnimatedImage extends StatefulWidget {
|
||||
this.filterQuality = FilterQuality.medium,
|
||||
this.isAntiAlias = false,
|
||||
this.part,
|
||||
this.onError,
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
@@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget {
|
||||
|
||||
final ImagePart? part;
|
||||
|
||||
final Function? onError;
|
||||
|
||||
static void clear() => _AnimatedImageState.clear();
|
||||
|
||||
@override
|
||||
@@ -169,6 +172,8 @@ class _AnimatedImageState extends State<AnimatedImage>
|
||||
_handleImageFrame,
|
||||
onChunk: _handleImageChunk,
|
||||
onError: (Object error, StackTrace? stackTrace) {
|
||||
// 图片加错错误回调
|
||||
widget.onError?.call(error, stackTrace);
|
||||
setState(() {
|
||||
_lastException = error;
|
||||
});
|
||||
@@ -271,36 +276,39 @@ class _AnimatedImageState extends State<AnimatedImage>
|
||||
Widget result;
|
||||
|
||||
if (_imageInfo != null) {
|
||||
if(widget.part != null) {
|
||||
return CustomPaint(
|
||||
if (widget.part != null) {
|
||||
result = CustomPaint(
|
||||
isComplex: true,
|
||||
painter: ImagePainter(
|
||||
image: _imageInfo!.image,
|
||||
part: widget.part!,
|
||||
fit: widget.fit ?? BoxFit.cover,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
result = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: BoxFit.cover,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
}
|
||||
result = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
fit: BoxFit.cover,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(Icons.error),
|
||||
@@ -357,10 +365,13 @@ class ImagePainter extends CustomPainter {
|
||||
|
||||
final ImagePart part;
|
||||
|
||||
final BoxFit fit;
|
||||
|
||||
/// Render a part of the image.
|
||||
const ImagePainter({
|
||||
required this.image,
|
||||
this.part = const ImagePart(),
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -372,7 +383,8 @@ class ImagePainter extends CustomPainter {
|
||||
part.y2 ?? image.height.toDouble(),
|
||||
),
|
||||
);
|
||||
final Rect dst = Offset.zero & size;
|
||||
var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination;
|
||||
var dst = Alignment.center.inscribe(fitted, Offset.zero & size);
|
||||
canvas.drawImageRect(image, src, dst, Paint());
|
||||
}
|
||||
|
||||
|
||||
258
lib/components/js_ui.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
|
||||
import 'components.dart';
|
||||
|
||||
mixin class JsUiApi {
|
||||
final Map<int, LoadingDialogController> _loadingDialogControllers = {};
|
||||
|
||||
dynamic handleUIMessage(Map<String, dynamic> message) {
|
||||
switch (message['function']) {
|
||||
case 'showMessage':
|
||||
var m = message['message'];
|
||||
if (m.toString().isNotEmpty) {
|
||||
App.rootContext.showMessage(message: m.toString());
|
||||
}
|
||||
case 'showDialog':
|
||||
return _showDialog(message);
|
||||
case 'launchUrl':
|
||||
var url = message['url'];
|
||||
if (url.toString().isNotEmpty) {
|
||||
launchUrlString(url.toString());
|
||||
}
|
||||
case 'showLoading':
|
||||
var onCancel = message['onCancel'];
|
||||
if (onCancel != null && onCancel is! JSInvokable) {
|
||||
return;
|
||||
}
|
||||
return _showLoading(onCancel);
|
||||
case 'cancelLoading':
|
||||
var id = message['id'];
|
||||
if (id is int) {
|
||||
_cancelLoading(id);
|
||||
}
|
||||
case 'showInputDialog':
|
||||
var title = message['title'];
|
||||
var validator = message['validator'];
|
||||
var image = message['image'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
return _showInputDialog(title, validator, image);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
var options = message['options'];
|
||||
var initialIndex = message['initialIndex'];
|
||||
if (title is! String) return;
|
||||
if (options is! List) return;
|
||||
if (initialIndex != null && initialIndex is! int) return;
|
||||
return _showSelectDialog(
|
||||
title,
|
||||
options.whereType<String>().toList(),
|
||||
initialIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDialog(Map<String, dynamic> message) {
|
||||
BuildContext? dialogContext;
|
||||
var title = message['title'];
|
||||
var content = message['content'];
|
||||
var actions = <Widget>[];
|
||||
for (var action in message['actions']) {
|
||||
if (action['callback'] is! JSInvokable) {
|
||||
continue;
|
||||
}
|
||||
var callback = action['callback'] as JSInvokable;
|
||||
var text = action['text'].toString();
|
||||
var style = (action['style'] ?? 'text').toString();
|
||||
actions.add(_JSCallbackButton(
|
||||
text: text,
|
||||
callback: JSAutoFreeFunction(callback),
|
||||
style: style,
|
||||
onCallbackFinished: () {
|
||||
dialogContext?.pop();
|
||||
},
|
||||
));
|
||||
}
|
||||
if (actions.isEmpty) {
|
||||
actions.add(TextButton(
|
||||
onPressed: () {
|
||||
dialogContext?.pop();
|
||||
},
|
||||
child: Text('OK'),
|
||||
));
|
||||
}
|
||||
return showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
dialogContext = context;
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: Text(content).paddingHorizontal(16),
|
||||
actions: actions,
|
||||
);
|
||||
},
|
||||
).then((value) {
|
||||
dialogContext = null;
|
||||
});
|
||||
}
|
||||
|
||||
int _showLoading(JSInvokable? onCancel) {
|
||||
var func = onCancel == null ? null : JSAutoFreeFunction(onCancel);
|
||||
var controller = showLoadingDialog(
|
||||
App.rootContext,
|
||||
barrierDismissible: onCancel != null,
|
||||
allowCancel: onCancel != null,
|
||||
onCancel: onCancel == null
|
||||
? null
|
||||
: () {
|
||||
func?.call([]);
|
||||
},
|
||||
);
|
||||
var i = 0;
|
||||
while (_loadingDialogControllers.containsKey(i)) {
|
||||
i++;
|
||||
}
|
||||
_loadingDialogControllers[i] = controller;
|
||||
return i;
|
||||
}
|
||||
|
||||
void _cancelLoading(int id) {
|
||||
var controller = _loadingDialogControllers.remove(id);
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
String? imageUrl;
|
||||
Uint8List? imageData;
|
||||
if (image != null) {
|
||||
if (image is String) {
|
||||
imageUrl = image;
|
||||
} else if (image is Uint8List) {
|
||||
imageData = image;
|
||||
} else if (image is List<int>) {
|
||||
imageData = Uint8List.fromList(image);
|
||||
}
|
||||
}
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
image: imageUrl,
|
||||
imageData: imageData,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
if (res != null) {
|
||||
return res.toString();
|
||||
} else {
|
||||
result = v;
|
||||
}
|
||||
} else {
|
||||
result = v;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int?> _showSelectDialog(
|
||||
String title,
|
||||
List<String> options,
|
||||
int? initialIndex,
|
||||
) {
|
||||
if (options.isEmpty) {
|
||||
return Future.value(null);
|
||||
}
|
||||
if (initialIndex != null &&
|
||||
(initialIndex >= options.length || initialIndex < 0)) {
|
||||
initialIndex = null;
|
||||
}
|
||||
return showSelectDialog(
|
||||
title: title,
|
||||
options: options,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JSCallbackButton extends StatefulWidget {
|
||||
const _JSCallbackButton({
|
||||
required this.text,
|
||||
required this.callback,
|
||||
required this.style,
|
||||
this.onCallbackFinished,
|
||||
});
|
||||
|
||||
final JSAutoFreeFunction callback;
|
||||
|
||||
final String text;
|
||||
|
||||
final String style;
|
||||
|
||||
final void Function()? onCallbackFinished;
|
||||
|
||||
@override
|
||||
State<_JSCallbackButton> createState() => _JSCallbackButtonState();
|
||||
}
|
||||
|
||||
class _JSCallbackButtonState extends State<_JSCallbackButton> {
|
||||
bool isLoading = false;
|
||||
|
||||
void onClick() async {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
var res = widget.callback.call([]);
|
||||
if (res is Future) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await res;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
widget.onCallbackFinished?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (widget.style) {
|
||||
"filled" => FilledButton(
|
||||
onPressed: onClick,
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
"danger" => FilledButton(
|
||||
onPressed: onClick,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.all(context.colorScheme.error),
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
_ => TextButton(
|
||||
onPressed: onClick,
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,23 +74,23 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
|
||||
SliverGridDelegateWithComics();
|
||||
|
||||
final bool useBriefMode;
|
||||
final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief';
|
||||
|
||||
final double? scale;
|
||||
final double scale = (appdata.settings['comicTileScale'] as num).toDouble();
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) {
|
||||
if (useBriefMode) {
|
||||
return getBriefModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
} else {
|
||||
return getDetailedModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
SliverGridLayout getBriefModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.72;
|
||||
const childAspectRatio = 0.64;
|
||||
const crossAxisSpacing = 0.0;
|
||||
int crossAxisCount =
|
||||
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
|
||||
@@ -140,6 +140,52 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
return true;
|
||||
if (oldDelegate is! SliverGridDelegateWithComics) return true;
|
||||
if (oldDelegate.scale != scale ||
|
||||
oldDelegate.useBriefMode != useBriefMode) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SliverLazyToBoxAdapter extends StatelessWidget {
|
||||
/// Creates a sliver that contains a single box widget which can be lazy loaded.
|
||||
const SliverLazyToBoxAdapter({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList.list(children: [
|
||||
SizedBox(),
|
||||
child,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class SliverAnimatedVisibility extends StatelessWidget {
|
||||
const SliverAnimatedVisibility({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool visible;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var child = visible ? this.child : const SizedBox.shrink();
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ class NetworkError extends StatelessWidget {
|
||||
required this.message,
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
this.buttonText,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -14,6 +16,10 @@ class NetworkError extends StatelessWidget {
|
||||
|
||||
final bool withAppbar;
|
||||
|
||||
final String? buttonText;
|
||||
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cfe = CloudflareException.fromString(message);
|
||||
@@ -38,29 +44,42 @@ class NetworkError extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
cfe == null ? message : "Cloudflare verification required".tl,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
),
|
||||
if (retry != null)
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
saveFile(
|
||||
data: utf8.encode(Log().toString()),
|
||||
filename: 'log.txt',
|
||||
);
|
||||
},
|
||||
child: Text("Export logs".tl),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (retry != null)
|
||||
if (cfe != null)
|
||||
FilledButton(
|
||||
onPressed: () => passCloudflare(
|
||||
CloudflareException.fromString(message)!, retry!),
|
||||
CloudflareException.fromString(message)!,
|
||||
retry!,
|
||||
),
|
||||
child: Text('Verify'.tl),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text('Retry'.tl),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (action != null)
|
||||
action!.paddingRight(8),
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -69,15 +88,11 @@ class NetworkError extends StatelessWidget {
|
||||
body = Column(
|
||||
children: [
|
||||
const Appbar(title: Text("")),
|
||||
Expanded(
|
||||
child: body,
|
||||
)
|
||||
Expanded(child: body),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
child: body,
|
||||
);
|
||||
return Material(child: body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +104,20 @@ class ListLoadingIndicator extends StatelessWidget {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
child: Center(
|
||||
child: FiveDotLoadingAnimation(),
|
||||
),
|
||||
child: Center(child: FiveDotLoadingAnimation()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SliverListLoadingIndicator extends StatelessWidget {
|
||||
const SliverListLoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// SliverToBoxAdapter can not been lazy loaded.
|
||||
// Use SliverList to make sure the animation can be lazy loaded.
|
||||
return SliverList.list(
|
||||
children: const [SizedBox(), ListLoadingIndicator()],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +139,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
if (res.success) {
|
||||
return res;
|
||||
} else {
|
||||
if(!mounted) return res;
|
||||
if (!mounted) return res;
|
||||
if (retry >= 3) {
|
||||
return res;
|
||||
}
|
||||
@@ -159,10 +185,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
}
|
||||
|
||||
Widget buildError() {
|
||||
return NetworkError(
|
||||
message: error!,
|
||||
retry: retry,
|
||||
);
|
||||
return NetworkError(message: error!, retry: retry);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -171,7 +194,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
isLoading = true;
|
||||
Future.microtask(() {
|
||||
loadDataWithRetry().then((value) async {
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
if (value.success) {
|
||||
data = value.data;
|
||||
await onDataLoaded();
|
||||
@@ -299,28 +322,12 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
||||
|
||||
Widget buildLoading(BuildContext context) {
|
||||
return Center(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(32).fixHeight(32),
|
||||
child: const CircularProgressIndicator().fixWidth(32).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildError(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, maxLines: 3),
|
||||
const SizedBox(height: 12),
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
reset();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
return NetworkError(withAppbar: false, message: error, retry: reset);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -381,7 +388,7 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
||||
Colors.green,
|
||||
Colors.blue,
|
||||
Colors.yellow,
|
||||
Colors.purple
|
||||
Colors.purple,
|
||||
];
|
||||
|
||||
static const _padding = 12.0;
|
||||
@@ -393,16 +400,15 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: _dotSize * 5 + _padding * 6,
|
||||
height: _height,
|
||||
child: Stack(
|
||||
children: List.generate(5, (index) => buildDot(index)),
|
||||
),
|
||||
);
|
||||
});
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: _dotSize * 5 + _padding * 6,
|
||||
height: _height,
|
||||
child: Stack(children: List.generate(5, (index) => buildDot(index))),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDot(int index) {
|
||||
@@ -410,7 +416,8 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
||||
var startValue = index * 0.8;
|
||||
return Positioned(
|
||||
left: index * _dotSize + (index + 1) * _padding,
|
||||
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
||||
bottom:
|
||||
(math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
||||
(_height - _dotSize),
|
||||
child: Container(
|
||||
width: _dotSize,
|
||||
|
||||
@@ -20,17 +20,22 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
@override
|
||||
String? get barrierLabel => "menu";
|
||||
|
||||
double get entryHeight => App.isMobile ? 42 : 36;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
var width = entries.first.icon == null ? 216.0 : 242.0;
|
||||
final size = MediaQuery.of(context).size;
|
||||
var left = location.dx;
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
if (left + width > size.width - 10) {
|
||||
left = size.width - width - 10;
|
||||
}
|
||||
var top = location.dy;
|
||||
var height = 16 + 32 * entries.length;
|
||||
var height = 16 + entryHeight * entries.length;
|
||||
if (top + height > size.height - 15) {
|
||||
top = size.height - height - 15;
|
||||
}
|
||||
@@ -42,9 +47,12 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: context.brightness == Brightness.dark
|
||||
? Border.all(color: context.colorScheme.outlineVariant)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.shadow.withOpacity(0.2),
|
||||
color: context.colorScheme.shadow.toOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
blurStyle: BlurStyle.outer,
|
||||
),
|
||||
@@ -53,9 +61,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
child: BlurEffect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Material(
|
||||
color: context.brightness == Brightness.light
|
||||
? const Color(0xFFFAFAFA).withOpacity(0.82)
|
||||
: const Color(0xFF090909).withOpacity(0.82),
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: width,
|
||||
@@ -83,7 +89,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
entry.onClick();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: App.isMobile ? 42 : 36,
|
||||
height: entryHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
@@ -92,9 +98,13 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
Icon(
|
||||
entry.icon,
|
||||
size: 18,
|
||||
color: entry.color
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(entry.text),
|
||||
Text(
|
||||
entry.text,
|
||||
style: TextStyle(color: entry.color)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -119,7 +129,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
class MenuEntry {
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
final void Function() onClick;
|
||||
|
||||
MenuEntry({required this.text, this.icon, required this.onClick});
|
||||
MenuEntry({required this.text, this.icon, this.color, required this.onClick});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ void showToast({
|
||||
required BuildContext context,
|
||||
Widget? icon,
|
||||
Widget? trailing,
|
||||
int? seconds,
|
||||
}) {
|
||||
var newEntry = OverlayEntry(
|
||||
builder: (context) => _ToastOverlay(
|
||||
@@ -17,7 +18,7 @@ void showToast({
|
||||
|
||||
state?.addOverlay(newEntry);
|
||||
|
||||
Timer(const Duration(seconds: 2), () => state?.remove(newEntry));
|
||||
Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
|
||||
}
|
||||
|
||||
class _ToastOverlay extends StatelessWidget {
|
||||
@@ -46,21 +47,29 @@ class _ToastOverlay extends StatelessWidget {
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: Theme.of(context).colorScheme.onInverseSurface),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) icon!.paddingRight(8),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (trailing != null) trailing!.paddingLeft(8)
|
||||
],
|
||||
child: IntrinsicWidth(
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: context.width - 32,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) icon!.paddingRight(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!.paddingLeft(8)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -116,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
|
||||
void showDialogMessage(BuildContext context, String title, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
builder: (context) => ContentDialog(
|
||||
title: title,
|
||||
content: Text(message).paddingHorizontal(16),
|
||||
actions: [
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: context.pop,
|
||||
child: Text("OK".tl),
|
||||
)
|
||||
@@ -135,6 +144,7 @@ Future<void> showConfirmDialog({
|
||||
required String content,
|
||||
required void Function() onConfirm,
|
||||
String confirmText = "Confirm",
|
||||
Color? btnColor,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
@@ -147,6 +157,9 @@ Future<void> showConfirmDialog({
|
||||
context.pop();
|
||||
onConfirm();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: btnColor,
|
||||
),
|
||||
child: Text(confirmText.tl),
|
||||
),
|
||||
],
|
||||
@@ -155,7 +168,15 @@ Future<void> showConfirmDialog({
|
||||
}
|
||||
|
||||
class LoadingDialogController {
|
||||
void Function()? closeDialog;
|
||||
double? _progress;
|
||||
|
||||
String? _message;
|
||||
|
||||
void Function()? _closeDialog;
|
||||
|
||||
void Function(double? value)? _serProgress;
|
||||
|
||||
void Function(String message)? _setMessage;
|
||||
|
||||
bool closed = false;
|
||||
|
||||
@@ -164,63 +185,86 @@ class LoadingDialogController {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (closeDialog == null) {
|
||||
Future.microtask(closeDialog!);
|
||||
if (_closeDialog == null) {
|
||||
Future.microtask(_closeDialog!);
|
||||
} else {
|
||||
closeDialog!();
|
||||
_closeDialog!();
|
||||
}
|
||||
}
|
||||
|
||||
void setProgress(double? value) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
_serProgress?.call(value);
|
||||
}
|
||||
|
||||
void setMessage(String message) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
_setMessage?.call(message);
|
||||
}
|
||||
}
|
||||
|
||||
LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
{void Function()? onCancel,
|
||||
bool barrierDismissible = true,
|
||||
bool allowCancel = true,
|
||||
String? message,
|
||||
String cancelButtonText = "Cancel"}) {
|
||||
LoadingDialogController showLoadingDialog(
|
||||
BuildContext context, {
|
||||
void Function()? onCancel,
|
||||
bool barrierDismissible = true,
|
||||
bool allowCancel = true,
|
||||
String? message,
|
||||
String cancelButtonText = "Cancel",
|
||||
bool withProgress = false,
|
||||
}) {
|
||||
var controller = LoadingDialogController();
|
||||
controller._message = message;
|
||||
|
||||
if (withProgress) {
|
||||
controller._progress = 0;
|
||||
}
|
||||
|
||||
var loadingDialogRoute = DialogRoute(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 100,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Text(
|
||||
message ?? 'Loading',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const Spacer(),
|
||||
if (allowCancel)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
controller.close();
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(cancelButtonText.tl))
|
||||
],
|
||||
),
|
||||
),
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
controller._serProgress = (value) {
|
||||
setState(() {
|
||||
controller._progress = value;
|
||||
});
|
||||
};
|
||||
controller._setMessage = (message) {
|
||||
setState(() {
|
||||
controller._message = message;
|
||||
});
|
||||
};
|
||||
return ContentDialog(
|
||||
title: controller._message ?? 'Loading',
|
||||
content: LinearProgressIndicator(
|
||||
value: controller._progress,
|
||||
backgroundColor: context.colorScheme.surfaceContainer,
|
||||
).paddingHorizontal(16).paddingVertical(16),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: allowCancel
|
||||
? () {
|
||||
controller.close();
|
||||
onCancel?.call();
|
||||
}
|
||||
: null,
|
||||
child: Text(cancelButtonText.tl),
|
||||
)
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
var navigator = Navigator.of(context);
|
||||
var navigator = Navigator.of(context, rootNavigator: true);
|
||||
|
||||
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
||||
|
||||
controller.closeDialog = () {
|
||||
controller._closeDialog = () {
|
||||
navigator.removeRoute(loadingDialogRoute);
|
||||
};
|
||||
|
||||
@@ -230,13 +274,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
class ContentDialog extends StatelessWidget {
|
||||
const ContentDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.title, // 如果不传 title 将不会展示
|
||||
required this.content,
|
||||
this.dismissible = true,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? title;
|
||||
|
||||
final Widget content;
|
||||
|
||||
@@ -246,26 +290,30 @@ class ContentDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Appbar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: dismissible ? context.pop : null,
|
||||
),
|
||||
title: Text(title),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
this.content,
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions,
|
||||
).paddingRight(12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
var content = SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title != null
|
||||
? Appbar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: dismissible ? context.pop : null,
|
||||
),
|
||||
title: Text(title!),
|
||||
backgroundColor: Colors.transparent,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
this.content,
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions,
|
||||
).paddingRight(12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -279,6 +327,7 @@ class ContentDialog extends StatelessWidget {
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
elevation: 2,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.topCenter,
|
||||
@@ -310,6 +359,8 @@ Future<void> showInputDialog({
|
||||
String confirmText = "Confirm",
|
||||
String cancelText = "Cancel",
|
||||
RegExp? inputValidator,
|
||||
String? image,
|
||||
Uint8List? imageData,
|
||||
}) {
|
||||
var controller = TextEditingController(text: initialValue);
|
||||
bool isLoading = false;
|
||||
@@ -322,14 +373,28 @@ Future<void> showInputDialog({
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: error,
|
||||
),
|
||||
).paddingHorizontal(12),
|
||||
content: Column(
|
||||
children: [
|
||||
if (image != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.network(image, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
if (image == null && imageData != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.memory(imageData, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: error,
|
||||
),
|
||||
).paddingHorizontal(12),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
isLoading: isLoading,
|
||||
@@ -348,7 +413,7 @@ Future<void> showInputDialog({
|
||||
} else {
|
||||
result = futureOr;
|
||||
}
|
||||
if(result == null) {
|
||||
if (result == null) {
|
||||
context.pop();
|
||||
} else {
|
||||
setState(() => error = result.toString());
|
||||
@@ -386,3 +451,57 @@ void showInfoDialog({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<int?> showSelectDialog({
|
||||
required String title,
|
||||
required List<String> options,
|
||||
int? initialIndex,
|
||||
}) async {
|
||||
int? current = initialIndex;
|
||||
|
||||
await showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Select(
|
||||
current: current == null ? "" : options[current!],
|
||||
values: options,
|
||||
minWidth: 156,
|
||||
onTap: (i) {
|
||||
setState(() {
|
||||
current = i;
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
current = null;
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Cancel'.tl),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: current == null ? null : context.pop,
|
||||
child: Text('Confirm'.tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ class PaneItemEntry {
|
||||
|
||||
IconData activeIcon;
|
||||
|
||||
PaneItemEntry(
|
||||
{required this.label, required this.icon, required this.activeIcon});
|
||||
PaneItemEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
});
|
||||
}
|
||||
|
||||
class PaneActionEntry {
|
||||
@@ -18,19 +21,24 @@ class PaneActionEntry {
|
||||
|
||||
VoidCallback onTap;
|
||||
|
||||
PaneActionEntry(
|
||||
{required this.label, required this.icon, required this.onTap});
|
||||
PaneActionEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class NaviPane extends StatefulWidget {
|
||||
const NaviPane({required this.paneItems,
|
||||
const NaviPane({
|
||||
required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChange,
|
||||
this.onPageChanged,
|
||||
required this.observer,
|
||||
required this.navigatorKey,
|
||||
super.key});
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<PaneItemEntry> paneItems;
|
||||
|
||||
@@ -38,7 +46,7 @@ class NaviPane extends StatefulWidget {
|
||||
|
||||
final Widget Function(int page) pageBuilder;
|
||||
|
||||
final void Function(int index)? onPageChange;
|
||||
final void Function(int index)? onPageChanged;
|
||||
|
||||
final int initialPage;
|
||||
|
||||
@@ -47,10 +55,16 @@ class NaviPane extends StatefulWidget {
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
|
||||
@override
|
||||
State<NaviPane> createState() => _NaviPaneState();
|
||||
State<NaviPane> createState() => NaviPaneState();
|
||||
|
||||
static NaviPaneState of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<NaviPaneState>()!;
|
||||
}
|
||||
}
|
||||
|
||||
class _NaviPaneState extends State<NaviPane>
|
||||
typedef NaviItemTapListener = void Function(int);
|
||||
|
||||
class NaviPaneState extends State<NaviPane>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentPage = widget.initialPage;
|
||||
|
||||
@@ -59,35 +73,48 @@ class _NaviPaneState extends State<NaviPane>
|
||||
set currentPage(int value) {
|
||||
if (value == _currentPage) return;
|
||||
_currentPage = value;
|
||||
widget.onPageChange?.call(value);
|
||||
widget.onPageChanged?.call(value);
|
||||
}
|
||||
|
||||
void Function()? mainViewUpdateHandler;
|
||||
|
||||
late AnimationController controller;
|
||||
|
||||
final _naviItemTapListeners = <NaviItemTapListener>[];
|
||||
|
||||
void addNaviItemTapListener(NaviItemTapListener listener) {
|
||||
_naviItemTapListeners.add(listener);
|
||||
}
|
||||
|
||||
void removeNaviItemTapListener(NaviItemTapListener listener) {
|
||||
_naviItemTapListeners.remove(listener);
|
||||
}
|
||||
|
||||
static const _kBottomBarHeight = 58.0;
|
||||
|
||||
static const _kFoldedSideBarWidth = 80.0;
|
||||
static const _kFoldedSideBarWidth = 72.0;
|
||||
|
||||
static const _kSideBarWidth = 256.0;
|
||||
static const _kSideBarWidth = 224.0;
|
||||
|
||||
static const _kTopBarHeight = 48.0;
|
||||
|
||||
double get bottomBarHeight =>
|
||||
_kBottomBarHeight + MediaQuery
|
||||
.of(context)
|
||||
.padding
|
||||
.bottom;
|
||||
_kBottomBarHeight + MediaQuery.of(context).padding.bottom;
|
||||
|
||||
void onNavigatorStateChange() {
|
||||
onRebuild(context);
|
||||
}
|
||||
|
||||
void updatePage(int index) {
|
||||
for (var listener in _naviItemTapListeners) {
|
||||
listener(index);
|
||||
}
|
||||
if (widget.observer.routes.length > 1) {
|
||||
widget.navigatorKey.currentState!.popUntil((route) => route.isFirst);
|
||||
}
|
||||
if (currentPage == index) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
@@ -114,10 +141,7 @@ class _NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
|
||||
double targetFormContext(BuildContext context) {
|
||||
var width = MediaQuery
|
||||
.of(context)
|
||||
.size
|
||||
.width;
|
||||
var width = MediaQuery.of(context).size.width;
|
||||
double target = 0;
|
||||
if (width > changePoint) {
|
||||
target = 2;
|
||||
@@ -170,7 +194,8 @@ class _NaviPaneState extends State<NaviPane>
|
||||
child: buildLeft(),
|
||||
),
|
||||
Positioned.fill(
|
||||
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
left:
|
||||
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
||||
((value - 2).clamp(0, 1)),
|
||||
child: buildMainView(),
|
||||
@@ -183,17 +208,23 @@ class _NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
|
||||
Widget buildMainView() {
|
||||
return Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) =>
|
||||
AppPageRoute(
|
||||
return HeroControllerScope(
|
||||
controller: MaterialApp.createMaterialHeroController(),
|
||||
child: NavigatorPopHandler(
|
||||
onPopWithResult: (result) {
|
||||
widget.navigatorKey.currentState?.maybePop(result);
|
||||
},
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,7 +252,7 @@ class _NaviPaneState extends State<NaviPane>
|
||||
icon: Icon(action.icon),
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -230,40 +261,31 @@ class _NaviPaneState extends State<NaviPane>
|
||||
|
||||
Widget buildBottom() {
|
||||
return Material(
|
||||
textStyle: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.labelSmall,
|
||||
textStyle: Theme.of(context).textTheme.labelSmall,
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: _kBottomBarHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
children: List<Widget>.generate(widget.paneItems.length, (index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -271,67 +293,48 @@ class _NaviPaneState extends State<NaviPane>
|
||||
|
||||
Widget buildLeft() {
|
||||
final value = controller.value;
|
||||
const paddingHorizontal = 16.0;
|
||||
const paddingHorizontal = 12.0;
|
||||
return Material(
|
||||
child: Container(
|
||||
width: _kFoldedSideBarWidth +
|
||||
width:
|
||||
_kFoldedSideBarWidth +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant,
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: value == 3
|
||||
? (_kSideBarWidth - paddingHorizontal * 2 - 1)
|
||||
: (_kFoldedSideBarWidth - paddingHorizontal * 2 - 1),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery
|
||||
.of(context)
|
||||
.padding
|
||||
.top),
|
||||
...List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) =>
|
||||
_SideNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
showTitle: value == 3,
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...List<Widget>.generate(
|
||||
widget.paneActions.length,
|
||||
(index) =>
|
||||
_PaneActionWidget(
|
||||
entry: widget.paneActions[index],
|
||||
showTitle: value == 3,
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
...List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) => _SideNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
showTitle: value == 3,
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...List<Widget>.generate(
|
||||
widget.paneActions.length,
|
||||
(index) => _PaneActionWidget(
|
||||
entry: widget.paneActions[index],
|
||||
showTitle: value == 3,
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -339,12 +342,14 @@ class _NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
}
|
||||
|
||||
class _SideNaviWidget extends StatefulWidget {
|
||||
const _SideNaviWidget({required this.enabled,
|
||||
class _SideNaviWidget extends StatelessWidget {
|
||||
const _SideNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key});
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -354,119 +359,69 @@ class _SideNaviWidget extends StatefulWidget {
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_SideNaviWidget> createState() => _SideNaviWidgetState();
|
||||
}
|
||||
|
||||
class _SideNaviWidgetState extends State<_SideNaviWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme
|
||||
.of(context)
|
||||
.colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? colorScheme.primaryContainer
|
||||
: isHovering
|
||||
? colorScheme.surfaceContainerHigh
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon = Icon(enabled ? entry.activeIcon : entry.icon);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? colorScheme.primaryContainer : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: showTitle
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
);
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaneActionWidget extends StatefulWidget {
|
||||
const _PaneActionWidget(
|
||||
{required this.entry, required this.showTitle, super.key});
|
||||
class _PaneActionWidget extends StatelessWidget {
|
||||
const _PaneActionWidget({
|
||||
required this.entry,
|
||||
required this.showTitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final PaneActionEntry entry;
|
||||
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<_PaneActionWidget> createState() => _PaneActionWidgetState();
|
||||
}
|
||||
|
||||
class _PaneActionWidgetState extends State<_PaneActionWidget> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme
|
||||
.of(context)
|
||||
.colorScheme;
|
||||
final icon = Icon(widget.entry.icon);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (details) => setState(() => isHovering = true),
|
||||
onExit: (details) => setState(() => isHovering = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.entry.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: isHovering ? colorScheme.surfaceContainerHigh : null,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: widget.showTitle
|
||||
? Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(widget.entry.label)
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: icon,
|
||||
)),
|
||||
final icon = Icon(entry.icon);
|
||||
return InkWell(
|
||||
onTap: entry.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
height: 38,
|
||||
child: showTitle
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
);
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidget extends StatefulWidget {
|
||||
const _SingleBottomNaviWidget({required this.enabled,
|
||||
const _SingleBottomNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key});
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -534,11 +489,10 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
|
||||
|
||||
Widget buildContent() {
|
||||
final value = controller.value;
|
||||
final colorScheme = Theme
|
||||
.of(context)
|
||||
.colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon = Icon(
|
||||
widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
|
||||
);
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
@@ -625,8 +579,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
|
||||
}
|
||||
|
||||
class _NaviPopScope extends StatelessWidget {
|
||||
const _NaviPopScope(
|
||||
{required this.child, this.popGesture = false, required this.action});
|
||||
const _NaviPopScope({
|
||||
required this.child,
|
||||
this.popGesture = false,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool popGesture;
|
||||
@@ -636,32 +593,25 @@ class _NaviPopScope extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget res = App.isIOS
|
||||
? child
|
||||
: PopScope(
|
||||
canPop: App.isAndroid ? false : true,
|
||||
onPopInvokedWithResult: (value, result) {
|
||||
action();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
Widget res = child;
|
||||
if (popGesture) {
|
||||
res = GestureDetector(
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res);
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -670,14 +620,14 @@ class _NaviPopScope extends StatelessWidget {
|
||||
class _NaviMainView extends StatefulWidget {
|
||||
const _NaviMainView({required this.state});
|
||||
|
||||
final _NaviPaneState state;
|
||||
final NaviPaneState state;
|
||||
|
||||
@override
|
||||
State<_NaviMainView> createState() => _NaviMainViewState();
|
||||
}
|
||||
|
||||
class _NaviMainViewState extends State<_NaviMainView> {
|
||||
_NaviPaneState get state => widget.state;
|
||||
NaviPaneState get state => widget.state;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -703,8 +653,8 @@ class _NaviMainViewState extends State<_NaviMainView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (shouldShowAppBar) state.buildBottom().paddingBottom(
|
||||
context.padding.bottom),
|
||||
if (shouldShowAppBar)
|
||||
state.buildBottom().paddingBottom(context.padding.bottom),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,15 @@ class PopUpWidget<T> extends PopupRoute<T> {
|
||||
Widget body = PopupIndicatorWidget(
|
||||
child: Container(
|
||||
decoration: showPopUp
|
||||
? const BoxDecoration(
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
boxShadow: context.brightness == ui.Brightness.dark ? [
|
||||
BoxShadow(
|
||||
color: Colors.white.withAlpha(50),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
)
|
||||
: null,
|
||||
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
|
||||
@@ -86,7 +93,8 @@ class PopupIndicatorWidget extends InheritedWidget {
|
||||
}
|
||||
|
||||
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
||||
return await Navigator.of(context, rootNavigator: true).push(PopUpWidget(widget));
|
||||
return await Navigator.of(context, rootNavigator: true)
|
||||
.push(PopUpWidget(widget));
|
||||
}
|
||||
|
||||
class PopUpWidgetScaffold extends StatefulWidget {
|
||||
@@ -127,9 +135,8 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_sharp),
|
||||
onPressed: () => context.canPop()
|
||||
? context.pop()
|
||||
: App.pop(),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : App.pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
@@ -148,6 +155,9 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
),
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.axisDirection != AxisDirection.down) {
|
||||
return false;
|
||||
}
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
|
||||
@@ -51,10 +51,32 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
|
||||
static bool _isMouseScroll = App.isDesktop;
|
||||
|
||||
late int id;
|
||||
|
||||
static int _id = 0;
|
||||
|
||||
var activeChildren = <int>{};
|
||||
|
||||
ScrollState? parent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_controller = widget.controller ?? ScrollController();
|
||||
super.initState();
|
||||
id = _id;
|
||||
_id++;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
parent = ScrollState.maybeOf(context);
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
parent?.onChildInactive(id);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -66,8 +88,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
const BouncingScrollPhysics(),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
var child = Listener(
|
||||
onPointerDown: (event) {
|
||||
_futurePosition = null;
|
||||
if (_isMouseScroll) {
|
||||
@@ -77,7 +98,13 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
}
|
||||
},
|
||||
onPointerSignal: (pointerSignal) {
|
||||
if (activeChildren.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
if (pointerSignal is PointerScrollEvent) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
return;
|
||||
}
|
||||
if (pointerSignal.kind == PointerDeviceKind.mouse &&
|
||||
!_isMouseScroll) {
|
||||
setState(() {
|
||||
@@ -90,22 +117,261 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
_futurePosition ??= currentLocation;
|
||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
|
||||
var beforeOffset = (_futurePosition! - currentLocation).abs();
|
||||
_futurePosition = _futurePosition!.clamp(
|
||||
_controller.position.minScrollExtent,
|
||||
_controller.position.maxScrollExtent,
|
||||
);
|
||||
var afterOffset = (_futurePosition! - currentLocation).abs();
|
||||
if (_futurePosition == old) return;
|
||||
_controller.animateTo(_futurePosition!,
|
||||
duration: _fastAnimationDuration, curve: Curves.linear);
|
||||
var target = _futurePosition!;
|
||||
var duration = _fastAnimationDuration;
|
||||
if (afterOffset < beforeOffset) {
|
||||
duration = duration * (afterOffset / beforeOffset);
|
||||
if (duration < Duration(milliseconds: 10)) {
|
||||
duration = Duration(milliseconds: 10);
|
||||
}
|
||||
}
|
||||
_controller
|
||||
.animateTo(
|
||||
_futurePosition!,
|
||||
duration: duration,
|
||||
curve: Curves.linear,
|
||||
)
|
||||
.then((_) {
|
||||
var current = _controller.position.pixels;
|
||||
if (current == target && current == _futurePosition) {
|
||||
_futurePosition = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
_isMouseScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
child: ScrollState._(
|
||||
controller: _controller,
|
||||
onChildActive: (id) {
|
||||
activeChildren.add(id);
|
||||
},
|
||||
onChildInactive: (id) {
|
||||
activeChildren.remove(id);
|
||||
},
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
_isMouseScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (parent != null) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) {
|
||||
parent!.onChildActive(id);
|
||||
},
|
||||
onExit: (_) {
|
||||
parent!.onChildInactive(id);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollState extends InheritedWidget {
|
||||
const ScrollState._({
|
||||
required this.controller,
|
||||
required super.child,
|
||||
required this.onChildActive,
|
||||
required this.onChildInactive,
|
||||
});
|
||||
|
||||
final ScrollController controller;
|
||||
|
||||
final void Function(int id) onChildActive;
|
||||
|
||||
final void Function(int id) onChildInactive;
|
||||
|
||||
static ScrollState of(BuildContext context) {
|
||||
final ScrollState? provider =
|
||||
context.dependOnInheritedWidgetOfExactType<ScrollState>();
|
||||
return provider!;
|
||||
}
|
||||
|
||||
static ScrollState? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<ScrollState>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ScrollState oldWidget) {
|
||||
return oldWidget.controller != controller;
|
||||
}
|
||||
}
|
||||
|
||||
class AppScrollBar extends StatefulWidget {
|
||||
const AppScrollBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.child,
|
||||
this.topPadding = 0,
|
||||
});
|
||||
|
||||
final ScrollController controller;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
@override
|
||||
State<AppScrollBar> createState() => _AppScrollBarState();
|
||||
}
|
||||
|
||||
class _AppScrollBarState extends State<AppScrollBar> {
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
double minExtent = 0;
|
||||
double maxExtent = 0;
|
||||
double position = 0;
|
||||
|
||||
double viewHeight = 0;
|
||||
|
||||
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
|
||||
|
||||
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = widget.controller;
|
||||
_scrollController.addListener(onChanged);
|
||||
Future.microtask(onChanged);
|
||||
_dragGestureRecognizer = VerticalDragGestureRecognizer()
|
||||
..onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
void onUpdate(DragUpdateDetails details) {
|
||||
if (maxExtent - minExtent <= 0 ||
|
||||
viewHeight == 0 ||
|
||||
details.primaryDelta == null) {
|
||||
return;
|
||||
}
|
||||
var offset = details.primaryDelta!;
|
||||
var positionOffset =
|
||||
offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent);
|
||||
_scrollController.jumpTo((position + positionOffset).clamp(
|
||||
minExtent,
|
||||
maxExtent,
|
||||
));
|
||||
}
|
||||
|
||||
void onChanged() {
|
||||
if (_scrollController.positions.isEmpty) return;
|
||||
var position = _scrollController.position;
|
||||
if (position.minScrollExtent != minExtent ||
|
||||
position.maxScrollExtent != maxExtent ||
|
||||
position.pixels != this.position) {
|
||||
setState(() {
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
var scrollHeight = (maxExtent - minExtent);
|
||||
var height = constrains.maxHeight - widget.topPadding;
|
||||
viewHeight = height;
|
||||
var top = scrollHeight == 0
|
||||
? 0.0
|
||||
: (position - minExtent) /
|
||||
scrollHeight *
|
||||
(height - _scrollIndicatorSize);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
Positioned(
|
||||
top: top + widget.topPadding,
|
||||
right: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_dragGestureRecognizer.addPointer(event);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _scrollIndicatorSize/2,
|
||||
height: _scrollIndicatorSize,
|
||||
child: CustomPaint(
|
||||
painter: _ScrollIndicatorPainter(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(Icons.arrow_drop_up, size: 18),
|
||||
Icon(Icons.arrow_drop_down, size: 18),
|
||||
const Spacer(),
|
||||
],
|
||||
).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollIndicatorPainter extends CustomPainter {
|
||||
final Color backgroundColor;
|
||||
|
||||
final Color shadowColor;
|
||||
|
||||
const _ScrollIndicatorPainter({
|
||||
required this.backgroundColor,
|
||||
required this.shadowColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var path = Path()
|
||||
..moveTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..arcToPoint(
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawShadow(path, shadowColor, 2, true);
|
||||
var backgroundPaint = Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
path = Path()
|
||||
..moveTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..arcToPoint(
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawPath(path, backgroundPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return oldDelegate is! _ScrollIndicatorPainter ||
|
||||
oldDelegate.backgroundColor != backgroundColor ||
|
||||
oldDelegate.shadowColor != shadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ class Select extends StatelessWidget {
|
||||
var size = renderBox.size;
|
||||
showMenu(
|
||||
elevation: 3,
|
||||
color: context.colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
color: context.brightness == Brightness.light
|
||||
? const Color(0xFFF6F6F6)
|
||||
: const Color(0xFF1E1E1E),
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
constraints: BoxConstraints(
|
||||
@@ -41,8 +42,8 @@ class Select extends StatelessWidget {
|
||||
),
|
||||
position: RelativeRect.fromLTRB(
|
||||
offset.dx,
|
||||
offset.dy + size.height,
|
||||
offset.dx + size.height,
|
||||
offset.dy + size.height + 2,
|
||||
offset.dx + size.height + 2,
|
||||
offset.dy,
|
||||
),
|
||||
items: values
|
||||
@@ -266,13 +267,14 @@ class OptionChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return AnimatedContainer(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.colorScheme.primaryContainer
|
||||
? context.colorScheme.secondaryContainer
|
||||
: context.colorScheme.surface,
|
||||
border: isSelected
|
||||
? Border.all(color: context.colorScheme.primaryContainer)
|
||||
? Border.all(color: context.colorScheme.secondaryContainer)
|
||||
: Border.all(color: context.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SideBarRoute<T> extends PopupRoute<T> {
|
||||
SideBarRoute(this.title, this.widget,
|
||||
SideBarRoute(this.widget,
|
||||
{this.showBarrier = true,
|
||||
this.useSurfaceTintColor = false,
|
||||
required this.width,
|
||||
this.addBottomPadding = true,
|
||||
this.addTopPadding = true});
|
||||
|
||||
final String? title;
|
||||
|
||||
final Widget widget;
|
||||
|
||||
final bool showBarrier;
|
||||
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
Animation<double> secondaryAnimation) {
|
||||
bool showSideBar = MediaQuery.of(context).size.width > width;
|
||||
|
||||
Widget body = SidebarBody(
|
||||
title: title,
|
||||
widget: widget,
|
||||
autoChangeTitleBarColor: !useSurfaceTintColor,
|
||||
);
|
||||
Widget body = widget;
|
||||
|
||||
if (addTopPadding) {
|
||||
body = Padding(
|
||||
@@ -57,10 +51,18 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
|
||||
body = Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: showSideBar
|
||||
? const BorderRadius.horizontal(left: Radius.circular(16))
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.surfaceTint),
|
||||
borderRadius: showSideBar
|
||||
? const BorderRadius.horizontal(left: Radius.circular(16))
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.surfaceTint,
|
||||
boxShadow: context.brightness == ui.Brightness.dark ? [
|
||||
BoxShadow(
|
||||
color: Colors.white.withAlpha(50),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
constraints: BoxConstraints(maxWidth: sideBarWidth),
|
||||
height: MediaQuery.of(context).size.height,
|
||||
@@ -121,97 +123,13 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarBody extends StatefulWidget {
|
||||
const SidebarBody(
|
||||
{required this.title,
|
||||
required this.widget,
|
||||
required this.autoChangeTitleBarColor,
|
||||
super.key});
|
||||
|
||||
final String? title;
|
||||
final Widget widget;
|
||||
final bool autoChangeTitleBarColor;
|
||||
|
||||
@override
|
||||
State<SidebarBody> createState() => _SidebarBodyState();
|
||||
}
|
||||
|
||||
class _SidebarBodyState extends State<SidebarBody> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Expanded(child: widget.widget);
|
||||
|
||||
if (widget.autoChangeTitleBarColor) {
|
||||
body = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
height: 60 + MediaQuery.of(context).padding.top,
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
iconSize: 25,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
body
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showSideBar(BuildContext context, Widget widget,
|
||||
{String? title,
|
||||
bool showBarrier = true,
|
||||
{bool showBarrier = true,
|
||||
bool useSurfaceTintColor = false,
|
||||
double width = 500,
|
||||
bool addTopPadding = false}) {
|
||||
return Navigator.of(context).push(
|
||||
SideBarRoute(
|
||||
title,
|
||||
widget,
|
||||
showBarrier: showBarrier,
|
||||
useSurfaceTintColor: useSurfaceTintColor,
|
||||
|
||||
@@ -6,61 +6,102 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
const _kTitleBarHeight = 36.0;
|
||||
|
||||
class WindowFrameController extends StateController {
|
||||
bool useDarkTheme = false;
|
||||
class WindowFrameController extends InheritedWidget {
|
||||
/// Whether the window frame is hidden.
|
||||
final bool isWindowFrameHidden;
|
||||
|
||||
bool isHideWindowFrame = false;
|
||||
/// Sets the visibility of the window frame.
|
||||
final void Function(bool) setWindowFrame;
|
||||
|
||||
void setDarkTheme() {
|
||||
useDarkTheme = true;
|
||||
update();
|
||||
}
|
||||
/// Adds a listener that will be called when close button is clicked.
|
||||
/// The listener should return `true` to allow the window to be closed.
|
||||
final void Function(WindowCloseListener listener) addCloseListener;
|
||||
|
||||
void resetTheme() {
|
||||
useDarkTheme = false;
|
||||
update();
|
||||
}
|
||||
/// Removes a close listener.
|
||||
final void Function(WindowCloseListener listener) removeCloseListener;
|
||||
|
||||
VoidCallback openSideBar = () {};
|
||||
const WindowFrameController._create({
|
||||
required this.isWindowFrameHidden,
|
||||
required this.setWindowFrame,
|
||||
required this.addCloseListener,
|
||||
required this.removeCloseListener,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
void hideWindowFrame() {
|
||||
isHideWindowFrame = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void showWindowFrame() {
|
||||
isHideWindowFrame = false;
|
||||
update();
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class WindowFrame extends StatelessWidget {
|
||||
class WindowFrame extends StatefulWidget {
|
||||
const WindowFrame(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
StateController.putIfNotExists<WindowFrameController>(
|
||||
WindowFrameController());
|
||||
if (App.isMobile) return child;
|
||||
return StateBuilder<WindowFrameController>(builder: (controller) {
|
||||
if (controller.isHideWindowFrame) return child;
|
||||
State<WindowFrame> createState() => _WindowFrameState();
|
||||
|
||||
var body = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
|
||||
child: child,
|
||||
static WindowFrameController of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
|
||||
}
|
||||
}
|
||||
|
||||
typedef WindowCloseListener = bool Function();
|
||||
|
||||
class _WindowFrameState extends State<WindowFrame> {
|
||||
bool isWindowFrameHidden = false;
|
||||
bool useDarkTheme = false;
|
||||
var closeListeners = <WindowCloseListener>[];
|
||||
|
||||
/// Sets the visibility of the window frame.
|
||||
void setWindowFrame(bool show) {
|
||||
setState(() {
|
||||
isWindowFrameHidden = !show;
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a listener that will be called when close button is clicked.
|
||||
/// The listener should return `true` to allow the window to be closed.
|
||||
void addCloseListener(WindowCloseListener listener) {
|
||||
closeListeners.add(listener);
|
||||
}
|
||||
|
||||
/// Removes a close listener.
|
||||
void removeCloseListener(WindowCloseListener listener) {
|
||||
closeListeners.remove(listener);
|
||||
}
|
||||
|
||||
void _onClose() {
|
||||
for (var listener in closeListeners) {
|
||||
if (!listener()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (App.isMobile) return widget.child;
|
||||
|
||||
Widget body = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: isWindowFrameHidden
|
||||
? null
|
||||
: const EdgeInsets.only(top: _kTitleBarHeight),
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
if (!isWindowFrameHidden)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -69,7 +110,7 @@ class WindowFrame extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
brightness: controller.useDarkTheme ? Brightness.dark : null,
|
||||
brightness: useDarkTheme ? Brightness.dark : null,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
@@ -91,12 +132,14 @@ class WindowFrame extends StatelessWidget {
|
||||
'Venera',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: (controller.useDarkTheme ||
|
||||
color: (useDarkTheme ||
|
||||
context.brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)),
|
||||
)
|
||||
.toAlign(Alignment.centerLeft)
|
||||
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
@@ -104,7 +147,10 @@ class WindowFrame extends StatelessWidget {
|
||||
onPressed: debug,
|
||||
child: Text('Debug'),
|
||||
),
|
||||
if (!App.isMacOS) const WindowButtons()
|
||||
if (!App.isMacOS)
|
||||
_WindowButtons(
|
||||
onClose: _onClose,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -112,70 +158,33 @@ class WindowFrame extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
],
|
||||
);
|
||||
|
||||
if (App.isLinux) {
|
||||
return VirtualWindowFrame(child: body);
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (App.isLinux) {
|
||||
body = VirtualWindowFrame(child: body);
|
||||
}
|
||||
|
||||
Widget buildMenuButton(
|
||||
WindowFrameController controller, BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
controller.openSideBar();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 42,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(18, 20),
|
||||
painter: _MenuPainter(
|
||||
color: (controller.useDarkTheme ||
|
||||
Theme.of(context).brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black),
|
||||
),
|
||||
),
|
||||
));
|
||||
return WindowFrameController._create(
|
||||
isWindowFrameHidden: isWindowFrameHidden,
|
||||
setWindowFrame: setWindowFrame,
|
||||
addCloseListener: addCloseListener,
|
||||
removeCloseListener: removeCloseListener,
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuPainter extends CustomPainter {
|
||||
final Color color;
|
||||
class _WindowButtons extends StatefulWidget {
|
||||
const _WindowButtons({required this.onClose});
|
||||
|
||||
_MenuPainter({this.color = Colors.black});
|
||||
final void Function() onClose;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = getPaint(color);
|
||||
final path = Path()
|
||||
..moveTo(0, size.height / 4)
|
||||
..lineTo(size.width, size.height / 4)
|
||||
..moveTo(0, size.height / 4 * 2)
|
||||
..lineTo(size.width, size.height / 4 * 2)
|
||||
..moveTo(0, size.height / 4 * 3)
|
||||
..lineTo(size.width, size.height / 4 * 3);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
State<_WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class WindowButtons extends StatefulWidget {
|
||||
const WindowButtons({super.key});
|
||||
|
||||
@override
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
|
||||
bool isMaximized = false;
|
||||
|
||||
@override
|
||||
@@ -264,9 +273,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
color: !dark ? Colors.white : Colors.black,
|
||||
),
|
||||
hoverColor: Colors.red,
|
||||
onPressed: () {
|
||||
windowManager.close();
|
||||
},
|
||||
onPressed: widget.onClose,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -485,8 +492,15 @@ class WindowPlacement {
|
||||
}
|
||||
}
|
||||
|
||||
static Rect? lastValidRect;
|
||||
|
||||
static Future<WindowPlacement> get current async {
|
||||
var rect = await windowManager.getBounds();
|
||||
if (validate(rect)) {
|
||||
lastValidRect = rect;
|
||||
} else {
|
||||
rect = lastValidRect ?? defaultPlacement.rect;
|
||||
}
|
||||
var isMaximized = await windowManager.isMaximized();
|
||||
return WindowPlacement(rect, isMaximized);
|
||||
}
|
||||
@@ -501,9 +515,6 @@ class WindowPlacement {
|
||||
static void loop() async {
|
||||
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
|
||||
var placement = await WindowPlacement.current;
|
||||
if (!validate(placement.rect)) {
|
||||
return;
|
||||
}
|
||||
if (placement.rect != cache.rect ||
|
||||
placement.isMaximized != cache.isMaximized) {
|
||||
cache = placement;
|
||||
@@ -549,22 +560,18 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
}
|
||||
|
||||
Widget _buildVirtualWindowFrame(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: (_isMaximized || _isFullScreen) ? 0 : 1,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (!_isMaximized && !_isFullScreen)
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
offset: Offset(0.0, _isFocused ? 4 : 2),
|
||||
blurRadius: 6,
|
||||
)
|
||||
BoxShadow(
|
||||
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
|
||||
blurRadius: 4,
|
||||
)
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -573,7 +580,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
Widget build(BuildContext context) {
|
||||
return DragToResizeArea(
|
||||
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -630,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
|
||||
}
|
||||
|
||||
void debug() {
|
||||
ComicSource.reload();
|
||||
}
|
||||
ComicSourceManager().reload();
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
|
||||
import 'appdata.dart';
|
||||
import 'favorites.dart';
|
||||
import 'local.dart';
|
||||
|
||||
export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.0.2";
|
||||
final version = "1.5.3";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
@@ -27,6 +30,10 @@ class _App {
|
||||
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
// Whether the app has been initialized.
|
||||
// If current Isolate is main Isolate, this value is always true.
|
||||
bool isInitialized = false;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" &&
|
||||
@@ -44,6 +51,7 @@ class _App {
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
String? externalStoragePath;
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@@ -51,8 +59,16 @@ class _App {
|
||||
|
||||
BuildContext get rootContext => rootNavigatorKey.currentContext!;
|
||||
|
||||
final Appdata data = appdata;
|
||||
|
||||
final HistoryManager history = HistoryManager();
|
||||
|
||||
final LocalFavoritesManager favorites = LocalFavoritesManager();
|
||||
|
||||
final LocalManager local = LocalManager();
|
||||
|
||||
void rootPop() {
|
||||
rootNavigatorKey.currentState?.pop();
|
||||
rootNavigatorKey.currentState?.maybePop();
|
||||
}
|
||||
|
||||
void pop() {
|
||||
@@ -63,22 +79,22 @@ class _App {
|
||||
}
|
||||
}
|
||||
|
||||
var mainColor = Colors.blue;
|
||||
|
||||
Future<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
mainColor = switch (appdata.settings['color']) {
|
||||
'red' => Colors.red,
|
||||
'pink' => Colors.pink,
|
||||
'purple' => Colors.purple,
|
||||
'green' => Colors.green,
|
||||
'orange' => Colors.orange,
|
||||
'blue' => Colors.blue,
|
||||
'yellow' => Colors.yellow,
|
||||
'cyan' => Colors.cyan,
|
||||
_ => Colors.blue,
|
||||
};
|
||||
if (isAndroid) {
|
||||
externalStoragePath = (await getExternalStorageDirectory())!.path;
|
||||
}
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
await Future.wait([
|
||||
data.init(),
|
||||
history.init(),
|
||||
favorites.init(),
|
||||
local.init(),
|
||||
]);
|
||||
}
|
||||
|
||||
Function? _forceRebuildHandler;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
@@ -19,7 +20,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.preventRebuild = true,
|
||||
this.isRootRoute = false,
|
||||
}) {
|
||||
assert(opaque);
|
||||
}
|
||||
@@ -50,9 +50,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
|
||||
@override
|
||||
final bool isRootRoute;
|
||||
}
|
||||
|
||||
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
@@ -79,8 +76,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
bool get isRootRoute;
|
||||
|
||||
Widget? _child;
|
||||
|
||||
@override
|
||||
@@ -121,28 +116,19 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
if(isRootRoute) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: 1.0, end: 0).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
PageTransitionsBuilder builder;
|
||||
if (App.isAndroid) {
|
||||
builder = PredictiveBackPageTransitionsBuilder();
|
||||
} else {
|
||||
builder = SlidePageTransitionBuilder();
|
||||
}
|
||||
|
||||
return SlidePageTransitionBuilder().buildTransitions(
|
||||
return builder.buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
@@ -316,7 +302,7 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,27 +3,35 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
class _Appdata {
|
||||
final _Settings settings = _Settings();
|
||||
class Appdata with Init {
|
||||
Appdata._create();
|
||||
|
||||
final Settings settings = Settings._create();
|
||||
|
||||
var searchHistory = <String>[];
|
||||
|
||||
bool _isSavingData = false;
|
||||
|
||||
Future<void> saveData() async {
|
||||
if (_isSavingData) {
|
||||
await Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
return _isSavingData;
|
||||
});
|
||||
Future<void> saveData([bool sync = true]) async {
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_isSavingData = true;
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
_isSavingData = false;
|
||||
try {
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
} finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
if (sync) {
|
||||
DataSync().uploadData();
|
||||
}
|
||||
}
|
||||
|
||||
void addSearchHistory(String keyword) {
|
||||
@@ -47,52 +55,90 @@ class _Appdata {
|
||||
saveData();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
var file = File(FilePath.join(
|
||||
dataPath,
|
||||
'appdata.json',
|
||||
));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
||||
if (json['settings'][key] != null) {
|
||||
settings[key] = json['settings'][key];
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(json['searchHistory']);
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
if (await implicitDataFile.exists()) {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'settings': settings._data, 'searchHistory': searchHistory};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'settings': settings._data,
|
||||
'searchHistory': searchHistory,
|
||||
};
|
||||
/// Following fields are related to device-specific data and should not be synced.
|
||||
static const _disableSync = [
|
||||
"proxy",
|
||||
"authorizationRequired",
|
||||
"customImageProcessing",
|
||||
"webdav",
|
||||
];
|
||||
|
||||
/// Sync data from another device
|
||||
void syncData(Map<String, dynamic> data) {
|
||||
if (data['settings'] is Map) {
|
||||
var settings = data['settings'] as Map<String, dynamic>;
|
||||
for (var key in settings.keys) {
|
||||
if (!_disableSync.contains(key)) {
|
||||
this.settings[key] = settings[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(data['searchHistory'] ?? []);
|
||||
saveData();
|
||||
}
|
||||
|
||||
var implicitData = <String, dynamic>{};
|
||||
|
||||
void writeImplicitData() {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
file.writeAsString(jsonEncode(implicitData));
|
||||
void writeImplicitData() async {
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_isSavingData = true;
|
||||
try {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
await file.writeAsString(jsonEncode(implicitData));
|
||||
} finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> doInit() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
var file = File(FilePath.join(dataPath, 'appdata.json'));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
||||
if (json['settings'][key] != null) {
|
||||
settings[key] = json['settings'][key];
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(json['searchHistory']);
|
||||
} catch (e) {
|
||||
Log.error("Appdata", "Failed to load appdata", e);
|
||||
Log.info("Appdata", "Resetting appdata");
|
||||
file.deleteIgnoreError();
|
||||
}
|
||||
try {
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
if (await implicitDataFile.exists()) {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Appdata", "Failed to load implicit data", e);
|
||||
Log.info("Appdata", "Resetting implicit data");
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
implicitDataFile.deleteIgnoreError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
||||
final appdata = Appdata._create();
|
||||
|
||||
class _Settings with ChangeNotifier {
|
||||
_Settings();
|
||||
class Settings with ChangeNotifier {
|
||||
Settings._create();
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'comicDisplayMode': 'detailed', // detailed, brief
|
||||
'comicTileScale': 1.00, // 0.75-1.25
|
||||
'color': 'blue', // red, pink, purple, green, orange, blue
|
||||
'color': 'system', // red, pink, purple, green, orange, blue
|
||||
'theme_mode': 'system', // light, dark, system
|
||||
'newFavoriteAddTo': 'end', // start, end
|
||||
'moveFavoriteAfterRead': 'none', // none, end, start
|
||||
@@ -100,19 +146,54 @@ class _Settings with ChangeNotifier {
|
||||
'explore_pages': [],
|
||||
'categories': [],
|
||||
'favorites': [],
|
||||
'searchSources': null,
|
||||
'showFavoriteStatusOnTile': true,
|
||||
'showHistoryStatusOnTile': false,
|
||||
'blockedWords': [],
|
||||
'defaultSearchTarget': null,
|
||||
'autoPageTurningInterval': 5, // in seconds
|
||||
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
||||
'readerScreenPicNumberForLandscape': 1, // 1 - 5
|
||||
'readerScreenPicNumberForPortrait': 1, // 1 - 5
|
||||
'enableTapToTurnPages': true,
|
||||
'reverseTapToTurnPages': false,
|
||||
'enablePageAnimation': true,
|
||||
'language': 'system', // system, zh-CN, zh-TW, en-US
|
||||
'cacheSize': 2048, // in MB
|
||||
'downloadThreads': 5,
|
||||
'enableLongPressToZoom': true,
|
||||
'checkUpdateOnStart': true,
|
||||
'longPressZoomPosition': "press", // press, center
|
||||
'checkUpdateOnStart': false,
|
||||
'limitImageWidth': true,
|
||||
'webdav': [], // empty means not configured
|
||||
'dataVersion': 0,
|
||||
'quickFavorite': null,
|
||||
'enableTurnPageByVolumeKey': true,
|
||||
'enableClockAndBatteryInfoInReader': true,
|
||||
'quickCollectImage': 'No', // No, DoubleTap, Swipe
|
||||
'authorizationRequired': false,
|
||||
'onClickFavorite': 'viewDetail', // viewDetail, read
|
||||
'enableDnsOverrides': false,
|
||||
'dnsOverrides': {},
|
||||
'enableCustomImageProcessing': false,
|
||||
'customImageProcessing': defaultCustomImageProcessing,
|
||||
'sni': true,
|
||||
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
||||
'comicSourceListUrl': _defaultSourceListUrl,
|
||||
'preloadImageCount': 4,
|
||||
'followUpdatesFolder': null,
|
||||
'initialPage': '0',
|
||||
'comicListDisplayMode': 'paging', // paging, continuous
|
||||
'showPageNumberInReader': true,
|
||||
'showSingleImageOnFirstPage': false,
|
||||
'enableDoubleTapToZoom': true,
|
||||
'reverseChapterOrder': false,
|
||||
'showSystemStatusBar': false,
|
||||
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||
'ignoreBadCertificate': false,
|
||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||
'localFavoritesFirst': true,
|
||||
'autoCloseFavoritePanel': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -121,6 +202,45 @@ class _Settings with ChangeNotifier {
|
||||
|
||||
operator []=(String key, dynamic value) {
|
||||
_data[key] = value;
|
||||
if (key != "dataVersion") {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) {
|
||||
setReaderSetting(comicId, sourceKey, "enabled", enabled);
|
||||
}
|
||||
|
||||
bool isComicSpecificSettingsEnabled(String? comicId, String? sourceKey) {
|
||||
if (comicId == null || sourceKey == null) {
|
||||
return false;
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true;
|
||||
}
|
||||
|
||||
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
|
||||
if (!isComicSpecificSettingsEnabled(comicId, sourceKey)) {
|
||||
return _data[key];
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
|
||||
_data[key];
|
||||
}
|
||||
|
||||
void setReaderSetting(
|
||||
String comicId,
|
||||
String sourceKey,
|
||||
String key,
|
||||
dynamic value,
|
||||
) {
|
||||
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
|
||||
"$comicId@$sourceKey",
|
||||
() => <String, dynamic>{},
|
||||
)[key] = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetComicReaderSettings(String key) {
|
||||
(_data['comicSpecificSettings'] as Map).remove(key);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -129,3 +249,24 @@ class _Settings with ChangeNotifier {
|
||||
return _data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCustomImageProcessing = '''
|
||||
/**
|
||||
* Process an image
|
||||
* @param image {ArrayBuffer} - The image to process
|
||||
* @param cid {string} - The comic ID
|
||||
* @param eid {string} - The episode ID
|
||||
* @param page {number} - The page number
|
||||
* @param sourceKey {string} - The source key
|
||||
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
|
||||
*/
|
||||
function processImage(image, cid, eid, page, sourceKey) {
|
||||
let futureImage = new Promise((resolve, reject) => {
|
||||
resolve(image);
|
||||
});
|
||||
return futureImage;
|
||||
}
|
||||
''';
|
||||
|
||||
const _defaultSourceListUrl =
|
||||
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
@@ -21,7 +23,52 @@ class CacheManager {
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
CacheManager._create(){
|
||||
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
|
||||
var res = await Isolate.run(() async {
|
||||
int totalSize = 0;
|
||||
List<String> unmanagedFiles = [];
|
||||
var db = sqlite3.fromPointer(dbP);
|
||||
await for (var file in Directory(dir).list(recursive: true)) {
|
||||
if (file is File) {
|
||||
var size = await file.length();
|
||||
var segments = file.uri.pathSegments;
|
||||
var name = segments.last;
|
||||
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||
var res = db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE dir = ? AND name = ?
|
||||
''', [dir, name]);
|
||||
if (res.isEmpty) {
|
||||
unmanagedFiles.add(file.path);
|
||||
} else {
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
'totalSize': totalSize,
|
||||
'unmanagedFiles': unmanagedFiles,
|
||||
};
|
||||
});
|
||||
// delete unmanaged files
|
||||
// Only modify the database in the main isolate to avoid deadlock
|
||||
for (var filePath in res['unmanagedFiles'] as List<String>) {
|
||||
var file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
var segments = file.uri.pathSegments;
|
||||
var name = segments.last;
|
||||
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||
CacheManager()._db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE dir = ? AND name = ?
|
||||
''', [dir, name]);
|
||||
}
|
||||
return res['totalSize'] as int;
|
||||
}
|
||||
|
||||
CacheManager._create() {
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
_db.execute('''
|
||||
@@ -33,100 +80,103 @@ class CacheManager {
|
||||
type TEXT
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
_scanDir(_db.handle, cachePath).then((value) {
|
||||
_currentSize = value;
|
||||
checkCache();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the singleton instance of CacheManager.
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in MB
|
||||
void setLimitSize(int size){
|
||||
void setLimitSize(int size) {
|
||||
_limitSize = size * 1024 * 1024;
|
||||
}
|
||||
|
||||
void setType(String key, String? type){
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET type = ?
|
||||
WHERE key = ?
|
||||
''', [type, key]);
|
||||
}
|
||||
|
||||
String? getType(String key){
|
||||
var res = _db.select('''
|
||||
SELECT type FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
return res.first[0];
|
||||
}
|
||||
|
||||
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
||||
/// Write cache to disk.
|
||||
Future<void> writeCache(String key, List<int> data,
|
||||
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
|
||||
await delete(key);
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var name = md5.convert(key.codeUnits).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsBytes(data);
|
||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir.toString(), name, expires]);
|
||||
if(_currentSize != null) {
|
||||
if (_currentSize != null) {
|
||||
_currentSize = _currentSize! + data.length;
|
||||
}
|
||||
checkCacheIfRequired();
|
||||
}
|
||||
|
||||
Future<CachingFile> openWrite(String key) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
return CachingFile._(key, dir.toString(), name, file);
|
||||
}
|
||||
|
||||
Future<File?> findCache(String key) async{
|
||||
/// Find cache by key.
|
||||
/// If cache is expired, it will be deleted and return null.
|
||||
/// If cache is not found, it will return null.
|
||||
/// If cache is found, it will return the file, and update the expires time.
|
||||
Future<File?> findCache(String key) async {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var expires = row[3] as int;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
var now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (expires < now) {
|
||||
// expired
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (await file.exists()) {
|
||||
// update time
|
||||
var expires = now + 7 * 24 * 60 * 60 * 1000;
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET expires = ?
|
||||
WHERE key = ?
|
||||
''', [expires, key]);
|
||||
return file;
|
||||
} else {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isChecking = false;
|
||||
|
||||
/// Check cache size and delete expired cache.
|
||||
/// Only check cache if current size is greater than limit size.
|
||||
void checkCacheIfRequired() {
|
||||
if(_currentSize != null && _currentSize! > _limitSize){
|
||||
if (_currentSize != null && _currentSize! > _limitSize) {
|
||||
checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkCache() async{
|
||||
if(_isChecking){
|
||||
/// Check cache size and delete expired cache.
|
||||
/// If current size is greater than limit size,
|
||||
/// delete cache until current size is less than limit size.
|
||||
Future<void> checkCache() async {
|
||||
if (_isChecking) {
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
@@ -134,39 +184,42 @@ class CacheManager {
|
||||
SELECT * FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
for(var row in res){
|
||||
for (var row in res) {
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
var size = await file.length();
|
||||
_currentSize = _currentSize! - size;
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
if (res.isNotEmpty) {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
|
||||
int count = 0;
|
||||
var res2 = _db.select('''
|
||||
SELECT COUNT(*) FROM cache
|
||||
''');
|
||||
if(res2.isNotEmpty){
|
||||
count = res2.first[0] as int;
|
||||
}
|
||||
|
||||
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
|
||||
while (_currentSize != null && _currentSize! > _limitSize) {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
ORDER BY expires ASC
|
||||
limit 10
|
||||
''');
|
||||
for(var row in res){
|
||||
if (res.isEmpty) {
|
||||
// There are many files unmanaged by the cache manager.
|
||||
// Clear all cache.
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
break;
|
||||
}
|
||||
for (var row in res) {
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
var size = await file.length();
|
||||
await file.delete();
|
||||
_db.execute('''
|
||||
@@ -174,7 +227,7 @@ class CacheManager {
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
_currentSize = _currentSize! - size;
|
||||
if(_currentSize! <= _limitSize){
|
||||
if (_currentSize! <= _limitSize) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -183,18 +236,18 @@ class CacheManager {
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async{
|
||||
/// Delete cache by key.
|
||||
Future<void> delete(String key) async {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
if (res.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var row = res.first;
|
||||
@@ -202,7 +255,7 @@ class CacheManager {
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
@@ -210,11 +263,12 @@ class CacheManager {
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
if (_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cache.
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
@@ -223,75 +277,4 @@ class CacheManager {
|
||||
''');
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
Future<void> deleteKeyword(String keyword) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key LIKE ?
|
||||
''', ['%$keyword%']);
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
try {
|
||||
await file.delete();
|
||||
}
|
||||
finally {}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachingFile{
|
||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
||||
|
||||
final String key;
|
||||
|
||||
final String dir;
|
||||
|
||||
final String name;
|
||||
|
||||
final File file;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
Future<void> writeBytes(List<int> data) async{
|
||||
_buffer.addAll(data);
|
||||
if(_buffer.length > 1024 * 1024){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async{
|
||||
if(_buffer.isNotEmpty){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
}
|
||||
CacheManager()._db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
||||
CacheManager().checkCacheIfRequired();
|
||||
}
|
||||
|
||||
Future<void> cancel() async{
|
||||
await file.deleteIgnoreError();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer.clear();
|
||||
if(file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
part of comic_source;
|
||||
part of 'comic_source.dart';
|
||||
|
||||
class CategoryData {
|
||||
/// The title is displayed in the tab bar.
|
||||
@@ -34,24 +34,28 @@ class CategoryButtonData {
|
||||
});
|
||||
}
|
||||
|
||||
class CategoryItem {
|
||||
final String label;
|
||||
|
||||
final PageJumpTarget target;
|
||||
|
||||
const CategoryItem(this.label, this.target);
|
||||
}
|
||||
|
||||
abstract class BaseCategoryPart {
|
||||
String get title;
|
||||
|
||||
List<String> get categories;
|
||||
|
||||
List<String>? get categoryParams => null;
|
||||
List<CategoryItem> get categories;
|
||||
|
||||
bool get enableRandom;
|
||||
|
||||
String get categoryType;
|
||||
|
||||
/// Data class for building a part of category page.
|
||||
const BaseCategoryPart();
|
||||
}
|
||||
|
||||
class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final List<String> categories;
|
||||
final List<CategoryItem> categories;
|
||||
|
||||
@override
|
||||
bool get enableRandom => false;
|
||||
@@ -59,19 +63,12 @@ class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
@override
|
||||
final List<String>? categoryParams;
|
||||
|
||||
/// A [BaseCategoryPart] that show fixed tags on category page.
|
||||
const FixedCategoryPart(this.title, this.categories, this.categoryType,
|
||||
[this.categoryParams]);
|
||||
const FixedCategoryPart(this.title, this.categories);
|
||||
}
|
||||
|
||||
class RandomCategoryPart extends BaseCategoryPart {
|
||||
final List<String> tags;
|
||||
final List<CategoryItem> all;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@@ -81,71 +78,63 @@ class RandomCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
List<String> _categories() {
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags;
|
||||
List<CategoryItem> _categories() {
|
||||
if (randomNumber >= all.length) {
|
||||
return all;
|
||||
}
|
||||
var start = math.Random().nextInt(tags.length - randomNumber);
|
||||
return tags.sublist(start, start + randomNumber);
|
||||
var start = math.Random().nextInt(all.length - randomNumber);
|
||||
return all.sublist(start, start + randomNumber);
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
List<CategoryItem> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
/// A [BaseCategoryPart] that show a part of random tags on category page.
|
||||
const RandomCategoryPart(
|
||||
this.title, this.tags, this.randomNumber, this.categoryType);
|
||||
this.title,
|
||||
this.all,
|
||||
this.randomNumber,
|
||||
);
|
||||
}
|
||||
|
||||
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
|
||||
final Iterable<String> Function() loadTags;
|
||||
class DynamicCategoryPart extends BaseCategoryPart {
|
||||
final JSAutoFreeFunction loader;
|
||||
|
||||
final int randomNumber;
|
||||
final String sourceKey;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
static final random = math.Random();
|
||||
|
||||
List<String> _categories() {
|
||||
var tags = loadTags();
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags.toList();
|
||||
List<CategoryItem> get categories {
|
||||
var data = loader([]);
|
||||
if (data is! List) {
|
||||
throw "DynamicCategoryPart loader must return a List";
|
||||
}
|
||||
final start = random.nextInt(tags.length - randomNumber);
|
||||
var res = List.filled(randomNumber, '');
|
||||
int index = -1;
|
||||
for (var s in tags) {
|
||||
index++;
|
||||
if (start > index) {
|
||||
continue;
|
||||
} else if (index == start + randomNumber) {
|
||||
break;
|
||||
var res = <CategoryItem>[];
|
||||
for (var item in data) {
|
||||
if (item is! Map) {
|
||||
throw "DynamicCategoryPart loader must return a List of Map";
|
||||
}
|
||||
res[index - start] = s;
|
||||
var label = item['label'];
|
||||
var target = PageJumpTarget.parse(sourceKey, item['target']);
|
||||
if (label is! String) {
|
||||
throw "Category label must be a String";
|
||||
}
|
||||
res.add(CategoryItem(label, target));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
bool get enableRandom => false;
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
RandomCategoryPartWithRuntimeData(
|
||||
this.title, this.loadTags, this.randomNumber, this.categoryType);
|
||||
@override
|
||||
final String title;
|
||||
|
||||
/// A [BaseCategoryPart] that show dynamic tags on category page.
|
||||
const DynamicCategoryPart(this.title, this.loader, this.sourceKey);
|
||||
}
|
||||
|
||||
CategoryData getCategoryDataWithKey(String key) {
|
||||
for (var source in ComicSource._sources) {
|
||||
for (var source in ComicSource.all()) {
|
||||
if (source.categoryData?.key == key) {
|
||||
return source.categoryData!;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
library comic_source;
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
@@ -6,11 +6,16 @@ import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/pages/category_comics_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
@@ -25,81 +30,29 @@ part 'parser.dart';
|
||||
|
||||
part 'models.dart';
|
||||
|
||||
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
part 'types.dart';
|
||||
|
||||
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
|
||||
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
class ComicSourceManager with ChangeNotifier, Init {
|
||||
final List<ComicSource> _sources = [];
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
static ComicSourceManager? _instance;
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
ComicSourceManager._create();
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
List<ComicSource> all() => List.from(_sources);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
|
||||
/// [isLiking] is true if the user is liking the comment, false if unliking.
|
||||
/// return the new likes count or null.
|
||||
typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isLiking);
|
||||
|
||||
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
|
||||
/// return the new vote count or null.
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
|
||||
typedef HandleClickTagEvent = Map<String, String> Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
|
||||
class ComicSource {
|
||||
static final List<ComicSource> _sources = [];
|
||||
|
||||
static final List<Function> _listeners = [];
|
||||
|
||||
static void addListener(Function listener) {
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
static void removeListener(Function listener) {
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
|
||||
static void notifyListeners() {
|
||||
for (var listener in _listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
static List<ComicSource> all() => List.from(_sources);
|
||||
|
||||
static ComicSource? find(String key) =>
|
||||
ComicSource? find(String key) =>
|
||||
_sources.firstWhereOrNull((element) => element.key == key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
ComicSource? fromIntKey(int key) =>
|
||||
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
|
||||
|
||||
static Future<void> init() async {
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
await JsEngine().ensureInit();
|
||||
final path = "${App.dataPath}/comic_source";
|
||||
if (!(await Directory(path).exists())) {
|
||||
Directory(path).create();
|
||||
@@ -118,23 +71,50 @@ class ComicSource {
|
||||
}
|
||||
}
|
||||
|
||||
static Future reload() async {
|
||||
Future reload() async {
|
||||
_sources.clear();
|
||||
JsEngine().runCode("ComicSource.sources = {};");
|
||||
await init();
|
||||
await doInit();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static void add(ComicSource source) {
|
||||
void add(ComicSource source) {
|
||||
_sources.add(source);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static void remove(String key) {
|
||||
void remove(String key) {
|
||||
_sources.removeWhere((element) => element.key == key);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get isEmpty => _sources.isEmpty;
|
||||
|
||||
/// Key is the source key, value is the version.
|
||||
final _availableUpdates = <String, String>{};
|
||||
|
||||
void updateAvailableUpdates(Map<String, String> updates) {
|
||||
_availableUpdates.addAll(updates);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
|
||||
|
||||
void notifyStateChange() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class ComicSource {
|
||||
static List<ComicSource> all() => ComicSourceManager().all();
|
||||
|
||||
static ComicSource? find(String key) => ComicSourceManager().find(key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
ComicSourceManager().fromIntKey(key);
|
||||
|
||||
static bool get isEmpty => ComicSourceManager().isEmpty;
|
||||
|
||||
/// Name of this source.
|
||||
final String name;
|
||||
|
||||
@@ -198,12 +178,15 @@ class ComicSource {
|
||||
|
||||
final LikeCommentFunc? likeCommentFunc;
|
||||
|
||||
final Map<String, dynamic>? settings;
|
||||
final Map<String, Map<String, dynamic>>? settings;
|
||||
|
||||
final Map<String, Map<String, String>>? translations;
|
||||
|
||||
final HandleClickTagEvent? handleClickTagEvent;
|
||||
|
||||
/// Callback when a tag suggestion is selected in search.
|
||||
final TagSuggestionSelectFunc? onTagSuggestionSelected;
|
||||
|
||||
final LinkHandler? linkHandler;
|
||||
|
||||
final bool enableTagsSuggestions;
|
||||
@@ -212,6 +195,8 @@ class ComicSource {
|
||||
|
||||
final StarRatingFunc? starRatingFunc;
|
||||
|
||||
final ArchiveDownloader? archiveDownloader;
|
||||
|
||||
Future<void> loadData() async {
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (await file.exists()) {
|
||||
@@ -236,6 +221,7 @@ class ComicSource {
|
||||
}
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
_isSaving = false;
|
||||
DataSync().uploadData();
|
||||
}
|
||||
|
||||
Future<bool> reLogin() async {
|
||||
@@ -276,10 +262,12 @@ class ComicSource {
|
||||
this.idMatcher,
|
||||
this.translations,
|
||||
this.handleClickTagEvent,
|
||||
this.onTagSuggestionSelected,
|
||||
this.linkHandler,
|
||||
this.enableTagsSuggestions,
|
||||
this.enableTagsTranslate,
|
||||
this.starRatingFunc,
|
||||
this.archiveDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,7 +299,7 @@ class AccountConfig {
|
||||
this.onLoginWithWebviewSuccess,
|
||||
this.cookieFields,
|
||||
this.validateCookies,
|
||||
) : infoItems = const [];
|
||||
) : infoItems = const [];
|
||||
}
|
||||
|
||||
class AccountInfoItem {
|
||||
@@ -367,7 +355,7 @@ class ExplorePagePart {
|
||||
/// - category:categoryName
|
||||
///
|
||||
/// End with `@`+`param` if the category has a parameter.
|
||||
final String? viewMore;
|
||||
final PageJumpTarget? viewMore;
|
||||
|
||||
const ExplorePagePart(this.title, this.comics, this.viewMore);
|
||||
}
|
||||
@@ -407,15 +395,20 @@ class SearchOptions {
|
||||
|
||||
const SearchOptions(this.options, this.label, this.type, this.defaultVal);
|
||||
|
||||
String get defaultValue => defaultVal ?? options.keys.first;
|
||||
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
|
||||
}
|
||||
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
|
||||
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
|
||||
String category, String? param);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
final List<CategoryComicsOptions> options;
|
||||
final List<CategoryComicsOptions>? options;
|
||||
|
||||
final CategoryOptionsLoader? optionsLoader;
|
||||
|
||||
/// [category] is the one clicked by the user on the category page.
|
||||
///
|
||||
@@ -426,7 +419,7 @@ class CategoryComicsData {
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData(this.options, this.load, {this.rankingData});
|
||||
const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
@@ -441,6 +434,9 @@ class RankingData {
|
||||
}
|
||||
|
||||
class CategoryComicsOptions {
|
||||
// The label will not be displayed if it is empty.
|
||||
final String label;
|
||||
|
||||
/// Use a [LinkedHashMap] to describe an option list.
|
||||
/// key is for loading comics, value is the name displayed on screen.
|
||||
/// Default value will be the first of the Map.
|
||||
@@ -451,7 +447,7 @@ class CategoryComicsOptions {
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
|
||||
}
|
||||
|
||||
class LinkHandler {
|
||||
@@ -461,3 +457,11 @@ class LinkHandler {
|
||||
|
||||
const LinkHandler(this.domains, this.linkToId);
|
||||
}
|
||||
|
||||
class ArchiveDownloader {
|
||||
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
|
||||
|
||||
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
|
||||
|
||||
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ class FavoriteData {
|
||||
|
||||
final bool multiFolder;
|
||||
|
||||
// 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了
|
||||
// 如果为 null, 当做从新到旧
|
||||
final bool? isOldToNewSort;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page, [String? folder])?
|
||||
loadComic;
|
||||
|
||||
@@ -33,6 +37,8 @@ class FavoriteData {
|
||||
|
||||
final AddOrDelFavFunc? addOrDelFavorite;
|
||||
|
||||
final bool singleFolderForSingleComic;
|
||||
|
||||
const FavoriteData({
|
||||
required this.key,
|
||||
required this.title,
|
||||
@@ -44,6 +50,8 @@ class FavoriteData {
|
||||
this.addFolder,
|
||||
this.allFavoritesId,
|
||||
this.addOrDelFavorite,
|
||||
this.isOldToNewSort,
|
||||
this.singleFolderForSingleComic = false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ class Comic {
|
||||
this.sourceKey,
|
||||
this.maxPage,
|
||||
this.language,
|
||||
): favoriteId = null, stars = null;
|
||||
) : favoriteId = null,
|
||||
stars = null;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -92,7 +93,7 @@ class Comic {
|
||||
|
||||
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
|
||||
: title = json["title"],
|
||||
subtitle = json["subTitle"] ?? "",
|
||||
subtitle = json["subtitle"] ?? json["subTitle"] ?? "",
|
||||
cover = json["cover"],
|
||||
id = json["id"],
|
||||
tags = List<String>.from(json["tags"] ?? []),
|
||||
@@ -110,6 +111,29 @@ class Comic {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ sourceKey.hashCode;
|
||||
|
||||
@override
|
||||
toString() => "$sourceKey@$id";
|
||||
}
|
||||
|
||||
class ComicID {
|
||||
final ComicType type;
|
||||
|
||||
final String id;
|
||||
|
||||
const ComicID(this.type, this.id);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ComicID) return false;
|
||||
return other.type == type && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => type.hashCode ^ id.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => "$type@$id";
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
@@ -127,7 +151,7 @@ class ComicDetails with HistoryMixin {
|
||||
final Map<String, List<String>> tags;
|
||||
|
||||
/// id-name
|
||||
final Map<String, String>? chapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
final List<String>? thumbnails;
|
||||
|
||||
@@ -145,7 +169,7 @@ class ComicDetails with HistoryMixin {
|
||||
|
||||
final int? likesCount;
|
||||
|
||||
final int? commentsCount;
|
||||
final int? commentCount;
|
||||
|
||||
final String? uploader;
|
||||
|
||||
@@ -160,23 +184,25 @@ class ComicDetails with HistoryMixin {
|
||||
@override
|
||||
final int? maxPage;
|
||||
|
||||
final List<Comment>? comments;
|
||||
|
||||
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
res[key] = List<String>.from(value);
|
||||
if (value is List) {
|
||||
res[key] = List<String>.from(value);
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
ComicDetails.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"],
|
||||
subTitle = json["subtitle"],
|
||||
cover = json["cover"],
|
||||
description = json["description"],
|
||||
tags = _generateMap(json["tags"]),
|
||||
chapters = json["chapters"] == null
|
||||
? null
|
||||
: Map<String, String>.from(json["chapters"]),
|
||||
chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
|
||||
sourceKey = json["sourceKey"],
|
||||
comicId = json["comicId"],
|
||||
thumbnails = ListOrNull.from(json["thumbnails"]),
|
||||
@@ -187,13 +213,16 @@ class ComicDetails with HistoryMixin {
|
||||
subId = json["subId"],
|
||||
likesCount = json["likesCount"],
|
||||
isLiked = json["isLiked"],
|
||||
commentsCount = json["commentsCount"],
|
||||
commentCount = json["commentCount"],
|
||||
uploader = json["uploader"],
|
||||
uploadTime = json["uploadTime"],
|
||||
updateTime = json["updateTime"],
|
||||
url = json["url"],
|
||||
stars = (json["stars"] as num?)?.toDouble(),
|
||||
maxPage = json["maxPage"];
|
||||
maxPage = json["maxPage"],
|
||||
comments = (json["comments"] as List?)
|
||||
?.map((e) => Comment.fromJson(e))
|
||||
.toList();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -211,7 +240,7 @@ class ComicDetails with HistoryMixin {
|
||||
"subId": subId,
|
||||
"isLiked": isLiked,
|
||||
"likesCount": likesCount,
|
||||
"commentsCount": commentsCount,
|
||||
"commentsCount": commentCount,
|
||||
"uploader": uploader,
|
||||
"uploadTime": uploadTime,
|
||||
"updateTime": updateTime,
|
||||
@@ -226,4 +255,307 @@ class ComicDetails with HistoryMixin {
|
||||
String get id => comicId;
|
||||
|
||||
ComicType get comicType => ComicType(sourceKey.hashCode);
|
||||
|
||||
/// Convert tags map to plain list
|
||||
List<String> get plainTags {
|
||||
var res = <String>[];
|
||||
tags.forEach((key, value) {
|
||||
res.addAll(value.map((e) => "$key:$e"));
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Find the first author tag
|
||||
String? findAuthor() {
|
||||
var authorNamespaces = [
|
||||
"author",
|
||||
"authors",
|
||||
"artist",
|
||||
"artists",
|
||||
"作者",
|
||||
"画师"
|
||||
];
|
||||
for (var entry in tags.entries) {
|
||||
if (authorNamespaces.contains(entry.key.toLowerCase()) &&
|
||||
entry.value.isNotEmpty) {
|
||||
return entry.value.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateUpdateTime(String time) {
|
||||
time = time.split(" ").first;
|
||||
var segments = time.split("-");
|
||||
if (segments.length != 3) return null;
|
||||
var year = int.tryParse(segments[0]);
|
||||
var month = int.tryParse(segments[1]);
|
||||
var day = int.tryParse(segments[2]);
|
||||
if (year == null || month == null || day == null) return null;
|
||||
if (year < 2000 || year > 3000) return null;
|
||||
if (month < 1 || month > 12) return null;
|
||||
if (day < 1 || day > 31) return null;
|
||||
return "$year-$month-$day";
|
||||
}
|
||||
|
||||
String? findUpdateTime() {
|
||||
if (updateTime != null) {
|
||||
return _validateUpdateTime(updateTime!);
|
||||
}
|
||||
const acceptedNamespaces = [
|
||||
"更新",
|
||||
"最後更新",
|
||||
"最后更新",
|
||||
"update",
|
||||
"last update",
|
||||
];
|
||||
for (var entry in tags.entries) {
|
||||
if (acceptedNamespaces.contains(entry.key.toLowerCase()) &&
|
||||
entry.value.isNotEmpty) {
|
||||
var value = entry.value.first;
|
||||
return _validateUpdateTime(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveInfo {
|
||||
final String title;
|
||||
final String description;
|
||||
final String id;
|
||||
|
||||
ArchiveInfo.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
description = json["description"],
|
||||
id = json["id"];
|
||||
}
|
||||
|
||||
class ComicChapters {
|
||||
final Map<String, String>? _chapters;
|
||||
|
||||
final Map<String, Map<String, String>>? _groupedChapters;
|
||||
|
||||
/// Create a ComicChapters object with a flat map
|
||||
const ComicChapters(Map<String, String> this._chapters)
|
||||
: _groupedChapters = null;
|
||||
|
||||
/// Create a ComicChapters object with a grouped map
|
||||
const ComicChapters.grouped(
|
||||
Map<String, Map<String, String>> this._groupedChapters)
|
||||
: _chapters = null;
|
||||
|
||||
factory ComicChapters.fromJson(dynamic json) {
|
||||
if (json is! Map) throw ArgumentError("Invalid json type");
|
||||
var chapters = <String, String>{};
|
||||
var groupedChapters = <String, Map<String, String>>{};
|
||||
for (var entry in json.entries) {
|
||||
var key = entry.key;
|
||||
var value = entry.value;
|
||||
if (key is! String) throw ArgumentError("Invalid key type");
|
||||
if (value is Map) {
|
||||
groupedChapters[key] = Map.from(value);
|
||||
} else {
|
||||
chapters[key] = value.toString();
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty) {
|
||||
return ComicChapters(chapters);
|
||||
} else if (groupedChapters.isNotEmpty) {
|
||||
return ComicChapters.grouped(groupedChapters);
|
||||
} else {
|
||||
// return a empty list.
|
||||
return ComicChapters(chapters);
|
||||
}
|
||||
}
|
||||
|
||||
static fromJsonOrNull(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return ComicChapters.fromJson(json);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
if (_chapters != null) {
|
||||
return _chapters;
|
||||
} else {
|
||||
return _groupedChapters!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chapters are grouped
|
||||
bool get isGrouped => _groupedChapters != null;
|
||||
|
||||
/// All group names
|
||||
Iterable<String> get groups => _groupedChapters?.keys ?? [];
|
||||
|
||||
/// All chapters.
|
||||
/// If the chapters are grouped, all groups will be merged.
|
||||
Map<String, String> get allChapters {
|
||||
if (_chapters != null) return _chapters;
|
||||
var res = <String, String>{};
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
res.addAll(entry);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Get a group of chapters by name
|
||||
Map<String, String> getGroup(String group) {
|
||||
return _groupedChapters![group] ?? {};
|
||||
}
|
||||
|
||||
/// Get a group of chapters by index(0-based)
|
||||
Map<String, String> getGroupByIndex(int index) {
|
||||
return _groupedChapters!.values.elementAt(index);
|
||||
}
|
||||
|
||||
/// Get total number of chapters
|
||||
int get length {
|
||||
return isGrouped
|
||||
? _groupedChapters!.values.map((e) => e.length).reduce((a, b) => a + b)
|
||||
: _chapters!.length;
|
||||
}
|
||||
|
||||
/// Get the number of groups
|
||||
int get groupCount => _groupedChapters?.length ?? 0;
|
||||
|
||||
/// Iterate all chapter ids
|
||||
Iterable<String> get ids sync* {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
yield* entry.keys;
|
||||
}
|
||||
} else {
|
||||
yield* _chapters!.keys;
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate all chapter titles
|
||||
Iterable<String> get titles sync* {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
yield* entry.values;
|
||||
}
|
||||
} else {
|
||||
yield* _chapters!.values;
|
||||
}
|
||||
}
|
||||
|
||||
String? operator [](String key) {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
if (entry.containsKey(key)) return entry[key];
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return _chapters![key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PageJumpTarget {
|
||||
final String sourceKey;
|
||||
|
||||
final String page;
|
||||
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
const PageJumpTarget(this.sourceKey, this.page, this.attributes);
|
||||
|
||||
static PageJumpTarget parse(String sourceKey, dynamic value) {
|
||||
if (value is Map) {
|
||||
if (value['page'] != null) {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
value["page"] ?? "search",
|
||||
value["attributes"],
|
||||
);
|
||||
} else if (value["action"] != null) {
|
||||
// old version `onClickTag`
|
||||
var page = value["action"];
|
||||
if (page == "search") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"search",
|
||||
{
|
||||
"text": value["keyword"],
|
||||
},
|
||||
);
|
||||
} else if (page == "category") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": value["keyword"],
|
||||
"param": value["param"],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return PageJumpTarget(sourceKey, page, null);
|
||||
}
|
||||
}
|
||||
} else if (value is String) {
|
||||
// old version string encoding. search: `search:keyword`, category: `category:keyword` or `category:keyword@param`
|
||||
var segments = value.split(":");
|
||||
var page = segments[0];
|
||||
if (page == "search") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"search",
|
||||
{
|
||||
"text": segments[1],
|
||||
},
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var c = segments[1];
|
||||
if (c.contains('@')) {
|
||||
var parts = c.split('@');
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": parts[0],
|
||||
"param": parts[1],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": c,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return PageJumpTarget(sourceKey, page, null);
|
||||
}
|
||||
}
|
||||
return PageJumpTarget(sourceKey, "Invalid Data", null);
|
||||
}
|
||||
|
||||
void jump(BuildContext context) {
|
||||
if (page == "search") {
|
||||
context.to(
|
||||
() => SearchResultPage(
|
||||
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
|
||||
sourceKey: sourceKey,
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
),
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var key = ComicSource.find(sourceKey)!.categoryData!.key;
|
||||
context.to(
|
||||
() => CategoryComicsPage(
|
||||
categoryKey: key,
|
||||
category: attributes?["category"] ??
|
||||
(throw ArgumentError("Category name is required")),
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
param: attributes?["param"],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.error("Page Jump", "Unknown page: $page");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
/// return true if ver1 > ver2
|
||||
bool compareSemVer(String ver1, String ver2) {
|
||||
ver1 = ver1.replaceFirst("-", ".");
|
||||
ver2 = ver2.replaceFirst("-", ".");
|
||||
@@ -63,8 +64,13 @@ class ComicSourceParser {
|
||||
if (file.existsSync()) {
|
||||
int i = 0;
|
||||
while (file.existsSync()) {
|
||||
file = File(FilePath.join(App.dataPath, "comic_source",
|
||||
"${fileName.split('.').first}($i).js"));
|
||||
file = File(
|
||||
FilePath.join(
|
||||
App.dataPath,
|
||||
"comic_source",
|
||||
"${fileName.split('.').first}($i).js",
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +87,7 @@ class ComicSourceParser {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 = js
|
||||
.split('\n')
|
||||
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
|
||||
.firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
if (line1 == null ||
|
||||
!line1.startsWith("class ") ||
|
||||
!line1.contains("extends ComicSource")) {
|
||||
@@ -89,25 +95,27 @@ class ComicSourceParser {
|
||||
}
|
||||
var className = line1.split("class")[1].split("extends ComicSource").first;
|
||||
className = className.trim();
|
||||
JsEngine().runCode("""
|
||||
(() => {
|
||||
$js
|
||||
JsEngine().runCode("""(() => { $js
|
||||
this['temp'] = new $className()
|
||||
}).call()
|
||||
""");
|
||||
_name = JsEngine().runCode("this['temp'].name") ??
|
||||
""", className);
|
||||
_name =
|
||||
JsEngine().runCode("this['temp'].name") ??
|
||||
(throw ComicSourceParseException('name is required'));
|
||||
var key = JsEngine().runCode("this['temp'].key") ??
|
||||
var key =
|
||||
JsEngine().runCode("this['temp'].key") ??
|
||||
(throw ComicSourceParseException('key is required'));
|
||||
var version = JsEngine().runCode("this['temp'].version") ??
|
||||
var version =
|
||||
JsEngine().runCode("this['temp'].version") ??
|
||||
(throw ComicSourceParseException('version is required'));
|
||||
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
|
||||
var url = JsEngine().runCode("this['temp'].url");
|
||||
if (minAppVersion != null) {
|
||||
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
|
||||
throw ComicSourceParseException(
|
||||
"minAppVersion @version is required"
|
||||
.tlParams({"version": minAppVersion}),
|
||||
"minAppVersion @version is required".tlParams({
|
||||
"version": minAppVersion,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -149,17 +157,21 @@ class ComicSourceParser {
|
||||
_parseIdMatch(),
|
||||
_parseTranslation(),
|
||||
_parseClickTagEvent(),
|
||||
_parseTagSuggestionSelectFunc(),
|
||||
_parseLinkHandler(),
|
||||
_getValue("search.enableTagsSuggestions") ?? false,
|
||||
_getValue("comic.enableTagsTranslate") ?? false,
|
||||
_parseStarRatingFunc(),
|
||||
_parseArchiveDownloader(),
|
||||
);
|
||||
|
||||
await source.loadData();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
if (_checkExists("init")) {
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
@@ -172,8 +184,10 @@ class ComicSourceParser {
|
||||
}
|
||||
|
||||
bool _checkExists(String index) {
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined");
|
||||
return JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined",
|
||||
);
|
||||
}
|
||||
|
||||
dynamic _getValue(String index) {
|
||||
@@ -191,7 +205,7 @@ class ComicSourceParser {
|
||||
login = (account, pwd) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
${jsonEncode(pwd)})
|
||||
""");
|
||||
var source = ComicSource.find(_key!)!;
|
||||
@@ -274,16 +288,24 @@ class ComicSourceParser {
|
||||
if (type == "singlePageWithMultiPart") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
return Res(List.from(res.keys
|
||||
.map((e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null))
|
||||
.toList()));
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
res.keys
|
||||
.map(
|
||||
(e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Data Analysis", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -294,11 +316,15 @@ class ComicSourceParser {
|
||||
loadPage = (int page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -308,10 +334,13 @@ class ComicSourceParser {
|
||||
loadNext = (next) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -323,8 +352,9 @@ class ComicSourceParser {
|
||||
} else if (type == "multiPartPage") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
(res as List).map((e) {
|
||||
@@ -333,7 +363,7 @@ class ComicSourceParser {
|
||||
(e['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
e['viewMore'],
|
||||
PageJumpTarget.parse(_key!, e['viewMore']),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -347,19 +377,22 @@ class ComicSourceParser {
|
||||
loadMixed = (index) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
|
||||
);
|
||||
var list = <Object>[];
|
||||
for (var data in (res['data'] as List)) {
|
||||
if (data is List) {
|
||||
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
|
||||
} else if (data is Map) {
|
||||
list.add(ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
));
|
||||
list.add(
|
||||
ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Res(list, subData: res['maxPage']);
|
||||
@@ -369,21 +402,25 @@ class ComicSourceParser {
|
||||
}
|
||||
};
|
||||
}
|
||||
pages.add(ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ =>
|
||||
throw ComicSourceParseException("Unknown explore page type $type")
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
));
|
||||
pages.add(
|
||||
ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" =>
|
||||
ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ => throw ComicSourceParseException(
|
||||
"Unknown explore page type $type",
|
||||
),
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
),
|
||||
);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
@@ -401,50 +438,163 @@ class ComicSourceParser {
|
||||
var categoryParts = <BaseCategoryPart>[];
|
||||
|
||||
for (var c in doc["parts"]) {
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
|
||||
final String? groupParam = c["groupParam"];
|
||||
if (groupParam != null) {
|
||||
categoryParams = List.filled(tags.length, groupParam);
|
||||
if (c["categories"] != null && c["categories"] is! List) {
|
||||
continue;
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts
|
||||
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
|
||||
List? categories = c["categories"];
|
||||
if (categories == null || categories[0] is Map) {
|
||||
// new format
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final cs = categories
|
||||
?.map(
|
||||
(e) => CategoryItem(
|
||||
e['label'],
|
||||
PageJumpTarget.parse(_key!, e['target']),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (type != "dynamic" && (cs == null || cs.isEmpty)) {
|
||||
continue;
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs!));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
|
||||
);
|
||||
} else if (type == "dynamic" && categories == null) {
|
||||
var loader = c["loader"];
|
||||
if (loader is! JSInvokable) {
|
||||
throw "DynamicCategoryPart loader must be a function";
|
||||
}
|
||||
categoryParts.add(
|
||||
DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// old format
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
|
||||
final String? groupParam = c["groupParam"];
|
||||
if (groupParam != null) {
|
||||
categoryParams = List.filled(tags.length, groupParam);
|
||||
}
|
||||
var cs = <CategoryItem>[];
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
PageJumpTarget target;
|
||||
if (itemType == 'category') {
|
||||
target = PageJumpTarget(_key!, 'category', {
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
});
|
||||
} else if (itemType == 'search') {
|
||||
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
|
||||
} else if (itemType == 'search_with_namespace') {
|
||||
target = PageJumpTarget(_key!, 'search', {
|
||||
"keyword": "$name:$tags[i]",
|
||||
});
|
||||
} else {
|
||||
target = PageJumpTarget(_key!, itemType, null);
|
||||
}
|
||||
cs.add(CategoryItem(tags[i], target));
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CategoryData(
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title);
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title,
|
||||
);
|
||||
}
|
||||
|
||||
CategoryComicsData? _loadCategoryComicsData() {
|
||||
if (!_checkExists("categoryComics")) return null;
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
|
||||
List<CategoryComicsOptions>? options;
|
||||
if (_checkExists("categoryComics.optionList")) {
|
||||
options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
options.add(CategoryComicsOptions(
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"])));
|
||||
}
|
||||
|
||||
CategoryOptionsLoader? optionLoader;
|
||||
if (_checkExists("categoryComics.optionLoader")) {
|
||||
optionLoader = (category, param) async {
|
||||
try {
|
||||
dynamic res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.optionLoader(
|
||||
${jsonEncode(category)}, ${jsonEncode(param)})
|
||||
""");
|
||||
if (res is Future) {
|
||||
res = await res;
|
||||
}
|
||||
if (res is! List) {
|
||||
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}");
|
||||
}
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in res) {
|
||||
if (element is! Map) {
|
||||
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}");
|
||||
}
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"] ?? []) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Res(options);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Data Analysis", "Failed to load category options.\n$e");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
RankingData? rankingData;
|
||||
if (_checkExists("categoryComics.ranking")) {
|
||||
var options = <String, String>{};
|
||||
@@ -459,7 +609,7 @@ class ComicSourceParser {
|
||||
}
|
||||
Future<Res<List<Comic>>> Function(String option, int page)? load;
|
||||
Future<Res<List<Comic>>> Function(String option, String? next)?
|
||||
loadWithNext;
|
||||
loadWithNext;
|
||||
if (_checkExists("categoryComics.ranking.load")) {
|
||||
load = (option, page) async {
|
||||
try {
|
||||
@@ -468,9 +618,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -484,8 +637,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -496,25 +651,38 @@ class ComicSourceParser {
|
||||
}
|
||||
rankingData = RankingData(options, load, loadWithNext);
|
||||
}
|
||||
return CategoryComicsData(options, (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}, rankingData: rankingData);
|
||||
|
||||
if (options == null && optionLoader == null) {
|
||||
options = [];
|
||||
}
|
||||
|
||||
return CategoryComicsData(
|
||||
options: options,
|
||||
optionsLoader: optionLoader,
|
||||
load: (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
rankingData: rankingData,
|
||||
);
|
||||
}
|
||||
|
||||
SearchPageData? _loadSearchData() {
|
||||
@@ -531,12 +699,14 @@ class ComicSourceParser {
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
));
|
||||
options.add(
|
||||
SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
SearchFunction? loadPage;
|
||||
@@ -551,9 +721,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -567,8 +740,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -616,6 +791,10 @@ class ComicSourceParser {
|
||||
if (!_checkExists("favorites")) return null;
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
|
||||
final bool? singleFolderForSingleComic = _getValue(
|
||||
"favorites.singleFolderForSingleComic",
|
||||
);
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
|
||||
if (!ComicSource.find(_key!)!.isLogged) {
|
||||
@@ -668,9 +847,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(page)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -690,8 +872,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(next)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -768,6 +952,8 @@ class ComicSourceParser {
|
||||
addFolder: addFolder,
|
||||
deleteFolder: deleteFolder,
|
||||
addOrDelFavorite: addOrDelFavFunc,
|
||||
isOldToNewSort: isOldToNewSort,
|
||||
singleFolderForSingleComic: singleFolderForSingleComic ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -780,8 +966,9 @@ class ComicSourceParser {
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"]);
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -918,8 +1105,30 @@ class ComicSourceParser {
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parseSettings() {
|
||||
return _getValue("settings") ?? {};
|
||||
Map<String, Map<String, dynamic>> _parseSettings() {
|
||||
var value = _getValue("settings");
|
||||
if (value is Map) {
|
||||
var newMap = <String, Map<String, dynamic>>{};
|
||||
for (var e in value.entries) {
|
||||
if (e.key is! String) {
|
||||
continue;
|
||||
}
|
||||
var v = <String, dynamic>{};
|
||||
for (var e2 in e.value.entries) {
|
||||
if (e2.key is! String) {
|
||||
continue;
|
||||
}
|
||||
var v2 = e2.value;
|
||||
if (v2 is JSInvokable) {
|
||||
v2 = JSAutoFreeFunction(v2);
|
||||
}
|
||||
v[e2.key] = v2;
|
||||
}
|
||||
newMap[e.key] = v;
|
||||
}
|
||||
return newMap;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
RegExp? _parseIdMatch() {
|
||||
@@ -949,9 +1158,25 @@ class ComicSourceParser {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)})
|
||||
""");
|
||||
var r = Map<String, String?>.from(res);
|
||||
if (res is! Map) {
|
||||
return null;
|
||||
}
|
||||
var r = Map<String, dynamic>.from(res);
|
||||
r.removeWhere((key, value) => value == null);
|
||||
return Map.from(r);
|
||||
return PageJumpTarget.parse(_key!, r);
|
||||
};
|
||||
}
|
||||
|
||||
TagSuggestionSelectFunc? _parseTagSuggestionSelectFunc() {
|
||||
if (!_checkExists("search.onTagSuggestionSelected")) {
|
||||
return null;
|
||||
}
|
||||
return (namespace, tag) {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.search.onTagSuggestionSelected(
|
||||
${jsonEncode(namespace)}, ${jsonEncode(tag)})
|
||||
""");
|
||||
return res is String ? res : "$namespace:$tag";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -986,4 +1211,36 @@ class ComicSourceParser {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ArchiveDownloader? _parseArchiveDownloader() {
|
||||
if (!_checkExists("comic.archive")) {
|
||||
return null;
|
||||
}
|
||||
return ArchiveDownloader(
|
||||
(cid) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
|
||||
""");
|
||||
return Res(
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
(cid, aid) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)})
|
||||
""");
|
||||
return Res(res as String);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
53
lib/foundation/comic_source/types.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
|
||||
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
|
||||
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
|
||||
/// [isLiking] is true if the user is liking the comment, false if unliking.
|
||||
/// return the new likes count or null.
|
||||
typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isLiking);
|
||||
|
||||
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
|
||||
/// return the new vote count or null.
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
|
||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// Handle tag suggestion selection event. Should return the text to insert
|
||||
/// into the search field.
|
||||
typedef TagSuggestionSelectFunc = String Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
@@ -28,4 +28,12 @@ class ComicType {
|
||||
}
|
||||
|
||||
static const local = ComicType(0);
|
||||
|
||||
factory ComicType.fromKey(String key) {
|
||||
if(key == "local") {
|
||||
return local;
|
||||
} else {
|
||||
return ComicType(key.hashCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
/// If window width is less than this value, it is considered as mobile.
|
||||
const changePoint = 600;
|
||||
|
||||
/// If window width is less than this value, it is considered as tablet.
|
||||
///
|
||||
/// If it is more than this value, it is considered as desktop.
|
||||
const changePoint2 = 1300;
|
||||
|
||||
/// Default user agent for http requests.
|
||||
const webUA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
||||
|
||||
/// Pages for all comics is started from this value.
|
||||
const firstPage = 1;
|
||||
|
||||
/// Chapters for all comics is started from this value.
|
||||
const firstChapter = 1;
|
||||
@@ -36,6 +36,8 @@ extension Navigation on BuildContext {
|
||||
|
||||
Brightness get brightness => Theme.of(this).brightness;
|
||||
|
||||
bool get isDarkMode => brightness == Brightness.dark;
|
||||
|
||||
void showMessage({required String message}) {
|
||||
showToast(message: message, context: this);
|
||||
}
|
||||
|
||||
191
lib/foundation/follow_updates.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
class ComicUpdateResult {
|
||||
final bool updated;
|
||||
final String? errorMessage;
|
||||
|
||||
ComicUpdateResult(this.updated, this.errorMessage);
|
||||
}
|
||||
|
||||
Future<ComicUpdateResult> updateComic(
|
||||
FavoriteItemWithUpdateInfo c, String folder) async {
|
||||
int retries = 3;
|
||||
while (true) {
|
||||
try {
|
||||
var comicSource = c.type.comicSource;
|
||||
if (comicSource == null) {
|
||||
return ComicUpdateResult(false, "Comic source not found");
|
||||
}
|
||||
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
|
||||
|
||||
var newTags = <String>[];
|
||||
for (var entry in newInfo.tags.entries) {
|
||||
const shouldIgnore = ['author', 'artist', 'time'];
|
||||
var namespace = entry.key;
|
||||
if (shouldIgnore.contains(namespace.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
for (var tag in entry.value) {
|
||||
newTags.add("$namespace:$tag");
|
||||
}
|
||||
}
|
||||
|
||||
var item = FavoriteItem(
|
||||
id: c.id,
|
||||
name: newInfo.title,
|
||||
coverPath: newInfo.cover,
|
||||
author: newInfo.subTitle ??
|
||||
newInfo.tags['author']?.firstOrNull ??
|
||||
c.author,
|
||||
type: c.type,
|
||||
tags: newTags,
|
||||
);
|
||||
|
||||
LocalFavoritesManager().updateInfo(folder, item, false);
|
||||
|
||||
var updated = false;
|
||||
var updateTime = newInfo.findUpdateTime();
|
||||
if (updateTime != null && updateTime != c.updateTime) {
|
||||
LocalFavoritesManager().updateUpdateTime(
|
||||
folder,
|
||||
c.id,
|
||||
c.type,
|
||||
updateTime,
|
||||
);
|
||||
updated = true;
|
||||
} else {
|
||||
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
|
||||
}
|
||||
return ComicUpdateResult(updated, null);
|
||||
} catch (e, s) {
|
||||
Log.error("Check Updates", e, s);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
retries--;
|
||||
if (retries == 0) {
|
||||
return ComicUpdateResult(false, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateProgress {
|
||||
final int total;
|
||||
final int current;
|
||||
final int errors;
|
||||
final int updated;
|
||||
final FavoriteItemWithUpdateInfo? comic;
|
||||
final String? errorMessage;
|
||||
|
||||
UpdateProgress(this.total, this.current, this.errors, this.updated,
|
||||
[this.comic, this.errorMessage]);
|
||||
}
|
||||
|
||||
void updateFolderBase(
|
||||
String folder,
|
||||
StreamController<UpdateProgress> stream,
|
||||
bool ignoreCheckTime,
|
||||
) async {
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
int total = comics.length;
|
||||
int current = 0;
|
||||
int errors = 0;
|
||||
int updated = 0;
|
||||
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var comicsToUpdate = <FavoriteItemWithUpdateInfo>[];
|
||||
|
||||
for (var comic in comics) {
|
||||
if (!ignoreCheckTime) {
|
||||
var lastCheckTime = comic.lastCheckTime;
|
||||
if (lastCheckTime != null &&
|
||||
DateTime.now().difference(lastCheckTime).inDays < 1) {
|
||||
current++;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
comicsToUpdate.add(comic);
|
||||
}
|
||||
|
||||
total = comicsToUpdate.length;
|
||||
current = 0;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
|
||||
|
||||
// Producer
|
||||
() async {
|
||||
var c = 0;
|
||||
for (var comic in comicsToUpdate) {
|
||||
await channel.push(comic);
|
||||
c++;
|
||||
// Throttle
|
||||
if (c % 5 == 0) {
|
||||
var delay = c % 100 + 1;
|
||||
if (delay > 10) {
|
||||
delay = 10;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
}
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// Consumers
|
||||
var updateFutures = <Future>[];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var f = () async {
|
||||
while (true) {
|
||||
var comic = await channel.pop();
|
||||
if (comic == null) {
|
||||
break;
|
||||
}
|
||||
var result = await updateComic(comic, folder);
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
}
|
||||
}();
|
||||
updateFutures.add(f);
|
||||
}
|
||||
|
||||
await Future.wait(updateFutures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
}
|
||||
|
||||
stream.close();
|
||||
}
|
||||
|
||||
|
||||
Stream<UpdateProgress> updateFolder(String folder, bool ignoreCheckTime) {
|
||||
var stream = StreamController<UpdateProgress>();
|
||||
updateFolderBase(folder, stream, ignoreCheckTime);
|
||||
return stream.stream;
|
||||
}
|
||||
|
||||
Future<String> getUpdatedComicsAsJson(String folder) async {
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
var updatedComics = comics.where((c) => c.hasNewUpdate).toList();
|
||||
var jsonList = updatedComics.map((c) => {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'coverUrl': c.coverPath,
|
||||
'author': c.author,
|
||||
'type': c.type.sourceKey,
|
||||
'updateTime': c.updateTime,
|
||||
'tags': c.tags,
|
||||
}).toList();
|
||||
return jsonEncode(jsonList);
|
||||
}
|
||||
66
lib/foundation/global_state.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class GlobalState {
|
||||
static final _state = <Pair<Object?, State>>[];
|
||||
|
||||
static void register(State state, [Object? key]) {
|
||||
_state.add(Pair(key, state));
|
||||
}
|
||||
|
||||
static T find<T extends State>([Object? key]) {
|
||||
for (var pair in _state) {
|
||||
if ((key == null || pair.left == key) && pair.right is T) {
|
||||
return pair.right as T;
|
||||
}
|
||||
}
|
||||
throw Exception('State not found');
|
||||
}
|
||||
|
||||
static T? findOrNull<T extends State>([Object? key]) {
|
||||
for (var pair in _state) {
|
||||
if ((key == null || pair.left == key) && pair.right is T) {
|
||||
return pair.right as T;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void unregister(State state, [Object? key]) {
|
||||
_state.removeWhere(
|
||||
(pair) => (key == null || pair.left == key) && pair.right == state);
|
||||
}
|
||||
}
|
||||
|
||||
class Pair<K, V> {
|
||||
K left;
|
||||
V right;
|
||||
|
||||
Pair(this.left, this.right);
|
||||
}
|
||||
|
||||
abstract class AutomaticGlobalState<T extends StatefulWidget>
|
||||
extends State<T> {
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
super.initState();
|
||||
GlobalState.register(this, key);
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
GlobalState.unregister(this, key);
|
||||
}
|
||||
|
||||
Object? get key;
|
||||
|
||||
void update() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:ffi' as ffi;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'consts.dart';
|
||||
|
||||
part "image_favorites.dart";
|
||||
|
||||
typedef HistoryType = ComicType;
|
||||
|
||||
@@ -22,57 +37,57 @@ abstract mixin class HistoryMixin {
|
||||
HistoryType get historyType;
|
||||
}
|
||||
|
||||
class History {
|
||||
class History implements Comic {
|
||||
HistoryType type;
|
||||
|
||||
DateTime time;
|
||||
|
||||
@override
|
||||
String title;
|
||||
|
||||
@override
|
||||
String subtitle;
|
||||
|
||||
@override
|
||||
String cover;
|
||||
|
||||
|
||||
/// index of chapters. 1-based.
|
||||
int ep;
|
||||
|
||||
/// index of pages. 1-based.
|
||||
int page;
|
||||
|
||||
/// index of chapter groups. 1-based.
|
||||
/// If [group] is not null, [ep] is the index of chapter in the group.
|
||||
int? group;
|
||||
|
||||
@override
|
||||
String id;
|
||||
|
||||
/// readEpisode is a set of episode numbers that have been read.
|
||||
///
|
||||
/// The number of episodes is 1-based.
|
||||
Set<int> readEpisode;
|
||||
/// For normal chapters, it is a set of chapter numbers.
|
||||
/// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
|
||||
/// 1-based.
|
||||
Set<String> readEpisode;
|
||||
|
||||
@override
|
||||
int? maxPage;
|
||||
|
||||
History.fromModel(
|
||||
{required HistoryMixin model,
|
||||
required this.ep,
|
||||
required this.page,
|
||||
Set<int>? readChapters,
|
||||
this.group,
|
||||
Set<String>? readChapters,
|
||||
DateTime? time})
|
||||
: type = model.historyType,
|
||||
title = model.title,
|
||||
subtitle = model.subTitle ?? '',
|
||||
cover = model.cover,
|
||||
id = model.id,
|
||||
readEpisode = readChapters ?? <int>{},
|
||||
readEpisode = readChapters ?? <String>{},
|
||||
time = time ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
"type": type.value,
|
||||
"time": time.millisecondsSinceEpoch,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"cover": cover,
|
||||
"ep": ep,
|
||||
"page": page,
|
||||
"id": id,
|
||||
"readEpisode": readEpisode.toList(),
|
||||
"max_page": maxPage
|
||||
};
|
||||
|
||||
History.fromMap(Map<String, dynamic> map)
|
||||
: type = HistoryType(map["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
|
||||
@@ -82,8 +97,9 @@ class History {
|
||||
ep = map["ep"],
|
||||
page = map["page"],
|
||||
id = map["id"],
|
||||
readEpisode = Set<int>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
|
||||
readEpisode = Set<String>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ??
|
||||
const <String>{}),
|
||||
maxPage = map["max_page"];
|
||||
|
||||
@override
|
||||
@@ -100,35 +116,11 @@ class History {
|
||||
ep = row["ep"],
|
||||
page = row["page"],
|
||||
id = row["id"],
|
||||
readEpisode = Set<int>.from((row["readEpisode"] as String)
|
||||
readEpisode = Set<String>.from((row["readEpisode"] as String)
|
||||
.split(',')
|
||||
.where((element) => element != "")
|
||||
.map((e) => int.parse(e))),
|
||||
maxPage = row["max_page"];
|
||||
|
||||
static Future<History> findOrCreate(
|
||||
HistoryMixin model, {
|
||||
int ep = 0,
|
||||
int page = 0,
|
||||
}) async {
|
||||
var history = await HistoryManager().find(model.id, model.historyType);
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: ep, page: page);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
|
||||
static Future<History> createIfNull(
|
||||
History? history, HistoryMixin model) async {
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: 0, page: 0);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
.where((element) => element != "")),
|
||||
maxPage = row["max_page"],
|
||||
group = row["chapter_group"];
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -137,6 +129,52 @@ class History {
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, type);
|
||||
|
||||
@override
|
||||
String get description {
|
||||
var res = "";
|
||||
if (group != null){
|
||||
res += "${"Group @group".tlParams({
|
||||
"group": group!,
|
||||
})} - ";
|
||||
}
|
||||
if (ep >= 1) {
|
||||
res += "Chapter @ep".tlParams({
|
||||
"ep": ep,
|
||||
});
|
||||
}
|
||||
if (page >= 1) {
|
||||
if (ep >= 1) {
|
||||
res += " - ";
|
||||
}
|
||||
res += "Page @page".tlParams({
|
||||
"page": page,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get favoriteId => null;
|
||||
|
||||
@override
|
||||
String? get language => null;
|
||||
|
||||
@override
|
||||
String get sourceKey => type == ComicType.local
|
||||
? 'local'
|
||||
: type.comicSource?.key ?? "Unknown:${type.value}";
|
||||
|
||||
@override
|
||||
double? get stars => null;
|
||||
|
||||
@override
|
||||
List<String>? get tags => null;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryManager with ChangeNotifier {
|
||||
@@ -151,11 +189,18 @@ class HistoryManager with ChangeNotifier {
|
||||
|
||||
int get length => _db.select("select count(*) from history;").first[0] as int;
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
/// Cache of history ids. Improve the performance of find operation.
|
||||
Map<String, bool>? _cachedHistoryIds;
|
||||
|
||||
static const _kMaxHistoryLength = 200;
|
||||
/// Cache records recently modified by the app. Improve the performance of listeners.
|
||||
final cachedHistories = <String, History>{};
|
||||
|
||||
bool isInitialized = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
_db.execute("""
|
||||
@@ -169,27 +214,73 @@ class HistoryManager with ChangeNotifier {
|
||||
ep int,
|
||||
page int,
|
||||
readEpisode text,
|
||||
max_page int
|
||||
max_page int,
|
||||
chapter_group int
|
||||
);
|
||||
""");
|
||||
|
||||
var columns = _db.select("PRAGMA table_info(history);");
|
||||
if (!columns.any((element) => element["name"] == "chapter_group")) {
|
||||
_db.execute("alter table history add column chapter_group int;");
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
ImageFavoriteManager().init();
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
static const _insertHistorySql = """
|
||||
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""";
|
||||
|
||||
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
|
||||
db.execute(_insertHistorySql, [
|
||||
newItem.id,
|
||||
newItem.title,
|
||||
newItem.subtitle,
|
||||
newItem.cover,
|
||||
newItem.time.millisecondsSinceEpoch,
|
||||
newItem.type.value,
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage,
|
||||
newItem.group
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
bool _haveAsyncTask = false;
|
||||
|
||||
/// Create a isolate to add history to prevent blocking the UI thread.
|
||||
Future<void> addHistoryAsync(History newItem) async {
|
||||
while (_haveAsyncTask) {
|
||||
await Future.delayed(Duration(milliseconds: 20));
|
||||
}
|
||||
|
||||
_haveAsyncTask = true;
|
||||
await _addHistoryAsync(_db.handle.address, newItem);
|
||||
_haveAsyncTask = false;
|
||||
if (_cachedHistoryIds == null) {
|
||||
updateCache();
|
||||
} else {
|
||||
_cachedHistoryIds![newItem.id] = true;
|
||||
}
|
||||
cachedHistories[newItem.id] = newItem;
|
||||
if (cachedHistories.length > 10) {
|
||||
cachedHistories.remove(cachedHistories.keys.first);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// add history. if exists, update time.
|
||||
///
|
||||
/// This function would be called when user start reading.
|
||||
Future<void> addHistory(History newItem) async {
|
||||
while(count() >= _kMaxHistoryLength) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where time == (select min(time) from history);
|
||||
""");
|
||||
}
|
||||
_db.execute("""
|
||||
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
void addHistory(History newItem) {
|
||||
_db.execute(_insertHistorySql, [
|
||||
newItem.id,
|
||||
newItem.title,
|
||||
newItem.subtitle,
|
||||
@@ -199,9 +290,18 @@ class HistoryManager with ChangeNotifier {
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage
|
||||
newItem.maxPage,
|
||||
newItem.group
|
||||
]);
|
||||
updateCache();
|
||||
if (_cachedHistoryIds == null) {
|
||||
updateCache();
|
||||
} else {
|
||||
_cachedHistoryIds![newItem.id] = true;
|
||||
}
|
||||
cachedHistories[newItem.id] = newItem;
|
||||
if (cachedHistories.length > 10) {
|
||||
cachedHistories.remove(cachedHistories.keys.first);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -211,6 +311,31 @@ class HistoryManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearUnfavoritedHistory() {
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
final idAndTypes = _db.select("""
|
||||
select id, type from history;
|
||||
""");
|
||||
for (var element in idAndTypes) {
|
||||
final id = element["id"] as String;
|
||||
final type = ComicType(element["type"] as int);
|
||||
if (!LocalFavoritesManager().isExist(id, type)) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
}
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
} catch (e) {
|
||||
_db.execute('ROLLBACK;');
|
||||
rethrow;
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(String id, ComicType type) async {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
@@ -220,27 +345,31 @@ class HistoryManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<History?> find(String id, ComicType type) async {
|
||||
return findSync(id, type);
|
||||
}
|
||||
|
||||
void updateCache() {
|
||||
_cachedHistory = {};
|
||||
_cachedHistoryIds = {};
|
||||
var res = _db.select("""
|
||||
select * from history;
|
||||
select id from history;
|
||||
""");
|
||||
for (var element in res) {
|
||||
_cachedHistory![element["id"] as String] = true;
|
||||
_cachedHistoryIds![element["id"] as String] = true;
|
||||
}
|
||||
for (var key in cachedHistories.keys.toList()) {
|
||||
if (!_cachedHistoryIds!.containsKey(key)) {
|
||||
cachedHistories.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
History? findSync(String id, ComicType type) {
|
||||
if(_cachedHistory == null) {
|
||||
History? find(String id, ComicType type) {
|
||||
if (_cachedHistoryIds == null) {
|
||||
updateCache();
|
||||
}
|
||||
if (!_cachedHistory!.containsKey(id)) {
|
||||
if (!_cachedHistoryIds!.containsKey(id)) {
|
||||
return null;
|
||||
}
|
||||
if (cachedHistories.containsKey(id)) {
|
||||
return cachedHistories[id];
|
||||
}
|
||||
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
@@ -279,6 +408,26 @@ class HistoryManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
void close() {
|
||||
isInitialized = false;
|
||||
_db.dispose();
|
||||
}
|
||||
|
||||
void batchDeleteHistories(List<ComicID> histories) {
|
||||
if (histories.isEmpty) return;
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (var history in histories) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == ? and type == ?;
|
||||
""", [history.id, history.type.value]);
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
} catch (e) {
|
||||
_db.execute('ROLLBACK;');
|
||||
rethrow;
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
541
lib/foundation/image_favorites.dart
Normal file
@@ -0,0 +1,541 @@
|
||||
part of "history.dart";
|
||||
|
||||
class ImageFavorite {
|
||||
final String eid;
|
||||
final String id; // 漫画id
|
||||
final int ep;
|
||||
final String epName;
|
||||
final String sourceKey;
|
||||
String imageKey;
|
||||
int page;
|
||||
bool? isAutoFavorite;
|
||||
|
||||
ImageFavorite(
|
||||
this.page,
|
||||
this.imageKey,
|
||||
this.isAutoFavorite,
|
||||
this.eid,
|
||||
this.id,
|
||||
this.ep,
|
||||
this.sourceKey,
|
||||
this.epName,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'page': page,
|
||||
'imageKey': imageKey,
|
||||
'isAutoFavorite': isAutoFavorite,
|
||||
'eid': eid,
|
||||
'id': id,
|
||||
'ep': ep,
|
||||
'sourceKey': sourceKey,
|
||||
'epName': epName,
|
||||
};
|
||||
}
|
||||
|
||||
ImageFavorite.fromJson(Map<String, dynamic> json)
|
||||
: page = json['page'],
|
||||
imageKey = json['imageKey'],
|
||||
isAutoFavorite = json['isAutoFavorite'],
|
||||
eid = json['eid'],
|
||||
id = json['id'],
|
||||
ep = json['ep'],
|
||||
sourceKey = json['sourceKey'],
|
||||
epName = json['epName'];
|
||||
|
||||
ImageFavorite copyWith({
|
||||
int? page,
|
||||
String? imageKey,
|
||||
bool? isAutoFavorite,
|
||||
String? eid,
|
||||
String? id,
|
||||
int? ep,
|
||||
String? sourceKey,
|
||||
String? epName,
|
||||
}) {
|
||||
return ImageFavorite(
|
||||
page ?? this.page,
|
||||
imageKey ?? this.imageKey,
|
||||
isAutoFavorite ?? this.isAutoFavorite,
|
||||
eid ?? this.eid,
|
||||
id ?? this.id,
|
||||
ep ?? this.ep,
|
||||
sourceKey ?? this.sourceKey,
|
||||
epName ?? this.epName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavorite &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey &&
|
||||
other.page == page &&
|
||||
other.eid == eid &&
|
||||
other.ep == ep;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey, page, eid, ep);
|
||||
}
|
||||
|
||||
class ImageFavoritesEp {
|
||||
// 小心拷贝等多章节的可能更新章节顺序
|
||||
String eid;
|
||||
final int ep;
|
||||
int maxPage;
|
||||
String epName;
|
||||
List<ImageFavorite> imageFavorites;
|
||||
|
||||
ImageFavoritesEp(
|
||||
this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage);
|
||||
|
||||
// 是否有封面
|
||||
bool get isHasFirstPage {
|
||||
return imageFavorites[0].page == firstPage;
|
||||
}
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isHasImageKey {
|
||||
return imageFavorites.every((e) => e.imageKey != "");
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eid': eid,
|
||||
'ep': ep,
|
||||
'maxPage': maxPage,
|
||||
'epName': epName,
|
||||
'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoritesComic {
|
||||
final String id;
|
||||
final String title;
|
||||
String subTitle;
|
||||
String author;
|
||||
final String sourceKey;
|
||||
|
||||
// 不一定是真的这本漫画的所有页数, 如果是多章节的时候
|
||||
int maxPage;
|
||||
List<String> tags;
|
||||
List<String> translatedTags;
|
||||
final DateTime time;
|
||||
List<ImageFavoritesEp> imageFavoritesEp;
|
||||
final Map<String, dynamic> other;
|
||||
|
||||
ImageFavoritesComic(
|
||||
this.id,
|
||||
this.imageFavoritesEp,
|
||||
this.title,
|
||||
this.sourceKey,
|
||||
this.tags,
|
||||
this.translatedTags,
|
||||
this.time,
|
||||
this.author,
|
||||
this.other,
|
||||
this.subTitle,
|
||||
this.maxPage,
|
||||
);
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isAllHasImageKey {
|
||||
return imageFavoritesEp
|
||||
.every((e) => e.imageFavorites.every((j) => j.imageKey != ""));
|
||||
}
|
||||
|
||||
int get maxPageFromEp {
|
||||
int temp = 0;
|
||||
for (var e in imageFavoritesEp) {
|
||||
temp += e.maxPage;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
// 是否都有封面
|
||||
bool get isAllHasFirstPage {
|
||||
return imageFavoritesEp.every((e) => e.isHasFirstPage);
|
||||
}
|
||||
|
||||
Iterable<ImageFavorite> get images sync*{
|
||||
for (var e in imageFavoritesEp) {
|
||||
yield* e.imageFavorites;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavoritesComic &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey);
|
||||
|
||||
factory ImageFavoritesComic.fromRow(Row r) {
|
||||
var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]);
|
||||
List<ImageFavoritesEp> finalImageFavoritesEp = [];
|
||||
tempImageFavoritesEp.forEach((i) {
|
||||
List<ImageFavorite> temp = [];
|
||||
i["imageFavorites"].forEach((j) {
|
||||
temp.add(ImageFavorite(
|
||||
j["page"],
|
||||
j["imageKey"],
|
||||
j["isAutoFavorite"],
|
||||
i["eid"],
|
||||
r["id"],
|
||||
i["ep"],
|
||||
r["source_key"],
|
||||
i["epName"],
|
||||
));
|
||||
});
|
||||
finalImageFavoritesEp.add(ImageFavoritesEp(
|
||||
i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1));
|
||||
});
|
||||
return ImageFavoritesComic(
|
||||
r["id"],
|
||||
finalImageFavoritesEp,
|
||||
r["title"],
|
||||
r["source_key"],
|
||||
r["tags"].split(","),
|
||||
r["translated_tags"].split(","),
|
||||
DateTime.fromMillisecondsSinceEpoch(r["time"]),
|
||||
r["author"],
|
||||
jsonDecode(r["other"]),
|
||||
r["sub_title"],
|
||||
r["max_page"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoriteManager with ChangeNotifier {
|
||||
Database get _db => HistoryManager()._db;
|
||||
|
||||
List<ImageFavoritesComic> get comics => getAll();
|
||||
|
||||
static ImageFavoriteManager? _cache;
|
||||
|
||||
ImageFavoriteManager._();
|
||||
|
||||
factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._());
|
||||
|
||||
/// 检查表image_favorites是否存在, 不存在则创建
|
||||
void init() {
|
||||
_db.execute("CREATE TABLE IF NOT EXISTS image_favorites ("
|
||||
"id TEXT,"
|
||||
"title TEXT NOT NULL,"
|
||||
"sub_title TEXT,"
|
||||
"author TEXT,"
|
||||
"tags TEXT,"
|
||||
"translated_tags TEXT,"
|
||||
"time int,"
|
||||
"max_page int,"
|
||||
"source_key TEXT NOT NULL,"
|
||||
"image_favorites_ep TEXT NOT NULL,"
|
||||
"other TEXT NOT NULL,"
|
||||
"PRIMARY KEY (id,source_key)"
|
||||
");");
|
||||
}
|
||||
|
||||
// 做排序和去重的操作
|
||||
void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) {
|
||||
// 没有章节了就删掉
|
||||
if (favorite.imageFavoritesEp.isEmpty) {
|
||||
_db.execute("""
|
||||
delete from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [favorite.id, favorite.sourceKey]);
|
||||
} else {
|
||||
// 去重章节
|
||||
List<ImageFavoritesEp> tempImageFavoritesEp = [];
|
||||
for (var e in favorite.imageFavoritesEp) {
|
||||
int index = tempImageFavoritesEp.indexWhere((i) {
|
||||
return i.ep == e.ep;
|
||||
});
|
||||
// 再做一层保险, 防止出现ep为0的脏数据
|
||||
if (index == -1 && e.ep > 0) {
|
||||
tempImageFavoritesEp.add(e);
|
||||
}
|
||||
}
|
||||
tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep));
|
||||
List<dynamic> finalImageFavoritesEp =
|
||||
jsonDecode(jsonEncode(tempImageFavoritesEp));
|
||||
for (var e in tempImageFavoritesEp) {
|
||||
List<Map> finalImageFavorites = [];
|
||||
int epIndex = tempImageFavoritesEp.indexOf(e);
|
||||
for (ImageFavorite j in e.imageFavorites) {
|
||||
int index =
|
||||
finalImageFavorites.indexWhere((i) => i["page"] == j.page);
|
||||
if (index == -1 && j.page > 0) {
|
||||
// isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里
|
||||
if (j.isAutoFavorite != null) {
|
||||
finalImageFavorites.add({
|
||||
"page": j.page,
|
||||
"imageKey": j.imageKey,
|
||||
"isAutoFavorite": j.isAutoFavorite
|
||||
});
|
||||
} else {
|
||||
finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey});
|
||||
}
|
||||
}
|
||||
}
|
||||
finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"]));
|
||||
finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites;
|
||||
}
|
||||
if (tempImageFavoritesEp.isEmpty) {
|
||||
throw "Error: No ImageFavoritesEp";
|
||||
}
|
||||
_db.execute("""
|
||||
insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other)
|
||||
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
favorite.id,
|
||||
favorite.title,
|
||||
favorite.subTitle,
|
||||
favorite.author,
|
||||
favorite.tags.join(","),
|
||||
favorite.translatedTags.join(","),
|
||||
favorite.time.millisecondsSinceEpoch,
|
||||
favorite.maxPage,
|
||||
favorite.sourceKey,
|
||||
jsonEncode(finalImageFavoritesEp),
|
||||
jsonEncode(favorite.other)
|
||||
]);
|
||||
}
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool has(String id, String sourceKey, String eid, int page, int ep) {
|
||||
var comic = find(id, sourceKey);
|
||||
if (comic == null) {
|
||||
return false;
|
||||
}
|
||||
var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull;
|
||||
if (epIndex == null) {
|
||||
return false;
|
||||
}
|
||||
return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep);
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> getAll([String? keyword]) {
|
||||
ResultSet res;
|
||||
if (keyword == null || keyword == "") {
|
||||
res = _db.select("select * from image_favorites;");
|
||||
} else {
|
||||
res = _db.select(
|
||||
"""
|
||||
select * from image_favorites
|
||||
WHERE title LIKE ?
|
||||
OR sub_title LIKE ?
|
||||
OR LOWER(tags) LIKE LOWER(?)
|
||||
OR LOWER(translated_tags) LIKE LOWER(?)
|
||||
OR author LIKE ?;
|
||||
""",
|
||||
['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'],
|
||||
);
|
||||
}
|
||||
try {
|
||||
return res.map((e) => ImageFavoritesComic.fromRow(e)).toList();
|
||||
} catch (e, stackTrace) {
|
||||
Log.error("Unhandled Exception", e.toString(), stackTrace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void deleteImageFavorite(Iterable<ImageFavorite> imageFavoriteList) {
|
||||
if (imageFavoriteList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (var i in imageFavoriteList) {
|
||||
ImageFavoritesProvider.deleteFromCache(i);
|
||||
}
|
||||
var comics = <ImageFavoritesComic>{};
|
||||
for (var i in imageFavoriteList) {
|
||||
var comic = comics
|
||||
.where((c) => c.id == i.id && c.sourceKey == i.sourceKey)
|
||||
.firstOrNull ??
|
||||
find(i.id, i.sourceKey);
|
||||
if (comic == null) {
|
||||
continue;
|
||||
}
|
||||
var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep);
|
||||
if (ep == null) {
|
||||
continue;
|
||||
}
|
||||
ep.imageFavorites.remove(i);
|
||||
if (ep.imageFavorites.isEmpty) {
|
||||
comic.imageFavoritesEp.remove(ep);
|
||||
}
|
||||
comics.add(comic);
|
||||
}
|
||||
for (var i in comics) {
|
||||
addOrUpdateOrDelete(i, false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int get length {
|
||||
var res = _db.select("select count(*) from image_favorites;");
|
||||
return res.first.values.first! as int;
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> search(String keyword) {
|
||||
if (keyword == "") {
|
||||
return [];
|
||||
}
|
||||
return getAll(keyword);
|
||||
}
|
||||
|
||||
static Future<ImageFavoritesComputed> computeImageFavorites() {
|
||||
var token = ServicesBinding.rootIsolateToken!;
|
||||
var count = ImageFavoriteManager().length;
|
||||
if (count == 0) {
|
||||
return Future.value(ImageFavoritesComputed([], [], [], 0));
|
||||
} else if (count > 100) {
|
||||
return Isolate.run(() async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
return _computeImageFavorites();
|
||||
});
|
||||
} else {
|
||||
return Future.value(_computeImageFavorites());
|
||||
}
|
||||
}
|
||||
|
||||
static ImageFavoritesComputed _computeImageFavorites() {
|
||||
const maxLength = 20;
|
||||
|
||||
var comics = ImageFavoriteManager().getAll();
|
||||
// 去掉这些没有意义的标签
|
||||
const List<String> exceptTags = [
|
||||
'連載中',
|
||||
'',
|
||||
'translated',
|
||||
'chinese',
|
||||
'sole male',
|
||||
'sole female',
|
||||
'original',
|
||||
'doujinshi',
|
||||
'manga',
|
||||
'multi-work series',
|
||||
'mosaic censorship',
|
||||
'dilf',
|
||||
'bbm',
|
||||
'uncensored',
|
||||
'full censorship'
|
||||
];
|
||||
|
||||
Map<String, int> tagCount = {};
|
||||
Map<String, int> authorCount = {};
|
||||
Map<ImageFavoritesComic, int> comicImageCount = {};
|
||||
Map<ImageFavoritesComic, int> comicMaxPages = {};
|
||||
int count = 0;
|
||||
|
||||
for (var comic in comics) {
|
||||
count += comic.images.length;
|
||||
for (var tag in comic.tags) {
|
||||
String finalTag = tag;
|
||||
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (comic.author != "") {
|
||||
String finalAuthor = comic.author;
|
||||
authorCount[finalAuthor] =
|
||||
(authorCount[finalAuthor] ?? 0) + comic.images.length;
|
||||
}
|
||||
// 小于10页的漫画不统计
|
||||
if (comic.maxPageFromEp < 10) {
|
||||
continue;
|
||||
}
|
||||
comicImageCount[comic] =
|
||||
(comicImageCount[comic] ?? 0) + comic.images.length;
|
||||
comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp;
|
||||
}
|
||||
|
||||
// 按数量排序标签
|
||||
List<String> sortedTags = tagCount.keys.toList()
|
||||
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
|
||||
|
||||
// 按数量排序作者
|
||||
List<String> sortedAuthors = authorCount.keys.toList()
|
||||
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
|
||||
|
||||
// 按收藏数量排序漫画
|
||||
List<MapEntry<ImageFavoritesComic, int>> sortedComicsByNum =
|
||||
comicImageCount.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
validateTag(String tag) {
|
||||
if (tag.startsWith("Category:")) {
|
||||
return false;
|
||||
}
|
||||
return !exceptTags.contains(tag.split(":").last.toLowerCase()) &&
|
||||
!tag.isNum;
|
||||
}
|
||||
|
||||
return ImageFavoritesComputed(
|
||||
sortedTags
|
||||
.where(validateTag)
|
||||
.map((tag) => TextWithCount(tag, tagCount[tag]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedAuthors
|
||||
.map((author) => TextWithCount(author, authorCount[author]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedComicsByNum
|
||||
.map((comic) => TextWithCount(comic.key.title, comic.value))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
count,
|
||||
);
|
||||
}
|
||||
|
||||
ImageFavoritesComic? find(String id, String sourceKey) {
|
||||
var row = _db.select("""
|
||||
select * from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [id, sourceKey]);
|
||||
if (row.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ImageFavoritesComic.fromRow(row.first);
|
||||
}
|
||||
}
|
||||
|
||||
class TextWithCount {
|
||||
final String text;
|
||||
final int count;
|
||||
|
||||
const TextWithCount(this.text, this.count);
|
||||
}
|
||||
|
||||
class ImageFavoritesComputed {
|
||||
/// 基于收藏的标签数排序
|
||||
final List<TextWithCount> tags;
|
||||
|
||||
/// 基于收藏的作者数排序
|
||||
final List<TextWithCount> authors;
|
||||
|
||||
/// 基于喜欢的图片数排序
|
||||
final List<TextWithCount> comics;
|
||||
|
||||
final int count;
|
||||
|
||||
/// 计算后的图片收藏数据
|
||||
const ImageFavoritesComputed(
|
||||
this.tags,
|
||||
this.authors,
|
||||
this.comics,
|
||||
this.count,
|
||||
);
|
||||
|
||||
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
|
||||
}
|
||||
@@ -1,16 +1,48 @@
|
||||
import 'dart:async' show Future, StreamController, scheduleMicrotask;
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
|
||||
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
extends ImageProvider<T> {
|
||||
const BaseImageProvider();
|
||||
|
||||
static double? _effectiveScreenWidth;
|
||||
|
||||
static const double _normalComicImageRatio = 0.72;
|
||||
|
||||
static const double _minComicImageWidth = 1920 * _normalComicImageRatio;
|
||||
|
||||
static TargetImageSize _getTargetSize(width, height) {
|
||||
if (_effectiveScreenWidth == null) {
|
||||
final screens = PlatformDispatcher.instance.displays;
|
||||
for (var screen in screens) {
|
||||
if (screen.size.width > screen.size.height) {
|
||||
_effectiveScreenWidth = max(
|
||||
_effectiveScreenWidth ?? 0,
|
||||
screen.size.height * _normalComicImageRatio,
|
||||
);
|
||||
} else {
|
||||
_effectiveScreenWidth =
|
||||
max(_effectiveScreenWidth ?? 0, screen.size.width);
|
||||
}
|
||||
}
|
||||
if (_effectiveScreenWidth! < _minComicImageWidth) {
|
||||
_effectiveScreenWidth = _minComicImageWidth;
|
||||
}
|
||||
}
|
||||
if (width > _effectiveScreenWidth!) {
|
||||
height = (height * _effectiveScreenWidth! / width).round();
|
||||
width = _effectiveScreenWidth!.round();
|
||||
}
|
||||
return TargetImageSize(width: width, height: height);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
@@ -46,19 +78,18 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
|
||||
while (data == null && !stop) {
|
||||
try {
|
||||
if(_cache.containsKey(key.key)){
|
||||
data = _cache[key.key];
|
||||
} else {
|
||||
data = await load(chunkEvents);
|
||||
_checkCacheSize();
|
||||
_cache[key.key] = data;
|
||||
_cacheSize += data.length;
|
||||
}
|
||||
data = await load(chunkEvents, () {
|
||||
if (stop) {
|
||||
throw const _ImageLoadingStopException();
|
||||
}
|
||||
});
|
||||
} on _ImageLoadingStopException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
if(e.toString().contains("Invalid Status Code: 404")) {
|
||||
if (e.toString().contains("Invalid Status Code: 404")) {
|
||||
rethrow;
|
||||
}
|
||||
if(e.toString().contains("Invalid Status Code: 403")) {
|
||||
if (e.toString().contains("Invalid Status Code: 403")) {
|
||||
rethrow;
|
||||
}
|
||||
if (e.toString().contains("handshake")) {
|
||||
@@ -74,23 +105,27 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
}
|
||||
}
|
||||
|
||||
if(stop) {
|
||||
throw Exception("Image loading is stopped");
|
||||
if (stop) {
|
||||
throw const _ImageLoadingStopException();
|
||||
}
|
||||
|
||||
if(data!.isEmpty) {
|
||||
if (data!.isEmpty) {
|
||||
throw Exception("Empty image data");
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromUint8List(data);
|
||||
return await decode(buffer);
|
||||
return await decode(
|
||||
buffer,
|
||||
getTargetSize: enableResize ? _getTargetSize : null,
|
||||
);
|
||||
} catch (e) {
|
||||
await CacheManager().delete(this.key);
|
||||
if (data.length < 2 * 1024) {
|
||||
// data is too short, it's likely that the data is text, not image
|
||||
try {
|
||||
var text = const Utf8Codec(allowMalformed: false).decoder.convert(data);
|
||||
var text =
|
||||
const Utf8Codec(allowMalformed: false).decoder.convert(data);
|
||||
throw Exception("Expected image data, but got text: $text");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
@@ -98,41 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
} on _ImageLoadingStopException {
|
||||
rethrow;
|
||||
} catch (e, s) {
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
Log.error("Image Loading", e, s);
|
||||
rethrow;
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
}
|
||||
}
|
||||
|
||||
static final _cache = LinkedHashMap<String, Uint8List>();
|
||||
|
||||
static var _cacheSize = 0;
|
||||
|
||||
static var _cacheSizeLimit = 50 * 1024 * 1024;
|
||||
|
||||
static void _checkCacheSize(){
|
||||
while (_cacheSize > _cacheSizeLimit){
|
||||
var firstKey = _cache.keys.first;
|
||||
_cacheSize -= _cache[firstKey]!.length;
|
||||
_cache.remove(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache(){
|
||||
_cache.clear();
|
||||
_cacheSize = 0;
|
||||
}
|
||||
|
||||
static void setCacheSizeLimit(int size){
|
||||
_cacheSizeLimit = size;
|
||||
_checkCacheSize();
|
||||
}
|
||||
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
|
||||
Future<Uint8List> load(
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
void Function() checkStop,
|
||||
);
|
||||
|
||||
String get key;
|
||||
|
||||
@@ -148,6 +165,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
String toString() {
|
||||
return "$runtimeType($key)";
|
||||
}
|
||||
|
||||
bool get enableResize => false;
|
||||
}
|
||||
|
||||
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
|
||||
|
||||
class _ImageLoadingStopException implements Exception {
|
||||
const _ImageLoadingStopException();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'cached_image.dart' as image_provider;
|
||||
|
||||
class CachedImageProvider
|
||||
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey});
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const CachedImageProvider(this.url, {
|
||||
this.headers,
|
||||
this.sourceKey,
|
||||
this.cid,
|
||||
this.fallbackToLocalCover = false,
|
||||
});
|
||||
|
||||
final String url;
|
||||
|
||||
@@ -16,18 +26,60 @@ class CachedImageProvider
|
||||
|
||||
final String? sourceKey;
|
||||
|
||||
final String? cid;
|
||||
|
||||
// Use local cover if network image fails to load.
|
||||
final bool fallbackToLocalCover;
|
||||
|
||||
static int loadingCount = 0;
|
||||
|
||||
static const _kMaxLoadingCount = 8;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if(progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
Future<Uint8List> load(chunkEvents, checkStop) async {
|
||||
while(loadingCount > _kMaxLoadingCount) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
checkStop();
|
||||
}
|
||||
loadingCount++;
|
||||
try {
|
||||
if(url.startsWith("file://")) {
|
||||
var file = File(url.substring(7));
|
||||
return file.readAsBytes();
|
||||
}
|
||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
|
||||
checkStop();
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if(progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
catch(e) {
|
||||
if (fallbackToLocalCover && sourceKey != null && cid != null) {
|
||||
final localComic = LocalManager().find(
|
||||
cid!,
|
||||
ComicType.fromKey(sourceKey!),
|
||||
);
|
||||
if (localComic != null) {
|
||||
var file = localComic.coverFile;
|
||||
if (await file.exists()) {
|
||||
var data = await file.readAsBytes();
|
||||
if (data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
finally {
|
||||
loadingCount--;
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -36,5 +88,5 @@ class CachedImageProvider
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => url;
|
||||
String get key => url + (sourceKey ?? "") + (cid ?? "");
|
||||
}
|
||||
|
||||
59
lib/foundation/image_provider/history_image_provider.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import '../history.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'history_image_provider.dart' as image_provider;
|
||||
|
||||
class HistoryImageProvider
|
||||
extends BaseImageProvider<image_provider.HistoryImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const HistoryImageProvider(this.history);
|
||||
|
||||
final History history;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(chunkEvents, checkStop) async {
|
||||
var url = history.cover;
|
||||
if (!url.contains('/')) {
|
||||
var localComic = LocalManager().find(history.id, history.type);
|
||||
if (localComic != null) {
|
||||
return localComic.coverFile.readAsBytes();
|
||||
}
|
||||
var comicSource =
|
||||
history.type.comicSource ?? (throw "Comic source not found.");
|
||||
var comic = await comicSource.loadComicInfo!(history.id);
|
||||
checkStop();
|
||||
url = comic.data.cover;
|
||||
history.cover = url;
|
||||
HistoryManager().addHistory(history);
|
||||
}
|
||||
await for (var progress in ImageDownloader.loadThumbnail(
|
||||
url,
|
||||
history.type.sourceKey,
|
||||
history.id,
|
||||
)) {
|
||||
checkStop();
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if (progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HistoryImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => "history${history.id}${history.type.value}";
|
||||
}
|
||||
155
lib/foundation/image_provider/image_favorites_provider.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import '../history.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'image_favorites_provider.dart' as image_provider;
|
||||
|
||||
class ImageFavoritesProvider
|
||||
extends BaseImageProvider<image_provider.ImageFavoritesProvider> {
|
||||
/// Image provider for imageFavorites
|
||||
const ImageFavoritesProvider(this.imageFavorite);
|
||||
|
||||
final ImageFavorite imageFavorite;
|
||||
|
||||
int get page => imageFavorite.page;
|
||||
|
||||
String get sourceKey => imageFavorite.sourceKey;
|
||||
|
||||
String get cid => imageFavorite.id;
|
||||
|
||||
String get eid => imageFavorite.eid;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(
|
||||
StreamController<ImageChunkEvent>? chunkEvents,
|
||||
void Function()? checkStop,
|
||||
) async {
|
||||
var imageKey = imageFavorite.imageKey;
|
||||
var localImage = await getImageFromLocal();
|
||||
checkStop?.call();
|
||||
if (localImage != null) {
|
||||
return localImage;
|
||||
}
|
||||
var cacheImage = await readFromCache();
|
||||
checkStop?.call();
|
||||
if (cacheImage != null) {
|
||||
return cacheImage;
|
||||
}
|
||||
var gotImageKey = false;
|
||||
if (imageKey == "") {
|
||||
imageKey = await getImageKey();
|
||||
checkStop?.call();
|
||||
gotImageKey = true;
|
||||
}
|
||||
Uint8List image;
|
||||
try {
|
||||
image = await getImageFromNetwork(imageKey, chunkEvents, checkStop);
|
||||
} catch (e) {
|
||||
if (gotImageKey) {
|
||||
rethrow;
|
||||
} else {
|
||||
imageKey = await getImageKey();
|
||||
image = await getImageFromNetwork(imageKey, chunkEvents, checkStop);
|
||||
}
|
||||
}
|
||||
await writeToCache(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
Future<void> writeToCache(Uint8List image) async {
|
||||
var fileName = md5.convert(key.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (!file.existsSync()) {
|
||||
file.createSync(recursive: true);
|
||||
}
|
||||
await file.writeAsBytes(image);
|
||||
}
|
||||
|
||||
Future<Uint8List?> readFromCache() async {
|
||||
var fileName = md5.convert(key.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (!file.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
return await file.readAsBytes();
|
||||
}
|
||||
|
||||
/// Delete a image favorite cache
|
||||
static Future<void> deleteFromCache(ImageFavorite imageFavorite) async {
|
||||
var fileName = md5.convert(imageFavorite.imageKey.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> getImageFromLocal() async {
|
||||
var localComic =
|
||||
LocalManager().find(sourceKey, ComicType.fromKey(sourceKey));
|
||||
if (localComic == null) {
|
||||
return null;
|
||||
}
|
||||
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
|
||||
if (epIndex == -1 && localComic.hasChapters) {
|
||||
return null;
|
||||
}
|
||||
var images = await LocalManager().getImages(
|
||||
sourceKey,
|
||||
ComicType.fromKey(sourceKey),
|
||||
epIndex,
|
||||
);
|
||||
var data = await File(images[page]).readAsBytes();
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Uint8List> getImageFromNetwork(
|
||||
String imageKey,
|
||||
StreamController<ImageChunkEvent>? chunkEvents,
|
||||
void Function()? checkStop,
|
||||
) async {
|
||||
await for (var progress
|
||||
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||
checkStop?.call();
|
||||
if (chunkEvents != null) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
}
|
||||
if (progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
Future<String> getImageKey() async {
|
||||
String sourceKey = imageFavorite.sourceKey;
|
||||
String cid = imageFavorite.id;
|
||||
String eid = imageFavorite.eid;
|
||||
var page = imageFavorite.page;
|
||||
var comicSource = ComicSource.find(sourceKey);
|
||||
if (comicSource == null) {
|
||||
throw "Error: Comic source not found.";
|
||||
}
|
||||
var res = await comicSource.loadComicPages!(cid, eid);
|
||||
return res.data[page - 1];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageFavoritesProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key =>
|
||||
"ImageFavorites ${imageFavorite.imageKey}@${imageFavorite.sourceKey}@${imageFavorite.id}@${imageFavorite.eid}";
|
||||
}
|
||||
67
lib/foundation/image_provider/local_comic_image.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'local_comic_image.dart' as image_provider;
|
||||
|
||||
class LocalComicImageProvider
|
||||
extends BaseImageProvider<image_provider.LocalComicImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const LocalComicImageProvider(this.comic);
|
||||
|
||||
final LocalComic comic;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(chunkEvents, checkStop) async {
|
||||
File? file = comic.coverFile;
|
||||
if(! await file.exists()) {
|
||||
file = null;
|
||||
var dir = Directory(comic.directory);
|
||||
if (! await dir.exists()) {
|
||||
throw "Error: Comic not found.";
|
||||
}
|
||||
Directory? firstDir;
|
||||
await for (var entity in dir.list()) {
|
||||
if(entity is File) {
|
||||
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
|
||||
file = entity;
|
||||
break;
|
||||
}
|
||||
} else if(entity is Directory) {
|
||||
firstDir ??= entity;
|
||||
}
|
||||
}
|
||||
if(file == null && firstDir != null) {
|
||||
await for (var entity in firstDir.list()) {
|
||||
if(entity is File) {
|
||||
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
|
||||
file = entity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(file == null) {
|
||||
throw "Error: Cover not found.";
|
||||
}
|
||||
checkStop();
|
||||
var data = await file.readAsBytes();
|
||||
if(data.isEmpty) {
|
||||
throw "Exception: Empty file(${file.path}).";
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocalComicImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => "local${comic.id}${comic.comicType.value}";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
@@ -22,13 +22,13 @@ class LocalFavoriteImageProvider
|
||||
static void delete(String id, int intKey) {
|
||||
var fileName = (id + intKey.toString()).hashCode.toString();
|
||||
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
|
||||
if(file.existsSync()) {
|
||||
if (file.existsSync()) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
Future<Uint8List> load(chunkEvents, checkStop) async {
|
||||
var sourceKey = ComicSource.fromIntKey(intKey)?.key;
|
||||
var fileName = key.hashCode.toString();
|
||||
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
|
||||
@@ -37,12 +37,14 @@ class LocalFavoriteImageProvider
|
||||
} else {
|
||||
await file.create(recursive: true);
|
||||
}
|
||||
checkStop();
|
||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
|
||||
checkStop();
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if(progress.imageBytes != null) {
|
||||
if (progress.imageBytes != null) {
|
||||
var data = progress.imageBytes!;
|
||||
await file.writeAsBytes(data);
|
||||
return data;
|
||||
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
Future<LocalFavoriteImageProvider> obtainKey(
|
||||
ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'reader_image.dart' as image_provider;
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
|
||||
class ReaderImageProvider
|
||||
extends BaseImageProvider<image_provider.ReaderImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid);
|
||||
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page);
|
||||
|
||||
final String imageKey;
|
||||
|
||||
@@ -18,19 +22,95 @@ class ReaderImageProvider
|
||||
|
||||
final String eid;
|
||||
|
||||
final int page;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
await for (var event
|
||||
Future<Uint8List> load(chunkEvents, checkStop) async {
|
||||
Uint8List? imageBytes;
|
||||
if (imageKey.startsWith('file://')) {
|
||||
var file = File(imageKey);
|
||||
if (await file.exists()) {
|
||||
imageBytes = await file.readAsBytes();
|
||||
} else {
|
||||
throw "Error: File not found.";
|
||||
}
|
||||
} else {
|
||||
await for (var event
|
||||
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: event.currentBytes,
|
||||
expectedTotalBytes: event.totalBytes,
|
||||
));
|
||||
if (event.imageBytes != null) {
|
||||
return event.imageBytes!;
|
||||
checkStop();
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: event.currentBytes,
|
||||
expectedTotalBytes: event.totalBytes,
|
||||
));
|
||||
if (event.imageBytes != null) {
|
||||
imageBytes = event.imageBytes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
if (imageBytes == null) {
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
if (appdata.settings['enableCustomImageProcessing']) {
|
||||
var script = appdata.settings['customImageProcessing'].toString();
|
||||
if (!script.contains('async function processImage')) {
|
||||
return imageBytes;
|
||||
}
|
||||
var func = JsEngine().runCode('''
|
||||
(() => {
|
||||
$script
|
||||
return processImage;
|
||||
})()
|
||||
''');
|
||||
if (func is JSInvokable) {
|
||||
var autoFreeFunc = JSAutoFreeFunction(func);
|
||||
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
|
||||
if (result is Uint8List) {
|
||||
imageBytes = result;
|
||||
} else if (result is Future) {
|
||||
var futureResult = await result;
|
||||
if (futureResult is Uint8List) {
|
||||
imageBytes = futureResult;
|
||||
}
|
||||
} else if (result is Map) {
|
||||
var image = result['image'];
|
||||
if (image is Uint8List) {
|
||||
imageBytes = image;
|
||||
} else if (image is Future) {
|
||||
JSAutoFreeFunction? onCancel;
|
||||
if (result['onCancel'] is JSInvokable) {
|
||||
onCancel = JSAutoFreeFunction(result['onCancel']);
|
||||
}
|
||||
if (onCancel == null) {
|
||||
var futureImage = await image;
|
||||
if (futureImage is Uint8List) {
|
||||
imageBytes = futureImage;
|
||||
}
|
||||
} else {
|
||||
dynamic futureImage;
|
||||
image.then((value) {
|
||||
futureImage = value;
|
||||
futureImage ??= Uint8List(0);
|
||||
});
|
||||
while (futureImage == null) {
|
||||
try {
|
||||
checkStop();
|
||||
}
|
||||
catch(e) {
|
||||
onCancel([]);
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: 50));
|
||||
}
|
||||
if (futureImage is Uint8List) {
|
||||
imageBytes = futureImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageBytes!;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,4 +120,7 @@ class ReaderImageProvider
|
||||
|
||||
@override
|
||||
String get key => "$imageKey@$sourceKey@$cid@$eid";
|
||||
|
||||
@override
|
||||
bool get enableResize => true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:enough_convert/enough_convert.dart';
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -19,8 +22,13 @@ import 'package:pointycastle/block/modes/cfb.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:venera/components/js_ui.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/js_pool.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
|
||||
import 'comic_source/comic_source.dart';
|
||||
import 'consts.dart';
|
||||
@@ -37,7 +45,7 @@ class JavaScriptRuntimeException implements Exception {
|
||||
}
|
||||
}
|
||||
|
||||
class JsEngine with _JSEngineApi {
|
||||
class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||
|
||||
static JsEngine? _cache;
|
||||
@@ -56,24 +64,46 @@ class JsEngine with _JSEngineApi {
|
||||
JsEngine().init();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
void resetDio() {
|
||||
_dio = AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
}
|
||||
|
||||
static Uint8List? _jsInitCache;
|
||||
|
||||
static void cacheJsInit(Uint8List jsInit) {
|
||||
_jsInitCache = jsInit;
|
||||
}
|
||||
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
if (!_closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (App.isInitialized) {
|
||||
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
|
||||
}
|
||||
_dio ??= AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
||||
_closed = false;
|
||||
_engine = FlutterQjs();
|
||||
_engine!.dispatch();
|
||||
var setGlobalFunc =
|
||||
_engine!.evaluate("(key, value) => { this[key] = value; }");
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc(["appVersion", App.version]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
Uint8List jsInit;
|
||||
if (_jsInitCache != null) {
|
||||
jsInit = _jsInitCache!;
|
||||
} else {
|
||||
var buffer = await rootBundle.load("assets/init.js");
|
||||
jsInit = buffer.buffer.asUint8List();
|
||||
}
|
||||
_engine!
|
||||
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
||||
.evaluate(utf8.decode(jsInit), name: "<init>");
|
||||
} catch (e, s) {
|
||||
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
||||
}
|
||||
@@ -82,88 +112,96 @@ class JsEngine with _JSEngineApi {
|
||||
Object? _messageReceiver(dynamic message) {
|
||||
try {
|
||||
if (message is Map<dynamic, dynamic>) {
|
||||
if (message["method"] == null) return null;
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
{
|
||||
String level = message["level"];
|
||||
Log.addLog(
|
||||
switch (level) {
|
||||
"error" => LogLevel.error,
|
||||
"warning" => LogLevel.warning,
|
||||
"info" => LogLevel.info,
|
||||
_ => LogLevel.warning
|
||||
},
|
||||
message["title"],
|
||||
message["content"].toString());
|
||||
}
|
||||
String level = message["level"];
|
||||
Log.addLog(
|
||||
switch (level) {
|
||||
"error" => LogLevel.error,
|
||||
"warning" => LogLevel.warning,
|
||||
"info" => LogLevel.info,
|
||||
_ => LogLevel.warning
|
||||
},
|
||||
message["title"],
|
||||
message["content"].toString());
|
||||
case 'load_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
return ComicSource.find(key)?.data[dataKey];
|
||||
}
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
return ComicSource.find(key)?.data[dataKey];
|
||||
case 'save_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
if (dataKey == 'setting') {
|
||||
throw "setting is not allowed to be saved";
|
||||
}
|
||||
var data = message["data"];
|
||||
var source = ComicSource.find(key)!;
|
||||
source.data[dataKey] = data;
|
||||
source.saveData();
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
if (dataKey == 'setting') {
|
||||
throw "setting is not allowed to be saved";
|
||||
}
|
||||
var data = message["data"];
|
||||
var source = ComicSource.find(key)!;
|
||||
source.data[dataKey] = data;
|
||||
source.saveData();
|
||||
case 'delete_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var source = ComicSource.find(key);
|
||||
source?.data.remove(dataKey);
|
||||
source?.saveData();
|
||||
}
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var source = ComicSource.find(key);
|
||||
source?.data.remove(dataKey);
|
||||
source?.saveData();
|
||||
case 'http':
|
||||
{
|
||||
return _http(Map.from(message));
|
||||
}
|
||||
return _http(Map.from(message));
|
||||
case 'html':
|
||||
{
|
||||
return handleHtmlCallback(Map.from(message));
|
||||
}
|
||||
return handleHtmlCallback(Map.from(message));
|
||||
case 'convert':
|
||||
{
|
||||
return _convert(Map.from(message));
|
||||
}
|
||||
return _convert(Map.from(message));
|
||||
case "random":
|
||||
{
|
||||
return _random(
|
||||
message["min"] ?? 0,
|
||||
message["max"] ?? 1,
|
||||
message["type"],
|
||||
);
|
||||
}
|
||||
return _random(
|
||||
message["min"] ?? 0,
|
||||
message["max"] ?? 1,
|
||||
message["type"],
|
||||
);
|
||||
case "cookie":
|
||||
{
|
||||
return handleCookieCallback(Map.from(message));
|
||||
}
|
||||
return handleCookieCallback(Map.from(message));
|
||||
case "uuid":
|
||||
{
|
||||
return const Uuid().v1();
|
||||
}
|
||||
return const Uuid().v1();
|
||||
case "load_setting":
|
||||
{
|
||||
String key = message["key"];
|
||||
String settingKey = message["setting_key"];
|
||||
var source = ComicSource.find(key)!;
|
||||
return source.data["settings"]?[settingKey] ??
|
||||
source.settings?[settingKey]['default'] ??
|
||||
(throw "Setting not found: $settingKey");
|
||||
}
|
||||
String key = message["key"];
|
||||
String settingKey = message["setting_key"];
|
||||
var source = ComicSource.find(key)!;
|
||||
return source.data["settings"]?[settingKey] ??
|
||||
source.settings?[settingKey]!['default'] ??
|
||||
(throw "Setting not found: $settingKey");
|
||||
case "isLogged":
|
||||
{
|
||||
return ComicSource.find(message["key"])!.isLogged;
|
||||
return ComicSource.find(message["key"])!.isLogged;
|
||||
// temporary solution for [setTimeout] function
|
||||
// TODO: implement [setTimeout] in quickjs project
|
||||
case "delay":
|
||||
return Future.delayed(Duration(milliseconds: message["time"]));
|
||||
case "UI":
|
||||
return handleUIMessage(Map.from(message));
|
||||
case "getLocale":
|
||||
return "${App.locale.languageCode}_${App.locale.countryCode}";
|
||||
case "getPlatform":
|
||||
return Platform.operatingSystem;
|
||||
case "setClipboard":
|
||||
return Clipboard.setData(ClipboardData(text: message["text"]));
|
||||
case "getClipboard":
|
||||
return Future.sync(() async {
|
||||
var res = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return res?.text;
|
||||
});
|
||||
case "compute":
|
||||
final func = message["function"];
|
||||
final args = message["args"];
|
||||
if (func is JSInvokable) {
|
||||
func.free();
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (func is! String) {
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (args != null && args is! List) {
|
||||
throw "Args must be a list";
|
||||
}
|
||||
return JSPool().execute(func, args ?? []);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -182,7 +220,24 @@ class JsEngine with _JSEngineApi {
|
||||
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
||||
headers["User-Agent"] = webUA;
|
||||
}
|
||||
response = await _dio!.request(req["url"],
|
||||
var dio = _dio;
|
||||
if (headers['http_client'] == "dart:io") {
|
||||
dio = Dio(BaseOptions(
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (status) => true,
|
||||
));
|
||||
var proxy = await getProxy();
|
||||
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
);
|
||||
dio.interceptors
|
||||
.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
dio.interceptors.add(LogInterceptor());
|
||||
}
|
||||
response = await dio!.request(req["url"],
|
||||
data: req["data"],
|
||||
options: Options(
|
||||
method: req['http_method'],
|
||||
@@ -348,6 +403,11 @@ mixin class _JSEngineApi {
|
||||
switch (type) {
|
||||
case "utf8":
|
||||
return isEncode ? utf8.encode(value) : utf8.decode(value);
|
||||
case "gbk":
|
||||
final codec = const GbkCodec();
|
||||
return isEncode
|
||||
? Uint8List.fromList(codec.encode(value))
|
||||
: codec.decode(value);
|
||||
case "base64":
|
||||
return isEncode ? base64Encode(value) : base64Decode(value);
|
||||
case "md5":
|
||||
@@ -663,3 +723,21 @@ class DocumentWrapper {
|
||||
return elements.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
class JSAutoFreeFunction {
|
||||
final JSInvokable func;
|
||||
|
||||
/// Automatically free the function when it's not used anymore
|
||||
JSAutoFreeFunction(this.func) {
|
||||
func.dup();
|
||||
finalizer.attach(this, func);
|
||||
}
|
||||
|
||||
dynamic call(List<dynamic> args) {
|
||||
return func(args);
|
||||
}
|
||||
|
||||
static final finalizer = Finalizer<JSInvokable>((func) {
|
||||
func.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
163
lib/foundation/js_pool.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
|
||||
class JSPool {
|
||||
static final int _maxInstances = 4;
|
||||
final List<IsolateJsEngine> _instances = [];
|
||||
bool _isInitializing = false;
|
||||
|
||||
static final JSPool _singleton = JSPool._internal();
|
||||
factory JSPool() {
|
||||
return _singleton;
|
||||
}
|
||||
JSPool._internal();
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isInitializing) return;
|
||||
_isInitializing = true;
|
||||
var jsInitBuffer = await rootBundle.load("assets/init.js");
|
||||
var jsInit = jsInitBuffer.buffer.asUint8List();
|
||||
for (int i = 0; i < _maxInstances; i++) {
|
||||
_instances.add(IsolateJsEngine(jsInit));
|
||||
}
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
await init();
|
||||
var selectedInstance = _instances[0];
|
||||
for (var instance in _instances) {
|
||||
if (instance.pendingTasks < selectedInstance.pendingTasks) {
|
||||
selectedInstance = instance;
|
||||
}
|
||||
}
|
||||
return selectedInstance.execute(jsFunction, args);
|
||||
}
|
||||
}
|
||||
|
||||
class _IsolateJsEngineInitParam {
|
||||
final SendPort sendPort;
|
||||
|
||||
final Uint8List jsInit;
|
||||
|
||||
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
|
||||
}
|
||||
|
||||
class IsolateJsEngine {
|
||||
Isolate? _isolate;
|
||||
|
||||
SendPort? _sendPort;
|
||||
ReceivePort? _receivePort;
|
||||
|
||||
int _counter = 0;
|
||||
final Map<int, Completer<dynamic>> _tasks = {};
|
||||
|
||||
bool _isClosed = false;
|
||||
|
||||
int get pendingTasks => _tasks.length;
|
||||
|
||||
IsolateJsEngine(Uint8List jsInit) {
|
||||
_receivePort = ReceivePort();
|
||||
_receivePort!.listen(_onMessage);
|
||||
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
|
||||
}
|
||||
|
||||
void _onMessage(dynamic message) {
|
||||
if (message is SendPort) {
|
||||
_sendPort = message;
|
||||
} else if (message is TaskResult) {
|
||||
final completer = _tasks.remove(message.id);
|
||||
if (completer != null) {
|
||||
if (message.error != null) {
|
||||
completer.completeError(message.error!);
|
||||
} else {
|
||||
completer.complete(message.result);
|
||||
}
|
||||
}
|
||||
} else if (message is Exception) {
|
||||
Log.error("IsolateJsEngine", message.toString());
|
||||
for (var completer in _tasks.values) {
|
||||
completer.completeError(message);
|
||||
}
|
||||
_tasks.clear();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
static void _run(_IsolateJsEngineInitParam params) async {
|
||||
var sendPort = params.sendPort;
|
||||
final port = ReceivePort();
|
||||
sendPort.send(port.sendPort);
|
||||
final engine = JsEngine();
|
||||
try {
|
||||
JsEngine.cacheJsInit(params.jsInit);
|
||||
await engine.init();
|
||||
}
|
||||
catch(e, s) {
|
||||
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
|
||||
return;
|
||||
}
|
||||
await for (final message in port) {
|
||||
if (message is Task) {
|
||||
try {
|
||||
final jsFunc = engine.runCode(message.jsFunction);
|
||||
if (jsFunc is! JSInvokable) {
|
||||
throw Exception("The provided code does not evaluate to a function.");
|
||||
}
|
||||
final result = jsFunc.invoke(message.args);
|
||||
jsFunc.free();
|
||||
sendPort.send(TaskResult(message.id, result, null));
|
||||
} catch (e) {
|
||||
sendPort.send(TaskResult(message.id, null, e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
if (_isClosed) {
|
||||
throw Exception("IsolateJsEngine is closed.");
|
||||
}
|
||||
while (_sendPort == null) {
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
final completer = Completer<dynamic>();
|
||||
final taskId = _counter++;
|
||||
_tasks[taskId] = completer;
|
||||
final task = Task(taskId, jsFunction, args);
|
||||
_sendPort?.send(task);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void close() async {
|
||||
if (!_isClosed) {
|
||||
_isClosed = true;
|
||||
while (_tasks.isNotEmpty) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
_receivePort?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Task {
|
||||
final int id;
|
||||
final String jsFunction;
|
||||
final List<dynamic> args;
|
||||
|
||||
const Task(this.id, this.jsFunction, this.args);
|
||||
}
|
||||
|
||||
class TaskResult {
|
||||
final int id;
|
||||
final Object? result;
|
||||
final String? error;
|
||||
|
||||
const TaskResult(this.id, this.result, this.error);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
import 'app.dart';
|
||||
@@ -32,7 +35,9 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
/// key: chapter id, value: chapter title
|
||||
///
|
||||
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
||||
final Map<String, String>? chapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
bool get hasChapters => chapters != null;
|
||||
|
||||
/// relative path to the cover image
|
||||
@override
|
||||
@@ -63,25 +68,27 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
subtitle = row[2] as String,
|
||||
tags = List.from(jsonDecode(row[3] as String)),
|
||||
directory = row[4] as String,
|
||||
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
|
||||
chapters = ComicChapters.fromJsonOrNull(jsonDecode(row[5] as String)),
|
||||
cover = row[6] as String,
|
||||
comicType = ComicType(row[7] as int),
|
||||
downloadedChapters = List.from(jsonDecode(row[8] as String)),
|
||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
|
||||
|
||||
File get coverFile => File(FilePath.join(
|
||||
LocalManager().path,
|
||||
directory,
|
||||
baseDir,
|
||||
cover,
|
||||
));
|
||||
|
||||
String get baseDir => (directory.contains('/') || directory.contains('\\'))
|
||||
? directory
|
||||
: FilePath.join(LocalManager().path, directory);
|
||||
|
||||
@override
|
||||
String get description => "";
|
||||
|
||||
@override
|
||||
String get sourceKey => comicType == ComicType.local
|
||||
? "local"
|
||||
: comicType.sourceKey;
|
||||
String get sourceKey =>
|
||||
comicType == ComicType.local ? "local" : comicType.sourceKey;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -93,27 +100,59 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
"chapters": chapters?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
int? get maxPage => null;
|
||||
|
||||
void read() async {
|
||||
var history = await HistoryManager().find(id, comicType);
|
||||
void read() {
|
||||
var history = HistoryManager().find(id, comicType);
|
||||
int? firstDownloadedChapter;
|
||||
int? firstDownloadedChapterGroup;
|
||||
if (downloadedChapters.isNotEmpty && chapters != null) {
|
||||
final chapters = this.chapters!;
|
||||
if (chapters.isGrouped) {
|
||||
for (int i=0; i<chapters.groupCount; i++) {
|
||||
var group = chapters.getGroupByIndex(i);
|
||||
var keys = group.keys.toList();
|
||||
for (int j=0; j<keys.length; j++) {
|
||||
var chapterId = keys[j];
|
||||
if (downloadedChapters.contains(chapterId)) {
|
||||
firstDownloadedChapter = j + 1;
|
||||
firstDownloadedChapterGroup = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var keys = chapters.allChapters.keys;
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (downloadedChapters.contains(keys.elementAt(i))) {
|
||||
firstDownloadedChapter = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
App.rootContext.to(
|
||||
() => Reader(
|
||||
type: comicType,
|
||||
cid: id,
|
||||
name: title,
|
||||
chapters: chapters,
|
||||
initialChapter: history?.ep,
|
||||
initialChapter: history?.ep ?? firstDownloadedChapter,
|
||||
initialPage: history?.page,
|
||||
history: history ?? History.fromModel(
|
||||
model: this,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: this,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
author: subtitle,
|
||||
tags: tags,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -148,6 +187,17 @@ class LocalManager with ChangeNotifier {
|
||||
/// path to the directory where all the comics are stored
|
||||
late String path;
|
||||
|
||||
Directory get directory => Directory(path);
|
||||
|
||||
void _checkNoMedia() {
|
||||
if (App.isAndroid) {
|
||||
var file = File(FilePath.join(path, '.nomedia'));
|
||||
if (!file.existsSync()) {
|
||||
file.createSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return error message if failed
|
||||
Future<String?> setNewPath(String newPath) async {
|
||||
var newDir = Directory(newPath);
|
||||
@@ -158,19 +208,56 @@ class LocalManager with ChangeNotifier {
|
||||
return "Directory is not empty";
|
||||
}
|
||||
try {
|
||||
await copyDirectory(
|
||||
Directory(path),
|
||||
await copyDirectoryIsolate(
|
||||
directory,
|
||||
newDir,
|
||||
);
|
||||
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path);
|
||||
} catch (e) {
|
||||
await File(FilePath.join(App.dataPath, 'local_path'))
|
||||
.writeAsString(newPath);
|
||||
} catch (e, s) {
|
||||
Log.error("IO", e, s);
|
||||
return e.toString();
|
||||
}
|
||||
await Directory(path).deleteIgnoreError(recursive:true);
|
||||
await directory.deleteContents(recursive: true);
|
||||
path = newPath;
|
||||
_checkNoMedia();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> findDefaultPath() async {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
return FilePath.join(external.first.path, 'local');
|
||||
} else {
|
||||
return FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
} else if (App.isIOS) {
|
||||
var oldPath = FilePath.join(App.dataPath, 'local');
|
||||
if (Directory(oldPath).existsSync() &&
|
||||
Directory(oldPath).listSync().isNotEmpty) {
|
||||
return oldPath;
|
||||
} else {
|
||||
var directory = await getApplicationDocumentsDirectory();
|
||||
return FilePath.join(directory.path, 'local');
|
||||
}
|
||||
} else {
|
||||
return FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPathValidation() async {
|
||||
var testFile = File(FilePath.join(path, 'venera_test'));
|
||||
try {
|
||||
testFile.createSync();
|
||||
testFile.deleteSync();
|
||||
} catch (e) {
|
||||
Log.error("IO",
|
||||
"Failed to create test file in local path: $e\nUsing default path instead.");
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
@@ -192,31 +279,33 @@ class LocalManager with ChangeNotifier {
|
||||
''');
|
||||
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
|
||||
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
|
||||
} else {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
path = FilePath.join(external.first.path, 'local');
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
if (!directory.existsSync()) {
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
} else {
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
if (!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
try {
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create();
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("IO", "Failed to create local folder: $e", s);
|
||||
}
|
||||
_checkPathValidation();
|
||||
_checkNoMedia();
|
||||
await ComicSourceManager().ensureInit();
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
|
||||
String findValidId(ComicType type) {
|
||||
final res = _db.select(
|
||||
'''
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
ORDER BY CAST(id AS INTEGER) DESC
|
||||
LIMIT 1;
|
||||
''', [type.value],
|
||||
''',
|
||||
[type.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return '1';
|
||||
@@ -264,8 +353,8 @@ class LocalManager with ChangeNotifier {
|
||||
List<LocalComic> getComics(LocalSortType sortType) {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
ORDER BY
|
||||
${sortType.value == 'name' ? 'title' : 'created_at'}
|
||||
ORDER BY
|
||||
${sortType.value == 'name' ? 'title' : 'created_at'}
|
||||
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
|
||||
;
|
||||
''');
|
||||
@@ -307,7 +396,7 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
LocalComic? findByName(String name) {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
SELECT * FROM comics
|
||||
WHERE title = ? OR directory = ?;
|
||||
''', [name, name]);
|
||||
if (res.isEmpty) {
|
||||
@@ -326,22 +415,27 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<List<String>> getImages(String id, ComicType type, Object ep) async {
|
||||
if(ep is! String && ep is! int) {
|
||||
if (ep is! String && ep is! int) {
|
||||
throw "Invalid ep";
|
||||
}
|
||||
var comic = find(id, type) ?? (throw "Comic Not Found");
|
||||
var directory = Directory(FilePath.join(path, comic.directory));
|
||||
if (comic.chapters != null) {
|
||||
var cid = ep is int
|
||||
? comic.chapters!.keys.elementAt(ep - 1)
|
||||
: (ep as String);
|
||||
var directory = Directory(comic.baseDir);
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||
cid = getChapterDirectoryName(cid);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
await for (var entity in directory.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.absolute.path.replaceFirst(path, '').substring(1) ==
|
||||
comic.cover) {
|
||||
// Do not exclude comic.cover, since it may be the first page of the chapter.
|
||||
// A file with name starting with 'cover.' is not a comic page.
|
||||
if (entity.name.startsWith('cover.')) {
|
||||
continue;
|
||||
}
|
||||
//Hidden file in some file system
|
||||
if (entity.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
files.add(entity);
|
||||
@@ -358,12 +452,30 @@ class LocalManager with ChangeNotifier {
|
||||
return files.map((e) => "file://${e.path}").toList();
|
||||
}
|
||||
|
||||
Future<bool> isDownloaded(String id, ComicType type, int ep) async {
|
||||
bool isDownloaded(String id, ComicType type,
|
||||
[int? ep, ComicChapters? chapters]) {
|
||||
var comic = find(id, type);
|
||||
if (comic == null) return false;
|
||||
if (comic.chapters == null) return true;
|
||||
if (comic.chapters == null || ep == null) return true;
|
||||
if (chapters != null) {
|
||||
if (comic.chapters?.length != chapters.length) {
|
||||
// update
|
||||
add(LocalComic(
|
||||
id: comic.id,
|
||||
title: comic.title,
|
||||
subtitle: comic.subtitle,
|
||||
tags: comic.tags,
|
||||
directory: comic.directory,
|
||||
chapters: chapters,
|
||||
cover: comic.cover,
|
||||
comicType: comic.comicType,
|
||||
downloadedChapters: comic.downloadedChapters,
|
||||
createdAt: comic.createdAt,
|
||||
));
|
||||
}
|
||||
}
|
||||
return comic.downloadedChapters
|
||||
.contains(comic.chapters!.keys.elementAt(ep-1));
|
||||
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
|
||||
}
|
||||
|
||||
List<DownloadTask> downloadingTasks = [];
|
||||
@@ -379,6 +491,10 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic != null) {
|
||||
return Directory(FilePath.join(path, comic.directory));
|
||||
}
|
||||
const comicDirectoryMaxLength = 80;
|
||||
if (name.length > comicDirectoryMaxLength) {
|
||||
name = name.substring(0, comicDirectoryMaxLength);
|
||||
}
|
||||
var dir = findValidDirectoryName(path, name);
|
||||
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
||||
}
|
||||
@@ -420,12 +536,17 @@ class LocalManager with ChangeNotifier {
|
||||
void restoreDownloadingTasks() {
|
||||
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
|
||||
if (file.existsSync()) {
|
||||
var tasks = jsonDecode(file.readAsStringSync());
|
||||
for (var e in tasks) {
|
||||
var task = DownloadTask.fromJson(e);
|
||||
if (task != null) {
|
||||
downloadingTasks.add(task);
|
||||
try {
|
||||
var tasks = jsonDecode(file.readAsStringSync());
|
||||
for (var e in tasks) {
|
||||
var task = DownloadTask.fromJson(e);
|
||||
if (task != null) {
|
||||
downloadingTasks.add(task);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
file.delete();
|
||||
Log.error("LocalManager", "Failed to restore downloading tasks: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,12 +558,135 @@ class LocalManager with ChangeNotifier {
|
||||
downloadingTasks.first.resume();
|
||||
}
|
||||
|
||||
void deleteComic(LocalComic c) {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
dir.deleteIgnoreError(recursive: true);
|
||||
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
|
||||
if (removeFileOnDisk) {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
dir.deleteIgnoreError(recursive: true);
|
||||
}
|
||||
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
|
||||
if (c.comicType == ComicType.local) {
|
||||
if (HistoryManager().find(c.id, c.comicType) != null) {
|
||||
HistoryManager().remove(c.id, c.comicType);
|
||||
}
|
||||
var folders = LocalFavoritesManager().find(c.id, c.comicType);
|
||||
for (var f in folders) {
|
||||
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
|
||||
}
|
||||
}
|
||||
remove(c.id, c.comicType);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComicChapters(LocalComic c, List<String> chapters) {
|
||||
if (chapters.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var newDownloadedChapters = c.downloadedChapters
|
||||
.where((e) => !chapters.contains(e))
|
||||
.toList();
|
||||
if (newDownloadedChapters.isNotEmpty) {
|
||||
_db.execute(
|
||||
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
|
||||
[
|
||||
jsonEncode(newDownloadedChapters),
|
||||
c.id,
|
||||
c.comicType.value,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[c.id, c.comicType.value],
|
||||
);
|
||||
}
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
for (var chapter in chapters) {
|
||||
var dir = Directory(FilePath.join(
|
||||
c.baseDir,
|
||||
getChapterDirectoryName(chapter),
|
||||
));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
}
|
||||
if (shouldRemovedDirs.isNotEmpty) {
|
||||
_deleteDirectories(shouldRemovedDirs);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
|
||||
if (comics.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (var c in comics) {
|
||||
if (removeFileOnDisk) {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
}
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[c.id, c.comicType.value],
|
||||
);
|
||||
}
|
||||
}
|
||||
catch(e, s) {
|
||||
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
|
||||
_db.execute('ROLLBACK;');
|
||||
return;
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
|
||||
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
|
||||
|
||||
if (removeFavoriteAndHistory) {
|
||||
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
|
||||
HistoryManager().batchDeleteHistories(comicIDs);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
if (removeFileOnDisk) {
|
||||
_deleteDirectories(shouldRemovedDirs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
|
||||
static void _deleteDirectories(List<Directory> directories) {
|
||||
Isolate.run(() async {
|
||||
await SAFTaskWorker().init();
|
||||
for (var dir in directories) {
|
||||
try {
|
||||
if (dir.existsSync()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String getChapterDirectoryName(String name) {
|
||||
var builder = StringBuffer();
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
var char = name[i];
|
||||
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
|
||||
char == '?'
|
||||
|| char == '"' || char == '<' || char == '>' || char == '|') {
|
||||
builder.write('_');
|
||||
} else {
|
||||
builder.write(char);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalSortType {
|
||||
@@ -462,4 +706,4 @@ enum LocalSortType {
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||