Compare commits

...

185 Commits

Author SHA1 Message Date
dom
ffbbd8e702 Release 2.0.1
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 17:52:15 +08:00
dom
a1815c4cc7 unify platform file name
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 17:44:10 +08:00
dom
b9e543f26b improve export/import
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 17:09:23 +08:00
dom
0788a4de2d upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 13:40:58 +08:00
dom
b0c6e2f5cd fix dyn folded link
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 13:40:58 +08:00
dom
9489d8a7ca opt handle dyn additional
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-14 12:19:30 +08:00
My-Responsitories
aee4424dbf opt: escape performReassemble (#1864) 2026-03-13 18:38:37 +08:00
dom
96f9972895 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-13 09:49:48 +08:00
dom
6ddf282555 fix header progress
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-13 09:49:48 +08:00
dom
e98b2b69bb tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-12 17:23:51 +08:00
dom
448192b635 DynamicFlexibleSpaceBar: provide height
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-12 15:45:36 +08:00
My-Responsitories
6cda3a1880 tweaks (#1862)
* opt: linter

* tweaks

* opt: TopImage

* update

* remove repaintBoundary [skip ci]

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-03-12 15:45:18 +08:00
dom
99128b2641 stateless DynamicSliverAppBar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-12 13:01:05 +08:00
dom
b8098fe067 show member collection top
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-11 17:46:09 +08:00
dom
9fef3284db opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-11 17:17:47 +08:00
dom
f2b0a3a5ed link up to launcher
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-10 17:22:53 +08:00
dom
3090cfc6f9 live first frame
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-10 13:12:33 +08:00
dom
98ce99202e decrease image max ratio
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-10 13:12:33 +08:00
dom
fddf46a90a upgrade dep
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-10 11:13:15 +08:00
dom
a5231a55b8 fix dyn with title only
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 19:40:30 +08:00
dom
b8cae015d7 upgrade dep
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 17:43:50 +08:00
dom
3b09534320 upgrade dep
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 15:56:18 +08:00
dom
702cf988d3 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 11:53:46 +08:00
dom
5586d12b1f opt live chat list
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 10:22:15 +08:00
dom
4683939364 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-09 10:22:02 +08:00
My-Responsitories
f825f87dc1 opt: save reply (#1860)
* opt: save reply

* opt: reply save switch

* remove unneeded sort

* clear sub replies [skip ci]

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-03-08 20:37:59 +08:00
dom
4ad422c3ea suppress build annotation
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-08 15:47:26 +08:00
My-Responsitories
c01318c066 feat: sliver wrap (#1858)
* feat: sliver wrap

* opt: list

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-03-08 15:25:44 +08:00
dom
01a74e191a redirect ugc to pgc
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-08 11:28:57 +08:00
dom
a1f15b5da5 feat: record reply
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-08 11:28:51 +08:00
Dxeee
1e83a23c5c feat: "Selected Comments" status displayed (#1857)
* Add support for UpSelection in reply display and controller

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-03-08 00:26:58 +08:00
dom
2d69c05f33 tweak
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-08 00:24:18 +08:00
dom
7a2dbe68c7 build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-08 00:24:10 +08:00
dom
db08af6ca5 correct scrollable position when hiding top bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-07 15:05:57 +08:00
dom
fefb5c837b build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-07 15:05:57 +08:00
dom
a88429d6d7 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-07 10:49:29 +08:00
dom
cbe99a32eb fix player interactive viewer recognizer
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 18:54:01 +08:00
dom
b65d10ac5f flutter 3.41.4
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 17:51:43 +08:00
dom
868f7f5055 increase image max ratio
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:43:55 +08:00
dom
e843684109 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:06:42 +08:00
dom
631197e3b9 opt perf
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:05:54 +08:00
dom
381c385726 refactor image grid
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:05:54 +08:00
dom
077255e776 opt member profile
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:05:54 +08:00
dom
0bcc1a7f12 build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:05:54 +08:00
dom
9b145b525a opt floating header
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-06 12:05:53 +08:00
dom
b61a54bf9b fix auto fullscreen
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 23:57:22 +08:00
dom
cf103a09c1 fix typo
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 16:16:41 +08:00
dom
a802bc1cdf opt auto fullscreen
opt video fit

Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 16:03:41 +08:00
dom
8d312d8cf1 build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 11:38:13 +08:00
dom
6738142ac0 opt player listener
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 11:19:11 +08:00
dom
3d99e6c761 fetch dyn type onlyfansQaCard
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 11:19:11 +08:00
dom
f9f52e918a resize image placeholder
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 11:19:11 +08:00
dom
6108290b4b remove unused property
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-04 11:19:05 +08:00
dom
8bae275120 fix trackpad not respecting enableSlideVolumeBrightness property
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-03 18:38:18 +08:00
dom
0504011ba0 Release 2.0.0
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-03 10:54:58 +08:00
dom
dc9d4f9eed upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-03 09:56:48 +08:00
dom
187c92d691 flutter 3.41.3
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-03 09:55:59 +08:00
dom
9c7b18710c refa persistent header & dynamic sliver appbar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-03 09:44:29 +08:00
dom
1dbc54f063 opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-03-02 10:31:16 +08:00
My-Responsitories
348bc8b920 opt: set repeat ui (#1854) 2026-03-01 20:38:03 +08:00
dom
a375d8525f upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-28 18:12:52 +08:00
dom
e3e423f9b1 fix app scheme
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-28 18:06:01 +08:00
NLsdt
62048992be chore(ci): upgrade upload-artifact to v7 and set archive options (#1852) 2026-02-28 13:31:45 +08:00
dom
ec9498a2ca fix profile actions constraints
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-28 12:04:05 +08:00
dom
1d35abef63 fixes
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-27 18:15:34 +08:00
dom
889f6d01c2 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-27 16:03:56 +08:00
dom
d9c47be2a9 opt chat list
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-27 15:55:40 +08:00
dom
cf44036589 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-27 15:52:14 +08:00
My-Responsitories
7276cde48a refa player (#1848)
* tweak

* Reapply "opt: audio uri" (#1833)

This reverts commit 8e726f49b2.

* opt: player

* opt: player

* refa: create player

* refa: player

* opt: UaType

* fix: sb seek preview

* opt: setSub

* fix: screenshot

* opt: unnecessary final player state

* opt: player track

* opt FileSource constructor [skip ci]

* fixes

* fix: dispose player

* fix: quote

* update

* fix: multi ua & remove unused loop

* remove unneeded check [skip ci]

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-27 15:51:55 +08:00
My-Responsitories
6782bee11a opt: HeaderLayout with SlottedMultiChild (#1850)
* opt: HeaderLayout with SlottedMultiChild

* ordered

* update [skip ci]
2026-02-27 14:40:17 +08:00
barmxds6ch
b55e102dc3 feat(whisper): add uploader message attachment display (#1849)
* feat(whisper): add uploader message attachment display

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-27 10:56:44 +08:00
dom
65ad8a0fdc opt anim
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-26 21:26:28 +08:00
dom
fdb3bf3edc opt member profile
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-26 16:59:51 +08:00
dom
95506ad896 tweak
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-26 14:06:34 +08:00
dom
348b2533dc opt image gesture
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-26 10:05:06 +08:00
dom
2bdab71138 opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-25 18:02:36 +08:00
My-Responsitories
e707764f84 tweaks (#1846)
* opt: live extra

* opt: remove addPointer

* opt: use ssd

* opt: cache svg

* opt: localToGlobal

* opt: disabled icon

* opt: onVideoDetailChange switch

* fix

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-25 18:01:43 +08:00
dom
4a3d827f7a opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-24 21:48:42 +08:00
dom
e88cd12dfa opt refresh
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-24 12:17:18 +08:00
dom
ee04978e0c opt scrollPhysics
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-23 12:03:35 +08:00
dom
d15ad4911d upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-23 11:53:26 +08:00
dom
14b6c115b5 opt refresh
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-23 11:44:39 +08:00
dom
ee188da6b0 opt scrollPhysics
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-22 20:45:06 +08:00
dom
998b70cd87 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-22 16:47:51 +08:00
dom
7563a52bed opt refresh
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-22 15:51:57 +08:00
dom
7e81fae2bc fix jump to item
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-22 15:22:12 +08:00
dom
639dfac8af build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-21 21:13:47 +08:00
dom
d8950adb64 opt player bottom bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-21 19:50:51 +08:00
dom
9092db86ca upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-21 18:54:22 +08:00
My-Responsitories
d7d9655f81 opt: RepaintBoundary (#1840)
* opt: RepaintBoundary

* fix [skip ci]

* opt time width

* opt: video position

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-20 21:04:34 +08:00
dom
a63ca93762 flutter 3.41.2
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-20 12:00:54 +08:00
dom
243178c112 format
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-20 12:00:54 +08:00
Just_A_Pony
dcb3a02da8 allow user to configure window decorations(CSD/SSD) (#1839)
* allow user to configure window decorations(CSD/SSD)

* rename piliplus.desktop
to com.example.piliplus.desktop

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-20 11:46:25 +08:00
Starfallen
b1c0eca328 fix(player): clamp loudnorm parameters to valid FFmpeg ranges (#1838)
- Define Integrated True Peak (TP) boundary constants.
- Use .clamp() in Volume.format to ensure parameters stay within [-9, 0] for 'TP'.
- Prevents FFmpeg filter errors when video metadata contains very low peak values (e.g., TP = -22).
2026-02-19 15:31:29 +08:00
dom
e3a1eb5c87 opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-19 15:12:29 +08:00
dom
736478b1c5 fix patch
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-19 13:33:05 +08:00
dom
12919804dc mouse cursor patch
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-19 12:23:20 +08:00
dom
888b3d8173 remove unused val
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-19 10:45:57 +08:00
dom
1e6b0f0b53 opt hide bottom bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-19 10:33:40 +08:00
dom
aa3e5a4737 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 23:12:03 +08:00
dom
3f3d54fd27 add bar hide type
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 21:41:15 +08:00
dom
a142b15344 fix hide bottom bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 18:33:33 +08:00
dom
651e79ce26 opt handle scroll
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 18:16:22 +08:00
dom
9b93ce84ab upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 17:44:46 +08:00
dom
dfa258b9e6 opt hide top/bottom bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-18 17:36:11 +08:00
dom
a5efca4e1f upgrade dep
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-17 18:35:03 +08:00
dom
1fe84d1d34 Revert "disable windows thread merging"
This reverts commit bebf34db23.
2026-02-17 18:35:03 +08:00
dom
b978ff5649 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-17 18:34:58 +08:00
dom
fa85ae47ac gitignore
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-16 10:22:03 +08:00
dom
3209ecd0ba opt image recognizer
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-15 22:19:54 +08:00
dom
807de41ff0 opt gesture
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-15 21:43:51 +08:00
dom
d273e72a44 opt image viewer
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-15 19:14:17 +08:00
dom
2c0597175d opt gesture
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-15 17:16:24 +08:00
My-Responsitories
85292a3df2 opt: image viewer (#1837)
* opt: image

* opt: MatrixTransition

* update


---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-15 17:13:55 +08:00
dom
9c7c6f9e4e tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-15 10:47:20 +08:00
Gujial
511ff71f5f opt share video link (#1835)
* feat: share video with current time position preference

* simplify


---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-14 21:16:47 +08:00
систем
e104982246 handle Cmd+Q on macOS (#1834)
* feat(video): add Pref.keyboardControl check &  support in video page

- 在 player_focus 处理按键事件前增加 Pref.keyboardControl 检查,允许禁用键盘快捷键
- 修复 Cmd+Q 事件被 Q 键逻辑拦截导致无法退出的问题

* Update player_focus.dart
2026-02-14 21:01:34 +08:00
dom
e7e79eb62a opt image preview
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 18:26:31 +08:00
dom
352e314ee1 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 18:26:31 +08:00
dom
e9dafbc227 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 12:03:28 +08:00
dom
96727469ac build
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 12:03:28 +08:00
dom
c70c9829c0 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 12:03:18 +08:00
dom
beb7eb1aea refa image preview
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-14 11:03:02 +08:00
My-Responsitories
8e726f49b2 Revert "opt: audio uri" (#1833)
This reverts commit 78739d9c0a.
2026-02-13 20:36:51 +08:00
dom
007375371e revert new overscrollindicator
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 17:39:26 +08:00
dom
6d79551566 fix updateTicker
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 15:27:10 +08:00
dom
483953cf56 sync flutter widgets
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 13:44:55 +08:00
dom
fbf7116edf fix modal barrier patch
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 11:14:51 +08:00
dom
6c164d81e3 use TickerMode.getValuesNotifier
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:30:40 +08:00
dom
d0789734ec fix get icon assets
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:20:11 +08:00
dom
f3bd305337 use findItemIndexCallback
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:15:19 +08:00
dom
5ab7000716 platform assets
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:12:08 +08:00
dom
dc1c33f086 adapt RadioClient
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:12:08 +08:00
dom
920c51100a flutter 3.41.0
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:12:08 +08:00
dom
05a385d69e tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-12 10:12:08 +08:00
My-Responsitories
9411785d26 opt: PlPlayerController (#1832)
* opt: audio uri

* opt: PlPlayerController
2026-02-10 16:33:02 +08:00
My-Responsitories
ed2bd069ee opt: segment (#1831)
* opt: segment

* revert: block config
2026-02-10 16:13:38 +08:00
My-Responsitories
0460030a2b feat: tempPlayerConf add playRepeat (#1830) 2026-02-09 18:16:45 +08:00
My-Responsitories
7e570d11d8 fix: live dm 2026-02-09 16:43:21 +08:00
dom
32cd3209d0 opt init recognizer
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-09 09:55:01 +08:00
dom
0cb07aef1c audio block
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-08 23:35:58 +08:00
dom
0c65605ac0 audio sschedule shutdown
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-08 20:51:13 +08:00
My-Responsitories
8234b7ac92 opt: matrix anim (#1829) 2026-02-08 15:27:03 +08:00
dom
4ac855d393 Release 1.1.6
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 16:43:37 +08:00
dom
7381939c0f upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 16:43:37 +08:00
dom
a380bcd96a tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 12:38:52 +08:00
dom
d253ef468b tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 11:39:23 +08:00
систем
e8145ef65a fix: initialize controller before super (#1827) 2026-02-07 11:32:02 +08:00
dom
0c175abc0b opt image physics
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 10:55:00 +08:00
dom
946a5a1e47 opt image gesture
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 10:40:21 +08:00
dom
29e7e0e556 feat: vertical tabbar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 09:28:02 +08:00
dom
cc1704a021 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-07 09:25:23 +08:00
My-Responsitories
7ab2cf973f tweaks (#1826)
* opt: dataStatus

* tweaks

* opt: ui

* update

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-06 14:25:45 +08:00
dom
32386bf146 fix check bottom bar
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-06 12:52:35 +08:00
dom
40269da391 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-06 12:34:08 +08:00
dom
42e082bbc6 scroll to current rank
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-06 11:39:31 +08:00
dom
1ad710c1cf opt settings
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-06 10:48:36 +08:00
dom
cfa925549e tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-05 16:54:27 +08:00
dom
ca387787b3 opt handle reply note
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-05 11:19:36 +08:00
dom
29a9b22c29 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-05 11:18:17 +08:00
dom
672375b925 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-04 19:54:44 +08:00
wuhenzha
c099738802 fix: respect title bar setting after exiting PiP (#1825) 2026-02-04 19:50:52 +08:00
dom
50561b8dc1 opt persist header
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-03 18:28:06 +08:00
dom
2596859778 custom dyn text menu
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-03 10:21:34 +08:00
dom
3d453bafdb modal barrier patch
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-03 10:21:34 +08:00
систем
18e0b93ca7 feat: add copy/move support to fav/later search results (#1822)
* feat: add copy/move support to fav/later search results

* update

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-03 10:21:01 +08:00
My-Responsitories
7260a387f9 tweaks (#1821)
* opt: _onTapUp

* opt: set contains

* opt: elementAtOrNull
2026-02-02 20:12:12 +08:00
dom
37fa165f59 opt pip
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-01 17:06:21 +08:00
dom
8f08104f37 upgrade dep
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-01 11:57:13 +08:00
lesetong
6ee4deab05 Fix duplicate items in Up list when showAllUp is enabled. (#1819)
Overrode UpItem's == operator and hashCode using 'mid' to prevent
redundant entries.
2026-02-01 00:18:18 +08:00
My-Responsitories
77fff92939 opt: binary search fontsize (#1818)
* opt: permission

* opt: opt: binary search fontsize

* use transform

Signed-off-by: dom <githubaccount56556@proton.me>

* fix

Signed-off-by: dom <githubaccount56556@proton.me>

* opt: matrix

* opt [skip ci]

* tweaks [skip ci]

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-02-01 00:01:34 +08:00
dom
8964197b73 tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-31 13:20:51 +08:00
dom
dbc7bcd0dd upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-31 13:20:51 +08:00
dom
207ad2753c opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-30 15:16:22 +08:00
My-Responsitories
d6e6e52df2 tweaks (#1816)
* opt: getFileName

* opt: audio-pitch-correction

* opt: spring dialog

* opt: account dialog

* update [skip ci]

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-01-30 10:31:26 +08:00
систем
9442b17d63 opt select account (#1815)
* feat: switchAccountDialog pages simple-detaile

* update

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
2026-01-29 22:06:10 +08:00
dom
058ff44e39 opt autosync
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 19:35:50 +08:00
dom
48c7dc0eed custom autosync
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 18:12:08 +08:00
dom
99634a66ab tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 18:12:03 +08:00
nakixii
21fad89cde Add autosync property with value '30' (#1813) 2026-01-29 15:22:00 +08:00
dom
5979ddb60c tweaks
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 14:55:16 +08:00
dom
bcbfe5c849 SpringDescription with duration
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 12:35:58 +08:00
dom
1640732f5d opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 12:35:58 +08:00
dom
9567910611 upgrade deps
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 10:24:16 +08:00
dom
d1713504a0 opt gesture
Signed-off-by: dom <githubaccount56556@proton.me>
2026-01-29 10:16:16 +08:00
405 changed files with 23446 additions and 16572 deletions

2
.fvmrc
View File

@@ -1,3 +1,3 @@
{
"flutter": "3.38.6"
"flutter": "3.41.4"
}

View File

@@ -49,7 +49,7 @@ on:
jobs:
android:
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_android == 'true' }}
if: ${{ (github.event_name == 'pull_request' && github.repository == 'bggRGjQaUbCoE/PiliPlus') || github.event.inputs.build_android == 'true' }}
name: Release Android
runs-on: ubuntu-latest
permissions: write-all
@@ -78,9 +78,9 @@ jobs:
flutter-version-file: pubspec.yaml
cache: true
- name: apply bottom sheet patch
working-directory: ${{ env.FLUTTER_ROOT }}
run: git apply $GITHUB_WORKSPACE/lib/scripts/bottom_sheet_patch.diff
- name: Apply Patch
shell: pwsh
run: lib/scripts/patch.ps1 android
continue-on-error: true
- name: Write key
@@ -95,13 +95,20 @@ jobs:
fi
- name: Set and Extract version
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: pwsh
run: lib/scripts/build.ps1 android
- name: flutter build apk
- name: Flutter Build Release Apk
if: ${{ github.event_name == 'workflow_dispatch' }}
run: flutter build apk --release --split-per-abi --dart-define-from-file=pili_release.json --pub
- name: rename
- name: Flutter Build Dev Apk
if: ${{ github.event_name == 'pull_request' }}
run: |
flutter build apk --release --split-per-abi --android-project-arg dev=1 --pub
- name: Rename
run: |
for file in build/app/outputs/flutter-apk/app-*-release.apk; do
abi=$(echo "$file" | sed -E 's|.*app-(.*)-release\.apk|\1|')
@@ -115,32 +122,31 @@ jobs:
with:
tag_name: ${{ github.event.inputs.tag }}
name: ${{ github.event.inputs.tag }}
files: |
PiliPlus_android_*.apk
files: PiliPlus_android_*.apk
- name: 上传
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Android_arm64-v8a
path: |
PiliPlus_android_*_arm64-v8a.apk
path: PiliPlus_android_*_arm64-v8a.apk
- name: 上传
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Android_armeabi-v7a
path: |
PiliPlus_android_*_armeabi-v7a.apk
path: PiliPlus_android_*_armeabi-v7a.apk
- name: 上传
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Android_x86_64
path: |
PiliPlus_android_*_x86_64.apk
path: PiliPlus_android_*_x86_64.apk
ios:
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_ios == 'true' }}
if: ${{ (github.event_name == 'pull_request' && github.repository == 'bggRGjQaUbCoE/PiliPlus') || github.event.inputs.build_ios == 'true' }}
uses: ./.github/workflows/ios.yml
permissions: write-all
with:
@@ -154,7 +160,7 @@ jobs:
tag: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
win_x64:
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_win_x64 == 'true' }}
if: ${{ (github.event_name == 'pull_request' && github.repository == 'bggRGjQaUbCoE/PiliPlus') || github.event.inputs.build_win_x64 == 'true' }}
uses: ./.github/workflows/win_x64.yml
permissions: write-all
with:

View File

@@ -30,13 +30,18 @@ jobs:
shell: pwsh
run: lib/scripts/build.ps1
- name: Apply Patch
shell: pwsh
run: lib/scripts/patch.ps1 iOS
continue-on-error: true
- name: Build iOS
run: |
flutter build ios --release --no-codesign --dart-define-from-file=pili_release.json
ln -sf ./build/ios/iphoneos Payload
# make AltSign happy...
find Payload/Runner.app/Frameworks -type d -name "*.framework" -exec codesign --force --sign - --preserve-metadata=identifier,entitlements {} \;
zip -r9 PiliPlus_ios_${{env.version}}.ipa Payload/runner.app
zip -r9 PiliPlus_ios_${{env.version}}.ipa Payload/Runner.app
- name: Release
if: ${{ github.event.inputs.tag != '' }}
@@ -48,7 +53,8 @@ jobs:
PiliPlus_ios_*.ipa
- name: Upload ios release
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: iOS-release
path: PiliPlus_ios_*.ipa

View File

@@ -51,6 +51,11 @@ jobs:
shell: pwsh
run: lib/scripts/build.ps1
- name: Apply Patch
shell: pwsh
run: lib/scripts/patch.ps1 Linux
continue-on-error: true
#TODO: deb and rpm packages need to be build
- name: Build Linux
run: flutter build linux --release -v --pub --dart-define-from-file=pili_release.json
@@ -70,7 +75,7 @@ jobs:
printf "复制文件...\n"
cp -r ../build/linux/x64/release/bundle/* opt/PiliPlus
cp -r ../assets/linux/DEBIAN .
cp ../assets/linux/piliplus.desktop usr/share/applications
cp ../assets/linux/com.example.piliplus.desktop usr/share/applications
cp ../assets/images/logo/logo.png usr/share/icons/hicolor/512x512/apps/piliplus.png
printf "修改控制文件...\n"
@@ -112,7 +117,7 @@ jobs:
SRC_DIR="$PWD/piliplus-${{ env.version }}"
mkdir -p "$SRC_DIR/bundle" "$SRC_DIR/assets"
cp -r build/linux/x64/release/bundle/* "$SRC_DIR/bundle/"
cp assets/linux/piliplus.desktop "$SRC_DIR/assets/piliplus.desktop"
cp assets/linux/com.example.piliplus.desktop "$SRC_DIR/assets/com.example.piliplus.desktop"
cp assets/images/logo/logo.png "$SRC_DIR/assets/piliplus.png"
tar -zcvf "$RPM_BUILD_ROOT/SOURCES/piliplus-${{ env.version }}.tar.gz" -C "$PWD" "piliplus-${{ env.version }}"
@@ -145,7 +150,7 @@ jobs:
# 桌面集成
mkdir -p %{buildroot}/usr/share/applications
install -m 644 assets/piliplus.desktop %{buildroot}/usr/share/applications/piliplus.desktop
install -m 644 assets/com.example.piliplus.desktop %{buildroot}/usr/share/applications/com.example.piliplus.desktop
mkdir -p %{buildroot}/usr/share/icons/hicolor/512x512/apps
install -m 644 assets/piliplus.png %{buildroot}/usr/share/icons/hicolor/512x512/apps/piliplus.png
@@ -161,7 +166,7 @@ jobs:
%files
/opt/PiliPlus
/usr/bin/piliplus
/usr/share/applications/piliplus.desktop
/usr/share/applications/com.example.piliplus.desktop
/usr/share/icons/hicolor/512x512/apps/piliplus.png
%changelog
@@ -197,8 +202,8 @@ jobs:
cp -r build/linux/x64/release/bundle/* "$APPDIR/usr/bin/"
printf "复制桌面文件和图标...\n"
cp assets/linux/piliplus.desktop "$APPDIR/piliplus.desktop"
cp assets/linux/piliplus.desktop "$APPDIR/usr/share/applications/piliplus.desktop"
cp assets/linux/com.example.piliplus.desktop "$APPDIR/com.example.piliplus.desktop"
cp assets/linux/com.example.piliplus.desktop "$APPDIR/usr/share/applications/com.example.piliplus.desktop"
cp assets/images/logo/logo.png "$APPDIR/piliplus.png"
cp assets/images/logo/logo.png "$APPDIR/usr/share/icons/hicolor/512x512/apps/piliplus.png"
@@ -214,8 +219,8 @@ jobs:
chmod +x "$APPDIR/AppRun"
printf "修改桌面文件中的 Exec 路径...\n"
sed -i 's|Exec=piliplus|Exec=piliplus|g' "$APPDIR/piliplus.desktop"
sed -i 's|Icon=piliplus|Icon=piliplus|g' "$APPDIR/piliplus.desktop"
sed -i 's|Exec=piliplus|Exec=piliplus|g' "$APPDIR/com.example.piliplus.desktop"
sed -i 's|Icon=piliplus|Icon=piliplus|g' "$APPDIR/com.example.piliplus.desktop"
printf "打包 AppImage...\n"
ARCH=x86_64 ./appimagetool-x86_64.AppImage "$APPDIR" "PiliPlus_linux_${{ env.version }}_amd64.AppImage"
@@ -236,25 +241,29 @@ jobs:
PiliPlus_linux_*.AppImage
- name: Upload linux targz package
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Linux_targz_amd64_packege
path: PiliPlus_linux_*.tar.gz
- name: Upload linux deb package
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Linux_deb_amd64_package
path: PiliPlus_linux_*.deb
- name: Upload linux rpm package
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Linux_rpm_amd64_package
path: PiliPlus_linux_*.rpm
- name: Upload linux AppImage package
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Linux_AppImage_amd64_package
path: PiliPlus_linux_*.AppImage

View File

@@ -30,13 +30,18 @@ jobs:
shell: pwsh
run: lib/scripts/build.ps1
- name: Apply Patch
shell: pwsh
run: lib/scripts/patch.ps1 macOS
continue-on-error: true
- name: Build Mac
run: flutter build macos --release --dart-define-from-file=pili_release.json
- name: Prepare Upload
run: |
npm install --global create-dmg
create-dmg build/macos/Build/Products/Release/PiliPlus.app
create-dmg build/macos/Build/Products/Release/PiliPlus.app || true
continue-on-error: true
- name: Rename DMG
@@ -52,7 +57,8 @@ jobs:
PiliPlus_macos_*.dmg
- name: Upload macos release
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: macOS-release
path: PiliPlus_macos_*.dmg

View File

@@ -26,6 +26,11 @@ jobs:
channel: stable
flutter-version-file: pubspec.yaml
- name: Apply Patch
shell: pwsh
run: lib/scripts/patch.ps1 windows
continue-on-error: true
- name: Add fastforge and Inno Setup
run: |
dart pub global activate fastforge
@@ -54,7 +59,7 @@ jobs:
- name: Compress
if: ${{ github.event.inputs.tag != '' }}
run: |
Compress-Archive -Path "Release/PiliPlus-Win" -DestinationPath "PiliPlus_windows_${{env.version}}_x64.zip"
Compress-Archive -Path "Release/PiliPlus-Win" -DestinationPath "PiliPlus_windows_${{env.version}}_x64_portable.zip"
shell: pwsh
- name: Release
@@ -68,13 +73,15 @@ jobs:
PiliPlus-Win-Setup/PiliPlus_windows_*.exe
- name: Upload windows file release
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
name: Windows-file-x64-release
archive: true
name: PiliPlus_windows_${{env.version}}_x64_portable
path: Release
- name: Upload windows setup release
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
archive: false
name: Windows-setup-x64-release
path: PiliPlus-Win-Setup
path: PiliPlus-Win-Setup/PiliPlus_windows_*.exe

2
.gitignore vendored
View File

@@ -146,4 +146,4 @@ pili_release.json
dist
test.dart
test*.dart

View File

@@ -72,5 +72,11 @@ linter:
- use_truncating_division
- use_string_buffers
- unnecessary_statements
- unnecessary_nullable_for_final_variable_declarations
- tighten_type_of_initializing_formals
- prefer_void_to_null
- prefer_spread_collections
- unnecessary_to_list_in_spreads
- prefer_for_elements_to_map_fromIterable
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -54,6 +54,14 @@ android {
signingConfig = config ?: signingConfigs["debug"]
}
release {
if (project.hasProperty("dev")) {
applicationIdSuffix = ".dev"
resValue(
type = "string",
name = "app_name",
value = "PiliPlus dev",
)
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -7,7 +7,7 @@ abstract final class StyleString {
static const Radius imgRadius = Radius.circular(10);
static const double aspectRatio = 16 / 10;
static const double aspectRatio16x9 = 16 / 9;
static const double imgMaxRatio = 22 / 9;
static const double imgMaxRatio = 2.6;
static const bottomSheetRadius = BorderRadius.vertical(
top: Radius.circular(18),
);
@@ -15,6 +15,14 @@ abstract final class StyleString {
minWidth: 420,
maxWidth: 420,
);
static const topBarHeight = 52.0;
static const buttonStyle = ButtonStyle(
visualDensity: VisualDensity(
horizontal: -2,
vertical: -1.25,
),
tapTargetSize: .shrinkWrap,
);
}
abstract final class Constants {
@@ -58,6 +66,9 @@ abstract final class Constants {
static const goodsUrlPrefix = "https://gaoneng.bilibili.com/tetris";
// 'itemOpusStyle,opusBigCover,onlyfansVote,endFooterHidden,decorationCard,onlyfansAssetsV2,ugcDelete,onlyfansQaCard,editable,opusPrivateVisible,avatarAutoTheme,sunflowerStyle,cardsEnhance,eva3CardOpus,eva3CardVideo,eva3CardComment,eva3CardVote,eva3CardUser'
static const dynFeatures = 'itemOpusStyle,listOnlyfans,onlyfansQaCard';
// 超分辨率滤镜
static const List<String> mpvAnime4KShaders = [
'Anime4K_Clamp_Highlights.glsl',

View File

@@ -1,6 +1,7 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/skeleton/skeleton.dart';
import 'package:flutter/material.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:flutter/material.dart' hide LayoutBuilder;
class FavPgcItemSkeleton extends StatelessWidget {
const FavPgcItemSkeleton({super.key});

View File

@@ -1,6 +1,7 @@
import 'package:PiliPlus/common/skeleton/skeleton.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide LayoutBuilder;
class SpaceOpusSkeleton extends StatelessWidget {
const SpaceOpusSkeleton({super.key});

View File

@@ -8,12 +8,14 @@ Widget avatars({
}) {
const gap = 6.0;
const size = 22.0;
const padding = 0.8;
const offset = size - gap;
const imgSize = size - 2 * padding;
if (users.length == 1) {
return NetworkImgLayer(
src: users.first.face,
width: size,
height: size,
width: imgSize,
height: imgSize,
type: .avatar,
);
} else {
@@ -36,11 +38,11 @@ Widget avatars({
child: DecoratedBox(
decoration: decoration,
child: Padding(
padding: const .all(.8),
padding: const .all(padding),
child: NetworkImgLayer(
src: e.$2.face,
width: size - .8,
height: size - .8,
width: imgSize,
height: imgSize,
type: .avatar,
),
),

View File

@@ -55,18 +55,19 @@ class ColorPalette extends StatelessWidget {
],
);
}
return Container(
width: 50,
height: 50,
padding: const EdgeInsets.all(6),
decoration: showBgColor
? BoxDecoration(
color: colorScheme.onInverseSurface,
borderRadius: StyleString.mdRadius,
)
: null,
child: child,
);
if (showBgColor) {
return Container(
width: 50,
height: 50,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.onInverseSurface,
borderRadius: StyleString.mdRadius,
),
child: child,
);
}
return child;
}
static Widget _coloredBox(Color color) => Expanded(

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class ColoredBoxTransition extends AnimatedWidget {
const ColoredBoxTransition({
super.key,
required this.color,
this.child,
}) : super(listenable: color);
final Animation<Color?> color;
final Widget? child;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: color.value!,
child: child,
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderProxyBox, BoxHitTestResult;
class CustomHeightWidget extends SingleChildRenderObjectWidget {
const CustomHeightWidget({
super.key,
this.height,
this.offset = .zero,
required Widget super.child,
});
final double? height;
final Offset offset;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderCustomHeightWidget(
height: height,
offset: offset,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderCustomHeightWidget renderObject,
) {
renderObject
..height = height
..offset = offset;
}
}
class RenderCustomHeightWidget extends RenderProxyBox {
RenderCustomHeightWidget({
double? height,
required Offset offset,
}) : _height = height,
_offset = offset;
double? _height;
double? get height => _height;
set height(double? value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
Offset _offset;
Offset get offset => _offset;
set offset(Offset value) {
if (_offset == value) return;
_offset = value;
markNeedsPaint();
}
@override
void performLayout() {
if (height != null) {
child!.layout(constraints.copyWith(maxHeight: .infinity));
size = constraints.constrainDimensions(constraints.maxWidth, height!);
} else {
child!.layout(
constraints.copyWith(maxHeight: .infinity),
parentUsesSize: true,
);
size = constraints.constrainDimensions(
constraints.maxWidth,
child!.size.height,
);
}
}
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset + _offset);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
return result.addWithPaintOffset(
offset: _offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - _offset);
return child!.hitTest(result, position: transformed);
},
);
}
@override
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
transform.translateByDouble(_offset.dx, _offset.dy, 0.0, 1.0);
}
}

View File

@@ -1,60 +0,0 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
class CustomSliverPersistentHeaderDelegate
extends SliverPersistentHeaderDelegate {
const CustomSliverPersistentHeaderDelegate({
required this.child,
required this.bgColor,
double extent = 45,
this.needRebuild = false,
}) : _minExtent = extent,
_maxExtent = extent;
final double _minExtent;
final double _maxExtent;
final Widget child;
final Color? bgColor;
final bool needRebuild;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
//创建child子组件
//shrinkOffsetchild偏移值minExtent~maxExtent
//overlapsContentSliverPersistentHeader覆盖其他子组件返回true否则返回false
return bgColor != null
? DecoratedBox(
decoration: BoxDecoration(
color: bgColor,
boxShadow: Platform.isIOS
? null
: [
BoxShadow(
color: bgColor!,
offset: const Offset(0, -1),
),
],
),
child: child,
)
: child;
}
//SliverPersistentHeader最大高度
@override
double get maxExtent => _maxExtent;
//SliverPersistentHeader最小高度
@override
double get minExtent => _minExtent;
@override
bool shouldRebuild(CustomSliverPersistentHeaderDelegate oldDelegate) {
return oldDelegate.bgColor != bgColor ||
(needRebuild && oldDelegate.child != child);
}
}

View File

@@ -252,16 +252,8 @@ class _RenderToolTip extends RenderBox
@override
void paint(PaintingContext context, Offset offset) {
RenderBox? child = firstChild;
while (child != null) {
final childParentData = child.parentData as MultiChildLayoutParentData;
context.paintChild(child, childParentData.offset + offset);
child = childParentData.nextSibling;
}
defaultPaint(context, offset);
}
@override
bool get isRepaintBoundary => true;
}
class Triangle extends LeafRenderObjectWidget {
@@ -328,14 +320,11 @@ class RenderTriangle extends RenderBox {
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..lineTo(size.width / 2, size.height)
..moveTo(offset.dx, offset.dy)
..lineTo(offset.dx + size.width, offset.dy)
..lineTo(offset.dx + size.width / 2, size.height + offset.dy)
..close();
context.canvas.drawPath(path, paint);
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -11,34 +11,32 @@ Future<bool> showConfirmDialog({
assert(content is String? || content is Widget);
return await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: content is String
? Text(content)
: content is Widget
? content
: null,
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
builder: (context) => AlertDialog(
title: Text(title),
content: content is String
? Text(content)
: content is Widget
? content
: null,
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
TextButton(
onPressed: () {
Get.back(result: true);
onConfirm?.call();
},
child: const Text('确认'),
),
],
);
},
),
TextButton(
onPressed: () {
Get.back(result: true);
onConfirm?.call();
},
child: const Text('确认'),
),
],
),
) ??
false;
}

View File

@@ -0,0 +1,278 @@
import 'dart:async' show FutureOr;
import 'dart:convert' show utf8, jsonDecode;
import 'dart:io' show File;
import 'package:PiliPlus/common/constants.dart' show StyleString;
import 'package:PiliPlus/utils/extension/context_ext.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard;
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/src/extension_navigation.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:re_highlight/languages/json.dart';
import 'package:re_highlight/re_highlight.dart';
import 'package:re_highlight/styles/base16/github.dart';
import 'package:re_highlight/styles/github-dark.dart';
void exportToClipBoard({
required ValueGetter<String> onExport,
}) {
Utils.copyText(onExport());
}
void exportToLocalFile({
required ValueGetter<String> onExport,
required ValueGetter<String> localFileName,
}) {
final res = utf8.encode(onExport());
Utils.saveBytes2File(
name:
'piliplus_${localFileName()}_'
'${DateFormat('yyyyMMddHHmmss').format(DateTime.now())}.json',
bytes: res,
allowedExtensions: const ['json'],
);
}
Future<void> importFromClipBoard<T>(
BuildContext context, {
required String title,
required ValueGetter<String> onExport,
required FutureOr<void> Function(T json) onImport,
bool showConfirmDialog = true,
}) async {
final data = await Clipboard.getData('text/plain');
if (data?.text?.isNotEmpty != true) {
SmartDialog.showToast('剪贴板无数据');
return;
}
if (!context.mounted) return;
final text = data!.text!;
late final T json;
late final String formatText;
try {
json = jsonDecode(text);
formatText = Utils.jsonEncoder.convert(json);
} catch (e) {
SmartDialog.showToast('解析json失败$e');
return;
}
bool? executeImport;
if (showConfirmDialog) {
final highlight = Highlight()..registerLanguage('json', langJson);
final result = highlight.highlight(
code: formatText,
language: 'json',
);
late TextSpanRenderer renderer;
bool? isDarkMode;
executeImport = await showDialog(
context: context,
builder: (context) {
final isDark = context.isDarkMode;
if (isDark != isDarkMode) {
isDarkMode = isDark;
renderer = TextSpanRenderer(
const TextStyle(),
isDark ? githubDarkTheme : githubTheme,
);
result.render(renderer);
}
return AlertDialog(
title: Text('是否导入如下$title'),
content: SingleChildScrollView(
child: Text.rich(renderer.span!),
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
),
TextButton(
onPressed: () => Get.back(result: true),
child: const Text('确定'),
),
],
);
},
);
} else {
executeImport = true;
}
if (executeImport ?? false) {
try {
await onImport(json);
SmartDialog.showToast('导入成功');
} catch (e) {
SmartDialog.showToast('导入失败:$e');
}
}
}
Future<void> importFromLocalFile<T>({
required FutureOr<void> Function(T json) onImport,
}) async {
final result = await FilePicker.pickFiles();
if (result != null) {
final path = result.files.first.path;
if (path != null) {
final data = await File(path).readAsString();
late final T json;
try {
json = jsonDecode(data);
} catch (e) {
SmartDialog.showToast('解析json失败$e');
return;
}
try {
await onImport(json);
SmartDialog.showToast('导入成功');
} catch (e) {
SmartDialog.showToast('导入失败:$e');
}
}
}
}
void importFromInput<T>(
BuildContext context, {
required String title,
required FutureOr<void> Function(T json) onImport,
}) {
final key = GlobalKey<FormFieldState<String>>();
late T json;
String? forceErrorText;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('输入$title'),
constraints: StyleString.dialogFixedConstraints,
content: TextFormField(
key: key,
minLines: 4,
maxLines: 12,
autofocus: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
errorMaxLines: 3,
),
validator: (value) {
if (forceErrorText != null) return forceErrorText;
try {
json = jsonDecode(value!) as T;
return null;
} catch (e) {
if (e is FormatException) {}
return '解析json失败$e';
}
},
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
),
TextButton(
onPressed: () async {
if (key.currentState?.validate() == true) {
try {
await onImport(json);
Get.back();
SmartDialog.showToast('导入成功');
return;
} catch (e) {
forceErrorText = '导入失败:$e';
}
key.currentState?.validate();
forceErrorText = null;
}
},
child: const Text('确定'),
),
],
),
);
}
Future<void> showImportExportDialog<T>(
BuildContext context, {
required String title,
String? label,
required ValueGetter<String> onExport,
required FutureOr<void> Function(T json) onImport,
required ValueGetter<String> localFileName,
}) => showDialog(
context: context,
builder: (context) {
const style = TextStyle(fontSize: 15);
return SimpleDialog(
clipBehavior: Clip.hardEdge,
title: Text('导入/导出$title'),
children: [
ListTile(
dense: true,
title: const Text('导出至剪贴板', style: style),
onTap: () {
Get.back();
exportToClipBoard(onExport: onExport);
},
),
ListTile(
dense: true,
title: const Text('导出文件至本地', style: style),
onTap: () {
Get.back();
exportToLocalFile(onExport: onExport, localFileName: localFileName);
},
),
Divider(
height: 1,
color: ColorScheme.of(context).outline.withValues(alpha: 0.1),
),
ListTile(
dense: true,
title: const Text('输入', style: style),
onTap: () {
Get.back();
importFromInput<T>(context, title: title, onImport: onImport);
},
),
ListTile(
dense: true,
title: const Text('从剪贴板导入', style: style),
onTap: () {
Get.back();
importFromClipBoard<T>(
context,
title: title,
onExport: onExport,
onImport: onImport,
);
},
),
ListTile(
dense: true,
title: const Text('从本地文件导入', style: style),
onTap: () {
Get.back();
importFromLocalFile<T>(onImport: onImport);
},
),
],
);
},
);

View File

@@ -19,116 +19,114 @@ Future<void> autoWrapReportDialog(
late final key = GlobalKey<FormFieldState<String>>();
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('举报'),
titlePadding: const .only(left: 22, top: 16, right: 22),
contentPadding: const .symmetric(vertical: 5),
actionsPadding: const .only(left: 16, right: 16, bottom: 10),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: .only(left: 22, right: 22, bottom: 5),
child: Text('请选择举报的理由:'),
builder: (context) => AlertDialog(
title: const Text('举报'),
titlePadding: const .only(left: 22, top: 16, right: 22),
contentPadding: const .symmetric(vertical: 5),
actionsPadding: const .only(left: 16, right: 16, bottom: 10),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: .only(left: 22, right: 22, bottom: 5),
child: Text('请选择举报的理由:'),
),
RadioGroup(
onChanged: (value) {
reasonType = value;
(context as Element).markNeedsBuild();
},
groupValue: reasonType,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: options.entries.map((entry) {
return WrapRadioOptionsGroup<int>(
groupTitle: entry.key,
options: entry.value,
);
}).toList(),
),
RadioGroup(
onChanged: (value) {
reasonType = value;
(context as Element).markNeedsBuild();
},
groupValue: reasonType,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: options.entries.map((entry) {
return WrapRadioOptionsGroup<int>(
groupTitle: entry.key,
options: entry.value,
);
}).toList(),
),
),
if (reasonType == 0)
Padding(
padding: const .only(left: 22, top: 5, right: 22),
child: TextFormField(
key: key,
autofocus: true,
minLines: 2,
maxLines: 4,
initialValue: reasonDesc,
decoration: const InputDecoration(
labelText: '为帮助审核人员更快处理,请补充问题类型和出现位置等详细信息',
border: OutlineInputBorder(),
contentPadding: .all(10),
labelStyle: TextStyle(fontSize: 14),
floatingLabelStyle: TextStyle(fontSize: 14),
),
onChanged: (value) => reasonDesc = value,
validator: (value) =>
value.isNullOrEmpty ? '理由不能为空' : null,
),
if (reasonType == 0)
Padding(
padding: const .only(left: 22, top: 5, right: 22),
child: TextFormField(
key: key,
autofocus: true,
minLines: 2,
maxLines: 4,
initialValue: reasonDesc,
decoration: const InputDecoration(
labelText: '为帮助审核人员更快处理,请补充问题类型和出现位置等详细信息',
border: OutlineInputBorder(),
contentPadding: .all(10),
labelStyle: TextStyle(fontSize: 14),
floatingLabelStyle: TextStyle(fontSize: 14),
),
onChanged: (value) => reasonDesc = value,
validator: (value) =>
value.isNullOrEmpty ? '理由不能为空' : null,
),
],
),
),
],
),
),
),
),
if (ban)
Padding(
padding: const EdgeInsets.only(left: 14, top: 6),
child: CheckBoxText(
text: '拉黑该用户',
onChanged: (value) => banUid = value,
),
),
if (ban)
Padding(
padding: const EdgeInsets.only(left: 14, top: 6),
child: CheckBoxText(
text: '拉黑该用户',
onChanged: (value) => banUid = value,
),
],
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: ColorScheme.of(context).outline),
),
),
TextButton(
onPressed: () async {
if (reasonType == null ||
(reasonType == 0 && key.currentState?.validate() != true)) {
return;
}
SmartDialog.showLoading();
try {
final res = await onSuccess(reasonType!, reasonDesc, banUid);
SmartDialog.dismiss();
if (res.isSuccess) {
Get.back();
SmartDialog.showToast('举报成功');
} else {
res.toast();
}
} catch (e, s) {
SmartDialog.dismiss();
SmartDialog.showToast('提交失败:$e');
Utils.reportError(e, s);
}
},
child: const Text('确定'),
),
],
);
},
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: ColorScheme.of(context).outline),
),
),
TextButton(
onPressed: () async {
if (reasonType == null ||
(reasonType == 0 && key.currentState?.validate() != true)) {
return;
}
SmartDialog.showLoading();
try {
final res = await onSuccess(reasonType!, reasonDesc, banUid);
SmartDialog.dismiss();
if (res.isSuccess) {
Get.back();
SmartDialog.showToast('举报成功');
} else {
res.toast();
}
} catch (e, s) {
SmartDialog.dismiss();
SmartDialog.showToast('提交失败:$e');
Utils.reportError(e, s);
}
},
child: const Text('确定'),
),
],
),
);
}

View File

@@ -3,18 +3,16 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class DisabledIcon<T extends Widget> extends SingleChildRenderObjectWidget {
class DisabledIcon extends SingleChildRenderObjectWidget {
const DisabledIcon({
super.key,
required T child,
required Widget super.child,
this.disable = false,
this.color,
this.iconSize,
double? lineLengthScale,
StrokeCap? strokeCap,
}) : lineLengthScale = lineLengthScale ?? 0.9,
strokeCap = strokeCap ?? StrokeCap.butt,
super(child: child);
this.lineLengthScale = 0.9,
this.strokeCap = .butt,
});
final bool disable;
final Color? color;
@@ -22,20 +20,16 @@ class DisabledIcon<T extends Widget> extends SingleChildRenderObjectWidget {
final StrokeCap strokeCap;
final double lineLengthScale;
Icon? get _icon => child is Icon ? child as Icon : null;
@override
RenderObject createRenderObject(BuildContext context) {
late final iconTheme = IconTheme.of(context);
final icon = _icon;
return RenderMaskedIcon(
disable: disable,
iconSize:
iconSize ??
(child is Icon ? (child as Icon).size : null) ??
iconTheme.size ??
24.0,
color:
color ??
(child is Icon ? (child as Icon).color : null) ??
iconTheme.color!,
iconSize: iconSize ?? icon?.size ?? iconTheme.size ?? 24.0,
color: color ?? icon?.color ?? iconTheme.color!,
strokeCap: strokeCap,
lineLengthScale: lineLengthScale,
);
@@ -44,17 +38,11 @@ class DisabledIcon<T extends Widget> extends SingleChildRenderObjectWidget {
@override
void updateRenderObject(BuildContext context, RenderMaskedIcon renderObject) {
late final iconTheme = IconTheme.of(context);
final icon = _icon;
renderObject
..disable = disable
..iconSize =
iconSize ??
(child is Icon ? (child as Icon?)?.size : null) ??
iconTheme.size ??
24.0
..color =
color ??
(child is Icon ? (child as Icon?)?.color : null) ??
iconTheme.color!
..iconSize = iconSize ?? icon?.size ?? iconTheme.size ?? 24.0
..color = color ?? icon?.color ?? iconTheme.color!
..strokeCap = strokeCap
..lineLengthScale = lineLengthScale;
}
@@ -121,6 +109,7 @@ class RenderMaskedIcon extends RenderProxyBox {
final canvas = context.canvas;
var rectOffset = offset;
Size size = this.size;
final exceedWidth = size.width > _iconSize;
final exceedHeight = size.height > _iconSize;
@@ -128,14 +117,14 @@ class RenderMaskedIcon extends RenderProxyBox {
final dx = exceedWidth ? (size.width - _iconSize) / 2.0 : 0.0;
final dy = exceedHeight ? (size.height - _iconSize) / 2.0 : 0.0;
size = Size.square(_iconSize);
offset = Offset(dx, dy);
rectOffset += Offset(dx, dy);
} else if (size.width < _iconSize && size.height < _iconSize) {
size = Size.square(_iconSize);
}
final strokeWidth = size.width / 12;
var rect = offset & size;
var rect = rectOffset & size;
final sqrt2Width = strokeWidth * sqrt2; // rotate pi / 4
@@ -155,7 +144,7 @@ class RenderMaskedIcon extends RenderProxyBox {
canvas
..save()
..clipPath(path, doAntiAlias: false);
super.paint(context, .zero);
super.paint(context, offset);
canvas.restore();
@@ -174,7 +163,4 @@ class RenderMaskedIcon extends RenderProxyBox {
linePaint,
);
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -0,0 +1,492 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/custom_height_widget.dart';
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/rendering/sliver_persistent_header.dart';
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/sliver_persistent_header.dart';
import 'package:PiliPlus/common/widgets/only_layout_widget.dart'
show LayoutCallback;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'
hide SliverPersistentHeader, SliverPersistentHeaderDelegate;
import 'package:flutter/rendering.dart' show RenderOpacity, OpacityLayer;
import 'package:flutter/services.dart';
/// ref [SliverAppBar]
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
required this.leading,
required this.automaticallyImplyLeading,
required this.title,
required this.actions,
required this.automaticallyImplyActions,
required this.flexibleSpace,
required this.bottom,
required this.elevation,
required this.scrolledUnderElevation,
required this.shadowColor,
required this.surfaceTintColor,
required this.forceElevated,
required this.backgroundColor,
required this.foregroundColor,
required this.iconTheme,
required this.actionsIconTheme,
required this.primary,
required this.centerTitle,
required this.excludeHeaderSemantics,
required this.titleSpacing,
required this.collapsedHeight,
required this.topPadding,
required this.shape,
required this.toolbarHeight,
required this.leadingWidth,
required this.toolbarTextStyle,
required this.titleTextStyle,
required this.systemOverlayStyle,
required this.forceMaterialTransparency,
required this.useDefaultSemanticsOrder,
required this.clipBehavior,
required this.actionsPadding,
}) : assert(primary || topPadding == 0.0),
_bottomHeight = bottom?.preferredSize.height ?? 0.0;
final Widget? leading;
final bool automaticallyImplyLeading;
final Widget title;
final List<Widget>? actions;
final bool automaticallyImplyActions;
final Widget flexibleSpace;
final PreferredSizeWidget? bottom;
final double? elevation;
final double? scrolledUnderElevation;
final Color? shadowColor;
final Color? surfaceTintColor;
final bool forceElevated;
final Color? backgroundColor;
final Color? foregroundColor;
final IconThemeData? iconTheme;
final IconThemeData? actionsIconTheme;
final bool primary;
final bool? centerTitle;
final bool excludeHeaderSemantics;
final double? titleSpacing;
final double collapsedHeight;
final double topPadding;
final ShapeBorder? shape;
final double? toolbarHeight;
final double? leadingWidth;
final TextStyle? toolbarTextStyle;
final TextStyle? titleTextStyle;
final SystemUiOverlayStyle? systemOverlayStyle;
final double _bottomHeight;
final bool forceMaterialTransparency;
final bool useDefaultSemanticsOrder;
final Clip? clipBehavior;
final EdgeInsetsGeometry? actionsPadding;
@override
double get minExtent => collapsedHeight;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
double? maxExtent,
) {
maxExtent ??= double.infinity;
final bool isScrolledUnder =
overlapsContent ||
forceElevated ||
(shrinkOffset > maxExtent - minExtent);
final effectiveTitle = AnimatedOpacity(
opacity: isScrolledUnder ? 1 : 0,
duration: const Duration(milliseconds: 500),
curve: const Cubic(0.2, 0.0, 0.0, 1.0),
child: title,
);
return FlexibleSpaceBar.createSettings(
minExtent: minExtent,
maxExtent: maxExtent,
currentExtent: math.max(minExtent, maxExtent - shrinkOffset),
isScrolledUnder: isScrolledUnder,
hasLeading: leading != null || automaticallyImplyLeading,
child: AppBar(
clipBehavior: clipBehavior,
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
title: effectiveTitle,
actions: actions,
automaticallyImplyActions: automaticallyImplyActions,
flexibleSpace: IgnorePointer(
ignoring: isScrolledUnder,
child: DynamicFlexibleSpaceBar(background: flexibleSpace),
),
bottom: bottom,
elevation: isScrolledUnder ? elevation : 0.0,
scrolledUnderElevation: scrolledUnderElevation,
shadowColor: shadowColor,
surfaceTintColor: surfaceTintColor,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
iconTheme: iconTheme,
actionsIconTheme: actionsIconTheme,
primary: primary,
centerTitle: centerTitle,
excludeHeaderSemantics: excludeHeaderSemantics,
titleSpacing: titleSpacing,
shape: shape,
toolbarHeight: toolbarHeight,
leadingWidth: leadingWidth,
toolbarTextStyle: toolbarTextStyle,
titleTextStyle: titleTextStyle,
systemOverlayStyle: systemOverlayStyle,
forceMaterialTransparency: forceMaterialTransparency,
useDefaultSemanticsOrder: useDefaultSemanticsOrder,
actionsPadding: actionsPadding,
),
);
}
@override
bool shouldRebuild(covariant _SliverAppBarDelegate oldDelegate) {
return leading != oldDelegate.leading ||
automaticallyImplyLeading != oldDelegate.automaticallyImplyLeading ||
title != oldDelegate.title ||
actions != oldDelegate.actions ||
automaticallyImplyActions != oldDelegate.automaticallyImplyActions ||
flexibleSpace != oldDelegate.flexibleSpace ||
bottom != oldDelegate.bottom ||
_bottomHeight != oldDelegate._bottomHeight ||
elevation != oldDelegate.elevation ||
shadowColor != oldDelegate.shadowColor ||
backgroundColor != oldDelegate.backgroundColor ||
foregroundColor != oldDelegate.foregroundColor ||
iconTheme != oldDelegate.iconTheme ||
actionsIconTheme != oldDelegate.actionsIconTheme ||
primary != oldDelegate.primary ||
centerTitle != oldDelegate.centerTitle ||
titleSpacing != oldDelegate.titleSpacing ||
topPadding != oldDelegate.topPadding ||
forceElevated != oldDelegate.forceElevated ||
toolbarHeight != oldDelegate.toolbarHeight ||
leadingWidth != oldDelegate.leadingWidth ||
toolbarTextStyle != oldDelegate.toolbarTextStyle ||
titleTextStyle != oldDelegate.titleTextStyle ||
systemOverlayStyle != oldDelegate.systemOverlayStyle ||
forceMaterialTransparency != oldDelegate.forceMaterialTransparency ||
useDefaultSemanticsOrder != oldDelegate.useDefaultSemanticsOrder ||
actionsPadding != oldDelegate.actionsPadding;
}
@override
String toString() {
return '${describeIdentity(this)}(topPadding: ${topPadding.toStringAsFixed(1)}, bottomHeight: ${_bottomHeight.toStringAsFixed(1)}, ...)';
}
}
class DynamicSliverAppBar extends StatelessWidget {
const DynamicSliverAppBar.medium({
super.key,
this.leading,
this.automaticallyImplyLeading = true,
required this.title,
this.actions,
this.automaticallyImplyActions = true,
required this.flexibleSpace,
this.bottom,
this.elevation,
this.scrolledUnderElevation,
this.shadowColor,
this.surfaceTintColor,
this.forceElevated = false,
this.backgroundColor,
this.foregroundColor,
this.iconTheme,
this.actionsIconTheme,
this.primary = true,
this.centerTitle,
this.excludeHeaderSemantics = false,
this.titleSpacing,
this.shape,
this.leadingWidth,
this.toolbarTextStyle,
this.titleTextStyle,
this.systemOverlayStyle,
this.forceMaterialTransparency = false,
this.useDefaultSemanticsOrder = true,
this.clipBehavior,
this.actionsPadding,
this.onPerformLayout,
});
final LayoutCallback? onPerformLayout;
final Widget? leading;
final bool automaticallyImplyLeading;
final Widget title;
final List<Widget>? actions;
final bool automaticallyImplyActions;
final Widget flexibleSpace;
final PreferredSizeWidget? bottom;
final double? elevation;
final double? scrolledUnderElevation;
final Color? shadowColor;
final Color? surfaceTintColor;
final bool forceElevated;
final Color? backgroundColor;
final Color? foregroundColor;
final IconThemeData? iconTheme;
final IconThemeData? actionsIconTheme;
final bool primary;
final bool? centerTitle;
final bool excludeHeaderSemantics;
final double? titleSpacing;
final ShapeBorder? shape;
final double? leadingWidth;
final TextStyle? toolbarTextStyle;
final TextStyle? titleTextStyle;
final SystemUiOverlayStyle? systemOverlayStyle;
final bool forceMaterialTransparency;
final bool useDefaultSemanticsOrder;
final Clip? clipBehavior;
final EdgeInsetsGeometry? actionsPadding;
@override
Widget build(BuildContext context) {
final double bottomHeight = bottom?.preferredSize.height ?? 0.0;
final double topPadding = primary
? MediaQuery.viewPaddingOf(context).top
: 0.0;
final double effectiveCollapsedHeight =
topPadding + kToolbarHeight + bottomHeight + 1;
return SliverPinnedHeader(
onPerformLayout: onPerformLayout,
delegate: _SliverAppBarDelegate(
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
title: title,
actions: actions,
automaticallyImplyActions: automaticallyImplyActions,
flexibleSpace: flexibleSpace,
bottom: bottom,
elevation: elevation,
scrolledUnderElevation: scrolledUnderElevation,
shadowColor: shadowColor,
surfaceTintColor: surfaceTintColor,
forceElevated: forceElevated,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
iconTheme: iconTheme,
actionsIconTheme: actionsIconTheme,
primary: primary,
centerTitle: centerTitle,
excludeHeaderSemantics: excludeHeaderSemantics,
titleSpacing: titleSpacing,
collapsedHeight: effectiveCollapsedHeight,
topPadding: topPadding,
shape: shape,
toolbarHeight: kToolbarHeight,
leadingWidth: leadingWidth,
toolbarTextStyle: toolbarTextStyle,
titleTextStyle: titleTextStyle,
systemOverlayStyle: systemOverlayStyle,
forceMaterialTransparency: forceMaterialTransparency,
useDefaultSemanticsOrder: useDefaultSemanticsOrder,
clipBehavior: clipBehavior,
actionsPadding: actionsPadding,
),
);
}
}
/// ref [FlexibleSpaceBar]
class DynamicFlexibleSpaceBar extends StatelessWidget {
const DynamicFlexibleSpaceBar({
super.key,
required this.background,
this.collapseMode = CollapseMode.parallax,
});
final Widget background;
final CollapseMode collapseMode;
static double _getCollapsePadding(
CollapseMode collapseMode,
double t,
FlexibleSpaceBarSettings settings,
) {
switch (collapseMode) {
case CollapseMode.pin:
return -(settings.maxExtent - settings.currentExtent);
case CollapseMode.none:
return 0.0;
case CollapseMode.parallax:
final double deltaExtent = settings.maxExtent - settings.minExtent;
return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
}
}
@override
Widget build(BuildContext context) {
final FlexibleSpaceBarSettings settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
double? height;
final double opacity;
final double topPadding;
if (settings.maxExtent == .infinity) {
opacity = 1.0;
topPadding = 0.0;
} else {
height = settings.maxExtent;
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t = clampDouble(
1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent,
0.0,
1.0,
);
final double fadeStart = math.max(
0.0,
1.0 - kToolbarHeight / deltaExtent,
);
const fadeEnd = 1.0;
assert(fadeStart <= fadeEnd);
// If the min and max extent are the same, the app bar cannot collapse
// and the content should be visible, so opacity = 1.
opacity = settings.maxExtent == settings.minExtent
? 1.0
: 1.0 - Interval(fadeStart, fadeEnd).transform(t);
topPadding = _getCollapsePadding(collapseMode, t, settings);
}
return ClipRect(
child: CustomHeightWidget(
height: height,
offset: Offset(0.0, topPadding),
child: _FlexibleSpaceHeaderOpacity(
// IOS is relying on this semantics node to correctly traverse
// through the app bar when it is collapsed.
alwaysIncludeSemantics: true,
opacity: opacity,
child: background,
),
),
);
}
}
/// [_FlexibleSpaceHeaderOpacity]
class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget {
const _FlexibleSpaceHeaderOpacity({
required this.opacity,
required super.child,
required this.alwaysIncludeSemantics,
});
final double opacity;
final bool alwaysIncludeSemantics;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderFlexibleSpaceHeaderOpacity(
opacity: opacity,
alwaysIncludeSemantics: alwaysIncludeSemantics,
);
}
@override
void updateRenderObject(
BuildContext context,
covariant _RenderFlexibleSpaceHeaderOpacity renderObject,
) {
renderObject
..alwaysIncludeSemantics = alwaysIncludeSemantics
..opacity = opacity;
}
}
class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity {
_RenderFlexibleSpaceHeaderOpacity({
super.opacity,
super.alwaysIncludeSemantics,
});
@override
bool get isRepaintBoundary => false;
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) {
return;
}
if ((opacity * 255).roundToDouble() <= 0) {
layer = null;
return;
}
assert(needsCompositing);
layer = context.pushOpacity(
offset,
(opacity * 255).round(),
super.paint,
oldLayer: layer as OpacityLayer?,
);
assert(() {
layer!.debugCreator = debugCreator;
return true;
}());
}
}

View File

@@ -0,0 +1,285 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/sliver_persistent_header.dart';
import 'package:PiliPlus/common/widgets/only_layout_widget.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart' hide LayoutCallback;
import 'package:flutter/widgets.dart'
hide SliverPersistentHeader, SliverPersistentHeaderDelegate;
/// ref [SliverPersistentHeader]
Rect? _trim(
Rect? original, {
double top = -double.infinity,
double right = double.infinity,
double bottom = double.infinity,
double left = -double.infinity,
}) => original?.intersect(Rect.fromLTRB(left, top, right, bottom));
abstract class RenderSliverPersistentHeader extends RenderSliver
with RenderObjectWithChildMixin<RenderBox>, RenderSliverHelpers {
RenderSliverPersistentHeader({RenderBox? child}) {
this.child = child;
}
SliverPersistentHeaderElement? element;
double get minExtent =>
(element!.widget as SliverPinnedHeader).delegate.minExtent;
bool _needsUpdateChild = true;
double get lastShrinkOffset => _lastShrinkOffset;
double _lastShrinkOffset = 0.0;
bool get lastOverlapsContent => _lastOverlapsContent;
bool _lastOverlapsContent = false;
@protected
void updateChild(
double shrinkOffset,
bool overlapsContent,
double? maxExtent,
) {
assert(element != null);
element!.build(shrinkOffset, overlapsContent, maxExtent);
}
@override
void markNeedsLayout() {
_needsUpdateChild = true;
super.markNeedsLayout();
}
@protected
void updateChildIfNeeded(
double scrollOffset,
double? maxExtent, {
bool overlapsContent = false,
}) {
final double shrinkOffset = maxExtent == null
? scrollOffset
: math.min(scrollOffset, maxExtent);
if (_needsUpdateChild ||
_lastShrinkOffset != shrinkOffset ||
_lastOverlapsContent != overlapsContent) {
invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
assert(constraints == this.constraints);
updateChild(shrinkOffset, overlapsContent, maxExtent);
});
_lastShrinkOffset = shrinkOffset;
_lastOverlapsContent = overlapsContent;
_needsUpdateChild = false;
}
}
@override
double childMainAxisPosition(covariant RenderObject child) =>
super.childMainAxisPosition(child);
@override
bool hitTestChildren(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
assert(geometry!.hitTestExtent > 0.0);
if (child != null) {
return hitTestBoxChild(
BoxHitTestResult.wrap(result),
child!,
mainAxisPosition: mainAxisPosition,
crossAxisPosition: crossAxisPosition,
);
}
return false;
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
applyPaintTransformForBoxChild(child as RenderBox, transform);
}
void triggerRebuild() {
markNeedsLayout();
}
}
class SliverPinnedHeader extends RenderObjectWidget {
const SliverPinnedHeader({
super.key,
required this.delegate,
this.onPerformLayout,
});
final SliverPersistentHeaderDelegate delegate;
final LayoutCallback? onPerformLayout;
@override
SliverPersistentHeaderElement createElement() =>
SliverPersistentHeaderElement(this);
@override
RenderSliverPinnedHeader createRenderObject(BuildContext context) {
return RenderSliverPinnedHeader(onPerformLayout: onPerformLayout);
}
@override
void updateRenderObject(
BuildContext context,
RenderSliverPinnedHeader renderObject,
) {
renderObject.onPerformLayout = onPerformLayout;
}
}
class RenderSliverPinnedHeader extends RenderSliverPersistentHeader {
RenderSliverPinnedHeader({
super.child,
this.onPerformLayout,
});
LayoutCallback? onPerformLayout;
({double crossAxisExtent, double maxExtent})? _maxExtent;
double? get maxExtent => _maxExtent?.maxExtent;
void _rawLayout() {
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
_maxExtent = (
crossAxisExtent: constraints.crossAxisExtent,
maxExtent: child!.size.height,
);
onPerformLayout?.call(child!.size);
}
void _layout() {
final double shrinkOffset = math.min(
constraints.scrollOffset,
_maxExtent!.maxExtent,
);
child!.layout(
constraints.asBoxConstraints(
maxExtent: math.max(minExtent, _maxExtent!.maxExtent - shrinkOffset),
),
parentUsesSize: true,
);
}
@override
void performLayout() {
final constraints = this.constraints;
final bool overlapsContent = constraints.overlap > 0.0;
if (_maxExtent == null) {
updateChildIfNeeded(
constraints.scrollOffset,
_maxExtent?.maxExtent,
overlapsContent: overlapsContent,
);
_rawLayout();
} else {
if (_maxExtent!.crossAxisExtent == constraints.crossAxisExtent) {
updateChildIfNeeded(
constraints.scrollOffset,
_maxExtent?.maxExtent,
overlapsContent: overlapsContent,
);
_layout();
} else {
_needsUpdateChild = true;
updateChildIfNeeded(
constraints.scrollOffset,
null,
overlapsContent: overlapsContent,
);
_rawLayout();
if (constraints.scrollOffset > 0.0) {
_needsUpdateChild = true;
updateChildIfNeeded(
constraints.scrollOffset,
_maxExtent?.maxExtent,
overlapsContent: overlapsContent,
);
_layout();
}
}
}
final childExtent = child!.size.height;
final maxExtent = _maxExtent!.maxExtent;
final double effectiveRemainingPaintExtent = math.max(
0,
constraints.remainingPaintExtent - constraints.overlap,
);
final double layoutExtent = clampDouble(
maxExtent - constraints.scrollOffset,
0.0,
effectiveRemainingPaintExtent,
);
geometry = SliverGeometry(
scrollExtent: maxExtent,
paintOrigin: constraints.overlap,
paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
layoutExtent: layoutExtent,
maxPaintExtent: maxExtent,
maxScrollObstructionExtent: minExtent,
cacheExtent: layoutExtent > 0.0
? -constraints.cacheOrigin + layoutExtent
: layoutExtent,
hasVisualOverflow: false,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
context.paintChild(child!, offset);
}
}
@override
double childMainAxisPosition(RenderBox child) => 0.0;
@override
void showOnScreen({
RenderObject? descendant,
Rect? rect,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
}) {
final Rect? localBounds = descendant != null
? MatrixUtils.transformRect(
descendant.getTransformTo(this),
rect ?? descendant.paintBounds,
)
: rect;
final Rect? newRect = _trim(localBounds, top: 0);
super.showOnScreen(
descendant: this,
rect: newRect,
duration: duration,
curve: curve,
);
}
}

View File

@@ -0,0 +1,148 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/rendering/sliver_persistent_header.dart';
import 'package:flutter/widgets.dart';
/// ref [SliverPersistentHeader]
abstract class SliverPersistentHeaderDelegate {
const SliverPersistentHeaderDelegate();
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
double? maxExtent,
);
double get minExtent;
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);
}
class SliverPersistentHeaderElement extends RenderObjectElement {
SliverPersistentHeaderElement(
SliverPinnedHeader super.widget,
);
@override
RenderSliverPinnedHeader get renderObject =>
super.renderObject as RenderSliverPinnedHeader;
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
renderObject.element = this;
}
@override
void unmount() {
renderObject.element = null;
super.unmount();
}
@override
void update(SliverPinnedHeader newWidget) {
final oldWidget = widget as SliverPinnedHeader;
super.update(newWidget);
final SliverPersistentHeaderDelegate newDelegate = newWidget.delegate;
final SliverPersistentHeaderDelegate oldDelegate = oldWidget.delegate;
if (newDelegate != oldDelegate &&
(newDelegate.runtimeType != oldDelegate.runtimeType ||
newDelegate.shouldRebuild(oldDelegate))) {
final RenderSliverPinnedHeader renderObject = this.renderObject;
_updateChild(
newDelegate,
renderObject.lastShrinkOffset,
renderObject.lastOverlapsContent,
renderObject.maxExtent,
);
renderObject.triggerRebuild();
}
}
@override
void performRebuild() {
super.performRebuild();
renderObject.triggerRebuild();
}
Element? child;
void _updateChild(
SliverPersistentHeaderDelegate delegate,
double shrinkOffset,
bool overlapsContent,
double? maxExtent,
) {
final Widget newWidget = delegate.build(
this,
shrinkOffset,
overlapsContent,
maxExtent,
);
child = updateChild(child, newWidget, null);
}
void build(double shrinkOffset, bool overlapsContent, double? maxExtent) {
owner!.buildScope(this, () {
final sliverPersistentHeaderRenderObjectWidget =
widget as SliverPinnedHeader;
_updateChild(
sliverPersistentHeaderRenderObjectWidget.delegate,
shrinkOffset,
overlapsContent,
maxExtent,
);
});
}
@override
void forgetChild(Element child) {
assert(child == this.child);
this.child = null;
super.forgetChild(child);
}
@override
void insertRenderObjectChild(covariant RenderBox child, Object? slot) {
assert(renderObject.debugValidateChild(child));
renderObject.child = child;
}
@override
void moveRenderObjectChild(
covariant RenderObject child,
Object? oldSlot,
Object? newSlot,
) {
assert(false);
}
@override
void removeRenderObjectChild(covariant RenderObject child, Object? slot) {
renderObject.child = null;
}
@override
void visitChildren(ElementVisitor visitor) {
if (child != null) {
visitor(child!);
}
}
}

View File

@@ -1,173 +0,0 @@
import 'package:PiliPlus/common/widgets/only_layout_widget.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class DynamicSliverAppBarMedium extends StatefulWidget {
const DynamicSliverAppBarMedium({
this.flexibleSpace,
super.key,
this.leading,
this.automaticallyImplyLeading = true,
this.title,
this.actions,
this.bottom,
this.elevation,
this.scrolledUnderElevation,
this.shadowColor,
this.surfaceTintColor,
this.forceElevated = false,
this.backgroundColor,
this.backgroundGradient,
this.foregroundColor,
this.iconTheme,
this.actionsIconTheme,
this.primary = true,
this.centerTitle,
this.excludeHeaderSemantics = false,
this.titleSpacing,
this.collapsedHeight,
this.expandedHeight,
this.floating = false,
this.pinned = false,
this.snap = false,
this.stretch = false,
this.stretchTriggerOffset = 100.0,
this.onStretchTrigger,
this.shape,
this.toolbarHeight = kToolbarHeight,
this.leadingWidth,
this.toolbarTextStyle,
this.titleTextStyle,
this.systemOverlayStyle,
this.forceMaterialTransparency = false,
this.clipBehavior,
this.appBarClipper,
this.afterCalc,
});
final ValueChanged<double>? afterCalc;
final Widget? flexibleSpace;
final Widget? leading;
final bool automaticallyImplyLeading;
final Widget? title;
final List<Widget>? actions;
final PreferredSizeWidget? bottom;
final double? elevation;
final double? scrolledUnderElevation;
final Color? shadowColor;
final Color? surfaceTintColor;
final bool forceElevated;
final Color? backgroundColor;
/// If backgroundGradient is non null, backgroundColor will be ignored
final LinearGradient? backgroundGradient;
final Color? foregroundColor;
final IconThemeData? iconTheme;
final IconThemeData? actionsIconTheme;
final bool primary;
final bool? centerTitle;
final bool excludeHeaderSemantics;
final double? titleSpacing;
final double? expandedHeight;
final double? collapsedHeight;
final bool floating;
final bool pinned;
final ShapeBorder? shape;
final double toolbarHeight;
final double? leadingWidth;
final TextStyle? toolbarTextStyle;
final TextStyle? titleTextStyle;
final SystemUiOverlayStyle? systemOverlayStyle;
final bool forceMaterialTransparency;
final Clip? clipBehavior;
final bool snap;
final bool stretch;
final double stretchTriggerOffset;
final AsyncCallback? onStretchTrigger;
final CustomClipper<Path>? appBarClipper;
@override
State<DynamicSliverAppBarMedium> createState() =>
_DynamicSliverAppBarMediumState();
}
class _DynamicSliverAppBarMediumState extends State<DynamicSliverAppBarMedium> {
final GlobalKey _key = GlobalKey();
double? _height;
double? _width;
late double _topPadding;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_topPadding = MediaQuery.viewPaddingOf(context).top;
final width = MediaQuery.widthOf(context);
if (_width != width) {
_width = width;
_height = null;
}
}
@override
Widget build(BuildContext context) {
if (_height == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_height =
(_key.currentContext!.findRenderObject() as RenderBox).size.height;
widget.afterCalc?.call(_height!);
setState(() {});
});
return SliverToBoxAdapter(
child: OnlyLayoutWidget(
child: UnconstrainedBox(
alignment: Alignment.topLeft,
child: SizedBox(
key: _key,
width: _width,
child: widget.flexibleSpace,
),
),
),
);
}
return SliverAppBar.medium(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
title: widget.title,
actions: widget.actions,
bottom: widget.bottom,
elevation: widget.elevation,
scrolledUnderElevation: widget.scrolledUnderElevation,
shadowColor: widget.shadowColor,
surfaceTintColor: widget.surfaceTintColor,
forceElevated: widget.forceElevated,
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
iconTheme: widget.iconTheme,
actionsIconTheme: widget.actionsIconTheme,
primary: widget.primary,
centerTitle: widget.centerTitle,
excludeHeaderSemantics: widget.excludeHeaderSemantics,
titleSpacing: widget.titleSpacing,
floating: widget.floating,
pinned: widget.pinned,
snap: widget.snap,
stretch: widget.stretch,
stretchTriggerOffset: widget.stretchTriggerOffset,
onStretchTrigger: widget.onStretchTrigger,
shape: widget.shape,
toolbarHeight: kToolbarHeight,
collapsedHeight: kToolbarHeight + _topPadding + 1,
expandedHeight: _height! - _topPadding,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
systemOverlayStyle: widget.systemOverlayStyle,
forceMaterialTransparency: widget.forceMaterialTransparency,
clipBehavior: widget.clipBehavior,
flexibleSpace: FlexibleSpaceBar(background: widget.flexibleSpace),
);
}
}

View File

@@ -0,0 +1,385 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ChatListView extends BoxScrollView {
ChatListView.separated({
super.key,
super.scrollDirection,
super.controller,
super.primary,
super.physics,
super.padding,
required NullableIndexedWidgetBuilder itemBuilder,
@Deprecated(
'Use findItemIndexCallback instead. '
'findChildIndexCallback returns child indices (which include separators), '
'while findItemIndexCallback returns item indices (which do not). '
'If you were multiplying results by 2 to account for separators, '
'you can remove that workaround when migrating to findItemIndexCallback. '
'This feature was deprecated after v3.37.0-1.0.pre.',
)
ChildIndexGetter? findChildIndexCallback,
ChildIndexGetter? findItemIndexCallback,
required IndexedWidgetBuilder separatorBuilder,
required int itemCount,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
super.cacheExtent,
super.dragStartBehavior,
super.keyboardDismissBehavior,
super.restorationId,
super.clipBehavior,
super.hitTestBehavior,
}) : assert(itemCount >= 0),
assert(
findItemIndexCallback == null || findChildIndexCallback == null,
'Cannot provide both findItemIndexCallback and findChildIndexCallback. '
'Use findItemIndexCallback as findChildIndexCallback is deprecated.',
),
childrenDelegate = SliverChildBuilderDelegate(
(BuildContext context, int index) {
final int itemIndex = index ~/ 2;
if (index.isEven) {
return itemBuilder(context, itemIndex);
}
return separatorBuilder(context, itemIndex);
},
findChildIndexCallback: findItemIndexCallback != null
? (Key key) {
final int? itemIndex = findItemIndexCallback(key);
return itemIndex == null ? null : itemIndex * 2;
}
: findChildIndexCallback,
childCount: _computeActualChildCount(itemCount),
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
addSemanticIndexes: addSemanticIndexes,
semanticIndexCallback: (Widget widget, int index) {
return index.isEven ? index ~/ 2 : null;
},
),
super(semanticChildCount: itemCount, reverse: true);
final SliverChildDelegate childrenDelegate;
@override
Widget buildChildLayout(BuildContext context) {
return SliverChatList(delegate: childrenDelegate);
}
static int _computeActualChildCount(int itemCount) {
return math.max(0, itemCount * 2 - 1);
}
}
class SliverChatList extends SliverMultiBoxAdaptorWidget {
const SliverChatList({super.key, required super.delegate});
@override
SliverMultiBoxAdaptorElement createElement() =>
SliverMultiBoxAdaptorElement(this, replaceMovedChildren: true);
@override
RenderSliverChatList createRenderObject(BuildContext context) {
final element = context as SliverMultiBoxAdaptorElement;
return RenderSliverChatList(childManager: element);
}
}
class RenderSliverChatList extends RenderSliverMultiBoxAdaptor
with ExtendedRenderObjectMixin {
RenderSliverChatList({required super.childManager});
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
childManager
..didStartLayout()
..setDidUnderflow(false);
final double scrollOffset =
constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetEndScrollOffset = scrollOffset + remainingExtent;
final BoxConstraints childConstraints = constraints.asBoxConstraints();
var leadingGarbage = 0;
var trailingGarbage = 0;
var reachedEnd = false;
if (firstChild == null) {
if (!addInitialChild()) {
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
}
///
handleCloseToTrailingBegin();
RenderBox? leadingChildWithLayout, trailingChildWithLayout;
RenderBox? earliestUsefulChild = firstChild;
if (childScrollOffset(firstChild!) == null) {
var leadingChildrenWithoutLayoutOffset = 0;
while (earliestUsefulChild != null &&
childScrollOffset(earliestUsefulChild) == null) {
earliestUsefulChild = childAfter(earliestUsefulChild);
leadingChildrenWithoutLayoutOffset += 1;
}
collectGarbage(leadingChildrenWithoutLayoutOffset, 0);
if (firstChild == null) {
if (!addInitialChild()) {
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
}
}
earliestUsefulChild = firstChild;
for (
double earliestScrollOffset = childScrollOffset(earliestUsefulChild!)!;
earliestScrollOffset > scrollOffset;
earliestScrollOffset = childScrollOffset(earliestUsefulChild)!
) {
earliestUsefulChild = insertAndLayoutLeadingChild(
childConstraints,
parentUsesSize: true,
);
if (earliestUsefulChild == null) {
final childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
if (scrollOffset == 0.0) {
firstChild!.layout(childConstraints, parentUsesSize: true);
earliestUsefulChild = firstChild;
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
break;
} else {
geometry = SliverGeometry(scrollOffsetCorrection: -scrollOffset);
return;
}
}
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild!);
if (firstChildScrollOffset < -precisionErrorTolerance) {
geometry = SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
);
final childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
return;
}
final childParentData =
earliestUsefulChild.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = firstChildScrollOffset;
assert(earliestUsefulChild == firstChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
}
assert(childScrollOffset(firstChild!)! > -precisionErrorTolerance);
if (scrollOffset < precisionErrorTolerance) {
while (indexOf(firstChild!) > 0) {
final double earliestScrollOffset = childScrollOffset(firstChild!)!;
earliestUsefulChild = insertAndLayoutLeadingChild(
childConstraints,
parentUsesSize: true,
);
assert(earliestUsefulChild != null);
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild!);
final childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
if (firstChildScrollOffset < -precisionErrorTolerance) {
geometry = SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
);
return;
}
}
}
assert(earliestUsefulChild == firstChild);
assert(childScrollOffset(earliestUsefulChild!)! <= scrollOffset);
if (leadingChildWithLayout == null) {
earliestUsefulChild!.layout(childConstraints, parentUsesSize: true);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout = earliestUsefulChild;
}
var inLayoutRange = true;
var child = earliestUsefulChild;
int index = indexOf(child!);
double endScrollOffset = childScrollOffset(child)! + paintExtentOf(child);
bool advance() {
assert(child != null);
if (child == trailingChildWithLayout) {
inLayoutRange = false;
}
child = childAfter(child!);
if (child == null) {
inLayoutRange = false;
}
index += 1;
if (!inLayoutRange) {
if (child == null || indexOf(child!) != index) {
child = insertAndLayoutChild(
childConstraints,
after: trailingChildWithLayout,
parentUsesSize: true,
);
if (child == null) {
return false;
}
} else {
child!.layout(childConstraints, parentUsesSize: true);
}
trailingChildWithLayout = child;
}
assert(child != null);
final childParentData =
child!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = endScrollOffset;
assert(childParentData.index == index);
endScrollOffset = childScrollOffset(child!)! + paintExtentOf(child!);
return true;
}
while (endScrollOffset < scrollOffset) {
leadingGarbage += 1;
if (!advance()) {
assert(leadingGarbage == childCount);
assert(child == null);
collectGarbage(leadingGarbage - 1, 0);
assert(firstChild == lastChild);
final double extent =
childScrollOffset(lastChild!)! + paintExtentOf(lastChild!);
geometry = SliverGeometry(scrollExtent: extent, maxPaintExtent: extent);
return;
}
}
while (endScrollOffset < targetEndScrollOffset) {
if (!advance()) {
reachedEnd = true;
break;
}
}
if (child != null) {
child = childAfter(child!);
while (child != null) {
trailingGarbage += 1;
child = childAfter(child!);
}
}
collectGarbage(leadingGarbage, trailingGarbage);
assert(debugAssertChildListIsNonEmptyAndContiguous());
final double estimatedMaxScrollOffset;
///
endScrollOffset = handleCloseToTrailingEnd(endScrollOffset);
if (reachedEnd) {
estimatedMaxScrollOffset = endScrollOffset;
} else {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: indexOf(firstChild!),
lastIndex: indexOf(lastChild!),
leadingScrollOffset: childScrollOffset(firstChild!),
trailingScrollOffset: endScrollOffset,
);
assert(
estimatedMaxScrollOffset >=
endScrollOffset - childScrollOffset(firstChild!)!,
);
}
final double firstChildScrollOffset = childScrollOffset(firstChild!)!;
double paintExtent = calculatePaintOffset(
constraints,
from: firstChildScrollOffset,
to: endScrollOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: firstChildScrollOffset,
to: endScrollOffset,
);
final double targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
///
paintExtent += _closeToTrailingDistance;
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
hasVisualOverflow:
endScrollOffset > targetEndScrollOffsetForPaint ||
constraints.scrollOffset > 0.0,
);
if (estimatedMaxScrollOffset == endScrollOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
}
const double kChatListPadding = 14.0;
/// from https://github.com/fluttercandies/extended_list
mixin ExtendedRenderObjectMixin on RenderSliverMultiBoxAdaptor {
void handleCloseToTrailingBegin() {
_closeToTrailingDistance = 0.0;
}
double handleCloseToTrailingEnd(double endScrollOffset) {
final extent = constraints.remainingPaintExtent - kChatListPadding;
if (endScrollOffset < extent) {
_closeToTrailingDistance = extent - endScrollOffset;
return extent;
}
return endScrollOffset;
}
double _closeToTrailingDistance = 0.0;
@override
double? childScrollOffset(RenderObject child) {
return (super.childScrollOffset(child) ?? 0.0) + _closeToTrailingDistance;
}
}

View File

@@ -17,10 +17,12 @@ library;
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart'
hide DraggableScrollableSheet, LayoutBuilder;
/// Controls a [DraggableScrollableSheet].
///
@@ -112,11 +114,10 @@ class DraggableScrollableController extends ChangeNotifier {
_assertAttached();
assert(size >= 0 && size <= 1);
assert(duration != Duration.zero);
final AnimationController animationController =
AnimationController.unbounded(
vsync: _attachedController!.position.context.vsync,
value: _attachedController!.extent.currentSize,
);
final animationController = AnimationController.unbounded(
vsync: _attachedController!.position.context.vsync,
value: _attachedController!.extent.currentSize,
);
_animationControllers.add(animationController);
_attachedController!.position.goIdle();
// This disables any snapping until the next user interaction with the sheet.
@@ -583,7 +584,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
}
List<double> _impliedSnapSizes() {
for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
for (var index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
final double snapSize = widget.snapSizes![index];
assert(
snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize,
@@ -684,11 +685,11 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
// have changed when the widget was updated.
WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) {
for (
int index = 0;
var index = 0;
index < _scrollController.positions.length;
index++
) {
final _DraggableScrollableSheetScrollPosition position =
final position =
_scrollController.positions.elementAt(index)
as _DraggableScrollableSheetScrollPosition;
position.goBallistic(0);
@@ -702,7 +703,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
.asMap()
.keys
.map((int index) {
final String snapSizeString = widget.snapSizes![index].toString();
final snapSizeString = widget.snapSizes![index].toString();
if (index == invalidIndex) {
return '>>> $snapSizeString <<<';
}
@@ -917,14 +918,10 @@ class _DraggableScrollableSheetScrollPosition
);
}
final AnimationController ballisticController =
AnimationController.unbounded(
debugLabel: objectRuntimeType(
this,
'_DraggableScrollableSheetPosition',
),
vsync: context.vsync,
);
final ballisticController = AnimationController.unbounded(
debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'),
vsync: context.vsync,
);
_ballisticControllers.add(ballisticController);
double lastPosition = extent.currentPixels;
@@ -1080,8 +1077,7 @@ class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
return false;
}
assert(widget is _InheritedResetNotifier);
final _InheritedResetNotifier inheritedNotifier =
widget as _InheritedResetNotifier;
final inheritedNotifier = widget as _InheritedResetNotifier;
final bool wasCalled = inheritedNotifier.notifier!._wasCalled;
inheritedNotifier.notifier!._wasCalled = false;
return wasCalled;
@@ -1158,6 +1154,10 @@ class _SnappingSimulation extends Simulation {
return pixelSnapSizes.first;
}
final double nextSize = pixelSnapSizes[indexOfNextSize];
// If already snapped - keep this as target size
if (nextSize == position) {
return nextSize;
}
final double previousSize = pixelSnapSizes[indexOfNextSize - 1];
if (initialVelocity.abs() <= tolerance.velocity) {
// If velocity is zero, snap to the nearest snap size with the minimum velocity.

View File

@@ -17,10 +17,12 @@ library;
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart'
hide DraggableScrollableSheet, LayoutBuilder;
/// Controls a [DraggableScrollableSheet].
///
@@ -112,11 +114,10 @@ class DraggableScrollableController extends ChangeNotifier {
_assertAttached();
assert(size >= 0 && size <= 1);
assert(duration != Duration.zero);
final AnimationController animationController =
AnimationController.unbounded(
vsync: _attachedController!.position.context.vsync,
value: _attachedController!.extent.currentSize,
);
final animationController = AnimationController.unbounded(
vsync: _attachedController!.position.context.vsync,
value: _attachedController!.extent.currentSize,
);
_animationControllers.add(animationController);
_attachedController!.position.goIdle();
// This disables any snapping until the next user interaction with the sheet.
@@ -587,7 +588,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
}
List<double> _impliedSnapSizes() {
for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
for (var index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
final double snapSize = widget.snapSizes![index];
assert(
snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize,
@@ -688,11 +689,11 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
// have changed when the widget was updated.
WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) {
for (
int index = 0;
var index = 0;
index < _scrollController.positions.length;
index++
) {
final _DraggableScrollableSheetScrollPosition position =
final position =
_scrollController.positions.elementAt(index)
as _DraggableScrollableSheetScrollPosition;
position.goBallistic(0);
@@ -706,7 +707,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
.asMap()
.keys
.map((int index) {
final String snapSizeString = widget.snapSizes![index].toString();
final snapSizeString = widget.snapSizes![index].toString();
if (index == invalidIndex) {
return '>>> $snapSizeString <<<';
}
@@ -920,14 +921,10 @@ class _DraggableScrollableSheetScrollPosition
);
}
final AnimationController ballisticController =
AnimationController.unbounded(
debugLabel: objectRuntimeType(
this,
'_DraggableScrollableSheetPosition',
),
vsync: context.vsync,
);
final ballisticController = AnimationController.unbounded(
debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'),
vsync: context.vsync,
);
_ballisticControllers.add(ballisticController);
double lastPosition = extent.currentPixels;
@@ -1082,8 +1079,7 @@ class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
return false;
}
assert(widget is _InheritedResetNotifier);
final _InheritedResetNotifier inheritedNotifier =
widget as _InheritedResetNotifier;
final inheritedNotifier = widget as _InheritedResetNotifier;
final bool wasCalled = inheritedNotifier.notifier!._wasCalled;
inheritedNotifier.notifier!._wasCalled = false;
return wasCalled;
@@ -1160,6 +1156,10 @@ class _SnappingSimulation extends Simulation {
return pixelSnapSizes.first;
}
final double nextSize = pixelSnapSizes[indexOfNextSize];
// If already snapped - keep this as target size
if (nextSize == position) {
return nextSize;
}
final double previousSize = pixelSnapSizes[indexOfNextSize - 1];
if (initialVelocity.abs() <= tolerance.velocity) {
// If velocity is zero, snap to the nearest snap size with the minimum velocity.

View File

@@ -1,789 +0,0 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'elevated_button_theme.dart';
/// @docImport 'menu_anchor.dart';
/// @docImport 'text_button_theme.dart';
/// @docImport 'text_theme.dart';
/// @docImport 'theme.dart';
library;
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/flutter/dyn/ink_well.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide InkWell;
import 'package:flutter/rendering.dart';
/// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object.
///
/// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf].
///
/// See also:
/// * [ElevatedButton], a filled button whose material elevates when pressed.
/// * [FilledButton], a filled button that doesn't elevate when pressed.
/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color.
/// * [OutlinedButton], a button with an outlined border and no fill color.
/// * [TextButton], a button with no outline or fill color.
/// * <https://m3.material.io/components/buttons/overview>, an overview of each of
/// the Material Design button types and how they should be used in designs.
abstract class ButtonStyleButton extends StatefulWidget {
/// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions.
const ButtonStyleButton({
super.key,
required this.onPressed,
required this.onLongPress,
required this.onHover,
required this.onFocusChange,
required this.style,
required this.focusNode,
required this.autofocus,
required this.clipBehavior,
this.statesController,
this.isSemanticButton = true,
@Deprecated(
'Remove this parameter as it is now ignored. '
'Use ButtonStyle.iconAlignment instead. '
'This feature was deprecated after v3.28.0-1.0.pre.',
)
this.iconAlignment,
this.tooltip,
required this.child,
});
/// Called when the button is tapped or otherwise activated.
///
/// If this callback and [onLongPress] are null, then the button will be disabled.
///
/// See also:
///
/// * [enabled], which is true if the button is enabled.
final VoidCallback? onPressed;
/// Called when the button is long-pressed.
///
/// If this callback and [onPressed] are null, then the button will be disabled.
///
/// See also:
///
/// * [enabled], which is true if the button is enabled.
final VoidCallback? onLongPress;
/// Called when a pointer enters or exits the button response area.
///
/// The value passed to the callback is true if a pointer has entered this
/// part of the material and false if a pointer has exited this part of the
/// material.
final ValueChanged<bool>? onHover;
/// Handler called when the focus changes.
///
/// Called with true if this widget's node gains focus, and false if it loses
/// focus.
final ValueChanged<bool>? onFocusChange;
/// Customizes this button's appearance.
///
/// Non-null properties of this style override the corresponding
/// properties in [themeStyleOf] and [defaultStyleOf]. [WidgetStateProperty]s
/// that resolve to non-null values will similarly override the corresponding
/// [WidgetStateProperty]s in [themeStyleOf] and [defaultStyleOf].
///
/// Null by default.
final ButtonStyle? style;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none] unless [ButtonStyle.backgroundBuilder] or
/// [ButtonStyle.foregroundBuilder] is specified. In those
/// cases the default is [Clip.antiAlias].
final Clip? clipBehavior;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// {@macro flutter.material.inkwell.statesController}
final WidgetStatesController? statesController;
/// Determine whether this subtree represents a button.
///
/// If this is null, the screen reader will not announce "button" when this
/// is focused. This is useful for [MenuItemButton] and [SubmenuButton] when we
/// traverse the menu system.
///
/// Defaults to true.
final bool? isSemanticButton;
/// {@macro flutter.material.ButtonStyleButton.iconAlignment}
@Deprecated(
'Remove this parameter as it is now ignored. '
'Use ButtonStyle.iconAlignment instead. '
'This feature was deprecated after v3.28.0-1.0.pre.',
)
final IconAlignment? iconAlignment;
/// Text that describes the action that will occur when the button is pressed or
/// hovered over.
///
/// This text is displayed when the user long-presses or hovers over the button
/// in a tooltip. This string is also used for accessibility.
///
/// If null, the button will not display a tooltip.
final String? tooltip;
/// Typically the button's label.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget? child;
/// Returns a [ButtonStyle] that's based primarily on the [Theme]'s
/// [ThemeData.textTheme] and [ThemeData.colorScheme], but has most values
/// filled out (non-null).
///
/// The returned style can be overridden by the [style] parameter and by the
/// style returned by [themeStyleOf] that some button-specific themes like
/// [TextButtonTheme] or [ElevatedButtonTheme] override. For example the
/// default style of the [TextButton] subclass can be overridden with its
/// [TextButton.style] constructor parameter, or with a [TextButtonTheme].
///
/// Concrete button subclasses should return a [ButtonStyle] with as many
/// non-null properties as possible, where all of the non-null
/// [WidgetStateProperty] properties resolve to non-null values.
///
/// ## Properties that can be null
///
/// Some properties, like [ButtonStyle.fixedSize] would override other values
/// in the same [ButtonStyle] if set, so they are allowed to be null. Here is
/// a summary of properties that are allowed to be null when returned in the
/// [ButtonStyle] returned by this function, an why:
///
/// - [ButtonStyle.fixedSize] because it would override other values in the
/// same [ButtonStyle], like [ButtonStyle.maximumSize].
/// - [ButtonStyle.side] because null is a valid value for a button that has
/// no side. [OutlinedButton] returns a non-null default for this, however.
/// - [ButtonStyle.backgroundBuilder] and [ButtonStyle.foregroundBuilder]
/// because they would override the [ButtonStyle.foregroundColor] and
/// [ButtonStyle.backgroundColor] of the same [ButtonStyle].
///
/// See also:
///
/// * [themeStyleOf], returns the ButtonStyle of this button's component
/// theme.
@protected
ButtonStyle defaultStyleOf(BuildContext context);
/// Returns the ButtonStyle that belongs to the button's component theme.
///
/// The returned style can be overridden by the [style] parameter.
///
/// Concrete button subclasses should return the ButtonStyle for the
/// nearest subclass-specific inherited theme, and if no such theme
/// exists, then the same value from the overall [Theme].
///
/// See also:
///
/// * [defaultStyleOf], Returns the default [ButtonStyle] for this button.
@protected
ButtonStyle? themeStyleOf(BuildContext context);
/// Whether the button is enabled or disabled.
///
/// Buttons are disabled by default. To enable a button, set its [onPressed]
/// or [onLongPress] properties to a non-null value.
bool get enabled => onPressed != null || onLongPress != null;
@override
State<ButtonStyleButton> createState() => _ButtonStyleState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(
FlagProperty('enabled', value: enabled, ifFalse: 'disabled'),
)
..add(
DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null),
)
..add(
DiagnosticsProperty<FocusNode>(
'focusNode',
focusNode,
defaultValue: null,
),
);
}
/// Returns null if [value] is null, otherwise `WidgetStatePropertyAll<T>(value)`.
///
/// A convenience method for subclasses.
static WidgetStateProperty<T>? allOrNull<T>(T? value) =>
value == null ? null : WidgetStatePropertyAll<T>(value);
/// Returns null if [enabled] and [disabled] are null.
/// Otherwise, returns a [WidgetStateProperty] that resolves to [disabled]
/// when [WidgetState.disabled] is active, and [enabled] otherwise.
///
/// A convenience method for subclasses.
static WidgetStateProperty<Color?>? defaultColor(
Color? enabled,
Color? disabled,
) {
if ((enabled ?? disabled) == null) {
return null;
}
return WidgetStateProperty<Color?>.fromMap(<WidgetStatesConstraint, Color?>{
WidgetState.disabled: disabled,
WidgetState.any: enabled,
});
}
/// A convenience method used by subclasses in the framework, that returns an
/// interpolated value based on the [fontSizeMultiplier] parameter:
///
/// * 0 - 1 [geometry1x]
/// * 1 - 2 lerp([geometry1x], [geometry2x], [fontSizeMultiplier] - 1)
/// * 2 - 3 lerp([geometry2x], [geometry3x], [fontSizeMultiplier] - 2)
/// * otherwise [geometry3x]
///
/// This method is used by the framework for estimating the default paddings to
/// use on a button with a text label, when the system text scaling setting
/// changes. It's usually supplied with empirical [geometry1x], [geometry2x],
/// [geometry3x] values adjusted for different system text scaling values, when
/// the unscaled font size is set to 14.0 (the default [TextTheme.labelLarge]
/// value).
///
/// The `fontSizeMultiplier` argument, for historical reasons, is the default
/// font size specified in the [ButtonStyle], scaled by the ambient font
/// scaler, then divided by 14.0 (the default font size used in buttons).
static EdgeInsetsGeometry scaledPadding(
EdgeInsetsGeometry geometry1x,
EdgeInsetsGeometry geometry2x,
EdgeInsetsGeometry geometry3x,
double fontSizeMultiplier,
) {
return switch (fontSizeMultiplier) {
<= 1 => geometry1x,
< 2 => EdgeInsetsGeometry.lerp(
geometry1x,
geometry2x,
fontSizeMultiplier - 1,
)!,
< 3 => EdgeInsetsGeometry.lerp(
geometry2x,
geometry3x,
fontSizeMultiplier - 2,
)!,
_ => geometry3x,
};
}
}
/// The base [State] class for buttons whose style is defined by a [ButtonStyle] object.
///
/// See also:
///
/// * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State].
/// * [ElevatedButton], a filled button whose material elevates when pressed.
/// * [FilledButton], a filled ButtonStyleButton that doesn't elevate when pressed.
/// * [OutlinedButton], similar to [TextButton], but with an outline.
/// * [TextButton], a simple button without a shadow.
class _ButtonStyleState extends State<ButtonStyleButton>
with TickerProviderStateMixin {
AnimationController? controller;
double? elevation;
Color? backgroundColor;
WidgetStatesController? internalStatesController;
void handleStatesControllerChange() {
// Force a rebuild to resolve WidgetStateProperty properties
setState(() {});
}
WidgetStatesController get statesController =>
widget.statesController ?? internalStatesController!;
void initStatesController() {
if (widget.statesController == null) {
internalStatesController = WidgetStatesController();
}
statesController
..update(WidgetState.disabled, !widget.enabled)
..addListener(handleStatesControllerChange);
}
@override
void initState() {
super.initState();
initStatesController();
}
@override
void didUpdateWidget(ButtonStyleButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.statesController != oldWidget.statesController) {
oldWidget.statesController?.removeListener(handleStatesControllerChange);
if (widget.statesController != null) {
internalStatesController?.dispose();
internalStatesController = null;
}
initStatesController();
}
if (widget.enabled != oldWidget.enabled) {
statesController.update(WidgetState.disabled, !widget.enabled);
if (!widget.enabled) {
// The button may have been disabled while a press gesture is currently underway.
statesController.update(WidgetState.pressed, false);
}
}
}
@override
void dispose() {
statesController.removeListener(handleStatesControllerChange);
internalStatesController?.dispose();
controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final IconThemeData iconTheme = IconTheme.of(context);
final ButtonStyle? widgetStyle = widget.style;
final ButtonStyle? themeStyle = widget.themeStyleOf(context);
final ButtonStyle defaultStyle = widget.defaultStyleOf(context);
T? effectiveValue<T>(T? Function(ButtonStyle? style) getProperty) {
final T? widgetValue = getProperty(widgetStyle);
final T? themeValue = getProperty(themeStyle);
final T? defaultValue = getProperty(defaultStyle);
return widgetValue ?? themeValue ?? defaultValue;
}
T? resolve<T>(
WidgetStateProperty<T>? Function(ButtonStyle? style) getProperty,
) {
return effectiveValue((ButtonStyle? style) {
return getProperty(style)?.resolve(statesController.value);
});
}
Color? effectiveIconColor() {
return widgetStyle?.iconColor?.resolve(statesController.value) ??
themeStyle?.iconColor?.resolve(statesController.value) ??
widgetStyle?.foregroundColor?.resolve(statesController.value) ??
themeStyle?.foregroundColor?.resolve(statesController.value) ??
defaultStyle.iconColor?.resolve(statesController.value) ??
// Fallback to foregroundColor if iconColor is null.
defaultStyle.foregroundColor?.resolve(statesController.value);
}
final double? resolvedElevation = resolve<double?>(
(ButtonStyle? style) => style?.elevation,
);
final TextStyle? resolvedTextStyle = resolve<TextStyle?>(
(ButtonStyle? style) => style?.textStyle,
);
Color? resolvedBackgroundColor = resolve<Color?>(
(ButtonStyle? style) => style?.backgroundColor,
);
final Color? resolvedForegroundColor = resolve<Color?>(
(ButtonStyle? style) => style?.foregroundColor,
);
final Color? resolvedShadowColor = resolve<Color?>(
(ButtonStyle? style) => style?.shadowColor,
);
final Color? resolvedSurfaceTintColor = resolve<Color?>(
(ButtonStyle? style) => style?.surfaceTintColor,
);
final EdgeInsetsGeometry? resolvedPadding = resolve<EdgeInsetsGeometry?>(
(ButtonStyle? style) => style?.padding,
);
final Size? resolvedMinimumSize = resolve<Size?>(
(ButtonStyle? style) => style?.minimumSize,
);
final Size? resolvedFixedSize = resolve<Size?>(
(ButtonStyle? style) => style?.fixedSize,
);
final Size? resolvedMaximumSize = resolve<Size?>(
(ButtonStyle? style) => style?.maximumSize,
);
final Color? resolvedIconColor = effectiveIconColor();
final double? resolvedIconSize = resolve<double?>(
(ButtonStyle? style) => style?.iconSize,
);
final BorderSide? resolvedSide = resolve<BorderSide?>(
(ButtonStyle? style) => style?.side,
);
final OutlinedBorder? resolvedShape = resolve<OutlinedBorder?>(
(ButtonStyle? style) => style?.shape,
);
final WidgetStateMouseCursor mouseCursor = _MouseCursor(
(Set<WidgetState> states) => effectiveValue(
(ButtonStyle? style) => style?.mouseCursor?.resolve(states),
),
);
final WidgetStateProperty<Color?> overlayColor =
WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) => effectiveValue(
(ButtonStyle? style) => style?.overlayColor?.resolve(states),
),
);
final VisualDensity? resolvedVisualDensity = effectiveValue(
(ButtonStyle? style) => style?.visualDensity,
);
final MaterialTapTargetSize? resolvedTapTargetSize = effectiveValue(
(ButtonStyle? style) => style?.tapTargetSize,
);
final Duration? resolvedAnimationDuration = effectiveValue(
(ButtonStyle? style) => style?.animationDuration,
);
final bool resolvedEnableFeedback =
effectiveValue((ButtonStyle? style) => style?.enableFeedback) ?? true;
final AlignmentGeometry? resolvedAlignment = effectiveValue(
(ButtonStyle? style) => style?.alignment,
);
final Offset densityAdjustment = resolvedVisualDensity!.baseSizeAdjustment;
final InteractiveInkFeatureFactory? resolvedSplashFactory = effectiveValue(
(ButtonStyle? style) => style?.splashFactory,
);
final ButtonLayerBuilder? resolvedBackgroundBuilder = effectiveValue(
(ButtonStyle? style) => style?.backgroundBuilder,
);
final ButtonLayerBuilder? resolvedForegroundBuilder = effectiveValue(
(ButtonStyle? style) => style?.foregroundBuilder,
);
final Clip effectiveClipBehavior =
widget.clipBehavior ??
((resolvedBackgroundBuilder ?? resolvedForegroundBuilder) != null
? Clip.antiAlias
: Clip.none);
BoxConstraints effectiveConstraints = resolvedVisualDensity
.effectiveConstraints(
BoxConstraints(
minWidth: resolvedMinimumSize!.width,
minHeight: resolvedMinimumSize.height,
maxWidth: resolvedMaximumSize!.width,
maxHeight: resolvedMaximumSize.height,
),
);
if (resolvedFixedSize != null) {
final Size size = effectiveConstraints.constrain(resolvedFixedSize);
if (size.width.isFinite) {
effectiveConstraints = effectiveConstraints.copyWith(
minWidth: size.width,
maxWidth: size.width,
);
}
if (size.height.isFinite) {
effectiveConstraints = effectiveConstraints.copyWith(
minHeight: size.height,
maxHeight: size.height,
);
}
}
// Per the Material Design team: don't allow the VisualDensity
// adjustment to reduce the width of the left/right padding. If we
// did, VisualDensity.compact, the default for desktop/web, would
// reduce the horizontal padding to zero.
final double dy = densityAdjustment.dy;
final double dx = math.max(0, densityAdjustment.dx);
final EdgeInsetsGeometry padding = resolvedPadding!
.add(EdgeInsets.fromLTRB(dx, dy, dx, dy))
.clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity);
// If an opaque button's background is becoming translucent while its
// elevation is changing, change the elevation first. Material implicitly
// animates its elevation but not its color. SKIA renders non-zero
// elevations as a shadow colored fill behind the Material's background.
if (resolvedAnimationDuration! > Duration.zero &&
elevation != null &&
backgroundColor != null &&
elevation != resolvedElevation &&
backgroundColor!.value != resolvedBackgroundColor!.value &&
backgroundColor!.opacity == 1 &&
resolvedBackgroundColor.opacity < 1 &&
resolvedElevation == 0) {
if (controller?.duration != resolvedAnimationDuration) {
controller?.dispose();
controller =
AnimationController(
duration: resolvedAnimationDuration,
vsync: this,
)..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
setState(() {}); // Rebuild with the final background color.
}
});
}
resolvedBackgroundColor =
backgroundColor; // Defer changing the background color.
controller!.value = 0;
controller!.forward();
}
elevation = resolvedElevation;
backgroundColor = resolvedBackgroundColor;
Widget result = Padding(
padding: padding,
child: Align(
alignment: resolvedAlignment!,
widthFactor: 1.0,
heightFactor: 1.0,
child: resolvedForegroundBuilder != null
? resolvedForegroundBuilder(
context,
statesController.value,
widget.child,
)
: widget.child,
),
);
if (resolvedBackgroundBuilder != null) {
result = resolvedBackgroundBuilder(
context,
statesController.value,
result,
);
}
result = AnimatedTheme(
duration: resolvedAnimationDuration,
data: theme.copyWith(
iconTheme: iconTheme.merge(
IconThemeData(color: resolvedIconColor, size: resolvedIconSize),
),
),
child: InkWell(
onTap: widget.onPressed,
onLongPress: widget.onLongPress,
onHover: widget.onHover,
mouseCursor: mouseCursor,
enableFeedback: resolvedEnableFeedback,
focusNode: widget.focusNode,
canRequestFocus: widget.enabled,
onFocusChange: widget.onFocusChange,
autofocus: widget.autofocus,
splashFactory: resolvedSplashFactory,
overlayColor: overlayColor,
highlightColor: Colors.transparent,
customBorder: resolvedShape!.copyWith(side: resolvedSide),
statesController: statesController,
child: result,
),
);
if (widget.tooltip != null) {
result = Tooltip(message: widget.tooltip, child: result);
}
final Size minSize;
switch (resolvedTapTargetSize!) {
case MaterialTapTargetSize.padded:
minSize = Size(
kMinInteractiveDimension + densityAdjustment.dx,
kMinInteractiveDimension + densityAdjustment.dy,
);
assert(minSize.width >= 0.0);
assert(minSize.height >= 0.0);
case MaterialTapTargetSize.shrinkWrap:
minSize = Size.zero;
}
return Semantics(
container: true,
button: widget.isSemanticButton,
enabled: widget.enabled,
child: _InputPadding(
minSize: minSize,
child: ConstrainedBox(
constraints: effectiveConstraints,
child: Material(
elevation: resolvedElevation!,
textStyle: resolvedTextStyle?.copyWith(
color: resolvedForegroundColor,
),
shape: resolvedShape.copyWith(side: resolvedSide),
color: resolvedBackgroundColor,
shadowColor: resolvedShadowColor,
surfaceTintColor: resolvedSurfaceTintColor,
type: resolvedBackgroundColor == null
? MaterialType.transparency
: MaterialType.button,
animationDuration: resolvedAnimationDuration,
clipBehavior: effectiveClipBehavior,
borderOnForeground: false,
child: result,
),
),
),
);
}
}
class _MouseCursor extends WidgetStateMouseCursor {
const _MouseCursor(this.resolveCallback);
final WidgetPropertyResolver<MouseCursor?> resolveCallback;
@override
MouseCursor resolve(Set<WidgetState> states) => resolveCallback(states)!;
@override
String get debugDescription => 'ButtonStyleButton_MouseCursor';
}
/// A widget to pad the area around a [ButtonStyleButton]'s inner [Material].
///
/// Redirect taps that occur in the padded area around the child to the center
/// of the child. This increases the size of the button and the button's
/// "tap target", but not its material or its ink splashes.
class _InputPadding extends SingleChildRenderObjectWidget {
const _InputPadding({super.child, required this.minSize});
final Size minSize;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderInputPadding(minSize);
}
@override
void updateRenderObject(
BuildContext context,
covariant _RenderInputPadding renderObject,
) {
renderObject.minSize = minSize;
}
}
class _RenderInputPadding extends RenderShiftedBox {
_RenderInputPadding(this._minSize, [RenderBox? child]) : super(child);
Size get minSize => _minSize;
Size _minSize;
set minSize(Size value) {
if (_minSize == value) {
return;
}
_minSize = value;
markNeedsLayout();
}
@override
double computeMinIntrinsicWidth(double height) {
if (child != null) {
return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
}
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
if (child != null) {
return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
}
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
if (child != null) {
return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
}
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
if (child != null) {
return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
}
return 0.0;
}
Size _computeSize({
required BoxConstraints constraints,
required ChildLayouter layoutChild,
}) {
if (child != null) {
final Size childSize = layoutChild(child!, constraints);
final double height = math.max(childSize.width, minSize.width);
final double width = math.max(childSize.height, minSize.height);
return constraints.constrain(Size(height, width));
}
return Size.zero;
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _computeSize(
constraints: constraints,
layoutChild: ChildLayoutHelper.dryLayoutChild,
);
}
@override
double? computeDryBaseline(
covariant BoxConstraints constraints,
TextBaseline baseline,
) {
final RenderBox? child = this.child;
if (child == null) {
return null;
}
final double? result = child.getDryBaseline(constraints, baseline);
if (result == null) {
return null;
}
final Size childSize = child.getDryLayout(constraints);
return result +
Alignment.center
.alongOffset(getDryLayout(constraints) - childSize as Offset)
.dy;
}
@override
void performLayout() {
size = _computeSize(
constraints: constraints,
layoutChild: ChildLayoutHelper.layoutChild,
);
if (child != null) {
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = Alignment.center.alongOffset(
size - child!.size as Offset,
);
}
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (super.hitTest(result, position: position)) {
return true;
}
final Offset center = child!.size.center(Offset.zero);
return result.addWithRawTransform(
transform: MatrixUtils.forceToPoint(center),
position: center,
hitTest: (BoxHitTestResult result, Offset position) {
assert(position == center);
return child!.hitTest(result, position: center);
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,676 +0,0 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'elevated_button.dart';
/// @docImport 'filled_button.dart';
/// @docImport 'material.dart';
/// @docImport 'outlined_button.dart';
library;
import 'dart:ui' show lerpDouble;
import 'package:PiliPlus/common/widgets/flutter/dyn/button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide InkWell, ButtonStyleButton;
/// A Material Design "Text Button".
///
/// Use text buttons on toolbars, in dialogs, or inline with other
/// content but offset from that content with padding so that the
/// button's presence is obvious. Text buttons do not have visible
/// borders and must therefore rely on their position relative to
/// other content for context. In dialogs and cards, they should be
/// grouped together in one of the bottom corners. Avoid using text
/// buttons where they would blend in with other content, for example
/// in the middle of lists.
///
/// A text button is a label [child] displayed on a (zero elevation)
/// [Material] widget. The label's [Text] and [Icon] widgets are
/// displayed in the [style]'s [ButtonStyle.foregroundColor]. The
/// button reacts to touches by filling with the [style]'s
/// [ButtonStyle.backgroundColor].
///
/// The text button's default style is defined by [defaultStyleOf].
/// The style of this text button can be overridden with its [style]
/// parameter. The style of all text buttons in a subtree can be
/// overridden with the [TextButtonTheme] and the style of all of the
/// text buttons in an app can be overridden with the [Theme]'s
/// [ThemeData.textButtonTheme] property.
///
/// The static [styleFrom] method is a convenient way to create a
/// text button [ButtonStyle] from simple values.
///
/// If the [onPressed] and [onLongPress] callbacks are null, then this
/// button will be disabled, it will not react to touch.
///
/// {@tool dartpad}
/// This sample shows various ways to configure TextButtons, from the
/// simplest default appearance to versions that don't resemble
/// Material Design at all.
///
/// ** See code in examples/api/lib/material/text_button/text_button.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This sample demonstrates using the [statesController] parameter to create a button
/// that adds support for [WidgetState.selected].
///
/// ** See code in examples/api/lib/material/text_button/text_button.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [ElevatedButton], a filled button whose material elevates when pressed.
/// * [FilledButton], a filled button that doesn't elevate when pressed.
/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color.
/// * [OutlinedButton], a button with an outlined border and no fill color.
/// * <https://material.io/design/components/buttons.html>
/// * <https://m3.material.io/components/buttons>
class TextButton extends ButtonStyleButton {
/// Create a [TextButton].
const TextButton({
super.key,
required super.onPressed,
super.onLongPress,
super.onHover,
super.onFocusChange,
super.style,
super.focusNode,
super.autofocus = false,
super.clipBehavior,
super.statesController,
super.isSemanticButton,
required Widget super.child,
});
/// Create a text button from a pair of widgets that serve as the button's
/// [icon] and [label].
///
/// The icon and label are arranged in a row and padded by 8 logical pixels
/// at the ends, with an 8 pixel gap in between.
///
/// If [icon] is null, will create a [TextButton] instead.
///
/// {@macro flutter.material.ButtonStyleButton.iconAlignment}
///
factory TextButton.icon({
Key? key,
required VoidCallback? onPressed,
VoidCallback? onLongPress,
ValueChanged<bool>? onHover,
ValueChanged<bool>? onFocusChange,
ButtonStyle? style,
FocusNode? focusNode,
bool? autofocus,
Clip? clipBehavior,
WidgetStatesController? statesController,
Widget? icon,
required Widget label,
IconAlignment? iconAlignment,
}) {
if (icon == null) {
return TextButton(
key: key,
onPressed: onPressed,
onLongPress: onLongPress,
onHover: onHover,
onFocusChange: onFocusChange,
style: style,
focusNode: focusNode,
autofocus: autofocus ?? false,
clipBehavior: clipBehavior ?? Clip.none,
statesController: statesController,
child: label,
);
}
return _TextButtonWithIcon(
key: key,
onPressed: onPressed,
onLongPress: onLongPress,
onHover: onHover,
onFocusChange: onFocusChange,
style: style,
focusNode: focusNode,
autofocus: autofocus ?? false,
clipBehavior: clipBehavior ?? Clip.none,
statesController: statesController,
icon: icon,
label: label,
iconAlignment: iconAlignment,
);
}
/// A static convenience method that constructs a text button
/// [ButtonStyle] given simple values.
///
/// The [foregroundColor] and [disabledForegroundColor] colors are used
/// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and
/// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified.
///
/// The [backgroundColor] and [disabledBackgroundColor] colors are
/// used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor].
///
/// Similarly, the [enabledMouseCursor] and [disabledMouseCursor]
/// parameters are used to construct [ButtonStyle.mouseCursor].
///
/// The [iconColor], [disabledIconColor] are used to construct
/// [ButtonStyle.iconColor] and [iconSize] is used to construct
/// [ButtonStyle.iconSize].
///
/// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also
/// null, the button icon will use the default icon color.
///
/// If [overlayColor] is specified and its value is [Colors.transparent]
/// then the pressed/focused/hovered highlights are effectively defeated.
/// Otherwise a [WidgetStateProperty] with the same opacities as the
/// default is created.
///
/// All of the other parameters are either used directly or used to
/// create a [WidgetStateProperty] with a single value for all
/// states.
///
/// All parameters default to null. By default this method returns
/// a [ButtonStyle] that doesn't override anything.
///
/// For example, to override the default text and icon colors for a
/// [TextButton], as well as its overlay color, with all of the
/// standard opacity adjustments for the pressed, focused, and
/// hovered states, one could write:
///
/// ```dart
/// TextButton(
/// style: TextButton.styleFrom(foregroundColor: Colors.green),
/// child: const Text('Give Kate a mix tape'),
/// onPressed: () {
/// // ...
/// },
/// ),
/// ```
static ButtonStyle styleFrom({
Color? foregroundColor,
Color? backgroundColor,
Color? disabledForegroundColor,
Color? disabledBackgroundColor,
Color? shadowColor,
Color? surfaceTintColor,
Color? iconColor,
double? iconSize,
IconAlignment? iconAlignment,
Color? disabledIconColor,
Color? overlayColor,
double? elevation,
TextStyle? textStyle,
EdgeInsetsGeometry? padding,
Size? minimumSize,
Size? fixedSize,
Size? maximumSize,
BorderSide? side,
OutlinedBorder? shape,
MouseCursor? enabledMouseCursor,
MouseCursor? disabledMouseCursor,
VisualDensity? visualDensity,
MaterialTapTargetSize? tapTargetSize,
Duration? animationDuration,
bool? enableFeedback,
AlignmentGeometry? alignment,
InteractiveInkFeatureFactory? splashFactory,
ButtonLayerBuilder? backgroundBuilder,
ButtonLayerBuilder? foregroundBuilder,
}) {
final WidgetStateProperty<Color?>? backgroundColorProp = switch ((
backgroundColor,
disabledBackgroundColor,
)) {
(_?, null) => WidgetStatePropertyAll<Color?>(backgroundColor),
(_, _) => ButtonStyleButton.defaultColor(
backgroundColor,
disabledBackgroundColor,
),
};
final WidgetStateProperty<Color?>? iconColorProp = switch ((
iconColor,
disabledIconColor,
)) {
(_?, null) => WidgetStatePropertyAll<Color?>(iconColor),
(_, _) => ButtonStyleButton.defaultColor(iconColor, disabledIconColor),
};
final WidgetStateProperty<Color?>? overlayColorProp = switch ((
foregroundColor,
overlayColor,
)) {
(null, null) => null,
(_, Color(a: 0.0)) => WidgetStatePropertyAll<Color?>(overlayColor),
(_, final Color color) || (final Color color, _) =>
WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{
WidgetState.pressed: color.withValues(alpha: 0.1),
WidgetState.hovered: color.withValues(alpha: 0.08),
WidgetState.focused: color.withValues(alpha: 0.1),
}),
};
return ButtonStyle(
textStyle: ButtonStyleButton.allOrNull<TextStyle>(textStyle),
foregroundColor: ButtonStyleButton.defaultColor(
foregroundColor,
disabledForegroundColor,
),
backgroundColor: backgroundColorProp,
overlayColor: overlayColorProp,
shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor),
surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor),
iconColor: iconColorProp,
iconSize: ButtonStyleButton.allOrNull<double>(iconSize),
iconAlignment: iconAlignment,
elevation: ButtonStyleButton.allOrNull<double>(elevation),
padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding),
minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize),
fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize),
maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize),
side: ButtonStyleButton.allOrNull<BorderSide>(side),
shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape),
mouseCursor: WidgetStateProperty<MouseCursor?>.fromMap(
<WidgetStatesConstraint, MouseCursor?>{
WidgetState.disabled: disabledMouseCursor,
WidgetState.any: enabledMouseCursor,
},
),
visualDensity: visualDensity,
tapTargetSize: tapTargetSize,
animationDuration: animationDuration,
enableFeedback: enableFeedback,
alignment: alignment,
splashFactory: splashFactory,
backgroundBuilder: backgroundBuilder,
foregroundBuilder: foregroundBuilder,
);
}
/// Defines the button's default appearance.
///
/// {@template flutter.material.text_button.default_style_of}
/// The button [child]'s [Text] and [Icon] widgets are rendered with
/// the [ButtonStyle]'s foreground color. The button's [InkWell] adds
/// the style's overlay color when the button is focused, hovered
/// or pressed. The button's background color becomes its [Material]
/// color and is transparent by default.
///
/// All of the [ButtonStyle]'s defaults appear below.
///
/// In this list "Theme.foo" is shorthand for
/// `Theme.of(context).foo`. Color scheme values like
/// "onSurface(0.38)" are shorthand for
/// `onSurface.withValues(alpha: 0.38)`. [WidgetStateProperty] valued
/// properties that are not followed by a sublist have the same
/// value for all states, otherwise the values are as specified for
/// each state and "others" means all other states.
///
/// The "default font size" below refers to the font size specified in the
/// [defaultStyleOf] method (or 14.0 if unspecified), scaled by the
/// `MediaQuery.textScalerOf(context).scale` method. And the names of the
/// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been abbreviated
/// for readability.
///
/// The color of the [ButtonStyle.textStyle] is not used, the
/// [ButtonStyle.foregroundColor] color is used instead.
/// {@endtemplate}
///
/// ## Material 2 defaults
///
/// * `textStyle` - Theme.textTheme.button
/// * `backgroundColor` - transparent
/// * `foregroundColor`
/// * disabled - Theme.colorScheme.onSurface(0.38)
/// * others - Theme.colorScheme.primary
/// * `overlayColor`
/// * hovered - Theme.colorScheme.primary(0.08)
/// * focused or pressed - Theme.colorScheme.primary(0.12)
/// * `shadowColor` - Theme.shadowColor
/// * `elevation` - 0
/// * `padding`
/// * `default font size <= 14` - (horizontal(12), vertical(8))
/// * `14 < default font size <= 28` - lerp(all(8), horizontal(8))
/// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4))
/// * `36 < default font size` - horizontal(4)
/// * `minimumSize` - Size(64, 36)
/// * `fixedSize` - null
/// * `maximumSize` - Size.infinite
/// * `side` - null
/// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))
/// * `mouseCursor`
/// * disabled - SystemMouseCursors.basic
/// * others - SystemMouseCursors.click
/// * `visualDensity` - theme.visualDensity
/// * `tapTargetSize` - theme.materialTapTargetSize
/// * `animationDuration` - kThemeChangeDuration
/// * `enableFeedback` - true
/// * `alignment` - Alignment.center
/// * `splashFactory` - InkRipple.splashFactory
///
/// The default padding values for the [TextButton.icon] factory are slightly different:
///
/// * `padding`
/// * `default font size <= 14` - all(8)
/// * `14 < default font size <= 28 `- lerp(all(8), horizontal(4))
/// * `28 < default font size` - horizontal(4)
///
/// The default value for `side`, which defines the appearance of the button's
/// outline, is null. That means that the outline is defined by the button
/// shape's [OutlinedBorder.side]. Typically the default value of an
/// [OutlinedBorder]'s side is [BorderSide.none], so an outline is not drawn.
///
/// ## Material 3 defaults
///
/// If [ThemeData.useMaterial3] is set to true the following defaults will
/// be used:
///
/// {@template flutter.material.text_button.material3_defaults}
/// * `textStyle` - Theme.textTheme.labelLarge
/// * `backgroundColor` - transparent
/// * `foregroundColor`
/// * disabled - Theme.colorScheme.onSurface(0.38)
/// * others - Theme.colorScheme.primary
/// * `overlayColor`
/// * hovered - Theme.colorScheme.primary(0.08)
/// * focused or pressed - Theme.colorScheme.primary(0.1)
/// * others - null
/// * `shadowColor` - Colors.transparent,
/// * `surfaceTintColor` - null
/// * `elevation` - 0
/// * `padding`
/// * `default font size <= 14` - lerp(horizontal(12), horizontal(4))
/// * `14 < default font size <= 28` - lerp(all(8), horizontal(8))
/// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4))
/// * `36 < default font size` - horizontal(4)
/// * `minimumSize` - Size(64, 40)
/// * `fixedSize` - null
/// * `maximumSize` - Size.infinite
/// * `side` - null
/// * `shape` - StadiumBorder()
/// * `mouseCursor`
/// * disabled - SystemMouseCursors.basic
/// * others - SystemMouseCursors.click
/// * `visualDensity` - theme.visualDensity
/// * `tapTargetSize` - theme.materialTapTargetSize
/// * `animationDuration` - kThemeChangeDuration
/// * `enableFeedback` - true
/// * `alignment` - Alignment.center
/// * `splashFactory` - Theme.splashFactory
///
/// For the [TextButton.icon] factory, the end (generally the right) value of
/// `padding` is increased from 12 to 16.
/// {@endtemplate}
@override
ButtonStyle defaultStyleOf(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
return Theme.of(context).useMaterial3
? _TextButtonDefaultsM3(context)
: styleFrom(
foregroundColor: colorScheme.primary,
disabledForegroundColor: colorScheme.onSurface.withValues(
alpha: 0.38,
),
backgroundColor: Colors.transparent,
disabledBackgroundColor: Colors.transparent,
shadowColor: theme.shadowColor,
elevation: 0,
textStyle: theme.textTheme.labelLarge,
padding: _scaledPadding(context),
minimumSize: const Size(64, 36),
maximumSize: Size.infinite,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)),
),
enabledMouseCursor: SystemMouseCursors.click,
disabledMouseCursor: SystemMouseCursors.basic,
visualDensity: theme.visualDensity,
tapTargetSize: theme.materialTapTargetSize,
animationDuration: kThemeChangeDuration,
enableFeedback: true,
alignment: Alignment.center,
splashFactory: InkRipple.splashFactory,
);
}
/// Returns the [TextButtonThemeData.style] of the closest
/// [TextButtonTheme] ancestor.
@override
ButtonStyle? themeStyleOf(BuildContext context) {
return TextButtonTheme.of(context).style;
}
}
EdgeInsetsGeometry _scaledPadding(BuildContext context) {
final ThemeData theme = Theme.of(context);
final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0;
final double effectiveTextScale =
MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0;
return ButtonStyleButton.scaledPadding(
theme.useMaterial3
? const EdgeInsets.symmetric(horizontal: 12, vertical: 8)
: const EdgeInsets.all(8),
const EdgeInsets.symmetric(horizontal: 8),
const EdgeInsets.symmetric(horizontal: 4),
effectiveTextScale,
);
}
class _TextButtonWithIcon extends TextButton {
_TextButtonWithIcon({
super.key,
required super.onPressed,
super.onLongPress,
super.onHover,
super.onFocusChange,
super.style,
super.focusNode,
bool? autofocus,
super.clipBehavior,
super.statesController,
required Widget icon,
required Widget label,
IconAlignment? iconAlignment,
}) : super(
autofocus: autofocus ?? false,
child: _TextButtonWithIconChild(
icon: icon,
label: label,
buttonStyle: style,
iconAlignment: iconAlignment,
),
);
@override
ButtonStyle defaultStyleOf(BuildContext context) {
final bool useMaterial3 = Theme.of(context).useMaterial3;
final ButtonStyle buttonStyle = super.defaultStyleOf(context);
final double defaultFontSize =
buttonStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0;
final double effectiveTextScale =
MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0;
final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding(
useMaterial3
? const EdgeInsetsDirectional.fromSTEB(12, 8, 16, 8)
: const EdgeInsets.all(8),
const EdgeInsets.symmetric(horizontal: 4),
const EdgeInsets.symmetric(horizontal: 4),
effectiveTextScale,
);
return buttonStyle.copyWith(
padding: WidgetStatePropertyAll<EdgeInsetsGeometry>(scaledPadding),
);
}
}
class _TextButtonWithIconChild extends StatelessWidget {
const _TextButtonWithIconChild({
required this.label,
required this.icon,
required this.buttonStyle,
required this.iconAlignment,
});
final Widget label;
final Widget icon;
final ButtonStyle? buttonStyle;
final IconAlignment? iconAlignment;
@override
Widget build(BuildContext context) {
final double defaultFontSize =
buttonStyle?.textStyle?.resolve(const <WidgetState>{})?.fontSize ??
14.0;
final double scale =
clampDouble(
MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0,
1.0,
2.0,
) -
1.0;
final TextButtonThemeData textButtonTheme = TextButtonTheme.of(context);
final IconAlignment effectiveIconAlignment =
iconAlignment ??
textButtonTheme.style?.iconAlignment ??
buttonStyle?.iconAlignment ??
IconAlignment.start;
return Row(
mainAxisSize: MainAxisSize.min,
spacing: lerpDouble(8, 4, scale)!,
children: effectiveIconAlignment == IconAlignment.start
? <Widget>[icon, Flexible(child: label)]
: <Widget>[Flexible(child: label), icon],
);
}
}
// BEGIN GENERATED TOKEN PROPERTIES - TextButton
// Do not edit by hand. The code between the "BEGIN GENERATED" and
// "END GENERATED" comments are generated from data in the Material
// Design token database by the script:
// dev/tools/gen_defaults/bin/gen_defaults.dart.
// dart format off
class _TextButtonDefaultsM3 extends ButtonStyle {
_TextButtonDefaultsM3(this.context)
: super(
animationDuration: kThemeChangeDuration,
enableFeedback: true,
alignment: Alignment.center,
);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
@override
WidgetStateProperty<TextStyle?> get textStyle =>
WidgetStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge);
@override
WidgetStateProperty<Color?>? get backgroundColor =>
const WidgetStatePropertyAll<Color>(Colors.transparent);
@override
WidgetStateProperty<Color?>? get foregroundColor =>
WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return _colors.onSurface.withValues(alpha: 0.38);
}
return _colors.primary;
});
@override
WidgetStateProperty<Color?>? get overlayColor =>
WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return _colors.primary.withValues(alpha: 0.1);
}
if (states.contains(WidgetState.hovered)) {
return _colors.primary.withValues(alpha: 0.08);
}
if (states.contains(WidgetState.focused)) {
return _colors.primary.withValues(alpha: 0.1);
}
return null;
});
@override
WidgetStateProperty<Color>? get shadowColor =>
const WidgetStatePropertyAll<Color>(Colors.transparent);
@override
WidgetStateProperty<Color>? get surfaceTintColor =>
const WidgetStatePropertyAll<Color>(Colors.transparent);
@override
WidgetStateProperty<double>? get elevation =>
const WidgetStatePropertyAll<double>(0.0);
@override
WidgetStateProperty<EdgeInsetsGeometry>? get padding =>
WidgetStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context));
@override
WidgetStateProperty<Size>? get minimumSize =>
const WidgetStatePropertyAll<Size>(Size(64.0, 40.0));
// No default fixedSize
@override
WidgetStateProperty<double>? get iconSize =>
const WidgetStatePropertyAll<double>(18.0);
@override
WidgetStateProperty<Color>? get iconColor {
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return _colors.onSurface.withValues(alpha: 0.38);
}
if (states.contains(WidgetState.pressed)) {
return _colors.primary;
}
if (states.contains(WidgetState.hovered)) {
return _colors.primary;
}
if (states.contains(WidgetState.focused)) {
return _colors.primary;
}
return _colors.primary;
});
}
@override
WidgetStateProperty<Size>? get maximumSize =>
const WidgetStatePropertyAll<Size>(Size.infinite);
// No default side
@override
WidgetStateProperty<OutlinedBorder>? get shape =>
const WidgetStatePropertyAll<OutlinedBorder>(StadiumBorder());
@override
WidgetStateProperty<MouseCursor?>? get mouseCursor =>
WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return SystemMouseCursors.basic;
}
return SystemMouseCursors.click;
});
@override
VisualDensity? get visualDensity => Theme.of(context).visualDensity;
@override
MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize;
@override
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
}
// dart format on
// END GENERATED TOKEN PROPERTIES - TextButton

View File

@@ -0,0 +1,526 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// An abstract superclass for widgets that defer their building until layout.
///
/// Similar to the [Builder] widget except that the implementation calls the [builder]
/// function at layout time and provides the [LayoutInfoType] that is required to
/// configure the child widget subtree.
///
/// This is useful when the child widget tree relies on information that are only
/// available during layout, and doesn't depend on the child's intrinsic size.
///
/// The [LayoutInfoType] should typically be immutable. The equality of the
/// [LayoutInfoType] type is used by the implementation to avoid unnecessary
/// rebuilds: if the new [LayoutInfoType] computed during layout is the same as
/// (defined by `LayoutInfoType.==`) the previous [LayoutInfoType], the
/// implementation will try to avoid calling the [builder] again unless
/// [updateShouldRebuild] returns true. The corresponding [RenderObject] produced
/// by this widget retains the most up-to-date [LayoutInfoType] for this purpose,
/// which may keep a [LayoutInfoType] object in memory until the widget is removed
/// from the tree.
///
/// Subclasses must return a [RenderObject] that mixes in [RenderAbstractLayoutBuilderMixin].
abstract class AbstractLayoutBuilder<LayoutInfoType>
extends RenderObjectWidget {
/// Creates a widget that defers its building until layout.
const AbstractLayoutBuilder({super.key});
/// Called at layout time to construct the widget tree.
///
/// The builder must not return null.
Widget Function(BuildContext context, LayoutInfoType layoutInfo) get builder;
@override
RenderObjectElement createElement() =>
_LayoutBuilderElement<LayoutInfoType>(this);
/// Whether [builder] needs to be called again even if the layout constraints
/// are the same.
///
/// When this widget's configuration is updated, the [builder] callback most
/// likely needs to be called to build this widget's child. However,
/// subclasses may provide ways in which the widget can be updated without
/// needing to rebuild the child. Such subclasses can use this method to tell
/// the framework when the child widget should be rebuilt.
///
/// When this method is called by the framework, the newly configured widget
/// is asked if it requires a rebuild, and it is passed the old widget as a
/// parameter.
///
/// See also:
///
/// * [State.setState] and [State.didUpdateWidget], which talk about widget
/// configuration changes and how they're triggered.
/// * [Element.update], the method that actually updates the widget's
/// configuration.
@protected
bool updateShouldRebuild(
covariant AbstractLayoutBuilder<LayoutInfoType> oldWidget,
) => true;
@override
RenderAbstractLayoutBuilderMixin<LayoutInfoType, RenderObject>
createRenderObject(
BuildContext context,
);
// updateRenderObject is redundant with the logic in the LayoutBuilderElement below.
}
/// A specialized [AbstractLayoutBuilder] whose widget subtree depends on the
/// incoming [ConstraintType] that will be imposed on the widget.
///
/// {@template flutter.widgets.ConstrainedLayoutBuilder}
/// The [builder] function is called in the following situations:
///
/// * The first time the widget is laid out.
/// * When the parent widget passes different layout constraints.
/// * When the parent widget updates this widget and [updateShouldRebuild] returns `true`.
/// * When the dependencies that the [builder] function subscribes to change.
///
/// The [builder] function is _not_ called during layout if the parent passes
/// the same constraints repeatedly.
///
/// In the event that an ancestor skips the layout of this subtree so the
/// constraints become outdated, the `builder` rebuilds with the last known
/// constraints.
/// {@endtemplate}
abstract class ConstrainedLayoutBuilder<ConstraintType extends Constraints>
extends AbstractLayoutBuilder<ConstraintType> {
/// Creates a widget that defers its building until layout.
const ConstrainedLayoutBuilder({super.key, required this.builder});
@override
final Widget Function(BuildContext context, ConstraintType constraints)
builder;
}
class _LayoutBuilderElement<LayoutInfoType> extends RenderObjectElement {
_LayoutBuilderElement(AbstractLayoutBuilder<LayoutInfoType> super.widget);
@override
RenderAbstractLayoutBuilderMixin<LayoutInfoType, RenderObject>
get renderObject =>
super.renderObject
as RenderAbstractLayoutBuilderMixin<LayoutInfoType, RenderObject>;
Element? _child;
// @override
// BuildScope get buildScope => _buildScope;
// late final BuildScope _buildScope = BuildScope(
// scheduleRebuild: _scheduleRebuild,
// );
// To schedule a rebuild, markNeedsLayout needs to be called on this Element's
// render object (as the rebuilding is done in its performLayout call). However,
// the render tree should typically be kept clean during the postFrameCallbacks
// and the idle phase, so the layout data can be safely read.
// bool _deferredCallbackScheduled = false;
// void _scheduleRebuild() {
// if (_deferredCallbackScheduled) {
// return;
// }
// final bool deferMarkNeedsLayout =
// switch (SchedulerBinding.instance.schedulerPhase) {
// SchedulerPhase.idle || SchedulerPhase.postFrameCallbacks => true,
// SchedulerPhase.transientCallbacks ||
// SchedulerPhase.midFrameMicrotasks ||
// SchedulerPhase.persistentCallbacks => false,
// };
// if (!deferMarkNeedsLayout) {
// renderObject.scheduleLayoutCallback();
// return;
// }
// _deferredCallbackScheduled = true;
// SchedulerBinding.instance.scheduleFrameCallback(_frameCallback);
// }
// void _frameCallback(Duration timestamp) {
// _deferredCallbackScheduled = false;
// // This method is only called when the render tree is stable, if the Element
// // is deactivated it will never be reincorporated back to the tree.
// if (mounted) {
// renderObject.scheduleLayoutCallback();
// }
// }
@override
void visitChildren(ElementVisitor visitor) {
if (_child != null) {
visitor(_child!);
}
}
@override
void forgetChild(Element child) {
assert(child == _child);
_child = null;
super.forgetChild(child);
}
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot); // Creates the renderObject.
renderObject._updateCallback(_rebuildWithConstraints);
}
@override
void update(AbstractLayoutBuilder<LayoutInfoType> newWidget) {
assert(widget != newWidget);
final oldWidget = widget as AbstractLayoutBuilder<LayoutInfoType>;
super.update(newWidget);
assert(widget == newWidget);
renderObject._updateCallback(_rebuildWithConstraints);
if (newWidget.updateShouldRebuild(oldWidget)) {
_needsBuild = true;
renderObject.scheduleLayoutCallback();
}
}
@override
void markNeedsBuild() {
// Calling super.markNeedsBuild is not needed. This Element does not need
// to performRebuild since this call already does what performRebuild does,
// So the element is clean as soon as this method returns and does not have
// to be added to the dirty list or marked as dirty.
renderObject.scheduleLayoutCallback();
_needsBuild = true;
}
@override
void performRebuild() {
// This gets called if markNeedsBuild() is called on us.
// That might happen if, e.g., our builder uses Inherited widgets.
// Force the callback to be called, even if the layout constraints are the
// same. This is because that callback may depend on the updated widget
// configuration, or an inherited widget.
renderObject.scheduleLayoutCallback();
_needsBuild = true;
super
.performRebuild(); // Calls widget.updateRenderObject (a no-op in this case).
}
@override
void unmount() {
renderObject._callback = null;
super.unmount();
}
// The LayoutInfoType that was used to invoke the layout callback with last time,
// during layout. The `_previousLayoutInfo` value is compared to the new one
// to determine whether [LayoutBuilderBase.builder] needs to be called.
LayoutInfoType? _previousLayoutInfo;
bool _needsBuild = true;
void _rebuildWithConstraints(Constraints _) {
final LayoutInfoType layoutInfo = renderObject.layoutInfo;
@pragma('vm:notify-debugger-on-exception')
void updateChildCallback() {
Widget built;
try {
assert(layoutInfo == renderObject.layoutInfo);
built = (widget as AbstractLayoutBuilder<LayoutInfoType>).builder(
this,
layoutInfo,
);
debugWidgetBuilderValue(widget, built);
} catch (e, stack) {
built = ErrorWidget.builder(
_reportException(
ErrorDescription('building $widget'),
e,
stack,
informationCollector: () => <DiagnosticsNode>[
if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)),
],
),
);
}
try {
_child = updateChild(_child, built, null);
assert(_child != null);
} catch (e, stack) {
built = ErrorWidget.builder(
_reportException(
ErrorDescription('building $widget'),
e,
stack,
informationCollector: () => <DiagnosticsNode>[
if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)),
],
),
);
_child = updateChild(null, built, slot);
} finally {
_needsBuild = false;
_previousLayoutInfo = layoutInfo;
}
}
final VoidCallback? callback =
_needsBuild || (layoutInfo != _previousLayoutInfo)
? updateChildCallback
: null;
owner!.buildScope(this, callback);
}
@override
void insertRenderObjectChild(RenderObject child, Object? slot) {
final RenderObjectWithChildMixin<RenderObject> renderObject =
this.renderObject;
assert(slot == null);
assert(renderObject.debugValidateChild(child));
renderObject.child = child;
assert(renderObject == this.renderObject);
}
@override
void moveRenderObjectChild(
RenderObject child,
Object? oldSlot,
Object? newSlot,
) {
assert(false);
}
@override
void removeRenderObjectChild(RenderObject child, Object? slot) {
final RenderAbstractLayoutBuilderMixin<LayoutInfoType, RenderObject>
renderObject = this.renderObject;
assert(renderObject.child == child);
renderObject.child = null;
assert(renderObject == this.renderObject);
}
}
/// Generic mixin for [RenderObject]s created by an [AbstractLayoutBuilder] with
/// the the same `LayoutInfoType`.
///
/// Provides a [layoutCallback] implementation which, if needed, invokes
/// [AbstractLayoutBuilder]'s builder callback.
///
/// Implementers can override the [layoutInfo] implementation with a value
/// that is safe to access in [layoutCallback], which is called in
/// [performLayout]. The default [layoutInfo] returns the incoming
/// [Constraints].
///
/// This mixin replaces [RenderConstrainedLayoutBuilder].
mixin RenderAbstractLayoutBuilderMixin<
LayoutInfoType,
ChildType extends RenderObject
>
on
RenderObjectWithChildMixin<ChildType>,
RenderObjectWithLayoutCallbackMixin {
LayoutCallback<Constraints>? _callback;
/// Change the layout callback.
void _updateCallback(LayoutCallback<Constraints> value) {
if (value == _callback) {
return;
}
_callback = value;
scheduleLayoutCallback();
}
/// Invokes the builder callback supplied via [AbstractLayoutBuilder] and
/// rebuilds the [AbstractLayoutBuilder]'s widget tree, if needed.
///
/// No further work will be done if [layoutInfo] has not changed since the last
/// time this method was called, and [AbstractLayoutBuilder.updateShouldRebuild]
/// returned `false` when the widget was rebuilt.
///
/// This method should typically be called as soon as possible in the class's
/// [performLayout] implementation, before any layout work is done.
@visibleForOverriding
@override
void layoutCallback() => _callback!(constraints);
/// The information to invoke the [AbstractLayoutBuilder.builder] callback with.
///
/// This is typically the information that are only made available in
/// [performLayout], which is inaccessible for regular [Builder] widget,
/// such as the incoming [Constraints], which are the default value.
@protected
LayoutInfoType get layoutInfo => constraints as LayoutInfoType;
}
/// Generic mixin for [RenderObject]s created by an [AbstractLayoutBuilder] with
/// the the same `LayoutInfoType`.
///
/// Use [RenderAbstractLayoutBuilderMixin] instead, which replaces this mixin.
typedef RenderConstrainedLayoutBuilder<
LayoutInfoType,
ChildType extends RenderObject
> = RenderAbstractLayoutBuilderMixin<LayoutInfoType, ChildType>;
/// Builds a widget tree that can depend on the parent widget's size.
///
/// Similar to the [Builder] widget except that the framework calls the [builder]
/// function at layout time and provides the parent widget's constraints. This
/// is useful when the parent constrains the child's size and doesn't depend on
/// the child's intrinsic size. The [LayoutBuilder]'s final size will match its
/// child's size.
///
/// {@macro flutter.widgets.ConstrainedLayoutBuilder}
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=IYDVcriKjsw}
///
/// If the child should be smaller than the parent, consider wrapping the child
/// in an [Align] widget. If the child might want to be bigger, consider
/// wrapping it in a [SingleChildScrollView] or [OverflowBox].
///
/// {@tool dartpad}
/// This example uses a [LayoutBuilder] to build a different widget depending on the available width. Resize the
/// DartPad window to see [LayoutBuilder] in action!
///
/// ** See code in examples/api/lib/widgets/layout_builder/layout_builder.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [SliverLayoutBuilder], the sliver counterpart of this widget.
/// * [Builder], which calls a `builder` function at build time.
/// * [StatefulBuilder], which passes its `builder` function a `setState` callback.
/// * [CustomSingleChildLayout], which positions its child during layout.
/// * The [catalog of layout widgets](https://flutter.dev/widgets/layout/).
class LayoutBuilder extends ConstrainedLayoutBuilder<BoxConstraints> {
/// Creates a widget that defers its building until layout.
const LayoutBuilder({super.key, required super.builder});
@override
RenderAbstractLayoutBuilderMixin<BoxConstraints, RenderBox>
createRenderObject(
BuildContext context,
) => _RenderLayoutBuilder();
}
class _RenderLayoutBuilder extends RenderBox
with
RenderObjectWithChildMixin<RenderBox>,
RenderObjectWithLayoutCallbackMixin,
RenderAbstractLayoutBuilderMixin<BoxConstraints, RenderBox> {
@override
double computeMinIntrinsicWidth(double height) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
Size computeDryLayout(BoxConstraints constraints) {
assert(
debugCannotComputeDryLayout(
reason:
'Calculating the dry layout would require running the layout callback '
'speculatively, which might mutate the live render object tree.',
),
);
return Size.zero;
}
@override
double? computeDryBaseline(
BoxConstraints constraints,
TextBaseline baseline,
) {
assert(
debugCannotComputeDryLayout(
reason:
'Calculating the dry baseline would require running the layout callback '
'speculatively, which might mutate the live render object tree.',
),
);
return null;
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
runLayoutCallback();
if (child != null) {
child!.layout(constraints, parentUsesSize: true);
size = constraints.constrain(child!.size);
} else {
size = constraints.biggest;
}
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return child?.getDistanceToActualBaseline(baseline) ??
super.computeDistanceToActualBaseline(baseline);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return child?.hitTest(result, position: position) ?? false;
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
context.paintChild(child!, offset);
}
}
bool _debugThrowIfNotCheckingIntrinsics() {
assert(() {
if (!RenderObject.debugCheckingIntrinsics) {
throw FlutterError(
'LayoutBuilder does not support returning intrinsic dimensions.\n'
'Calculating the intrinsic dimensions would require running the layout '
'callback speculatively, which might mutate the live render object tree.',
);
}
return true;
}());
return true;
}
}
FlutterErrorDetails _reportException(
DiagnosticsNode context,
Object exception,
StackTrace stack, {
InformationCollector? informationCollector,
}) {
final details = FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets library',
context: context,
informationCollector: informationCollector,
);
FlutterError.reportError(details);
return details;
}

View File

@@ -20,7 +20,7 @@ library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide ListTile;
import 'package:flutter/rendering.dart';
// Examples can assume:
@@ -335,6 +335,7 @@ class ListTile extends StatelessWidget {
this.contentPadding,
this.enabled = true,
this.onTap,
this.onTapUp,
this.onLongPress,
this.onSecondaryTap,
this.onSecondaryTapUp,
@@ -563,6 +564,8 @@ class ListTile extends StatelessWidget {
/// Inoperative if [enabled] is false.
final GestureTapCallback? onTap;
final GestureTapUpCallback? onTapUp;
/// Called when the user long-presses on this list tile.
///
/// Inoperative if [enabled] is false.
@@ -913,7 +916,12 @@ class ListTile extends StatelessWidget {
// Show basic cursor when ListTile isn't enabled or gesture callbacks are null.
final Set<WidgetState> mouseStates = <WidgetState>{
if (!enabled || (onTap == null && onLongPress == null))
if (!enabled ||
(onTap == null &&
onTapUp == null &&
onLongPress == null &&
onSecondaryTap == null &&
onSecondaryTapUp == null))
WidgetState.disabled,
};
final MouseCursor effectiveMouseCursor =
@@ -984,6 +992,7 @@ class ListTile extends StatelessWidget {
return InkWell(
customBorder: shape ?? tileTheme.shape,
onTap: enabled ? onTap : null,
onTapUp: enabled ? onTapUp : null,
onLongPress: enabled ? onLongPress : null,
onSecondaryTap: enabled ? onSecondaryTap : null,
onSecondaryTapUp: enabled ? onSecondaryTapUp : null,
@@ -1497,11 +1506,16 @@ class _RenderListTile extends RenderBox
@override
double computeMinIntrinsicHeight(double width) {
return math.max(
_targetTileHeight,
title.getMinIntrinsicHeight(width) +
(subtitle?.getMinIntrinsicHeight(width) ?? 0.0),
);
final double titleMinHeight = title.getMinIntrinsicHeight(width);
final double? subtitleMinHeight = subtitle?.getMinIntrinsicHeight(width);
const topAndBottomPaddingMultiplier = 2;
final double contentHeight =
titleMinHeight +
(subtitleMinHeight ?? 0.0) +
topAndBottomPaddingMultiplier * _minVerticalPadding;
return math.max(_targetTileHeight, contentHeight);
}
@override

View File

@@ -2,18 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'package:flutter/material.dart';
///
/// @docImport 'single_child_scroll_view.dart';
/// @docImport 'text.dart';
library;
import 'package:PiliPlus/common/widgets/flutter/page/scrollable.dart';
import 'package:flutter/gestures.dart'
show DragStartBehavior, HorizontalDragGestureRecognizer;
import 'package:flutter/material.dart' hide Scrollable, ScrollableState;
import 'package:flutter/material.dart'
hide PageView, Scrollable, ScrollableState;
import 'package:flutter/rendering.dart';
class _ForceImplicitScrollPhysics extends ScrollPhysics {
@@ -114,7 +107,7 @@ class PageView<T extends HorizontalDragGestureRecognizer>
required this.horizontalDragGestureRecognizer,
}) : childrenDelegate = SliverChildListDelegate(children);
final T horizontalDragGestureRecognizer;
final GestureRecognizerFactoryConstructor<T> horizontalDragGestureRecognizer;
/// Creates a scrollable list that works page by page using widgets that are
/// created on demand.
@@ -378,7 +371,7 @@ class _PageViewState<T extends HorizontalDragGestureRecognizer>
if (notification.depth == 0 &&
widget.onPageChanged != null &&
notification is ScrollUpdateNotification) {
final PageMetrics metrics = notification.metrics as PageMetrics;
final metrics = notification.metrics as PageMetrics;
final int currentPage = metrics.page!.round();
if (currentPage != _lastReportedPage) {
_lastReportedPage = currentPage;

View File

@@ -2,20 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'package:flutter/material.dart';
///
/// @docImport 'page_storage.dart';
/// @docImport 'page_view.dart';
/// @docImport 'scroll_metrics.dart';
/// @docImport 'scroll_notification.dart';
/// @docImport 'scroll_view.dart';
/// @docImport 'single_child_scroll_view.dart';
/// @docImport 'two_dimensional_scroll_view.dart';
/// @docImport 'two_dimensional_viewport.dart';
library;
import 'dart:async';
import 'dart:math' as math;
@@ -105,7 +91,7 @@ class Scrollable<T extends HorizontalDragGestureRecognizer>
required this.horizontalDragGestureRecognizer,
}) : assert(semanticChildCount == null || semanticChildCount >= 0);
final T horizontalDragGestureRecognizer;
final GestureRecognizerFactoryConstructor<T> horizontalDragGestureRecognizer;
/// {@template flutter.widgets.Scrollable.axisDirection}
/// The direction in which this widget scrolls.
@@ -350,7 +336,7 @@ class Scrollable<T extends HorizontalDragGestureRecognizer>
/// if no [Scrollable] ancestor is found.
static ScrollableState? maybeOf(BuildContext context, {Axis? axis}) {
// This is the context that will need to establish the dependency.
final BuildContext originalContext = context;
final originalContext = context;
InheritedElement? element = context
.getElementForInheritedWidgetOfExactType<_ScrollableScope>();
while (element != null) {
@@ -477,7 +463,7 @@ class Scrollable<T extends HorizontalDragGestureRecognizer>
ScrollPositionAlignmentPolicy alignmentPolicy =
ScrollPositionAlignmentPolicy.explicit,
}) {
final List<Future<void>> futures = <Future<void>>[];
final futures = <Future<void>>[];
// The targetRenderObject is used to record the first target renderObject.
// If there are multiple scrollable widgets nested, the targetRenderObject
@@ -815,7 +801,7 @@ class ScrollableState<T extends HorizontalDragGestureRecognizer>
case Axis.horizontal:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
T: GestureRecognizerFactoryWithHandlers<T>(
() => widget.horizontalDragGestureRecognizer,
widget.horizontalDragGestureRecognizer,
(T instance) {
instance
..onDown = _handleDragDown
@@ -855,7 +841,7 @@ class ScrollableState<T extends HorizontalDragGestureRecognizer>
}
_shouldIgnorePointer = value;
if (_ignorePointerKey.currentContext != null) {
final RenderIgnorePointer renderBox =
final renderBox =
_ignorePointerKey.currentContext!.findRenderObject()!
as RenderIgnorePointer;
renderBox.ignoring = _shouldIgnorePointer;
@@ -1014,7 +1000,7 @@ class ScrollableState<T extends HorizontalDragGestureRecognizer>
}
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
final details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
decorationClipBehavior: widget.clipBehavior,
@@ -1344,7 +1330,7 @@ class _ScrollableSelectionContainerDelegate
}
Offset _inferPositionRelatedToOrigin(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
if (!_selectionStartsInScrollable) {
// If the selection starts outside of the scrollable, selecting across the
@@ -1377,7 +1363,7 @@ class _ScrollableSelectionContainerDelegate
bool forceUpdateEnd = true,
}) {
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final box = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = box.getTransformTo(null);
if (currentSelectionStartIndex != -1 &&
(_currentDragStartRelatedToOrigin == null || forceUpdateStart)) {
@@ -1492,14 +1478,13 @@ class _ScrollableSelectionContainerDelegate
if (lineHeight == null || edge == null) {
return;
}
final RenderBox scrollableBox =
state.context.findRenderObject()! as RenderBox;
final scrollableBox = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = selectable.getTransformTo(scrollableBox);
final Offset edgeOffsetInScrollableCoordinates = MatrixUtils.transformPoint(
transform,
edge.localPosition,
);
final Rect scrollableRect = Rect.fromLTRB(
final scrollableRect = Rect.fromLTRB(
0,
0,
scrollableBox.size.width,
@@ -1568,9 +1553,9 @@ class _ScrollableSelectionContainerDelegate
}
bool _globalPositionInScrollable(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
final Rect rect = Rect.fromLTRB(0, 0, box.size.width, box.size.height);
final rect = Rect.fromLTRB(0, 0, box.size.width, box.size.height);
return rect.contains(localPosition);
}
@@ -1818,9 +1803,9 @@ class _RenderScrollSemantics extends RenderProxyBox {
(_innerNode ??= SemanticsNode(showOnScreen: showOnScreen)).rect = node.rect;
int? firstVisibleIndex;
final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode!];
final List<SemanticsNode> included = <SemanticsNode>[];
for (final SemanticsNode child in children) {
final excluded = <SemanticsNode>[_innerNode!];
final included = <SemanticsNode>[];
for (final child in children) {
assert(child.isTagged(RenderViewport.useTwoPaneSemantics));
if (child.isTagged(RenderViewport.excludeFromScrolling)) {
excluded.add(child);

View File

@@ -13,7 +13,8 @@ import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/flutter/page/scrollable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide ScrollableState;
import 'package:flutter/material.dart'
hide EdgeDraggingAutoScroller, Scrollable, ScrollableState;
/// An auto scroller that scrolls the [scrollable] if a drag gesture drags close
/// to its edge.
@@ -29,7 +30,7 @@ class EdgeDraggingAutoScroller {
required this.velocityScalar,
});
/// The [CustomScrollable] this auto scroller is scrolling.
/// The [Scrollable] this auto scroller is scrolling.
final ScrollableState scrollable;
/// Called when a scroll view is scrolled.
@@ -97,8 +98,7 @@ class EdgeDraggingAutoScroller {
}
Future<void> _scroll() async {
final RenderBox scrollRenderBox =
scrollable.context.findRenderObject()! as RenderBox;
final scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox;
final Matrix4 transform = scrollRenderBox.getTransformTo(null);
final Rect globalRect = MatrixUtils.transformRect(
transform,
@@ -123,7 +123,7 @@ class EdgeDraggingAutoScroller {
);
_scrolling = true;
double? newOffset;
const double overDragMax = 20.0;
const overDragMax = 20.0;
final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
final Offset viewportOrigin = globalRect.topLeft.translate(
@@ -194,9 +194,7 @@ class EdgeDraggingAutoScroller {
_scrolling = false;
return;
}
final Duration duration = Duration(
milliseconds: (1000 / velocityScalar).round(),
);
final duration = Duration(milliseconds: (1000 / velocityScalar).round());
await scrollable.position.animateTo(
newOffset,
duration: duration,

View File

@@ -6,7 +6,7 @@ import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart';
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart'
show DragStartBehavior, HorizontalDragGestureRecognizer;
import 'package:flutter/material.dart' hide PageView;
import 'package:flutter/material.dart' hide TabBarView, PageView;
/// A page view that displays the widget which corresponds to the currently
/// selected tab.
@@ -38,7 +38,7 @@ class TabBarView<T extends HorizontalDragGestureRecognizer>
required this.horizontalDragGestureRecognizer,
});
final T horizontalDragGestureRecognizer;
final GestureRecognizerFactoryConstructor<T> horizontalDragGestureRecognizer;
/// This widget's selection and animation state.
///
@@ -220,7 +220,7 @@ class _TabBarViewState<T extends HorizontalDragGestureRecognizer>
return;
}
final bool adjacentDestination =
final adjacentDestination =
(_currentIndex! - _controller!.previousIndex).abs() == 1;
if (adjacentDestination) {
_warpToAdjacentTab(_controller!.animationDuration);

View File

@@ -2,19 +2,23 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: uri_does_not_exist_in_doc_import
/// @docImport 'color_scheme.dart';
library;
import 'dart:async';
import 'dart:math' as math;
import 'dart:async' show Completer;
import 'dart:io' show Platform;
import 'package:PiliPlus/common/widgets/scroll_behavior.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/cupertino.dart';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'
show RefreshScrollPhysics;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/material.dart' hide RefreshIndicator;
/// The distance from the child's top or bottom [edgeOffset] where
/// the refresh indicator will settle. During the drag that exposes the refresh
/// indicator, its actual displacement may significantly exceed this value.
///
/// In most cases, [displacement] distance starts counting from the parent's
/// edges. However, if [edgeOffset] is larger than zero then the [displacement]
/// value is calculated from that offset instead of the parent's edge.
double displacement = Pref.refreshDisplacement;
// The over-scroll distance that moves the indicator to its maximum
@@ -33,21 +37,13 @@ const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150);
// has completed.
const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200);
/// The signature for a function that's called when the user has dragged a
/// [RefreshIndicator] far enough to demonstrate that they want the app to
/// refresh. The returned [Future] must complete when the refresh operation is
/// finished.
///
/// Used by [RefreshIndicator.onRefresh].
typedef RefreshCallback = Future<void> Function();
/// Indicates current status of Material `RefreshIndicator`.
enum RefreshIndicatorStatus {
/// Pointer is down.
drag,
/// Dragged far enough that an up event will run the onRefresh callback.
armed,
// armed,
/// Animating to the indicator's final "displacement".
snap,
@@ -62,19 +58,6 @@ enum RefreshIndicatorStatus {
canceled,
}
/// Used to configure how [RefreshIndicator] can be triggered.
enum RefreshIndicatorTriggerMode {
/// The indicator can be triggered regardless of the scroll position
/// of the [Scrollable] when the drag starts.
anywhere,
/// The indicator can only be triggered if the [Scrollable] is at the edge
/// when the drag starts.
onEdge,
}
enum _IndicatorType { material, adaptive, noSpinner }
/// A widget that supports the Material "swipe to refresh" idiom.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM}
@@ -149,80 +132,16 @@ class RefreshIndicator extends StatefulWidget {
/// The [semanticsValue] may be used to specify progress on the widget.
const RefreshIndicator({
super.key,
required this.child,
this.displacement = 40.0,
this.edgeOffset = 0.0,
required this.onRefresh,
this.color,
this.backgroundColor,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.semanticsLabel,
this.semanticsValue,
this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth,
this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
this.elevation = 2.0,
}) : _indicatorType = _IndicatorType.material,
onStatusChange = null,
assert(elevation >= 0.0);
/// Creates an adaptive [RefreshIndicator] based on whether the target
/// platform is iOS or macOS, following Material design's
/// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html).
///
/// When the descendant overscrolls, a different spinning progress indicator
/// is shown depending on platform. On iOS and macOS,
/// [CupertinoActivityIndicator] is shown, but on all other platforms,
/// [CircularProgressIndicator] appears.
///
/// If a [CupertinoActivityIndicator] is shown, the following parameters are ignored:
/// [backgroundColor], [semanticsLabel], [semanticsValue], [strokeWidth].
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
///
/// Notably the scrollable widget itself will have slightly different behavior
/// from [CupertinoSliverRefreshControl], due to a difference in structure.
const RefreshIndicator.adaptive({
super.key,
this.isClampingScrollPhysics = false,
required this.child,
this.displacement = 40.0,
this.edgeOffset = 0.0,
required this.onRefresh,
this.color,
this.backgroundColor,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.semanticsLabel,
this.semanticsValue,
this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth,
this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
this.elevation = 2.0,
}) : _indicatorType = _IndicatorType.adaptive,
onStatusChange = null,
assert(elevation >= 0.0);
/// Creates a [RefreshIndicator] with no spinner and calls `onRefresh` when
/// successfully armed by a drag event.
///
/// Events can be optionally listened by using the `onStatusChange` callback.
const RefreshIndicator.noSpinner({
super.key,
required this.child,
required this.onRefresh,
this.onStatusChange,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.semanticsLabel,
this.semanticsValue,
this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
this.elevation = 2.0,
}) : _indicatorType = _IndicatorType.noSpinner,
// The following parameters aren't used because [_IndicatorType.noSpinner] is being used,
// which involves showing no spinner, hence the following parameters are useless since
// their only use is to change the spinner's appearance.
displacement = 0.0,
edgeOffset = 0.0,
color = null,
backgroundColor = null,
strokeWidth = 0.0,
assert(elevation >= 0.0);
}) : assert(elevation >= 0.0);
/// The widget below this widget in the tree.
///
@@ -232,15 +151,6 @@ class RefreshIndicator extends StatefulWidget {
/// Typically a [ListView] or [CustomScrollView].
final Widget child;
/// The distance from the child's top or bottom [edgeOffset] where
/// the refresh indicator will settle. During the drag that exposes the refresh
/// indicator, its actual displacement may significantly exceed this value.
///
/// In most cases, [displacement] distance starts counting from the parent's
/// edges. However, if [edgeOffset] is larger than zero then the [displacement]
/// value is calculated from that offset instead of the parent's edge.
final double displacement;
/// The offset where [RefreshProgressIndicator] starts to appear on drag start.
///
/// Depending whether the indicator is showing on the top or bottom, the value
@@ -262,10 +172,6 @@ class RefreshIndicator extends StatefulWidget {
/// [Future] must complete when the refresh operation is finished.
final RefreshCallback onRefresh;
/// Called to get the current status of the [RefreshIndicator] to update the UI as needed.
/// This is an optional parameter, used to fine tune app cases.
final ValueChanged<RefreshIndicatorStatus?>? onStatusChange;
/// The progress indicator's foreground color. The current theme's
/// [ColorScheme.primary] by default.
final Color? color;
@@ -281,42 +187,18 @@ class RefreshIndicator extends StatefulWidget {
/// else for more complicated layouts.
final ScrollNotificationPredicate notificationPredicate;
/// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel}
///
/// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]
/// if it is null.
final String? semanticsLabel;
/// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue}
final String? semanticsValue;
/// Defines [strokeWidth] for `RefreshIndicator`.
///
/// By default, the value of [strokeWidth] is 2.0 pixels.
final double strokeWidth;
final _IndicatorType _indicatorType;
/// Defines how this [RefreshIndicator] can be triggered when users overscroll.
///
/// The [RefreshIndicator] can be pulled out in two cases,
/// 1, Keep dragging if the scrollable widget at the edge with zero scroll position
/// when the drag starts.
/// 2, Keep dragging after overscroll occurs if the scrollable widget has
/// a non-zero scroll position when the drag starts.
///
/// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered.
///
/// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered.
///
/// Defaults to [RefreshIndicatorTriggerMode.onEdge].
final RefreshIndicatorTriggerMode triggerMode;
/// Defines the elevation of the underlying [RefreshIndicator].
///
/// Defaults to 2.0.
final double elevation;
final bool isClampingScrollPhysics;
@override
RefreshIndicatorState createState() => RefreshIndicatorState();
}
@@ -334,7 +216,6 @@ class RefreshIndicatorState extends State<RefreshIndicator>
RefreshIndicatorStatus? _status;
late Future<void> _pendingRefreshFuture;
bool? _isIndicatorAtTop;
double? _dragOffset;
late Color _effectiveValueColor =
widget.color ?? Theme.of(context).colorScheme.primary;
@@ -417,17 +298,13 @@ class RefreshIndicatorState extends State<RefreshIndicator>
// If the notification.dragDetails is null, this scroll is not triggered by
// user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll.
// In this case, we don't want to trigger the refresh indicator.
return ((notification is ScrollStartNotification &&
return _status == null &&
((notification is ScrollStartNotification &&
notification.dragDetails != null) ||
(notification is ScrollUpdateNotification &&
notification.dragDetails != null &&
widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) &&
((notification.metrics.axisDirection == AxisDirection.up &&
notification.metrics.extentAfter == 0.0) ||
(notification.metrics.axisDirection == AxisDirection.down &&
notification.metrics.extentBefore == 0.0)) &&
_status == null &&
_start(notification.metrics.axisDirection);
notification.dragDetails != null)) &&
notification.metrics.extentBefore == 0.0 &&
_start();
}
bool _handleScrollNotification(ScrollNotification notification) {
@@ -437,57 +314,35 @@ class RefreshIndicatorState extends State<RefreshIndicator>
if (_shouldStart(notification)) {
setState(() {
_status = RefreshIndicatorStatus.drag;
widget.onStatusChange?.call(_status);
});
return false;
}
final bool? indicatorAtTopNow =
switch (notification.metrics.axisDirection) {
AxisDirection.down || AxisDirection.up => true,
AxisDirection.left || AxisDirection.right => null,
};
if (indicatorAtTopNow != _isIndicatorAtTop) {
if (_status == RefreshIndicatorStatus.drag ||
_status == RefreshIndicatorStatus.armed) {
_dismiss(RefreshIndicatorStatus.canceled);
}
} else if (notification is ScrollUpdateNotification) {
if (_status == RefreshIndicatorStatus.drag ||
_status == RefreshIndicatorStatus.armed) {
if (notification.metrics.axisDirection == AxisDirection.down) {
_dragOffset = _dragOffset! - notification.scrollDelta!;
} else if (notification.metrics.axisDirection == AxisDirection.up) {
_dragOffset = _dragOffset! + notification.scrollDelta!;
}
if (notification is ScrollUpdateNotification) {
if (_status == RefreshIndicatorStatus.drag) {
_dragOffset = _dragOffset! - notification.scrollDelta!;
_checkDragOffset(notification.metrics.viewportDimension);
}
if (_status == RefreshIndicatorStatus.armed &&
notification.dragDetails == null) {
// On iOS start the refresh when the Scrollable bounces back from the
// overscroll (ScrollNotification indicating this don't have dragDetails
// because the scroll activity is not directly triggered by a drag).
_show();
if (notification.dragDetails == null &&
_valueColor.value!.a == _effectiveValueColor.a) {
// On iOS start the refresh when the Scrollable bounces back from the
// overscroll (ScrollNotification indicating this don't have dragDetails
// because the scroll activity is not directly triggered by a drag).
_show();
}
}
} else if (notification is OverscrollNotification) {
if (_status == RefreshIndicatorStatus.drag ||
_status == RefreshIndicatorStatus.armed) {
if (notification.metrics.axisDirection == AxisDirection.down) {
_dragOffset = _dragOffset! - notification.overscroll;
} else if (notification.metrics.axisDirection == AxisDirection.up) {
_dragOffset = _dragOffset! + notification.overscroll;
}
if (_status == RefreshIndicatorStatus.drag) {
_dragOffset = _dragOffset! - notification.overscroll;
_checkDragOffset(notification.metrics.viewportDimension);
}
} else if (notification is ScrollEndNotification) {
switch (_status) {
case RefreshIndicatorStatus.armed:
if (_positionController.value < 1.0) {
_dismiss(RefreshIndicatorStatus.canceled);
} else {
_show();
}
case RefreshIndicatorStatus.drag:
_dismiss(RefreshIndicatorStatus.canceled);
if (_valueColor.value!.a == _effectiveValueColor.a) {
_show();
} else {
_dismiss(RefreshIndicatorStatus.canceled);
}
case RefreshIndicatorStatus.canceled:
case RefreshIndicatorStatus.done:
case RefreshIndicatorStatus.refresh:
@@ -513,20 +368,9 @@ class RefreshIndicatorState extends State<RefreshIndicator>
return false;
}
bool _start(AxisDirection direction) {
bool _start() {
assert(_status == null);
assert(_isIndicatorAtTop == null);
assert(_dragOffset == null);
switch (direction) {
case AxisDirection.down:
case AxisDirection.up:
_isIndicatorAtTop = true;
case AxisDirection.left:
case AxisDirection.right:
_isIndicatorAtTop = null;
// we do not support horizontal scroll views.
return false;
}
_dragOffset = 0.0;
_scaleController.value = 0.0;
_positionController.value = 0.0;
@@ -535,24 +379,15 @@ class RefreshIndicatorState extends State<RefreshIndicator>
void _checkDragOffset(double containerExtent) {
assert(
_status == RefreshIndicatorStatus.drag ||
_status == RefreshIndicatorStatus.armed,
_status == RefreshIndicatorStatus.drag,
);
double newValue =
_dragOffset! / (containerExtent * kDragContainerExtentPercentage);
if (_status == RefreshIndicatorStatus.armed) {
newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
}
_positionController.value = clampDouble(
newValue,
0.0,
1.0,
); // This triggers various rebuilds.
if (_status == RefreshIndicatorStatus.drag &&
_valueColor.value!.a == _effectiveValueColor.a) {
_status = RefreshIndicatorStatus.armed;
widget.onStatusChange?.call(_status);
}
}
// Stop showing the refresh indicator.
@@ -567,7 +402,6 @@ class RefreshIndicatorState extends State<RefreshIndicator>
);
setState(() {
_status = newMode;
widget.onStatusChange?.call(_status);
});
switch (_status!) {
case RefreshIndicatorStatus.done:
@@ -580,7 +414,6 @@ class RefreshIndicatorState extends State<RefreshIndicator>
0.0,
duration: _kIndicatorScaleDuration,
);
case RefreshIndicatorStatus.armed:
case RefreshIndicatorStatus.drag:
case RefreshIndicatorStatus.refresh:
case RefreshIndicatorStatus.snap:
@@ -588,7 +421,6 @@ class RefreshIndicatorState extends State<RefreshIndicator>
}
if (mounted && _status == newMode) {
_dragOffset = null;
_isIndicatorAtTop = null;
setState(() {
_status = null;
});
@@ -601,7 +433,6 @@ class RefreshIndicatorState extends State<RefreshIndicator>
final Completer<void> completer = Completer<void>();
_pendingRefreshFuture = completer.future;
_status = RefreshIndicatorStatus.snap;
widget.onStatusChange?.call(_status);
_positionController
.animateTo(
1.0 / _kDragSizeFactorLimit,
@@ -640,11 +471,11 @@ class RefreshIndicatorState extends State<RefreshIndicator>
/// When initiated in this manner, the refresh indicator is independent of any
/// actual scroll view. It defaults to showing the indicator at the top. To
/// show it at the bottom, set `atTop` to false.
Future<void> show({bool atTop = true}) {
Future<void> show() {
if (_status != RefreshIndicatorStatus.refresh &&
_status != RefreshIndicatorStatus.snap) {
if (_status == null) {
_start(atTop ? AxisDirection.down : AxisDirection.up);
_start();
}
_show();
}
@@ -655,7 +486,7 @@ class RefreshIndicatorState extends State<RefreshIndicator>
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
final Widget child = NotificationListener<ScrollNotification>(
Widget child = NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: NotificationListener<OverscrollIndicatorNotification>(
onNotification: _handleIndicatorNotification,
@@ -665,10 +496,8 @@ class RefreshIndicatorState extends State<RefreshIndicator>
assert(() {
if (_status == null) {
assert(_dragOffset == null);
assert(_isIndicatorAtTop == null);
} else {
assert(_dragOffset != null);
assert(_isIndicatorAtTop != null);
}
return true;
}());
@@ -677,75 +506,33 @@ class RefreshIndicatorState extends State<RefreshIndicator>
_status == RefreshIndicatorStatus.refresh ||
_status == RefreshIndicatorStatus.done;
return Stack(
child = Stack(
clipBehavior: Clip.none,
children: <Widget>[
child,
if (_status != null)
Positioned(
top: _isIndicatorAtTop! ? widget.edgeOffset : null,
bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null,
top: widget.edgeOffset,
left: 0.0,
right: 0.0,
child: SizeTransition(
axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0,
axisAlignment: 1.0,
sizeFactor: _positionFactor, // This is what brings it down.
child: Padding(
padding: _isIndicatorAtTop!
? EdgeInsets.only(top: widget.displacement)
: EdgeInsets.only(bottom: widget.displacement),
padding: EdgeInsets.only(top: displacement),
child: Align(
alignment: _isIndicatorAtTop!
? Alignment.topCenter
: Alignment.bottomCenter,
alignment: Alignment.topCenter,
child: ScaleTransition(
scale: _scaleFactor,
child: AnimatedBuilder(
animation: _positionController,
builder: (BuildContext context, Widget? child) {
final Widget materialIndicator =
RefreshProgressIndicator(
semanticsLabel:
widget.semanticsLabel ??
MaterialLocalizations.of(
context,
).refreshIndicatorSemanticLabel,
semanticsValue: widget.semanticsValue,
value: showIndeterminateIndicator
? null
: _value.value,
valueColor: _valueColor,
backgroundColor: widget.backgroundColor,
strokeWidth: widget.strokeWidth,
elevation: widget.elevation,
);
final Widget cupertinoIndicator =
CupertinoActivityIndicator(
color: widget.color,
);
switch (widget._indicatorType) {
case _IndicatorType.material:
return materialIndicator;
case _IndicatorType.adaptive:
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return materialIndicator;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return cupertinoIndicator;
}
case _IndicatorType.noSpinner:
return const SizedBox.shrink();
}
},
builder: (context, child) => RefreshProgressIndicator(
value: showIndeterminateIndicator ? null : _value.value,
valueColor: _valueColor,
backgroundColor: widget.backgroundColor,
strokeWidth: widget.strokeWidth,
elevation: widget.elevation,
),
),
),
),
@@ -754,16 +541,46 @@ class RefreshIndicatorState extends State<RefreshIndicator>
),
],
);
if (!widget.isClampingScrollPhysics &&
(Platform.isIOS || Platform.isMacOS)) {
return child;
}
return ScrollConfiguration(
behavior: RefreshScrollBehavior(
desktopDragDevices,
scrollPhysics: RefreshScrollPhysics(
parent: const RangeMaintainingScrollPhysics(),
onDrag: _onDrag,
),
),
child: child,
);
}
bool _onDrag(double offset, double viewportDimension) {
if (_positionController.value > 0.0 &&
_status == RefreshIndicatorStatus.drag) {
_dragOffset = _dragOffset! + offset;
_checkDragOffset(viewportDimension);
return true;
}
return false;
}
}
Widget refreshIndicator({
required RefreshCallback onRefresh,
required Widget child,
}) {
return RefreshIndicator(
displacement: displacement,
onRefresh: onRefresh,
child: child,
);
// ignore: camel_case_types
typedef refreshIndicator = RefreshIndicator;
class RefreshScrollBehavior extends CustomScrollBehavior {
const RefreshScrollBehavior(
super.dragDevices, {
required this.scrollPhysics,
});
final RefreshScrollPhysics scrollPhysics;
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return scrollPhysics;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:PiliPlus/common/widgets/flutter/text_intro/text_selection.dart';
import 'package:PiliPlus/common/widgets/flutter/selectable_text/text_selection.dart';
import 'package:flutter/cupertino.dart'
hide TextSelectionGestureDetectorBuilder;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' hide TextSelectionGestureDetectorBuilder;
import 'package:flutter/material.dart'
hide SelectableText, TextSelectionGestureDetectorBuilder;
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
@@ -610,8 +611,9 @@ class _SelectableTextState extends State<SelectableText>
super.didUpdateWidget(oldWidget);
if (widget.data != oldWidget.data ||
widget.textSpan != oldWidget.textSpan) {
_controller.removeListener(_onControllerChanged);
_controller.dispose();
_controller
..removeListener(_onControllerChanged)
..dispose();
_controller = _TextSpanEditingController(
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
);
@@ -766,7 +768,7 @@ class _SelectableTextState extends State<SelectableText>
cupertinoTheme.primaryColor;
selectionColor =
selectionStyle.selectionColor ??
cupertinoTheme.primaryColor.withOpacity(0.40);
cupertinoTheme.primaryColor.withValues(alpha: 0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(
iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context),
@@ -785,7 +787,7 @@ class _SelectableTextState extends State<SelectableText>
cupertinoTheme.primaryColor;
selectionColor =
selectionStyle.selectionColor ??
cupertinoTheme.primaryColor.withOpacity(0.40);
cupertinoTheme.primaryColor.withValues(alpha: 0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(
iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context),
@@ -804,7 +806,7 @@ class _SelectableTextState extends State<SelectableText>
theme.colorScheme.primary;
selectionColor =
selectionStyle.selectionColor ??
theme.colorScheme.primary.withOpacity(0.40);
theme.colorScheme.primary.withValues(alpha: 0.40);
case TargetPlatform.linux:
case TargetPlatform.windows:
@@ -818,7 +820,7 @@ class _SelectableTextState extends State<SelectableText>
theme.colorScheme.primary;
selectionColor =
selectionStyle.selectionColor ??
theme.colorScheme.primary.withOpacity(0.40);
theme.colorScheme.primary.withValues(alpha: 0.40);
}
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);

View File

@@ -0,0 +1,148 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:PiliPlus/common/widgets/flutter/selectable_text/selectable_region.dart';
import 'package:flutter/cupertino.dart'
hide
SelectableRegion,
SelectableRegionState,
SelectableRegionContextMenuBuilder;
import 'package:flutter/material.dart'
hide
SelectionArea,
SelectableRegion,
SelectableRegionState,
SelectableRegionContextMenuBuilder;
import 'package:flutter/rendering.dart';
/// A widget that introduces an area for user selections with adaptive selection
/// controls.
///
/// This widget creates a [SelectableRegion] with platform-adaptive selection
/// controls.
///
/// Flutter widgets are not selectable by default. To enable selection for
/// a specific screen, consider wrapping the body of the [Route] with a
/// [SelectionArea].
///
/// The [SelectionArea] widget must have a [Localizations] ancestor that
/// contains a [MaterialLocalizations] delegate; using the [MaterialApp] widget
/// ensures that such an ancestor is present.
///
/// {@tool dartpad}
/// This example shows how to make a screen selectable.
///
/// ** See code in examples/api/lib/material/selection_area/selection_area.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [SelectableRegion], which provides an overview of the selection system.
/// * [SelectableText], which enables selection on a single run of text.
/// * [SelectionListener], which enables accessing the [SelectionDetails] of
/// the selectable subtree it wraps.
class SelectionArea extends StatefulWidget {
/// Creates a [SelectionArea].
///
/// If [selectionControls] is null, a platform specific one is used.
const SelectionArea({
super.key,
this.focusNode,
this.selectionControls,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
this.onSelectionChanged,
required this.child,
});
/// The configuration for the magnifier in the selection region.
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier]
/// on Android, and builds nothing on all other platforms. To suppress the
/// magnifier, consider passing [TextMagnifierConfiguration.disabled].
///
/// {@macro flutter.widgets.magnifier.intro}
final TextMagnifierConfiguration? magnifierConfiguration;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
/// The delegate to build the selection handles and toolbar.
///
/// If it is null, the platform specific selection control is used.
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
///
/// If not provided, will build a default menu based on the ambient
/// [ThemeData.platform].
///
/// {@tool dartpad}
/// This example shows how to build a custom context menu for any selected
/// content in a SelectionArea.
///
/// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which is built by default.
final SelectableRegionContextMenuBuilder? contextMenuBuilder;
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
/// The child widget this selection area applies to.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
static Widget _defaultContextMenuBuilder(
BuildContext context,
SelectableRegionState selectableRegionState,
) => AdaptiveTextSelectionToolbar.buttonItems(
buttonItems: selectableRegionState.contextMenuButtonItems,
anchors: selectableRegionState.contextMenuAnchors,
);
@override
State<StatefulWidget> createState() => SelectionAreaState();
}
/// State for a [SelectionArea].
class SelectionAreaState extends State<SelectionArea> {
final GlobalKey<SelectableRegionState> _selectableRegionKey =
GlobalKey<SelectableRegionState>();
/// The [State] of the [SelectableRegion] for which this [SelectionArea] wraps.
SelectableRegionState get selectableRegion =>
_selectableRegionKey.currentState!;
@protected
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
final TextSelectionControls controls =
widget.selectionControls ??
switch (Theme.of(context).platform) {
TargetPlatform.android ||
TargetPlatform.fuchsia => materialTextSelectionHandleControls,
TargetPlatform.linux ||
TargetPlatform.windows => desktopTextSelectionHandleControls,
TargetPlatform.iOS => cupertinoTextSelectionHandleControls,
TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls,
};
return SelectableRegion(
key: _selectableRegionKey,
selectionControls: controls,
focusNode: widget.focusNode,
contextMenuBuilder: widget.contextMenuBuilder,
magnifierConfiguration:
widget.magnifierConfiguration ??
TextMagnifier.adaptiveMagnifierConfiguration,
onSelectionChanged: widget.onSelectionChanged,
child: widget.child,
);
}
}

View File

@@ -5,7 +5,8 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/gestures.dart'
hide TapAndHorizontalDragGestureRecognizer;
// Examples can assume:
// void setState(VoidCallback fn) { }
@@ -823,7 +824,7 @@ sealed class BaseTapAndDragGestureRecognizer
untransformedDelta: localDelta,
untransformedEndPosition: correctedLocalPosition,
);
final OffsetPair updateDelta = OffsetPair(
final updateDelta = OffsetPair(
local: localDelta,
global: globalUpdateDelta,
);
@@ -870,7 +871,7 @@ sealed class BaseTapAndDragGestureRecognizer
return;
}
final TapDragDownDetails details = TapDragDownDetails(
final details = TapDragDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
@@ -889,7 +890,7 @@ sealed class BaseTapAndDragGestureRecognizer
return;
}
final TapDragUpDetails upDetails = TapDragUpDetails(
final upDetails = TapDragUpDetails(
kind: event.kind,
globalPosition: event.position,
localPosition: event.localPosition,
@@ -908,7 +909,7 @@ sealed class BaseTapAndDragGestureRecognizer
void _checkDragStart(PointerEvent event) {
if (onDragStart != null) {
final TapDragStartDetails details = TapDragStartDetails(
final details = TapDragStartDetails(
sourceTimeStamp: event.timeStamp,
globalPosition: _initialPosition.global,
localPosition: _initialPosition.local,
@@ -926,7 +927,7 @@ sealed class BaseTapAndDragGestureRecognizer
final Offset globalPosition = corrected?.global ?? event.position;
final Offset localPosition = corrected?.local ?? event.localPosition;
final TapDragUpdateDetails details = TapDragUpdateDetails(
final details = TapDragUpdateDetails(
sourceTimeStamp: event.timeStamp,
delta: event.localDelta,
globalPosition: globalPosition,
@@ -962,7 +963,7 @@ sealed class BaseTapAndDragGestureRecognizer
_handleDragUpdateThrottled();
}
final TapDragEndDetails endDetails = TapDragEndDetails(
final endDetails = TapDragEndDetails(
globalPosition: globalPosition,
localPosition: localPosition,
primaryVelocity: 0.0,
@@ -1083,3 +1084,42 @@ class TapAndHorizontalDragGestureRecognizer
@override
String get debugDescription => 'tap and horizontal drag';
}
/// {@template flutter.gestures.selectionrecognizers.TapAndPanGestureRecognizer}
/// Recognizes taps along with both horizontal and vertical movement.
///
/// This recognizer will accept a drag on any axis, regardless if it has won the
/// arena for the primary pointer being tracked.
///
/// See also:
///
/// * [BaseTapAndDragGestureRecognizer], for the class that provides the main
/// implementation details of this recognizer.
/// * [TapAndHorizontalDragGestureRecognizer], for a similar recognizer that
/// only accepts horizontal drags before it has won the arena for the primary
/// pointer being tracked.
/// * [PanGestureRecognizer], for a similar recognizer that only recognizes
/// movement.
/// {@endtemplate}
class TapAndPanGestureRecognizer extends BaseTapAndDragGestureRecognizer {
/// Create a gesture recognizer for interactions on a plane.
TapAndPanGestureRecognizer({super.debugOwner, super.supportedDevices});
@override
bool _hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
) {
return true;
// return _globalDistanceMoved.abs() >
// computePanSlop(pointerDeviceKind, gestureSettings);
}
// @override
// Offset _getDeltaForDetails(Offset delta) => delta;
// @override
// double? _getPrimaryValueFromOffset(Offset value) => null;
@override
String get debugDescription => 'tap and pan';
}

View File

@@ -0,0 +1,42 @@
import 'package:PiliPlus/common/widgets/flutter/selectable_text/selectable_text.dart';
import 'package:PiliPlus/common/widgets/flutter/selectable_text/selection_area.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:flutter/material.dart' hide SelectableText, SelectionArea;
Widget selectableText(
String text, {
TextStyle? style,
}) {
if (PlatformUtils.isDesktop) {
return SelectionArea(
child: Text(
style: style,
text,
),
);
}
return SelectableText(
style: style,
text,
scrollPhysics: const NeverScrollableScrollPhysics(),
);
}
Widget selectableRichText(
TextSpan textSpan, {
TextStyle? style,
}) {
if (PlatformUtils.isDesktop) {
return SelectionArea(
child: Text.rich(
style: style,
textSpan,
),
);
}
return SelectableText.rich(
style: style,
textSpan,
scrollPhysics: const NeverScrollableScrollPhysics(),
);
}

View File

@@ -4,11 +4,14 @@
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/flutter/text_intro/tap_and_drag.dart';
import 'package:PiliPlus/common/widgets/flutter/selectable_text/tap_and_drag.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'
hide BaseTapAndDragGestureRecognizer, TapAndHorizontalDragGestureRecognizer;
import 'package:flutter/material.dart';
hide
BaseTapAndDragGestureRecognizer,
TapAndHorizontalDragGestureRecognizer,
TapAndPanGestureRecognizer;
import 'package:flutter/material.dart' hide TextSelectionGestureDetector;
class CustomTextSelectionGestureDetectorBuilder
extends TextSelectionGestureDetectorBuilder {
@@ -307,8 +310,7 @@ class _TextSelectionGestureDetectorState
@override
Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures =
<Type, GestureRecognizerFactory>{};
final gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(

View File

@@ -0,0 +1,83 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'
hide
ConstrainedLayoutBuilder,
LayoutBuilder,
RenderConstrainedLayoutBuilder;
/// Builds a sliver widget tree that can depend on its own [SliverConstraints].
///
/// Similar to the [LayoutBuilder] widget except its builder should return a sliver
/// widget, and [SliverLayoutBuilder] is itself a sliver. The framework calls the
/// [builder] function at layout time and provides the current [SliverConstraints].
/// The [SliverLayoutBuilder]'s final [SliverGeometry] will match the [SliverGeometry]
/// of its child.
///
/// {@macro flutter.widgets.ConstrainedLayoutBuilder}
///
/// See also:
///
/// * [LayoutBuilder], the non-sliver version of this widget.
class SliverLayoutBuilder extends ConstrainedLayoutBuilder<SliverConstraints> {
/// Creates a sliver widget that defers its building until layout.
const SliverLayoutBuilder({super.key, required super.builder});
@override
RenderConstrainedLayoutBuilder<SliverConstraints, RenderSliver>
createRenderObject(
BuildContext context,
) => _RenderSliverLayoutBuilder();
}
class _RenderSliverLayoutBuilder extends RenderSliver
with
RenderObjectWithChildMixin<RenderSliver>,
RenderObjectWithLayoutCallbackMixin,
RenderConstrainedLayoutBuilder<SliverConstraints, RenderSliver> {
@override
double childMainAxisPosition(RenderObject child) {
assert(child == this.child);
return 0;
}
@override
void performLayout() {
runLayoutCallback();
child?.layout(constraints, parentUsesSize: true);
geometry = child?.geometry ?? SliverGeometry.zero;
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
// child's offset is always (0, 0), transform.translate(0, 0) does not mutate the transform.
}
@override
void paint(PaintingContext context, Offset offset) {
// This renderObject does not introduce additional offset to child's position.
if (child?.geometry?.visible ?? false) {
context.paintChild(child!, offset);
}
}
@override
bool hitTestChildren(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
return child != null &&
child!.geometry!.hitTestExtent > 0 &&
child!.hitTest(
result,
mainAxisPosition: mainAxisPosition,
crossAxisPosition: crossAxisPosition,
);
}
}

View File

@@ -24,7 +24,7 @@ import 'dart:ui'
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/rendering.dart' hide RenderParagraph;
import 'package:flutter/services.dart';
/// The start and end positions for a text boundary.

View File

@@ -5,7 +5,7 @@
import 'dart:ui' as ui show TextHeightBehavior;
import 'package:PiliPlus/common/widgets/flutter/text/paragraph.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide RichText;
import 'package:flutter/rendering.dart' hide RenderParagraph;
/// A paragraph of rich text.
@@ -118,8 +118,8 @@ class RichText extends MultiChildRenderObjectWidget {
this.textHeightBehavior,
this.selectionRegistrar,
this.selectionColor,
this.onShowMore,
required this.primary,
this.onShowMore,
}) : assert(maxLines == null || maxLines > 0),
assert(selectionRegistrar == null || selectionColor != null),
assert(

View File

@@ -20,7 +20,7 @@ import 'dart:ui' as ui show TextHeightBehavior;
import 'package:PiliPlus/common/widgets/flutter/text/paragraph.dart';
import 'package:PiliPlus/common/widgets/flutter/text/rich_text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide RichText;
import 'package:flutter/material.dart' hide Text, RichText;
import 'package:flutter/rendering.dart' hide RenderParagraph;
/// A run of text with a single style.
@@ -180,8 +180,8 @@ class Text extends StatelessWidget {
this.textWidthBasis,
this.textHeightBehavior,
this.selectionColor,
this.onShowMore,
required this.primary,
this.onShowMore,
}) : textSpan = null,
assert(
textScaler == null || textScaleFactor == null,
@@ -219,8 +219,8 @@ class Text extends StatelessWidget {
this.textWidthBasis,
this.textHeightBehavior,
this.selectionColor,
this.onShowMore,
required this.primary,
this.onShowMore,
}) : data = null,
assert(
textScaler == null || textScaleFactor == null,
@@ -242,9 +242,19 @@ class Text extends StatelessWidget {
/// If the style's "inherit" property is true, the style will be merged with
/// the closest enclosing [DefaultTextStyle]. Otherwise, the style will
/// replace the closest enclosing [DefaultTextStyle].
///
/// The user or platform may override this [style]'s [TextStyle.fontWeight],
/// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
/// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
/// [MediaQueryData.lineHeightScaleFactorOverride],
/// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
/// regardless of its [TextStyle.inherit] value.
final TextStyle? style;
/// {@macro flutter.painting.textPainter.strutStyle}
///
/// The user or platform may override this [strutStyle]'s [StrutStyle.height]
/// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
final StrutStyle? strutStyle;
/// How the text should be aligned horizontally.
@@ -375,6 +385,30 @@ class Text extends StatelessWidget {
const TextStyle(fontWeight: FontWeight.bold),
);
}
// TODO(Renzo-Olivares): Investigate ways the framework can automatically
// apply MediaQueryData.paragraphSpacingOverride to its own text components.
// See: https://github.com/flutter/flutter/issues/177953 and https://github.com/flutter/flutter/issues/177408.
final double? lineHeightScaleFactor =
MediaQuery.maybeLineHeightScaleFactorOverrideOf(context);
final double? letterSpacing = MediaQuery.maybeLetterSpacingOverrideOf(
context,
);
final double? wordSpacing = MediaQuery.maybeWordSpacingOverrideOf(context);
final TextSpan effectiveTextSpan =
_OverridingTextStyleTextSpanUtils.applyTextSpacingOverrides(
lineHeightScaleFactor: lineHeightScaleFactor,
letterSpacing: letterSpacing,
wordSpacing: wordSpacing,
textSpan: TextSpan(
style: effectiveTextStyle,
text: data,
locale: locale,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
);
final StrutStyle? effectiveStrutStyle = strutStyle?.merge(
StrutStyle(height: lineHeightScaleFactor),
);
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
final TextScaler textScaler = switch ((this.textScaler, textScaleFactor)) {
(final TextScaler textScaler, _) => textScaler,
@@ -403,7 +437,7 @@ class Text extends StatelessWidget {
defaultTextStyle.overflow,
textScaler: textScaler,
maxLines: maxLines ?? defaultTextStyle.maxLines,
strutStyle: strutStyle,
strutStyle: effectiveStrutStyle,
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior:
textHeightBehavior ??
@@ -413,12 +447,7 @@ class Text extends StatelessWidget {
selectionColor ??
DefaultSelectionStyle.of(context).selectionColor ??
DefaultSelectionStyle.defaultColor,
text: TextSpan(
style: effectiveTextStyle,
text: data,
locale: locale,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
text: effectiveTextSpan,
primary: primary,
),
);
@@ -436,7 +465,7 @@ class Text extends StatelessWidget {
defaultTextStyle.overflow,
textScaler: textScaler,
maxLines: maxLines ?? defaultTextStyle.maxLines,
strutStyle: strutStyle,
strutStyle: effectiveStrutStyle,
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior:
textHeightBehavior ??
@@ -446,14 +475,9 @@ class Text extends StatelessWidget {
selectionColor ??
DefaultSelectionStyle.of(context).selectionColor ??
DefaultSelectionStyle.defaultColor,
text: TextSpan(
style: effectiveTextStyle,
text: data,
locale: locale,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
onShowMore: onShowMore,
text: effectiveTextSpan,
primary: primary,
onShowMore: onShowMore,
);
}
if (semanticsLabel != null || semanticsIdentifier != null) {
@@ -483,49 +507,50 @@ class Text extends StatelessWidget {
);
}
style?.debugFillProperties(properties);
properties.add(
EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null),
);
properties.add(
EnumProperty<TextDirection>(
'textDirection',
textDirection,
defaultValue: null,
),
);
properties.add(
DiagnosticsProperty<Locale>('locale', locale, defaultValue: null),
);
properties.add(
FlagProperty(
'softWrap',
value: softWrap,
ifTrue: 'wrapping at box width',
ifFalse: 'no wrapping except at line break characters',
showName: true,
),
);
properties.add(
EnumProperty<TextOverflow>('overflow', overflow, defaultValue: null),
);
properties.add(
DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null),
);
properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
properties.add(
EnumProperty<TextWidthBasis>(
'textWidthBasis',
textWidthBasis,
defaultValue: null,
),
);
properties.add(
DiagnosticsProperty<ui.TextHeightBehavior>(
'textHeightBehavior',
textHeightBehavior,
defaultValue: null,
),
);
properties
..add(
EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null),
)
..add(
EnumProperty<TextDirection>(
'textDirection',
textDirection,
defaultValue: null,
),
)
..add(
DiagnosticsProperty<Locale>('locale', locale, defaultValue: null),
)
..add(
FlagProperty(
'softWrap',
value: softWrap,
ifTrue: 'wrapping at box width',
ifFalse: 'no wrapping except at line break characters',
showName: true,
),
)
..add(
EnumProperty<TextOverflow>('overflow', overflow, defaultValue: null),
)
..add(
DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null),
)
..add(IntProperty('maxLines', maxLines, defaultValue: null))
..add(
EnumProperty<TextWidthBasis>(
'textWidthBasis',
textWidthBasis,
defaultValue: null,
),
)
..add(
DiagnosticsProperty<ui.TextHeightBehavior>(
'textHeightBehavior',
textHeightBehavior,
defaultValue: null,
),
);
if (semanticsLabel != null) {
properties.add(StringProperty('semanticsLabel', semanticsLabel));
}
@@ -693,7 +718,7 @@ class _SelectableTextContainerDelegate
SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) {
if (event.absorb) {
for (int index = 0; index < selectables.length; index += 1) {
for (var index = 0; index < selectables.length; index += 1) {
dispatchSelectionEventToChild(selectables[index], event);
}
currentSelectionStartIndex = 0;
@@ -703,7 +728,7 @@ class _SelectableTextContainerDelegate
// First pass, if the position is on a placeholder then dispatch the selection
// event to the [Selectable] at the location and terminate.
for (int index = 0; index < selectables.length; index += 1) {
for (var index = 0; index < selectables.length; index += 1) {
final bool selectableIsPlaceholder = !paragraph
.selectableBelongsToParagraph(selectables[index]);
if (selectableIsPlaceholder &&
@@ -722,9 +747,9 @@ class _SelectableTextContainerDelegate
}
SelectionResult? lastSelectionResult;
bool foundStart = false;
var foundStart = false;
int? lastNextIndex;
for (int index = 0; index < selectables.length; index += 1) {
for (var index = 0; index < selectables.length; index += 1) {
if (!paragraph.selectableBelongsToParagraph(selectables[index])) {
if (foundStart) {
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(
@@ -769,7 +794,7 @@ class _SelectableTextContainerDelegate
.overlaps(
selectables[index].value.selectionRects[0],
);
int startIndex = 0;
var startIndex = 0;
if (lastNextIndex != null && selectionAtStartOfSelectable) {
startIndex = lastNextIndex + 1;
} else {
@@ -777,7 +802,7 @@ class _SelectableTextContainerDelegate
? 0
: index;
}
for (int i = startIndex; i < index; i += 1) {
for (var i = startIndex; i < index; i += 1) {
final SelectionEvent synthesizedEvent =
SelectParagraphSelectionEvent(
globalPosition: event.globalPosition,
@@ -796,7 +821,7 @@ class _SelectableTextContainerDelegate
if (selectables[index].value != existingGeometry) {
if (!foundStart && lastNextIndex == null) {
currentSelectionStartIndex = 0;
for (int i = 0; i < index; i += 1) {
for (var i = 0; i < index; i += 1) {
final SelectionEvent synthesizedEvent =
SelectParagraphSelectionEvent(
globalPosition: event.globalPosition,
@@ -837,7 +862,7 @@ class _SelectableTextContainerDelegate
);
SelectionResult? finalResult;
// Begin the search for the selection edge at the opposite edge if it exists.
final bool hasOppositeEdge = isEnd
final hasOppositeEdge = isEnd
? currentSelectionStartIndex != -1
: currentSelectionEndIndex != -1;
int newIndex = switch ((isEnd, hasOppositeEdge)) {
@@ -932,10 +957,10 @@ class _SelectableTextContainerDelegate
//
// This can happen when there is a scrollable child and the edge being adjusted
// has been scrolled out of view.
final bool isCurrentEdgeWithinViewport = isEnd
final isCurrentEdgeWithinViewport = isEnd
? value.endSelectionPoint != null
: value.startSelectionPoint != null;
final bool isOppositeEdgeWithinViewport = isEnd
final isOppositeEdgeWithinViewport = isEnd
? value.startSelectionPoint != null
: value.endSelectionPoint != null;
int newIndex = switch ((
@@ -1107,9 +1132,9 @@ class _SelectableTextContainerDelegate
if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) {
return null;
}
int startOffset = 0;
int endOffset = 0;
bool foundStart = false;
var startOffset = 0;
var endOffset = 0;
var foundStart = false;
bool forwardSelection =
currentSelectionEndIndex >= currentSelectionStartIndex;
if (currentSelectionEndIndex == currentSelectionStartIndex) {
@@ -1121,7 +1146,7 @@ class _SelectableTextContainerDelegate
rangeAtSelectableInSelection.endOffset >=
rangeAtSelectableInSelection.startOffset;
}
for (int index = 0; index < selections.length; index++) {
for (var index = 0; index < selections.length; index++) {
final _SelectionInfo selection = selections[index];
if (selection.range == null) {
if (foundStart) {
@@ -1187,7 +1212,7 @@ class _SelectableTextContainerDelegate
/// this method will return `null`.
@override
SelectedContentRange? getSelection() {
final List<_SelectionInfo> selections = <_SelectionInfo>[
final selections = <_SelectionInfo>[
for (final Selectable selectable in selectables)
(
contentLength: selectable.contentLength,
@@ -1232,7 +1257,7 @@ class _SelectableTextContainerDelegate
currentSelectionStartIndex,
currentSelectionEndIndex,
);
for (int index = 0; index < selectables.length; index += 1) {
for (var index = 0; index < selectables.length; index += 1) {
if (index >= skipStart && index <= skipEnd) {
continue;
}
@@ -1266,3 +1291,55 @@ class _SelectableTextContainerDelegate
/// The length of the content that can be selected, and the range that is
/// selected.
typedef _SelectionInfo = ({int contentLength, SelectedContentRange? range});
/// A utility class for overriding the text styles of a [TextSpan] tree.
// When changes are made to this class, the equivalent API in editable_text.dart
// must also be updated.
// TODO(Renzo-Olivares): Remove after investigating a solution for overriding all
// styles for children in an [InlineSpan] tree, see: https://github.com/flutter/flutter/issues/177952.
class _OverridingTextStyleTextSpanUtils {
static TextSpan applyTextSpacingOverrides({
double? lineHeightScaleFactor,
double? letterSpacing,
double? wordSpacing,
required TextSpan textSpan,
}) {
if (lineHeightScaleFactor == null &&
letterSpacing == null &&
wordSpacing == null) {
return textSpan;
}
return _applyTextStyleOverrides(
TextStyle(
height: lineHeightScaleFactor,
letterSpacing: letterSpacing,
wordSpacing: wordSpacing,
),
textSpan,
);
}
static TextSpan _applyTextStyleOverrides(
TextStyle overrideTextStyle,
TextSpan textSpan,
) {
return TextSpan(
text: textSpan.text,
children: textSpan.children?.map((InlineSpan child) {
if (child is TextSpan && child.runtimeType == TextSpan) {
return _applyTextStyleOverrides(overrideTextStyle, child);
}
return child;
}).toList(),
style: textSpan.style?.merge(overrideTextStyle) ?? overrideTextStyle,
recognizer: textSpan.recognizer,
mouseCursor: textSpan.mouseCursor,
onEnter: textSpan.onEnter,
onExit: textSpan.onExit,
semanticsLabel: textSpan.semanticsLabel,
semanticsIdentifier: textSpan.semanticsIdentifier,
locale: textSpan.locale,
spellOut: textSpan.spellOut,
);
}
}

View File

@@ -273,8 +273,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
});
case TargetPlatform.fuchsia:
case TargetPlatform.android:
final List<Widget> buttons = <Widget>[];
for (int i = 0; i < buttonItems.length; i++) {
final buttons = <Widget>[];
for (var i = 0; i < buttonItems.length; i++) {
final ContextMenuButtonItem buttonItem = buttonItems[i];
buttons.add(
TextSelectionToolbarTextButton(

View File

@@ -85,11 +85,9 @@ class RichTextEditingDeltaInsertion extends TextEditingDeltaInsertion
this.emote,
this.id,
this.rawText,
}) {
this.type =
type ??
(composing.isValid ? RichTextType.composing : RichTextType.text);
}
}) : type =
type ??
(composing.isValid ? RichTextType.composing : RichTextType.text);
@override
late final RichTextType type;
@@ -116,11 +114,9 @@ class RichTextEditingDeltaReplacement extends TextEditingDeltaReplacement
this.emote,
this.id,
this.rawText,
}) {
this.type =
type ??
(composing.isValid ? RichTextType.composing : RichTextType.text);
}
}) : type =
type ??
(composing.isValid ? RichTextType.composing : RichTextType.text);
@override
late final RichTextType type;
@@ -158,9 +154,7 @@ class RichTextItem {
required this.range,
this.emote,
this.id,
}) {
_rawText = rawText;
}
}) : _rawText = rawText;
RichTextItem.fromStart(
this.text, {
@@ -168,10 +162,8 @@ class RichTextItem {
this.type = RichTextType.text,
this.emote,
this.id,
}) {
range = TextRange(start: 0, end: text.length);
_rawText = rawText;
}
}) : range = TextRange(start: 0, end: text.length),
_rawText = rawText;
List<RichTextItem>? onInsert(
TextEditingDeltaInsertion delta,

View File

@@ -90,7 +90,7 @@ class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
];
}
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final buttonItems = <ContextMenuButtonItem>[];
// Build suggestion buttons.
for (final String suggestion in spanAtCursorIndex.suggestions.take(

View File

@@ -99,7 +99,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder
// this handler. If the clear button widget recognizes the up event,
// then do not handle it.
if (_state._clearGlobalKey.currentContext != null) {
final RenderBox renderBox =
final renderBox =
_state._clearGlobalKey.currentContext!.findRenderObject()!
as RenderBox;
final Offset localOffset = renderBox.globalToLocal(
@@ -1482,6 +1482,7 @@ class _CupertinoRichTextFieldState extends State<CupertinoRichTextField>
child: _BaselineAlignedStack(
placeholder: placeholder,
editableText: editableText,
textAlignVertical: _textAlignVertical,
editableTextBaseline:
textStyle.textBaseline ?? TextBaseline.alphabetic,
placeholderBaseline:
@@ -1555,11 +1556,11 @@ class _CupertinoRichTextFieldState extends State<CupertinoRichTextField>
}
final bool enabled = widget.enabled;
final Offset cursorOffset = Offset(
final cursorOffset = Offset(
_iOSHorizontalCursorOffsetPixels / MediaQuery.devicePixelRatioOf(context),
0,
);
final List<TextInputFormatter> formatters = <TextInputFormatter>[
final formatters = <TextInputFormatter>[
...?widget.inputFormatters,
if (widget.maxLength != null)
LengthLimitingTextInputFormatter(
@@ -1617,7 +1618,7 @@ class _CupertinoRichTextFieldState extends State<CupertinoRichTextField>
);
final BoxBorder? border = widget.decoration?.border;
Border? resolvedBorder = border as Border?;
var resolvedBorder = border as Border?;
if (border is Border) {
BorderSide resolveBorderSide(BorderSide side) {
return side == BorderSide.none
@@ -1828,14 +1829,16 @@ class _BaselineAlignedStack
const _BaselineAlignedStack({
required this.editableTextBaseline,
required this.placeholderBaseline,
required this.textAlignVertical,
required this.editableText,
this.placeholder,
});
final TextBaseline editableTextBaseline;
final TextBaseline placeholderBaseline;
final Widget? placeholder;
final TextAlignVertical textAlignVertical;
final Widget editableText;
final Widget? placeholder;
@override
Iterable<_BaselineAlignedStackSlot> get slots =>
@@ -1852,6 +1855,7 @@ class _BaselineAlignedStack
@override
_RenderBaselineAlignedStack createRenderObject(BuildContext context) {
return _RenderBaselineAlignedStack(
textAlignVertical: textAlignVertical,
editableTextBaseline: editableTextBaseline,
placeholderBaseline: placeholderBaseline,
);
@@ -1863,6 +1867,7 @@ class _BaselineAlignedStack
_RenderBaselineAlignedStack renderObject,
) {
renderObject
..textAlignVertical = textAlignVertical
..editableTextBaseline = editableTextBaseline
..placeholderBaseline = placeholderBaseline;
}
@@ -1878,11 +1883,23 @@ class _RenderBaselineAlignedStack extends RenderBox
RenderBox
> {
_RenderBaselineAlignedStack({
required TextAlignVertical textAlignVertical,
required TextBaseline editableTextBaseline,
required TextBaseline placeholderBaseline,
}) : _editableTextBaseline = editableTextBaseline,
}) : _textAlignVertical = textAlignVertical,
_editableTextBaseline = editableTextBaseline,
_placeholderBaseline = placeholderBaseline;
TextAlignVertical get textAlignVertical => _textAlignVertical;
TextAlignVertical _textAlignVertical;
set textAlignVertical(TextAlignVertical value) {
if (_textAlignVertical == value) {
return;
}
_textAlignVertical = value;
markNeedsLayout();
}
TextBaseline get editableTextBaseline => _editableTextBaseline;
TextBaseline _editableTextBaseline;
set editableTextBaseline(TextBaseline value) {
@@ -1960,9 +1977,9 @@ class _RenderBaselineAlignedStack extends RenderBox
final RenderBox? placeholder = _placeholderChild;
final RenderBox editableText = _editableTextChild;
final _BaselineAlignedStackParentData editableTextParentData =
final editableTextParentData =
editableText.parentData! as _BaselineAlignedStackParentData;
final _BaselineAlignedStackParentData? placeholderParentData =
final placeholderParentData =
placeholder?.parentData as _BaselineAlignedStackParentData?;
size = _computeSize(
@@ -1979,13 +1996,17 @@ class _RenderBaselineAlignedStack extends RenderBox
);
assert(placeholder != null || placeholderBaselineValue == null);
final double placeholderY = placeholderBaselineValue != null
? editableTextBaselineValue - placeholderBaselineValue
: 0.0;
final Offset baselineDiff = placeholderBaselineValue != null
? Offset(0.0, editableTextBaselineValue - placeholderBaselineValue)
: Offset.zero;
final verticalAlignment = Alignment(0.0, textAlignVertical.y);
final double offsetYAdjustment = math.max(0, placeholderY);
editableTextParentData.offset = Offset(0, offsetYAdjustment);
placeholderParentData?.offset = Offset(0, placeholderY + offsetYAdjustment);
editableTextParentData.offset = verticalAlignment.alongOffset(
size - editableText.size as Offset,
);
// Baseline-align the placeholder to the editable text.
placeholderParentData?.offset =
editableTextParentData.offset + baselineDiff;
}
@override
@@ -1994,12 +2015,12 @@ class _RenderBaselineAlignedStack extends RenderBox
final RenderBox editableText = _editableTextChild;
if (placeholder != null) {
final _BaselineAlignedStackParentData placeholderParentData =
final placeholderParentData =
placeholder.parentData! as _BaselineAlignedStackParentData;
context.paintChild(placeholder, offset + placeholderParentData.offset);
}
final _BaselineAlignedStackParentData editableTextParentData =
final editableTextParentData =
editableText.parentData! as _BaselineAlignedStackParentData;
context.paintChild(editableText, offset + editableTextParentData.offset);
}
@@ -2054,7 +2075,7 @@ class _RenderBaselineAlignedStack extends RenderBox
height = math.max(height, editableTextSize.height);
width = math.max(width, editableTextSize.width);
final Size size = Size(width, height);
final size = Size(width, height);
assert(size.isFinite);
return constraints.constrain(size);
}
@@ -2062,7 +2083,7 @@ class _RenderBaselineAlignedStack extends RenderBox
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final RenderBox editableText = _editableTextChild;
final _BaselineAlignedStackParentData editableTextParentData =
final editableTextParentData =
editableText.parentData! as _BaselineAlignedStackParentData;
return result.addWithPaintOffset(

View File

@@ -145,17 +145,13 @@ class VerticalCaretMovementRun implements Iterator<TextPosition> {
}
assert(lineNumber != _currentLine);
final Offset newOffset = Offset(
final newOffset = Offset(
_currentOffset.dx,
_lineMetrics[lineNumber].baseline,
);
final TextPosition closestPosition = _editable._textPainter
.getPositionForOffset(newOffset);
final MapEntry<Offset, TextPosition> position =
MapEntry<Offset, TextPosition>(
newOffset,
closestPosition,
);
final position = MapEntry<Offset, TextPosition>(newOffset, closestPosition);
_positionCache[lineNumber] = position;
return position;
}
@@ -419,8 +415,9 @@ class RenderEditable extends RenderBox
);
if (_foregroundRenderObject == null) {
final _RenderEditableCustomPaint foregroundRenderObject =
_RenderEditableCustomPaint(painter: effectivePainter);
final foregroundRenderObject = _RenderEditableCustomPaint(
painter: effectivePainter,
);
adoptChild(foregroundRenderObject);
_foregroundRenderObject = foregroundRenderObject;
} else {
@@ -452,8 +449,9 @@ class RenderEditable extends RenderBox
);
if (_backgroundRenderObject == null) {
final _RenderEditableCustomPaint backgroundRenderObject =
_RenderEditableCustomPaint(painter: effectivePainter);
final backgroundRenderObject = _RenderEditableCustomPaint(
painter: effectivePainter,
);
adoptChild(backgroundRenderObject);
_backgroundRenderObject = backgroundRenderObject;
} else {
@@ -714,7 +712,7 @@ class RenderEditable extends RenderBox
// happens in paragraph.cc's layout and TextPainter's
// _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
// this can be changed to be a strict check instead of an approximation.
const double visibleRegionSlop = 0.5;
const visibleRegionSlop = 0.5;
_selectionStartInViewport.value = visibleRegion
.inflate(visibleRegionSlop)
.contains(startOffset + effectiveOffset);
@@ -1356,9 +1354,9 @@ class RenderEditable extends RenderBox
obscuringCharacter * plainText.length,
);
} else {
final StringBuffer buffer = StringBuffer();
int offset = 0;
final List<StringAttribute> attributes = <StringAttribute>[];
final buffer = StringBuffer();
var offset = 0;
final attributes = <StringAttribute>[];
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
final String label = info.semanticsLabel ?? info.text;
for (final StringAttribute infoAttribute in info.stringAttributes) {
@@ -1436,19 +1434,19 @@ class RenderEditable extends RenderBox
Iterable<SemanticsNode> children,
) {
assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty);
final List<SemanticsNode> newChildren = <SemanticsNode>[];
final newChildren = <SemanticsNode>[];
TextDirection currentDirection = textDirection;
Rect currentRect;
double ordinal = 0.0;
int start = 0;
int placeholderIndex = 0;
int childIndex = 0;
var ordinal = 0.0;
var start = 0;
var placeholderIndex = 0;
var childIndex = 0;
RenderBox? child = firstChild;
final Map<Key, SemanticsNode> newChildCache = <Key, SemanticsNode>{};
final newChildCache = <Key, SemanticsNode>{};
_cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
for (final InlineSpanSemanticsInformation info
in _cachedCombinedSemanticsInfos!) {
final TextSelection selection = TextSelection(
final selection = TextSelection(
baseOffset: start,
extentOffset: start + info.text.length,
);
@@ -1462,8 +1460,7 @@ class RenderEditable extends RenderBox
.elementAt(childIndex)
.isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
final SemanticsNode childNode = children.elementAt(childIndex);
final TextParentData parentData =
child!.parentData! as TextParentData;
final parentData = child!.parentData! as TextParentData;
assert(parentData.offset != null);
newChildren.add(childNode);
childIndex += 1;
@@ -1471,7 +1468,7 @@ class RenderEditable extends RenderBox
child = childAfter(child!);
placeholderIndex += 1;
} else {
final TextDirection initialDirection = currentDirection;
final initialDirection = currentDirection;
final List<ui.TextBox> rects = _textPainter.getBoxesForSelection(
selection,
);
@@ -1500,7 +1497,7 @@ class RenderEditable extends RenderBox
rect.right.ceilToDouble() + 4.0,
rect.bottom.ceilToDouble() + 4.0,
);
final SemanticsConfiguration configuration = SemanticsConfiguration()
final configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection
..attributedLabel = AttributedString(
@@ -1538,7 +1535,7 @@ class RenderEditable extends RenderBox
if (_cachedChildNodes?.isNotEmpty ?? false) {
newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!;
} else {
final UniqueKey key = UniqueKey();
final key = UniqueKey();
newChild = SemanticsNode(
key: key,
showOnScreen: _createShowOnScreenFor(key),
@@ -1980,8 +1977,8 @@ class RenderEditable extends RenderBox
if (cachedValue != null) {
return cachedValue;
}
int count = 0;
for (int index = 0; index < text.length; index += 1) {
var count = 0;
for (var index = 0; index < text.length; index += 1) {
switch (text.codeUnitAt(index)) {
case 0x000A: // LF
case 0x0085: // NEL
@@ -2242,7 +2239,7 @@ class RenderEditable extends RenderBox
extentOffset = isNormalized ? newOffset.endOffset : newOffset.startOffset;
}
final TextSelection newSelection = TextSelection(
final newSelection = TextSelection(
baseOffset: baseOffset,
extentOffset: extentOffset,
affinity: fromPosition.affinity,
@@ -2591,12 +2588,12 @@ class RenderEditable extends RenderBox
};
size = Size(width, constraints.constrainHeight(preferredHeight));
final Size contentSize = Size(
final contentSize = Size(
_textPainter.width + _caretMargin,
_textPainter.height,
);
final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize);
final painterConstraints = BoxConstraints.tight(contentSize);
_foregroundRenderObject?.layout(painterConstraints);
_backgroundRenderObject?.layout(painterConstraints);
@@ -2656,7 +2653,7 @@ class RenderEditable extends RenderBox
final double rightBound =
math.min(size.width, _textPainter.width) +
floatingCursorAddedMargin.right;
final Rect boundingRects = Rect.fromLTRB(
final boundingRects = Rect.fromLTRB(
leftBound,
topBound,
rightBound,
@@ -2780,7 +2777,7 @@ class RenderEditable extends RenderBox
startPosition,
Rect.zero,
);
for (final ui.LineMetrics lineMetrics in metrics) {
for (final lineMetrics in metrics) {
if (lineMetrics.baseline > offset.dy) {
return MapEntry<int, Offset>(
lineMetrics.lineNumber,
@@ -3163,7 +3160,7 @@ class _TextHighlightPainter extends RenderEditablePainter {
)
.toSet();
for (final TextBox box in boxes) {
for (final box in boxes) {
canvas.drawRect(
box
.toRect()
@@ -3290,7 +3287,7 @@ class _CaretPainter extends RenderEditablePainter {
if (radius == null) {
canvas.drawRect(integralRect, caretPaint);
} else {
final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius);
final caretRRect = RRect.fromRectAndRadius(integralRect, radius);
canvas.drawRRect(caretRRect, caretPaint);
}
}

View File

@@ -146,7 +146,7 @@ class _DiscreteKeyFrameSimulation extends Simulation {
: assert(_keyFrames.isNotEmpty),
assert(_keyFrames.last.time <= maxDuration),
assert(() {
for (int i = 0; i < _keyFrames.length - 1; i += 1) {
for (var i = 0; i < _keyFrames.length - 1; i += 1) {
if (_keyFrames[i].time > _keyFrames[i + 1].time) {
return false;
}
@@ -407,7 +407,11 @@ class _DiscreteKeyFrameSimulation extends Simulation {
/// ```dart
/// onChanged: (String newText) {
/// if (newText.isNotEmpty) {
/// SemanticsService.announce('\$$newText', Directionality.of(context));
/// SemanticsService.sendAnnouncement(
/// View.of(context),
/// '\$$newText',
/// Directionality.of(context),
/// );
/// }
/// }
/// ```
@@ -692,6 +696,13 @@ class EditableText extends StatefulWidget {
final bool enableSuggestions;
/// The text style to use for the editable text.
///
/// The user or platform may override this [style]'s [TextStyle.fontWeight],
/// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
/// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
/// [MediaQueryData.lineHeightScaleFactorOverride],
/// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
/// regardless of its [TextStyle.inherit] value.
final TextStyle style;
/// {@template flutter.widgets.editableText.strutStyle}
@@ -718,6 +729,9 @@ class EditableText extends StatefulWidget {
/// Within editable text and text fields, [StrutStyle] will not use its standalone
/// default values, and will instead inherit omitted/null properties from the
/// [TextStyle] instead. See [StrutStyle.inheritFromTextStyle].
///
/// The user or platform may override this [strutStyle]'s [StrutStyle.height]
/// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
StrutStyle get strutStyle {
if (_strutStyle == null) {
return StrutStyle.fromTextStyle(style, forceStrutHeight: true);
@@ -1750,8 +1764,7 @@ class EditableText extends StatefulWidget {
required final VoidCallback? onShare,
required final VoidCallback? onLiveTextInput,
}) {
final List<ContextMenuButtonItem> resultButtonItem =
<ContextMenuButtonItem>[];
final resultButtonItem = <ContextMenuButtonItem>[];
// Configure button items with clipboard.
if (onPaste == null || clipboardStatus != ClipboardStatus.unknown) {
@@ -1760,7 +1773,7 @@ class EditableText extends StatefulWidget {
// shown.
// On Android, the share button is before the select all button.
final bool showShareBeforeSelectAll =
final showShareBeforeSelectAll =
defaultTargetPlatform == TargetPlatform.android;
resultButtonItem.addAll(<ContextMenuButtonItem>[
@@ -1875,8 +1888,7 @@ class EditableText extends StatefulWidget {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
const Map<String, TextInputType>
iOSKeyboardType = <String, TextInputType>{
const iOSKeyboardType = <String, TextInputType>{
AutofillHints.addressCity: TextInputType.name,
AutofillHints.addressCityAndState:
TextInputType.name, // Autofill not working.
@@ -1931,77 +1943,76 @@ class EditableText extends StatefulWidget {
return TextInputType.multiline;
}
const Map<String, TextInputType> inferKeyboardType =
<String, TextInputType>{
AutofillHints.addressCity: TextInputType.streetAddress,
AutofillHints.addressCityAndState: TextInputType.streetAddress,
AutofillHints.addressState: TextInputType.streetAddress,
AutofillHints.birthday: TextInputType.datetime,
AutofillHints.birthdayDay: TextInputType.datetime,
AutofillHints.birthdayMonth: TextInputType.datetime,
AutofillHints.birthdayYear: TextInputType.datetime,
AutofillHints.countryCode: TextInputType.number,
AutofillHints.countryName: TextInputType.text,
AutofillHints.creditCardExpirationDate: TextInputType.datetime,
AutofillHints.creditCardExpirationDay: TextInputType.datetime,
AutofillHints.creditCardExpirationMonth: TextInputType.datetime,
AutofillHints.creditCardExpirationYear: TextInputType.datetime,
AutofillHints.creditCardFamilyName: TextInputType.name,
AutofillHints.creditCardGivenName: TextInputType.name,
AutofillHints.creditCardMiddleName: TextInputType.name,
AutofillHints.creditCardName: TextInputType.name,
AutofillHints.creditCardNumber: TextInputType.number,
AutofillHints.creditCardSecurityCode: TextInputType.number,
AutofillHints.creditCardType: TextInputType.text,
AutofillHints.email: TextInputType.emailAddress,
AutofillHints.familyName: TextInputType.name,
AutofillHints.fullStreetAddress: TextInputType.streetAddress,
AutofillHints.gender: TextInputType.text,
AutofillHints.givenName: TextInputType.name,
AutofillHints.impp: TextInputType.url,
AutofillHints.jobTitle: TextInputType.text,
AutofillHints.language: TextInputType.text,
AutofillHints.location: TextInputType.streetAddress,
AutofillHints.middleInitial: TextInputType.name,
AutofillHints.middleName: TextInputType.name,
AutofillHints.name: TextInputType.name,
AutofillHints.namePrefix: TextInputType.name,
AutofillHints.nameSuffix: TextInputType.name,
AutofillHints.newPassword: TextInputType.text,
AutofillHints.newUsername: TextInputType.text,
AutofillHints.nickname: TextInputType.text,
AutofillHints.oneTimeCode: TextInputType.text,
AutofillHints.organizationName: TextInputType.text,
AutofillHints.password: TextInputType.text,
AutofillHints.photo: TextInputType.text,
AutofillHints.postalAddress: TextInputType.streetAddress,
AutofillHints.postalAddressExtended: TextInputType.streetAddress,
AutofillHints.postalAddressExtendedPostalCode: TextInputType.number,
AutofillHints.postalCode: TextInputType.number,
AutofillHints.streetAddressLevel1: TextInputType.streetAddress,
AutofillHints.streetAddressLevel2: TextInputType.streetAddress,
AutofillHints.streetAddressLevel3: TextInputType.streetAddress,
AutofillHints.streetAddressLevel4: TextInputType.streetAddress,
AutofillHints.streetAddressLine1: TextInputType.streetAddress,
AutofillHints.streetAddressLine2: TextInputType.streetAddress,
AutofillHints.streetAddressLine3: TextInputType.streetAddress,
AutofillHints.sublocality: TextInputType.streetAddress,
AutofillHints.telephoneNumber: TextInputType.phone,
AutofillHints.telephoneNumberAreaCode: TextInputType.phone,
AutofillHints.telephoneNumberCountryCode: TextInputType.phone,
AutofillHints.telephoneNumberDevice: TextInputType.phone,
AutofillHints.telephoneNumberExtension: TextInputType.phone,
AutofillHints.telephoneNumberLocal: TextInputType.phone,
AutofillHints.telephoneNumberLocalPrefix: TextInputType.phone,
AutofillHints.telephoneNumberLocalSuffix: TextInputType.phone,
AutofillHints.telephoneNumberNational: TextInputType.phone,
AutofillHints.transactionAmount: TextInputType.numberWithOptions(
decimal: true,
),
AutofillHints.transactionCurrency: TextInputType.text,
AutofillHints.url: TextInputType.url,
AutofillHints.username: TextInputType.text,
};
const inferKeyboardType = <String, TextInputType>{
AutofillHints.addressCity: TextInputType.streetAddress,
AutofillHints.addressCityAndState: TextInputType.streetAddress,
AutofillHints.addressState: TextInputType.streetAddress,
AutofillHints.birthday: TextInputType.datetime,
AutofillHints.birthdayDay: TextInputType.datetime,
AutofillHints.birthdayMonth: TextInputType.datetime,
AutofillHints.birthdayYear: TextInputType.datetime,
AutofillHints.countryCode: TextInputType.number,
AutofillHints.countryName: TextInputType.text,
AutofillHints.creditCardExpirationDate: TextInputType.datetime,
AutofillHints.creditCardExpirationDay: TextInputType.datetime,
AutofillHints.creditCardExpirationMonth: TextInputType.datetime,
AutofillHints.creditCardExpirationYear: TextInputType.datetime,
AutofillHints.creditCardFamilyName: TextInputType.name,
AutofillHints.creditCardGivenName: TextInputType.name,
AutofillHints.creditCardMiddleName: TextInputType.name,
AutofillHints.creditCardName: TextInputType.name,
AutofillHints.creditCardNumber: TextInputType.number,
AutofillHints.creditCardSecurityCode: TextInputType.number,
AutofillHints.creditCardType: TextInputType.text,
AutofillHints.email: TextInputType.emailAddress,
AutofillHints.familyName: TextInputType.name,
AutofillHints.fullStreetAddress: TextInputType.streetAddress,
AutofillHints.gender: TextInputType.text,
AutofillHints.givenName: TextInputType.name,
AutofillHints.impp: TextInputType.url,
AutofillHints.jobTitle: TextInputType.text,
AutofillHints.language: TextInputType.text,
AutofillHints.location: TextInputType.streetAddress,
AutofillHints.middleInitial: TextInputType.name,
AutofillHints.middleName: TextInputType.name,
AutofillHints.name: TextInputType.name,
AutofillHints.namePrefix: TextInputType.name,
AutofillHints.nameSuffix: TextInputType.name,
AutofillHints.newPassword: TextInputType.text,
AutofillHints.newUsername: TextInputType.text,
AutofillHints.nickname: TextInputType.text,
AutofillHints.oneTimeCode: TextInputType.text,
AutofillHints.organizationName: TextInputType.text,
AutofillHints.password: TextInputType.text,
AutofillHints.photo: TextInputType.text,
AutofillHints.postalAddress: TextInputType.streetAddress,
AutofillHints.postalAddressExtended: TextInputType.streetAddress,
AutofillHints.postalAddressExtendedPostalCode: TextInputType.number,
AutofillHints.postalCode: TextInputType.number,
AutofillHints.streetAddressLevel1: TextInputType.streetAddress,
AutofillHints.streetAddressLevel2: TextInputType.streetAddress,
AutofillHints.streetAddressLevel3: TextInputType.streetAddress,
AutofillHints.streetAddressLevel4: TextInputType.streetAddress,
AutofillHints.streetAddressLine1: TextInputType.streetAddress,
AutofillHints.streetAddressLine2: TextInputType.streetAddress,
AutofillHints.streetAddressLine3: TextInputType.streetAddress,
AutofillHints.sublocality: TextInputType.streetAddress,
AutofillHints.telephoneNumber: TextInputType.phone,
AutofillHints.telephoneNumberAreaCode: TextInputType.phone,
AutofillHints.telephoneNumberCountryCode: TextInputType.phone,
AutofillHints.telephoneNumberDevice: TextInputType.phone,
AutofillHints.telephoneNumberExtension: TextInputType.phone,
AutofillHints.telephoneNumberLocal: TextInputType.phone,
AutofillHints.telephoneNumberLocalPrefix: TextInputType.phone,
AutofillHints.telephoneNumberLocalSuffix: TextInputType.phone,
AutofillHints.telephoneNumberNational: TextInputType.phone,
AutofillHints.transactionAmount: TextInputType.numberWithOptions(
decimal: true,
),
AutofillHints.transactionCurrency: TextInputType.text,
AutofillHints.url: TextInputType.url,
AutofillHints.username: TextInputType.text,
};
return inferKeyboardType[effectiveHint] ?? TextInputType.text;
}
@@ -2341,7 +2352,7 @@ class EditableTextState extends State<EditableText>
widget.cursorColor.alpha / 255.0,
_cursorBlinkOpacityController.value,
);
return widget.cursorColor.withOpacity(effectiveOpacity);
return widget.cursorColor.withValues(alpha: effectiveOpacity);
}
@override
@@ -2737,9 +2748,9 @@ class EditableTextState extends State<EditableText>
final List<SuggestionSpan> suggestionSpans =
spellCheckResults!.suggestionSpans;
int leftIndex = 0;
var leftIndex = 0;
int rightIndex = suggestionSpans.length - 1;
int midIndex = 0;
var midIndex = 0;
while (leftIndex <= rightIndex) {
midIndex = ((leftIndex + rightIndex) / 2).floor();
@@ -2983,7 +2994,7 @@ class EditableTextState extends State<EditableText>
}
List<ContextMenuButtonItem> get _textProcessingActionButtonItems {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final buttonItems = <ContextMenuButtonItem>[];
final TextSelection selection = textEditingValue.selection;
if (widget.obscureText || !selection.isValid || selection.isCollapsed) {
return buttonItems;
@@ -3033,17 +3044,29 @@ class EditableTextState extends State<EditableText>
_spellCheckConfiguration = _inferSpellCheckConfiguration(
widget.spellCheckConfiguration,
);
_appLifecycleListener = AppLifecycleListener(
onResume: () => _justResumed = true,
);
_appLifecycleListener = AppLifecycleListener(onResume: _onResume);
_initProcessTextActions();
}
void _onResume() {
_justResumed = true;
// To prevent adding multiple listeners, remove any existing one first.
FocusManager.instance.removeListener(_resetJustResumed);
// Reset _justResumed as soon as there is a focus change.
FocusManager.instance.addListener(_resetJustResumed);
}
void _resetJustResumed() {
_justResumed = false;
FocusManager.instance.removeListener(_resetJustResumed);
}
/// Query the engine to initialize the list of text processing actions to show
/// in the text selection toolbar.
Future<void> _initProcessTextActions() async {
_processTextActions.clear();
_processTextActions.addAll(await _processTextService.queryTextActions());
_processTextActions
..clear()
..addAll(await _processTextService.queryTextActions());
}
// Whether `TickerMode.of(context)` is true and animations (like blinking the
@@ -3269,11 +3292,13 @@ class EditableTextState extends State<EditableText>
WidgetsBinding.instance.removeObserver(this);
_liveTextInputStatus?.removeListener(_onChangedLiveTextInputStatus);
_liveTextInputStatus?.dispose();
clipboardStatus.removeListener(_onChangedClipboardStatus);
clipboardStatus.dispose();
clipboardStatus
..removeListener(_onChangedClipboardStatus)
..dispose();
_cursorVisibilityNotifier.dispose();
_appLifecycleListener.dispose();
FocusManager.instance.removeListener(_unflagInternalFocus);
FocusManager.instance.removeListener(_resetJustResumed);
_disposeScrollNotificationObserver();
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
@@ -3803,7 +3828,7 @@ class EditableTextState extends State<EditableText>
// The caret is vertically centered within the line. Expand the caret's
// height so that it spans the line because we're going to ensure that the
// entire expanded caret is scrolled into view.
final Rect expandedRect = Rect.fromCenter(
final expandedRect = Rect.fromCenter(
center: rect.center,
width: rect.width,
height: math.max(rect.height, renderEditable.preferredLineHeight),
@@ -4088,7 +4113,7 @@ class EditableTextState extends State<EditableText>
view.devicePixelRatio;
final double obscuredHorizontal =
(view.padding.left + view.padding.right) / view.devicePixelRatio;
final Size visibleScreenSize = Size(
final visibleScreenSize = Size(
screenSize.width - obscuredHorizontal,
screenSize.height - obscuredVertical,
);
@@ -4160,7 +4185,13 @@ class EditableTextState extends State<EditableText>
_showToolbarOnScreenScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_showToolbarOnScreenScheduled = false;
if (!mounted) {
if (!mounted || _dataWhenToolbarShowScheduled == null) {
return;
}
if (_dataWhenToolbarShowScheduled!.value != _value) {
// Value has changed so we should invalidate any toolbar scheduling.
_dataWhenToolbarShowScheduled = null;
_disposeScrollNotificationObserver();
return;
}
final Rect deviceRect = _calculateDeviceRect();
@@ -4210,7 +4241,7 @@ class EditableTextState extends State<EditableText>
TextSelectionOverlay _createSelectionOverlay() {
final EditableTextContextMenuBuilder? contextMenuBuilder =
widget.contextMenuBuilder;
final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
final selectionOverlay = TextSelectionOverlay(
controller: widget.controller,
clipboardStatus: clipboardStatus,
context: context,
@@ -4319,7 +4350,7 @@ class EditableTextState extends State<EditableText>
_showCaretOnScreenScheduled = false;
// Since we are in a post frame callback, check currentContext in case
// RenderEditable has been disposed (in which case it will be null).
final RenderEditable? renderEditable =
final renderEditable =
_editableKey.currentContext?.findRenderObject() as RenderEditable?;
if (renderEditable == null ||
!(renderEditable.selection?.isValid ?? false) ||
@@ -4435,13 +4466,30 @@ class EditableTextState extends State<EditableText>
.spellCheckService!
.fetchSpellCheckSuggestions(localeForSpellChecking!, text);
if (suggestions == null) {
// The request to fetch spell check suggestions was canceled due to ongoing request.
if (suggestions == null || !mounted) {
// The request to fetch spell check suggestions was canceled due to ongoing request,
// or the widget was unmounted.
return;
}
spellCheckResults = SpellCheckResults(text, suggestions);
renderEditable.text = buildTextSpan();
final double? lineHeightScaleFactor =
MediaQuery.maybeLineHeightScaleFactorOverrideOf(
context,
);
final double? letterSpacing = MediaQuery.maybeLetterSpacingOverrideOf(
context,
);
final double? wordSpacing = MediaQuery.maybeWordSpacingOverrideOf(
context,
);
renderEditable.text =
_OverridingTextStyleTextSpanUtils.applyTextSpacingOverrides(
lineHeightScaleFactor: lineHeightScaleFactor,
letterSpacing: letterSpacing,
wordSpacing: wordSpacing,
textSpan: buildTextSpan(),
);
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
@@ -4461,10 +4509,10 @@ class EditableTextState extends State<EditableText>
bool userInteraction = false,
}) {
final TextEditingValue oldValue = _value;
final bool textChanged = oldValue.text != value.text;
final textChanged = oldValue.text != value.text;
final bool textCommitted =
!oldValue.composing.isCollapsed && value.composing.isCollapsed;
final bool selectionChanged = oldValue.selection != value.selection;
final selectionChanged = oldValue.selection != value.selection;
if (textChanged || textCommitted) {
// Only apply input formatters if the text has changed (including uncommitted
@@ -4567,8 +4615,8 @@ class EditableTextState extends State<EditableText>
widget.cursorColor.alpha / 255.0,
_cursorBlinkOpacityController.value,
);
renderEditable.cursorColor = widget.cursorColor.withOpacity(
effectiveOpacity,
renderEditable.cursorColor = widget.cursorColor.withValues(
alpha: effectiveOpacity,
);
_cursorVisibilityNotifier.value =
widget.showCursor &&
@@ -4797,6 +4845,8 @@ class EditableTextState extends State<EditableText>
}
final InlineSpan inlineSpan = renderEditable.text!;
final double? lineHeightScaleFactor =
MediaQuery.maybeLineHeightScaleFactorOverrideOf(context);
final TextScaler effectiveTextScaler = switch ((
widget.textScaler,
widget.textScaleFactor,
@@ -4808,7 +4858,7 @@ class EditableTextState extends State<EditableText>
(null, null) => MediaQuery.textScalerOf(context),
};
final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey(
final newCacheKey = _ScribbleCacheKey(
inlineSpan: inlineSpan,
textAlign: widget.textAlign,
textDirection: _textDirection,
@@ -4817,7 +4867,9 @@ class EditableTextState extends State<EditableText>
widget.textHeightBehavior ??
DefaultTextHeightBehavior.maybeOf(context),
locale: widget.locale,
structStyle: widget.strutStyle,
structStyle: widget.strutStyle.merge(
StrutStyle(height: lineHeightScaleFactor),
),
placeholder: _placeholderLocation,
size: renderEditable.size,
);
@@ -4830,14 +4882,14 @@ class EditableTextState extends State<EditableText>
}
_scribbleCacheKey = newCacheKey;
final List<SelectionRect> rects = <SelectionRect>[];
int graphemeStart = 0;
final rects = <SelectionRect>[];
var graphemeStart = 0;
// Can't use _value.text here: the controller value could change between
// frames.
final String plainText = inlineSpan.toPlainText(
includeSemanticsLabels: false,
);
final CharacterRange characterRange = CharacterRange(plainText);
final characterRange = CharacterRange(plainText);
while (characterRange.moveNext()) {
final int graphemeEnd = graphemeStart + characterRange.current.length;
final List<TextBox> boxes = renderEditable.getBoxesForSelection(
@@ -4911,9 +4963,7 @@ class EditableTextState extends State<EditableText>
if (selection == null || !selection.isValid) {
return;
}
final TextPosition currentTextPosition = TextPosition(
offset: selection.start,
);
final currentTextPosition = TextPosition(offset: selection.start);
final Rect caretRect = renderEditable.getLocalRectForCaret(
currentTextPosition,
);
@@ -4942,7 +4992,7 @@ class EditableTextState extends State<EditableText>
) {
// Compare the current TextEditingValue with the pre-format new
// TextEditingValue value, in case the formatter would reject the change.
final bool shouldShowCaret = widget.readOnly
final shouldShowCaret = widget.readOnly
? _value.selection != value.selection
: _value != value;
if (shouldShowCaret) {
@@ -5353,11 +5403,8 @@ class EditableTextState extends State<EditableText>
final String text = _value.text;
final TextSelection selection = _value.selection;
final bool atEnd = selection.baseOffset == text.length;
final CharacterRange transposing = CharacterRange.at(
text,
selection.baseOffset,
);
final atEnd = selection.baseOffset == text.length;
final transposing = CharacterRange.at(text, selection.baseOffset);
if (atEnd) {
transposing.moveBack(2);
} else {
@@ -5459,8 +5506,7 @@ class EditableTextState extends State<EditableText>
return;
}
final ScrollableState? state =
_scrollableKey.currentState as ScrollableState?;
final state = _scrollableKey.currentState as ScrollableState?;
final double increment = ScrollAction.getDirectionalIncrement(
state!,
intent,
@@ -5487,8 +5533,7 @@ class EditableTextState extends State<EditableText>
final Rect extentRect = renderEditable.getLocalRectForCaret(
_value.selection.extent,
);
final ScrollableState? state =
_scrollableKey.currentState as ScrollableState?;
final state = _scrollableKey.currentState as ScrollableState?;
final double increment = ScrollAction.getDirectionalIncrement(
state!,
ScrollIntent(
@@ -5501,7 +5546,7 @@ class EditableTextState extends State<EditableText>
if (_value.selection.extentOffset >= _value.text.length) {
return;
}
final Offset nextExtentOffset = Offset(
final nextExtentOffset = Offset(
extentRect.left,
extentRect.top + increment,
);
@@ -5520,7 +5565,7 @@ class EditableTextState extends State<EditableText>
if (_value.selection.extentOffset <= 0) {
return;
}
final Offset nextExtentOffset = Offset(
final nextExtentOffset = Offset(
extentRect.left,
extentRect.top + increment,
);
@@ -5802,6 +5847,12 @@ class EditableTextState extends State<EditableText>
),
(null, null) => MediaQuery.textScalerOf(context),
};
final double? lineHeightScaleFactor =
MediaQuery.maybeLineHeightScaleFactorOverrideOf(context);
final double? letterSpacing = MediaQuery.maybeLetterSpacingOverrideOf(
context,
);
final double? wordSpacing = MediaQuery.maybeWordSpacingOverrideOf(context);
final ui.SemanticsInputType inputType;
switch (widget.keyboardType) {
case TextInputType.phone:
@@ -5892,7 +5943,14 @@ class EditableTextState extends State<EditableText>
startHandleLayerLink:
_startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
inlineSpan:
_OverridingTextStyleTextSpanUtils.applyTextSpacingOverrides(
lineHeightScaleFactor:
lineHeightScaleFactor,
letterSpacing: letterSpacing,
wordSpacing: wordSpacing,
textSpan: buildTextSpan(),
),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor:
@@ -5904,7 +5962,11 @@ class EditableTextState extends State<EditableText>
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
strutStyle: widget.strutStyle.merge(
StrutStyle(
height: lineHeightScaleFactor,
),
),
selectionColor:
_selectionOverlay
?.spellCheckToolbarIsVisible ??
@@ -5974,7 +6036,7 @@ class EditableTextState extends State<EditableText>
String text = _value.text;
text = widget.obscuringCharacter * text.length;
// Reveal the latest character in an obscured field only on mobile.
const Set<TargetPlatform> mobilePlatforms = <TargetPlatform>{
const mobilePlatforms = <TargetPlatform>{
TargetPlatform.android,
TargetPlatform.fuchsia,
TargetPlatform.iOS,
@@ -5994,7 +6056,7 @@ class EditableTextState extends State<EditableText>
}
if (_placeholderLocation >= 0 &&
_placeholderLocation <= _value.text.length) {
final List<_ScribblePlaceholder> placeholders = <_ScribblePlaceholder>[];
final placeholders = <_ScribblePlaceholder>[];
final int placeholderLocation = _value.text.length - _placeholderLocation;
if (_isMultiline) {
// The zero size placeholder here allows the line to break and keep the caret on the first line.
@@ -6379,7 +6441,7 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable>
return false;
}
final Rect intersection = calculatedBounds.intersect(rect);
final HitTestResult result = HitTestResult();
final result = HitTestResult();
WidgetsBinding.instance.hitTestInView(
result,
intersection.center,
@@ -6392,7 +6454,7 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable>
@override
Rect get bounds {
final RenderBox? box = context.findRenderObject() as RenderBox?;
final box = context.findRenderObject() as RenderBox?;
if (box == null || !mounted || !box.attached) {
return Rect.zero;
}
@@ -6422,7 +6484,7 @@ class _ScribblePlaceholder extends WidgetSpan {
List<PlaceholderDimensions>? dimensions,
}) {
assert(debugAssertIsValid());
final bool hasStyle = style != null;
final hasStyle = style != null;
if (hasStyle) {
builder.pushStyle(style!.getTextStyle(textScaler: textScaler));
}
@@ -6539,13 +6601,13 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent>
final TextBoundary atomicBoundary = state._characterBoundary();
if (!selection.isCollapsed) {
// Expands the selection to ensure the range covers full graphemes.
final TextRange range = TextRange(
final range = TextRange(
start:
atomicBoundary.getLeadingTextBoundaryAt(selection.start) ??
state._value.text.length,
end: atomicBoundary.getTrailingTextBoundaryAt(selection.end - 1) ?? 0,
);
final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent(
final replaceTextIntent = ReplaceTextIntent(
state._value,
'',
range,
@@ -6571,7 +6633,7 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent>
0,
extentOffset: target,
);
final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent(
final replaceTextIntent = ReplaceTextIntent(
state._value,
'',
rangeToDelete,
@@ -6609,7 +6671,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent>
// Returns true iff the given position is at a wordwrap boundary in the
// upstream position.
bool _isAtWordwrapUpstream(TextPosition position) {
final TextPosition end = TextPosition(
final end = TextPosition(
offset: state.renderEditable.getLineAtOffset(position).end,
affinity: TextAffinity.upstream,
);
@@ -6622,7 +6684,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent>
// Returns true if the given position at a wordwrap boundary in the
// downstream position.
bool _isAtWordwrapDownstream(TextPosition position) {
final TextPosition start = TextPosition(
final start = TextPosition(
offset: state.renderEditable.getLineAtOffset(position).start,
);
return start == position &&
@@ -6690,7 +6752,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent>
(selection.baseOffset - selection.extentOffset) *
(selection.baseOffset - newSelection.extentOffset) <
0;
final TextSelection newRange = shouldCollapseToBase
final newRange = shouldCollapseToBase
? TextSelection.fromPosition(selection.base)
: newSelection;
return Actions.invoke(
@@ -6948,3 +7010,55 @@ class _EditableTextTapUpOutsideAction
// The default action is a no-op.
}
}
/// A utility class for overriding the text styles of a [TextSpan] tree.
// When changes are made to this class, the equivalent API in text.dart
// must also be updated.
// TODO(Renzo-Olivares): Remove after investigating a solution for overriding all
// styles for children in an [InlineSpan] tree, see: https://github.com/flutter/flutter/issues/177952.
class _OverridingTextStyleTextSpanUtils {
static TextSpan applyTextSpacingOverrides({
double? lineHeightScaleFactor,
double? letterSpacing,
double? wordSpacing,
required TextSpan textSpan,
}) {
if (lineHeightScaleFactor == null &&
letterSpacing == null &&
wordSpacing == null) {
return textSpan;
}
return _applyTextStyleOverrides(
TextStyle(
height: lineHeightScaleFactor,
letterSpacing: letterSpacing,
wordSpacing: wordSpacing,
),
textSpan,
);
}
static TextSpan _applyTextStyleOverrides(
TextStyle overrideTextStyle,
TextSpan textSpan,
) {
return TextSpan(
text: textSpan.text,
children: textSpan.children?.map((InlineSpan child) {
if (child is TextSpan && child.runtimeType == TextSpan) {
return _applyTextStyleOverrides(overrideTextStyle, child);
}
return child;
}).toList(),
style: textSpan.style?.merge(overrideTextStyle) ?? overrideTextStyle,
recognizer: textSpan.recognizer,
mouseCursor: textSpan.mouseCursor,
onEnter: textSpan.onEnter,
onExit: textSpan.onExit,
semanticsLabel: textSpan.semanticsLabel,
semanticsIdentifier: textSpan.semanticsIdentifier,
locale: textSpan.locale,
spellOut: textSpan.spellOut,
);
}
}

View File

@@ -88,7 +88,7 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
return null;
}
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final buttonItems = <ContextMenuButtonItem>[];
// Build suggestion buttons.
for (final String suggestion in spanAtCursorIndex.suggestions.take(
@@ -112,7 +112,7 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
}
// Build delete button.
final ContextMenuButtonItem deleteButton = ContextMenuButtonItem(
final deleteButton = ContextMenuButtonItem(
onPressed: () {
if (!editableTextState.mounted) {
return;
@@ -174,18 +174,17 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
/// Builds the toolbar buttons based on the [buttonItems].
List<Widget> _buildToolbarButtons(BuildContext context) {
return buttonItems.map((ContextMenuButtonItem buttonItem) {
final TextSelectionToolbarTextButton button =
TextSelectionToolbarTextButton(
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
onPressed: buttonItem.onPressed,
alignment: Alignment.centerLeft,
child: Text(
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
style: buttonItem.type == ContextMenuButtonType.delete
? const TextStyle(color: Colors.blue)
: null,
),
);
final button = TextSelectionToolbarTextButton(
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
onPressed: buttonItem.onPressed,
alignment: Alignment.centerLeft,
child: Text(
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
style: buttonItem.type == ContextMenuButtonType.delete
? const TextStyle(color: Colors.blue)
: null,
),
);
if (buttonItem.type != ContextMenuButtonType.delete) {
return button;
@@ -216,7 +215,7 @@ class SpellCheckSuggestionsToolbar extends StatelessWidget {
mediaQueryData.padding.top +
CupertinoTextSelectionToolbar.kToolbarScreenPadding;
// Makes up for the Padding.
final Offset localAdjustment = Offset(
final localAdjustment = Offset(
CupertinoTextSelectionToolbar.kToolbarScreenPadding,
paddingAbove,
);

View File

@@ -156,19 +156,37 @@ class SystemContextMenu extends StatefulWidget {
static List<IOSSystemContextMenuItem> getDefaultItems(
EditableTextState editableTextState,
) {
return <IOSSystemContextMenuItem>[
if (editableTextState.copyEnabled) const IOSSystemContextMenuItemCopy(),
if (editableTextState.cutEnabled) const IOSSystemContextMenuItemCut(),
if (editableTextState.pasteEnabled) const IOSSystemContextMenuItemPaste(),
if (editableTextState.selectAllEnabled)
const IOSSystemContextMenuItemSelectAll(),
if (editableTextState.lookUpEnabled)
const IOSSystemContextMenuItemLookUp(),
if (editableTextState.searchWebEnabled)
const IOSSystemContextMenuItemSearchWeb(),
if (editableTextState.liveTextInputEnabled)
const IOSSystemContextMenuItemLiveText(),
];
final items = <IOSSystemContextMenuItem>[];
// Use the generic Flutter-rendered context menu model as the single source of truth.
for (final ContextMenuButtonItem button
in editableTextState.contextMenuButtonItems) {
switch (button.type) {
case ContextMenuButtonType.copy:
items.add(const IOSSystemContextMenuItemCopy());
case ContextMenuButtonType.cut:
items.add(const IOSSystemContextMenuItemCut());
case ContextMenuButtonType.paste:
items.add(const IOSSystemContextMenuItemPaste());
case ContextMenuButtonType.selectAll:
items.add(const IOSSystemContextMenuItemSelectAll());
case ContextMenuButtonType.lookUp:
items.add(const IOSSystemContextMenuItemLookUp());
case ContextMenuButtonType.searchWeb:
items.add(const IOSSystemContextMenuItemSearchWeb());
case ContextMenuButtonType.share:
items.add(const IOSSystemContextMenuItemShare());
case ContextMenuButtonType.liveTextInput:
items.add(const IOSSystemContextMenuItemLiveText());
case ContextMenuButtonType.delete:
// No native iOS system menu button for Delete — intentionally ignored.
case ContextMenuButtonType.custom:
// Custom items are provided explicitly via SystemContextMenu.items,
// not via defaults. Intentionally ignore in default mapping.
}
}
return items;
}
@override

View File

@@ -1302,14 +1302,17 @@ class RichTextFieldState extends State<RichTextField>
context,
);
final ThemeData themeData = Theme.of(context);
final InputDecorationThemeData decorationTheme = InputDecorationTheme.of(
context,
);
final InputDecoration effectiveDecoration =
(widget.decoration ?? const InputDecoration())
.applyDefaults(themeData.inputDecorationTheme)
.applyDefaults(decorationTheme)
.copyWith(
enabled: _isEnabled,
hintMaxLines:
widget.decoration?.hintMaxLines ??
themeData.inputDecorationTheme.hintMaxLines ??
decorationTheme.hintMaxLines ??
widget.maxLines,
);
@@ -1347,8 +1350,8 @@ class RichTextFieldState extends State<RichTextField>
return effectiveDecoration;
} // No counter widget
String counterText = '$currentLength';
String semanticCounterText = '';
var counterText = '$currentLength';
var semanticCounterText = '';
// Handle a real maxLength (positive number)
if (widget.maxLength! > 0) {
@@ -1655,7 +1658,7 @@ class RichTextFieldState extends State<RichTextField>
widget.keyboardAppearance ?? theme.brightness;
final RichTextEditingController controller = _effectiveController;
final FocusNode focusNode = _effectiveFocusNode;
final List<TextInputFormatter> formatters = <TextInputFormatter>[
final formatters = <TextInputFormatter>[
...?widget.inputFormatters,
if (widget.maxLength != null)
LengthLimitingTextInputFormatter(

View File

@@ -1,22 +0,0 @@
import 'package:PiliPlus/common/widgets/flutter/text_intro/selectable_text.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:flutter/material.dart' hide SelectableText;
Widget selectableRichText(
TextSpan textSpan, {
TextStyle? style,
}) {
if (PlatformUtils.isDesktop) {
return SelectionArea(
child: Text.rich(
style: style,
textSpan,
),
);
}
return SelectableText.rich(
style: style,
textSpan,
scrollPhysics: const NeverScrollableScrollPhysics(),
);
}

View File

@@ -1,63 +0,0 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
Future<TimeOfDay?> showTimePicker({
required BuildContext context,
required TimeOfDay initialTime,
TransitionBuilder? builder,
bool barrierDismissible = true,
Color? barrierColor,
String? barrierLabel,
bool useRootNavigator = true,
TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial,
String? cancelText,
String? confirmText,
String? helpText,
String? errorInvalidText,
String? hourLabelText,
String? minuteLabelText,
RouteSettings? routeSettings,
EntryModeChangeCallback? onEntryModeChanged,
Offset? anchorPoint,
Orientation? orientation,
Icon? switchToInputEntryModeIcon,
Icon? switchToTimerEntryModeIcon,
bool emptyInitialInput = false,
BoxConstraints? constraints,
}) {
assert(debugCheckHasMaterialLocalizations(context));
final Widget dialog = DialogTheme(
data: const DialogThemeData(constraints: BoxConstraints(minWidth: 280.0)),
child: TimePickerDialog(
initialTime: initialTime,
initialEntryMode: initialEntryMode,
cancelText: cancelText,
confirmText: confirmText,
helpText: helpText,
errorInvalidText: errorInvalidText,
hourLabelText: hourLabelText,
minuteLabelText: minuteLabelText,
orientation: orientation,
onEntryModeChanged: onEntryModeChanged,
switchToInputEntryModeIcon: switchToInputEntryModeIcon,
switchToTimerEntryModeIcon: switchToTimerEntryModeIcon,
emptyInitialInput: emptyInitialInput,
),
);
return showDialog<TimeOfDay>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
barrierLabel: barrierLabel,
useRootNavigator: useRootNavigator,
builder: (BuildContext context) {
return builder == null ? dialog : builder(context, dialog);
},
routeSettings: routeSettings,
anchorPoint: anchorPoint,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,11 @@ class CustomHorizontalDragGestureRecognizer
});
Offset? _initialPosition;
Offset? get initialPosition => _initialPosition;
@override
DeviceGestureSettings get gestureSettings => _gestureSettings;
final _gestureSettings = DeviceGestureSettings(touchSlop: touchSlopH);
@override
void addAllowedPointer(PointerDownEvent event) {

View File

@@ -0,0 +1,48 @@
import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart';
import 'package:flutter/gestures.dart';
mixin ImageGestureRecognizerMixin on GestureRecognizer {
int? _pointer;
@override
void addPointer(PointerDownEvent event) {
if (_pointer == event.pointer) {
return;
}
_pointer = event.pointer;
super.addPointer(event);
}
}
typedef IsBoundaryAllowed =
bool Function(Offset? initialPosition, OffsetPair lastPosition);
class ImageHorizontalDragGestureRecognizer
extends CustomHorizontalDragGestureRecognizer
with ImageGestureRecognizerMixin {
ImageHorizontalDragGestureRecognizer({
super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
});
IsBoundaryAllowed? isBoundaryAllowed;
@override
bool hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
double? deviceTouchSlop,
) {
return super.hasSufficientGlobalDistanceToAccept(
pointerDeviceKind,
deviceTouchSlop,
) &&
(isBoundaryAllowed?.call(initialPosition, lastPosition) ?? true);
}
@override
void dispose() {
isBoundaryAllowed = null;
super.dispose();
}
}

View File

@@ -686,6 +686,7 @@ class _MouseInteractiveViewerState extends State<MouseInteractiveViewer>
_scaleGestureRecognizer =
ScaleGestureRecognizer(
debugOwner: this,
dragStartBehavior: .start,
allowedButtonsFilter: (buttons) => buttons == kPrimaryButton,
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
trackpadScrollCausesScale: widget.trackpadScrollCausesScale,

View File

@@ -2,6 +2,7 @@
import 'dart:developer';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
@@ -67,31 +68,6 @@ class CachedNetworkSVGImage extends StatefulWidget {
@override
State<CachedNetworkSVGImage> createState() => _CachedNetworkSVGImageState();
static Future<void> preCache(
String imageUrl, {
String? cacheKey,
BaseCacheManager? cacheManager,
}) {
final key = cacheKey ?? _generateKeyFromUrl(imageUrl);
cacheManager ??= DefaultCacheManager();
return cacheManager.downloadFile(key);
}
static Future<void> clearCacheForUrl(
String imageUrl, {
String? cacheKey,
BaseCacheManager? cacheManager,
}) {
final key = cacheKey ?? _generateKeyFromUrl(imageUrl);
cacheManager ??= DefaultCacheManager();
return cacheManager.removeFile(key);
}
static Future<void> clearCache({BaseCacheManager? cacheManager}) {
cacheManager ??= DefaultCacheManager();
return cacheManager.emptyCache();
}
static String _generateKeyFromUrl(String url) => url.split('?').first;
}
@@ -156,7 +132,7 @@ class _CachedNetworkSVGImageState extends State<CachedNetworkSVGImage> {
_setState();
} catch (e) {
log('CachedNetworkSVGImage: $e');
if (kDebugMode) log('CachedNetworkSVGImage: $e');
_isError = true;
_isLoading = false;

View File

@@ -1,439 +0,0 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show Platform;
import 'dart:math' show min;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/models/common/badge_type.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/context_ext.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/size_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.dart'
hide CustomMultiChildLayout, MultiChildLayoutDelegate;
import 'package:flutter/rendering.dart'
show
ContainerRenderObjectMixin,
RenderBoxContainerDefaultsMixin,
MultiChildLayoutParentData,
BoxHitTestResult;
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/get_navigation.dart';
class ImageModel {
ImageModel({
required num? width,
required num? height,
required this.url,
this.liveUrl,
}) {
this.width = width == null || width == 0 ? 1 : width;
this.height = height == null || height == 0 ? 1 : height;
}
late num width;
late num height;
String url;
String? liveUrl;
bool? _isLongPic;
bool? _isLivePhoto;
bool get isLongPic =>
_isLongPic ??= (height / width) > StyleString.imgMaxRatio;
bool get isLivePhoto =>
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
static bool enableLivePhoto = Pref.enableLivePhoto;
}
class CustomGridView extends StatelessWidget {
const CustomGridView({
super.key,
this.space = 5,
required this.maxWidth,
required this.picArr,
this.onViewImage,
this.fullScreen = false,
});
final double maxWidth;
final double space;
final List<ImageModel> picArr;
final VoidCallback? onViewImage;
final bool fullScreen;
static bool horizontalPreview = Pref.horizontalPreview;
static const _routes = ['/videoV', '/dynamicDetail'];
void onTap(BuildContext context, int index) {
final imgList = picArr.map(
(item) {
bool isLive = item.isLivePhoto;
return SourceModel(
sourceType: isLive ? SourceType.livePhoto : SourceType.networkImage,
url: item.url,
liveUrl: isLive ? item.liveUrl : null,
width: isLive ? item.width.toInt() : null,
height: isLive ? item.height.toInt() : null,
);
},
).toList();
if (horizontalPreview &&
!fullScreen &&
_routes.contains(Get.currentRoute) &&
!context.mediaQuerySize.isPortrait) {
final scaffoldState = Scaffold.maybeOf(context);
if (scaffoldState != null) {
onViewImage?.call();
PageUtils.onHorizontalPreviewState(
scaffoldState,
imgList,
index,
);
return;
}
}
PageUtils.imageView(
initialPage: index,
imgList: imgList,
);
}
static BorderRadius _borderRadius(
int col,
int length,
int index, {
Radius r = StyleString.imgRadius,
}) {
if (length == 1) return StyleString.mdRadius;
final bool hasUp = index - col >= 0;
final bool hasDown = index + col < length;
final bool isRowStart = (index % col) == 0;
final bool isRowEnd = (index % col) == col - 1 || index == length - 1;
final bool hasLeft = !isRowStart;
final bool hasRight = !isRowEnd && (index + 1) < length;
return BorderRadius.only(
topLeft: !hasUp && !hasLeft ? r : Radius.zero,
topRight: !hasUp && !hasRight ? r : Radius.zero,
bottomLeft: !hasDown && !hasLeft ? r : Radius.zero,
bottomRight: !hasDown && !hasRight ? r : Radius.zero,
);
}
static bool enableImgMenu = Pref.enableImgMenu;
void _showMenu(BuildContext context, Offset offset, ImageModel item) {
HapticFeedback.mediumImpact();
showMenu(
context: context,
position: PageUtils.menuPosition(offset),
items: [
if (PlatformUtils.isMobile)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.onShareImg(item.url),
child: const Text('分享', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadImg([item.url]),
child: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
if (PlatformUtils.isDesktop)
PopupMenuItem(
height: 42,
onTap: () => PageUtils.launchURL(item.url),
child: const Text('网页打开', style: TextStyle(fontSize: 14)),
)
else if (picArr.length > 1)
PopupMenuItem(
height: 42,
onTap: () =>
ImageUtils.downloadImg(picArr.map((item) => item.url).toList()),
child: const Text('保存全部', style: TextStyle(fontSize: 14)),
),
if (item.isLivePhoto)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width.toInt(),
height: item.height.toInt(),
),
child: Text(
'保存${Platform.isIOS ? '实况' : '视频'}',
style: const TextStyle(fontSize: 14),
),
),
],
);
}
@override
Widget build(BuildContext context) {
double imageWidth;
double imageHeight;
final length = picArr.length;
final isSingle = length == 1;
final isFour = length == 4;
if (length == 2) {
imageWidth = imageHeight = (maxWidth - space) / 2;
} else {
imageHeight = imageWidth = (maxWidth - 2 * space) / 3;
if (isSingle) {
final img = picArr.first;
final width = img.width;
final height = img.height;
final ratioWH = width / height;
final ratioHW = height / width;
imageWidth = ratioWH > 1.5
? maxWidth
: (ratioWH >= 1 || (height > width && ratioHW < 1.5))
? 2 * imageWidth
: 1.5 * imageWidth;
if (width != 1) {
imageWidth = min(imageWidth, width.toDouble());
}
imageHeight = imageWidth * min(ratioHW, StyleString.imgMaxRatio);
}
}
final int column = isFour ? 2 : 3;
final int row = isFour ? 2 : (length / 3).ceil();
late final placeHolder = Container(
width: imageWidth,
height: imageHeight,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.4),
),
child: Image.asset(
'assets/images/loading.png',
width: imageWidth,
height: imageHeight,
cacheWidth: imageWidth.cacheSize(context),
),
);
return Padding(
padding: const EdgeInsets.only(top: 6),
child: SizedBox(
width: maxWidth,
height: imageHeight * row + space * (row - 1),
child: ImageGrid(
space: space,
column: column,
width: imageWidth,
height: imageHeight,
children: List.generate(length, (index) {
final item = picArr[index];
final borderRadius = _borderRadius(column, length, index);
return LayoutId(
id: index,
child: GestureDetector(
onTap: () => onTap(context, index),
onSecondaryTapUp: enableImgMenu && PlatformUtils.isDesktop
? (details) =>
_showMenu(context, details.globalPosition, item)
: null,
onLongPressStart: enableImgMenu && PlatformUtils.isMobile
? (details) =>
_showMenu(context, details.globalPosition, item)
: null,
child: Hero(
tag: item.url,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
NetworkImgLayer(
src: item.url,
width: imageWidth,
height: imageHeight,
borderRadius: borderRadius,
alignment: item.isLongPic ? .topCenter : .center,
cacheWidth: item.width <= item.height,
getPlaceHolder: () => placeHolder,
),
if (item.isLivePhoto)
const PBadge(
text: 'Live',
right: 8,
bottom: 8,
type: PBadgeType.gray,
)
else if (item.isLongPic)
const PBadge(
text: '长图',
right: 8,
bottom: 8,
),
],
),
),
),
);
}),
),
),
);
}
}
class ImageGrid extends MultiChildRenderObjectWidget {
const ImageGrid({
super.key,
super.children,
required this.space,
required this.column,
required this.width,
required this.height,
});
final double space;
final int column;
final double width;
final double height;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderImageGrid(
space: space,
column: column,
width: width,
height: height,
);
}
@override
void updateRenderObject(BuildContext context, RenderImageGrid renderObject) {
renderObject
..space = space
..column = column
..width = width
..height = height;
}
}
class RenderImageGrid extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
RenderImageGrid({
required double space,
required int column,
required double width,
required double height,
}) : _space = space,
_column = column,
_width = width,
_height = height;
double _space;
double get space => _space;
set space(double value) {
if (_space == value) return;
_space = value;
markNeedsLayout();
}
int _column;
int get column => _column;
set column(int value) {
if (_column == value) return;
_column = value;
markNeedsLayout();
}
double _width;
double get width => _width;
set width(double value) {
if (_width == value) return;
_width = value;
markNeedsLayout();
}
double _height;
double get height => _height;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
@override
void performLayout() {
size = constraints.constrain(constraints.biggest);
final itemConstraints = BoxConstraints(
minWidth: width,
maxWidth: width,
minHeight: height,
maxHeight: height,
);
RenderBox? child = firstChild;
while (child != null) {
final childParentData = child.parentData as MultiChildLayoutParentData;
final index = childParentData.id as int;
child.layout(itemConstraints, parentUsesSize: true);
childParentData.offset = Offset(
(space + width) * (index % column),
(space + height) * (index ~/ column),
);
child = childParentData.nextSibling;
}
}
@override
void paint(PaintingContext context, Offset offset) {
RenderBox? child = firstChild;
while (child != null) {
final childParentData = child.parentData as MultiChildLayoutParentData;
context.paintChild(child, childParentData.offset + offset);
child = childParentData.nextSibling;
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -0,0 +1,633 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:collection' show HashSet;
import 'dart:math' as math;
import 'package:PiliPlus/common/constants.dart' show StyleString;
import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'
show ImageModel;
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/gestures.dart'
show TapGestureRecognizer, LongPressGestureRecognizer;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show
ContainerRenderObjectMixin,
MultiChildLayoutParentData,
RenderBoxContainerDefaultsMixin,
RenderObjectWithLayoutCallbackMixin,
Constraints,
LayoutCallback,
BoxHitTestResult,
BoxHitTestEntry,
ContainerParentDataMixin,
InformationCollector,
DiagnosticsDebugCreator;
/// ref [LayoutBuilder]
const space = 5.0;
typedef ImageGridInfo = ({int column, int row, Size size});
class ImageGridBuilder extends RenderObjectWidget {
const ImageGridBuilder({
super.key,
required this.picArr,
required this.onTap,
required this.onSecondaryTapUp,
required this.onLongPressStart,
required this.builder,
});
final List<ImageModel> picArr;
final ValueChanged<int> onTap;
final OnShowMenu? onSecondaryTapUp;
final OnShowMenu? onLongPressStart;
final List<Widget> Function(BuildContext context, ImageGridInfo imageGridInfo)
builder;
@protected
bool updateShouldRebuild(ImageGridBuilder oldWidget) => true;
@override
ImageGridRenderObjectElement createElement() =>
ImageGridRenderObjectElement(this);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderImageGrid(
onTap: onTap,
onSecondaryTapUp: onSecondaryTapUp,
onLongPressStart: onLongPressStart,
);
}
@override
void updateRenderObject(BuildContext context, RenderImageGrid renderObject) {
renderObject
..onTap = onTap
..onSecondaryTapUp = onSecondaryTapUp
..onLongPressStart = onLongPressStart;
}
}
typedef OnShowMenu = Function(int index, Offset offset);
class RenderImageGrid extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData>,
RenderObjectWithLayoutCallbackMixin {
RenderImageGrid({
required ValueChanged<int> onTap,
required OnShowMenu? onSecondaryTapUp,
required OnShowMenu? onLongPressStart,
}) : _onTap = onTap,
_onSecondaryTapUp = onSecondaryTapUp,
_onLongPressStart = onLongPressStart {
_tapGestureRecognizer = TapGestureRecognizer()..onTap = _handleOnTap;
if (onSecondaryTapUp != null) {
_tapGestureRecognizer.onSecondaryTapUp = _handleSecondaryTapUp;
}
if (onLongPressStart != null) {
_longPressGestureRecognizer = LongPressGestureRecognizer()
..onLongPressStart = _handleLongPressStart;
}
}
ValueChanged<int> _onTap;
set onTap(ValueChanged<int> value) {
_onTap = value;
}
OnShowMenu? _onSecondaryTapUp;
set onSecondaryTapUp(OnShowMenu? value) {
_onSecondaryTapUp = value;
}
OnShowMenu? _onLongPressStart;
set onLongPressStart(OnShowMenu? value) {
_onLongPressStart = value;
}
int? _index;
void _handleOnTap() {
_onTap(_index!);
}
void _handleSecondaryTapUp(TapUpDetails details) {
_onSecondaryTapUp!(_index!, details.globalPosition);
}
void _handleLongPressStart(LongPressStartDetails details) {
_onLongPressStart!(_index!, details.globalPosition);
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
ImageGridInfo? imageGridInfo;
LayoutCallback<Constraints>? _callback;
void _updateCallback(LayoutCallback<Constraints> value) {
if (value == _callback) {
return;
}
_callback = value;
scheduleLayoutCallback();
}
@override
void layoutCallback() => _callback!(constraints);
@protected
BoxConstraints get layoutInfo => constraints;
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
runLayoutCallback();
final info = imageGridInfo!;
final row = info.row;
final column = info.column;
final width = info.size.width;
final height = info.size.height;
final childConstraints = BoxConstraints.tightFor(
width: width,
height: height,
);
RenderBox? child = firstChild;
while (child != null) {
child.layout(childConstraints);
final childParentData = child.parentData as MultiChildLayoutParentData;
final index = childParentData.id as int;
childParentData.offset = Offset(
(space + width) * (index % column),
(space + height) * (index ~/ column),
);
child = childParentData.nextSibling;
}
size = constraints.constrainDimensions(
width * column + space * (column - 1),
height * row + space * (row - 1),
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
RenderBox? child = lastChild;
while (child != null) {
final childParentData = child.parentData as MultiChildLayoutParentData;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
if (child!.size.contains(transformed)) {
result.add(BoxHitTestEntry(child, transformed));
return true;
}
return false;
},
);
if (isHit) {
_index = childParentData.id as int;
return true;
}
child = childParentData.previousSibling;
}
_index = null;
return false;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (event is PointerDownEvent) {
_tapGestureRecognizer.addPointer(event);
_longPressGestureRecognizer?.addPointer(event);
}
}
late final TapGestureRecognizer _tapGestureRecognizer;
LongPressGestureRecognizer? _longPressGestureRecognizer;
@override
void dispose() {
_tapGestureRecognizer
..onTap = null
..onSecondaryTapUp = null
..dispose();
_longPressGestureRecognizer
?..onLongPressStart = null
..dispose();
_longPressGestureRecognizer = null;
_onSecondaryTapUp = null;
_onLongPressStart = null;
super.dispose();
}
@override
bool get isRepaintBoundary => true; // gif repaint
}
class ImageGridRenderObjectElement extends RenderObjectElement {
ImageGridRenderObjectElement(ImageGridBuilder super.widget);
@override
RenderImageGrid get renderObject {
return super.renderObject as RenderImageGrid;
}
@protected
@visibleForTesting
Iterable<Element> get children =>
_children!.where((Element child) => !_forgottenChildren.contains(child));
List<Element>? _children;
// We keep a set of forgotten children to avoid O(n^2) work walking _children
// repeatedly to remove children.
final Set<Element> _forgottenChildren = HashSet<Element>();
// @override
// BuildScope get buildScope => _buildScope;
// late final BuildScope _buildScope = BuildScope(
// scheduleRebuild: _scheduleRebuild,
// );
// bool _deferredCallbackScheduled = false;
// void _scheduleRebuild() {
// if (_deferredCallbackScheduled) {
// return;
// }
// final bool deferMarkNeedsLayout =
// switch (SchedulerBinding.instance.schedulerPhase) {
// SchedulerPhase.idle || SchedulerPhase.postFrameCallbacks => true,
// SchedulerPhase.transientCallbacks ||
// SchedulerPhase.midFrameMicrotasks ||
// SchedulerPhase.persistentCallbacks => false,
// };
// if (!deferMarkNeedsLayout) {
// renderObject.scheduleLayoutCallback();
// return;
// }
// _deferredCallbackScheduled = true;
// SchedulerBinding.instance.scheduleFrameCallback(_frameCallback);
// }
// void _frameCallback(Duration timestamp) {
// _deferredCallbackScheduled = false;
// // This method is only called when the render tree is stable, if the Element
// // is deactivated it will never be reincorporated back to the tree.
// if (mounted) {
// renderObject.scheduleLayoutCallback();
// }
// }
@override
void insertRenderObjectChild(RenderObject child, IndexedSlot<Element?> slot) {
final ContainerRenderObjectMixin<
RenderObject,
ContainerParentDataMixin<RenderObject>
>
renderObject = this.renderObject;
assert(renderObject.debugValidateChild(child));
renderObject.insert(child, after: slot.value?.renderObject);
assert(renderObject == this.renderObject);
}
@override
void moveRenderObjectChild(
RenderObject child,
IndexedSlot<Element?> oldSlot,
IndexedSlot<Element?> newSlot,
) {
final ContainerRenderObjectMixin<
RenderObject,
ContainerParentDataMixin<RenderObject>
>
renderObject = this.renderObject;
assert(child.parent == renderObject);
renderObject.move(child, after: newSlot.value?.renderObject);
assert(renderObject == this.renderObject);
}
@override
void removeRenderObjectChild(RenderObject child, Object? slot) {
final ContainerRenderObjectMixin<
RenderObject,
ContainerParentDataMixin<RenderObject>
>
renderObject = this.renderObject;
assert(child.parent == renderObject);
renderObject.remove(child);
assert(renderObject == this.renderObject);
}
@override
void visitChildren(ElementVisitor visitor) {
if (_children == null) return;
for (final Element child in _children!) {
if (!_forgottenChildren.contains(child)) {
visitor(child);
}
}
}
@override
void forgetChild(Element child) {
if (_children == null) return;
assert(_children!.contains(child));
assert(!_forgottenChildren.contains(child));
_forgottenChildren.add(child);
super.forgetChild(child);
}
bool _debugCheckHasAssociatedRenderObject(Element newChild) {
assert(() {
if (newChild.renderObject == null) {
FlutterError.reportError(
FlutterErrorDetails(
exception: FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'The children of `MultiChildRenderObjectElement` must each has an associated render object.',
),
ErrorHint(
'This typically means that the `${newChild.widget}` or its children\n'
'are not a subtype of `RenderObjectWidget`.',
),
newChild.describeElement(
'The following element does not have an associated render object',
),
DiagnosticsDebugCreator(DebugCreator(newChild)),
]),
),
);
}
return true;
}());
return true;
}
@override
Element inflateWidget(Widget newWidget, Object? newSlot) {
final Element newChild = super.inflateWidget(newWidget, newSlot);
assert(_debugCheckHasAssociatedRenderObject(newChild));
return newChild;
}
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
renderObject._updateCallback(_rebuildWithConstraints);
// final multiChildRenderObjectWidget = widget as MultiChildRenderObjectWidget;
// final children = List<Element>.filled(
// multiChildRenderObjectWidget.children.length,
// _NullElement.instance,
// );
// Element? previousChild;
// for (var i = 0; i < children.length; i += 1) {
// final Element newChild = inflateWidget(
// multiChildRenderObjectWidget.children[i],
// IndexedSlot<Element?>(i, previousChild),
// );
// children[i] = newChild;
// previousChild = newChild;
// }
// _children = children;
}
@override
void update(ImageGridBuilder newWidget) {
super.update(newWidget);
final multiChildRenderObjectWidget = widget as ImageGridBuilder;
assert(widget == newWidget);
// _children = updateChildren(
// _children,
// multiChildRenderObjectWidget.children,
// forgottenChildren: _forgottenChildren,
// );
// _forgottenChildren.clear();
renderObject._updateCallback(_rebuildWithConstraints);
if (newWidget.updateShouldRebuild(multiChildRenderObjectWidget)) {
_needsBuild = true;
renderObject.scheduleLayoutCallback();
}
}
@override
void markNeedsBuild() {
// Calling super.markNeedsBuild is not needed. This Element does not need
// to performRebuild since this call already does what performRebuild does,
// So the element is clean as soon as this method returns and does not have
// to be added to the dirty list or marked as dirty.
renderObject.scheduleLayoutCallback();
_needsBuild = true;
}
@override
void performRebuild() {
// This gets called if markNeedsBuild() is called on us.
// That might happen if, e.g., our builder uses Inherited widgets.
// Force the callback to be called, even if the layout constraints are the
// same. This is because that callback may depend on the updated widget
// configuration, or an inherited widget.
renderObject.scheduleLayoutCallback();
_needsBuild = true;
super
.performRebuild(); // Calls widget.updateRenderObject (a no-op in this case).
}
@override
void unmount() {
renderObject._callback = null;
super.unmount();
}
// The LayoutInfoType that was used to invoke the layout callback with last time,
// during layout. The `_previousLayoutInfo` value is compared to the new one
// to determine whether [LayoutBuilderBase.builder] needs to be called.
BoxConstraints? _previousLayoutInfo;
bool _needsBuild = true;
static ImageGridInfo _calcGridInfo(
List<ImageModel> picArr,
BoxConstraints layoutInfo,
) {
final maxWidth = layoutInfo.maxWidth;
double imageWidth;
double imageHeight;
final length = picArr.length;
final isSingle = length == 1;
final isFour = length == 4;
if (length == 2) {
imageWidth = imageHeight = (maxWidth - space) / 2;
} else {
imageHeight = imageWidth = (maxWidth - 2 * space) / 3;
if (isSingle) {
final img = picArr.first;
final width = img.width;
final height = img.height;
final ratioWH = width / height;
final ratioHW = height / width;
imageWidth = ratioWH > 1.5
? maxWidth
: (ratioWH >= 1 || (height > width && ratioHW < 1.5))
? 2 * imageWidth
: 1.5 * imageWidth;
if (width != 1) {
imageWidth = math.min(imageWidth, width.toDouble());
}
imageHeight = imageWidth * math.min(ratioHW, StyleString.imgMaxRatio);
}
}
final int column = isFour ? 2 : 3;
final int row = isFour ? 2 : (length / 3).ceil();
return (
row: row,
column: column,
size: Size(imageWidth, imageHeight),
);
}
void _rebuildWithConstraints(Constraints _) {
final BoxConstraints layoutInfo = renderObject.layoutInfo;
@pragma('vm:notify-debugger-on-exception')
void updateChildCallback() {
List<Widget> built;
try {
assert(layoutInfo == renderObject.layoutInfo);
built = (widget as ImageGridBuilder).builder(
this,
renderObject.imageGridInfo = _calcGridInfo(
(widget as ImageGridBuilder).picArr,
layoutInfo,
),
);
} catch (e, stack) {
built = [
ErrorWidget.builder(
_reportException(
ErrorDescription('building $widget'),
e,
stack,
informationCollector: () => <DiagnosticsNode>[
if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)),
],
),
),
];
}
try {
if (_children == null) {
final children = List<Element>.filled(
built.length,
_NullElement.instance,
);
Element? previousChild;
for (var i = 0; i < children.length; i += 1) {
final Element newChild = inflateWidget(
built[i],
IndexedSlot<Element?>(i, previousChild),
);
children[i] = newChild;
previousChild = newChild;
}
_children = children;
} else {
_children = updateChildren(
_children!,
built,
forgottenChildren: _forgottenChildren,
);
}
} catch (e, stack) {
built = [
ErrorWidget.builder(
_reportException(
ErrorDescription('building $widget'),
e,
stack,
informationCollector: () => <DiagnosticsNode>[
if (kDebugMode) DiagnosticsDebugCreator(DebugCreator(this)),
],
),
),
];
_children = updateChildren([], built);
} finally {
_needsBuild = false;
_previousLayoutInfo = layoutInfo;
_forgottenChildren.clear();
}
}
final VoidCallback? callback =
_needsBuild || (layoutInfo != _previousLayoutInfo)
? updateChildCallback
: null;
owner!.buildScope(this, callback);
}
}
FlutterErrorDetails _reportException(
DiagnosticsNode context,
Object exception,
StackTrace stack, {
InformationCollector? informationCollector,
}) {
final details = FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets library',
context: context,
informationCollector: informationCollector,
);
FlutterError.reportError(details);
return details;
}
class _NullElement extends Element {
_NullElement() : super(const _NullWidget());
static _NullElement instance = _NullElement();
@override
bool get debugDoingBuild => throw UnimplementedError();
}
class _NullWidget extends Widget {
const _NullWidget();
@override
Element createElement() => throw UnimplementedError();
}

View File

@@ -0,0 +1,271 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show Platform;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/image_grid/image_grid_builder.dart';
import 'package:PiliPlus/models/common/badge_type.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/context_ext.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/size_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/get_navigation.dart';
class ImageModel {
ImageModel({
required num? width,
required num? height,
required this.url,
this.liveUrl,
}) {
this.width = width == null || width == 0 ? 1 : width;
this.height = height == null || height == 0 ? 1 : height;
}
late num width;
late num height;
String url;
String? liveUrl;
bool? _isLongPic;
bool? _isLivePhoto;
bool get isLongPic =>
_isLongPic ??= (height / width) > StyleString.imgMaxRatio;
bool get isLivePhoto =>
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
static bool enableLivePhoto = Pref.enableLivePhoto;
}
class ImageGridView extends StatelessWidget {
const ImageGridView({
super.key,
required this.picArr,
this.onViewImage,
this.fullScreen = false,
});
final List<ImageModel> picArr;
final VoidCallback? onViewImage;
final bool fullScreen;
static bool horizontalPreview = Pref.horizontalPreview;
static const _routes = ['/videoV', '/dynamicDetail'];
void _onTap(BuildContext context, int index) {
final imgList = picArr.map(
(item) {
bool isLive = item.isLivePhoto;
return SourceModel(
sourceType: isLive ? SourceType.livePhoto : SourceType.networkImage,
url: item.url,
liveUrl: isLive ? item.liveUrl : null,
width: isLive ? item.width.toInt() : null,
height: isLive ? item.height.toInt() : null,
isLongPic: item.isLongPic,
);
},
).toList();
if (horizontalPreview &&
!fullScreen &&
_routes.contains(Get.currentRoute) &&
!context.mediaQuerySize.isPortrait) {
final scaffoldState = Scaffold.maybeOf(context);
if (scaffoldState != null) {
onViewImage?.call();
PageUtils.onHorizontalPreviewState(
scaffoldState,
imgList,
index,
);
return;
}
}
PageUtils.imageView(
initialPage: index,
imgList: imgList,
);
}
static BorderRadius _borderRadius(
int col,
int length,
int index, {
Radius r = StyleString.imgRadius,
}) {
if (length == 1) return StyleString.mdRadius;
final bool hasUp = index - col >= 0;
final bool hasDown = index + col < length;
final bool isRowStart = (index % col) == 0;
final bool isRowEnd = (index % col) == col - 1 || index == length - 1;
final bool hasLeft = !isRowStart;
final bool hasRight = !isRowEnd && (index + 1) < length;
return BorderRadius.only(
topLeft: !hasUp && !hasLeft ? r : Radius.zero,
topRight: !hasUp && !hasRight ? r : Radius.zero,
bottomLeft: !hasDown && !hasLeft ? r : Radius.zero,
bottomRight: !hasDown && !hasRight ? r : Radius.zero,
);
}
static bool enableImgMenu = Pref.enableImgMenu;
void _showMenu(BuildContext context, int index, Offset offset) {
HapticFeedback.mediumImpact();
final item = picArr[index];
showMenu(
context: context,
position: PageUtils.menuPosition(offset),
items: [
if (PlatformUtils.isMobile)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.onShareImg(item.url),
child: const Text('分享', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadImg([item.url]),
child: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
if (PlatformUtils.isDesktop)
PopupMenuItem(
height: 42,
onTap: () => PageUtils.launchURL(item.url),
child: const Text('网页打开', style: TextStyle(fontSize: 14)),
)
else if (picArr.length > 1)
PopupMenuItem(
height: 42,
onTap: () =>
ImageUtils.downloadImg(picArr.map((item) => item.url).toList()),
child: const Text('保存全部', style: TextStyle(fontSize: 14)),
),
if (item.isLivePhoto)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width.toInt(),
height: item.height.toInt(),
),
child: Text(
'保存${Platform.isIOS ? '实况' : '视频'}',
style: const TextStyle(fontSize: 14),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const .only(top: 6),
child: ImageGridBuilder(
picArr: picArr,
onTap: (index) => _onTap(context, index),
onSecondaryTapUp: enableImgMenu && PlatformUtils.isDesktop
? (index, offset) => _showMenu(context, index, offset)
: null,
onLongPressStart: enableImgMenu && PlatformUtils.isMobile
? (index, offset) => _showMenu(context, index, offset)
: null,
builder: (BuildContext context, ImageGridInfo info) {
final width = info.size.width;
final height = info.size.height;
late final placeHolder = Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.4),
),
child: Image.asset(
'assets/images/loading.png',
width: width,
height: height,
cacheWidth: width.cacheSize(context),
),
);
return List.generate(picArr.length, (index) {
final item = picArr[index];
final borderRadius = _borderRadius(
info.column,
picArr.length,
index,
);
Widget child = Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
NetworkImgLayer(
src: item.url,
width: width,
height: height,
borderRadius: borderRadius,
alignment: item.isLongPic ? .topCenter : .center,
cacheWidth: item.width <= item.height,
getPlaceHolder: () => placeHolder,
),
if (item.isLivePhoto)
const PBadge(
text: 'Live',
right: 8,
bottom: 8,
type: PBadgeType.gray,
)
else if (item.isLongPic)
const PBadge(
text: '长图',
right: 8,
bottom: 8,
),
],
);
if (!item.isLongPic) {
child = Hero(
tag: item.url,
child: child,
);
}
return LayoutId(
id: index,
child: child,
);
});
},
),
);
}
}

View File

@@ -0,0 +1,629 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show File, Platform;
import 'package:PiliPlus/common/widgets/colored_box_transition.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart';
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image_viewer/image.dart';
import 'package:PiliPlus/common/widgets/image_viewer/loading_indicator.dart';
import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/string_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' hide Image, PageView, LayoutBuilder;
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
///
/// created by dom on 2026/02/14
///
class GalleryViewer extends StatefulWidget {
const GalleryViewer({
super.key,
this.minScale = 1.0,
this.maxScale = 8.0,
required this.quality,
required this.sources,
this.initIndex = 0,
this.onPageChanged,
});
final double minScale;
final double maxScale;
final int quality;
final List<SourceModel> sources;
final int initIndex;
final ValueChanged<int>? onPageChanged;
@override
State<GalleryViewer> createState() => _GalleryViewerState();
}
class _GalleryViewerState extends State<GalleryViewer>
with SingleTickerProviderStateMixin {
late Size _containerSize;
late final int _quality;
late final RxInt _currIndex;
GlobalKey? _key;
late bool _hasInit = false;
Player? _player;
VideoController? _videoController;
late final PageController _pageController;
late final TapGestureRecognizer _tapGestureRecognizer;
late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer;
late final ImageHorizontalDragGestureRecognizer
_horizontalDragGestureRecognizer;
late final LongPressGestureRecognizer _longPressGestureRecognizer;
late final AnimationController _animateController;
late final Animation<Color?> _opacityAnimation;
double dx = 0, dy = 0;
Offset _offset = Offset.zero;
bool _dragging = false;
String _getActualUrl(String url) {
return _quality != 100
? ImageUtils.thumbnailUrl(url, _quality)
: url.http2https;
}
Future<void> _initPlayer() async {
assert(_player == null);
final player = await Player.create();
_videoController = await VideoController.create(player);
if (!mounted) {
player.dispose();
_videoController = null;
return;
}
_player = player;
final currItem = widget.sources[_currIndex.value];
if (currItem.sourceType == .livePhoto) {
player.open(Media(currItem.liveUrl!));
_currIndex.refresh();
}
}
@override
void initState() {
super.initState();
_quality = Pref.previewQ;
_currIndex = widget.initIndex.obs;
final item = widget.sources[widget.initIndex];
_playIfNeeded(item);
if (!item.isLongPic) {
_key = GlobalKey();
WidgetsBinding.instance.addPostFrameCallback((_) => _key = null);
}
_pageController = PageController(initialPage: widget.initIndex);
final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!);
_tapGestureRecognizer = TapGestureRecognizer()
// ..onTap = _onTap
..gestureSettings = gestureSettings;
if (PlatformUtils.isDesktop) {
_tapGestureRecognizer.onSecondaryTapUp = _showDesktopMenu;
}
_doubleTapGestureRecognizer = DoubleTapGestureRecognizer()
..onDoubleTap = () {}
..gestureSettings = gestureSettings;
_horizontalDragGestureRecognizer = ImageHorizontalDragGestureRecognizer();
_longPressGestureRecognizer = LongPressGestureRecognizer()
..onLongPress = _onLongPress
..gestureSettings = gestureSettings;
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_tapGestureRecognizer.onTap = _onTap;
}
});
_animateController = AnimationController(
duration: const Duration(
milliseconds: 750,
), // reverse only if value <= 0.2
vsync: this,
);
_opacityAnimation = _animateController.drive(
ColorTween(
begin: Colors.black,
end: Colors.transparent,
),
);
}
Matrix4 _onTransform(double val) {
final scale = val.lerp(1.0, 0.25);
// Matrix4.identity()
// ..translateByDouble(size.width / 2, size.height / 2, 0, 1)
// ..translateByDouble(size.width * val * dx, size.height * val * dy, 0, 1)
// ..scaleByDouble(scale, scale, scale, 1)
// ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1);
final tmp = (1.0 - scale) / 2.0;
return Matrix4.diagonal3Values(scale, scale, scale)..setTranslationRaw(
_containerSize.width * (val * dx + tmp),
_containerSize.height * (val * dy + tmp),
0,
);
}
void _updateMoveAnimation() {
dy = _offset.dy.sign;
if (dy == 0) {
dx = 0;
} else {
dx = _offset.dx / _offset.dy.abs();
}
}
void _onDragStart(ScaleStartDetails details) {
_dragging = true;
if (_animateController.isAnimating) {
_animateController.stop();
} else {
_offset = Offset.zero;
_animateController.value = 0.0;
}
_updateMoveAnimation();
}
void _onDragUpdate(ScaleUpdateDetails details) {
if (!_dragging || _animateController.isAnimating) {
return;
}
_offset += details.focalPointDelta;
_updateMoveAnimation();
if (!_animateController.isAnimating) {
_animateController.value = _offset.dy.abs() / _containerSize.height;
}
}
void _onDragEnd(ScaleEndDetails details) {
if (!_dragging || _animateController.isAnimating) {
return;
}
_dragging = false;
if (!_animateController.isDismissed) {
if (_animateController.value > 0.2) {
Get.back();
} else {
_animateController.reverse();
}
}
}
@override
void dispose() {
_player?.dispose();
_player = null;
_videoController = null;
_pageController.dispose();
_animateController.dispose();
_tapGestureRecognizer.dispose();
_doubleTapGestureRecognizer
..onDoubleTapDown = null
..onDoubleTap = null
..dispose();
_longPressGestureRecognizer.dispose();
if (widget.quality != _quality) {
for (final item in widget.sources) {
if (item.sourceType == SourceType.networkImage) {
CachedNetworkImageProvider(_getActualUrl(item.url)).evict();
}
}
}
Future.delayed(const Duration(milliseconds: 200), _currIndex.close);
super.dispose();
}
void _onPointerDown(PointerDownEvent event) {
_tapGestureRecognizer.addPointer(event);
_doubleTapGestureRecognizer.addPointer(event);
_longPressGestureRecognizer.addPointer(event);
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: .opaque,
onPointerDown: _onPointerDown,
child: Stack(
fit: .expand,
alignment: .center,
clipBehavior: .none,
children: [
ColoredBoxTransition(color: _opacityAnimation),
LayoutBuilder(
builder: (context, constraints) {
_containerSize = constraints.biggest;
return MatrixTransition(
alignment: .topLeft,
animation: _animateController,
onTransform: _onTransform,
child: PageView<ImageHorizontalDragGestureRecognizer>.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const CustomTabBarViewScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
itemCount: widget.sources.length,
itemBuilder: _itemBuilder,
horizontalDragGestureRecognizer: () =>
_horizontalDragGestureRecognizer,
),
);
},
),
_buildIndicator,
],
),
);
}
Widget get _buildIndicator => Positioned(
bottom: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
padding:
MediaQuery.viewPaddingOf(context) +
const EdgeInsets.fromLTRB(12, 8, 20, 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
],
),
),
alignment: Alignment.center,
child: Obx(
() => Text(
"${_currIndex.value + 1}/${widget.sources.length}",
style: const TextStyle(color: Colors.white),
),
),
),
),
);
void _playIfNeeded(SourceModel item) {
if (item.sourceType == .livePhoto) {
if (_player != null) {
_player!.open(Media(item.liveUrl!));
} else if (!_hasInit) {
_hasInit = true;
_initPlayer();
}
}
}
void _onPageChanged(int index) {
_player?.pause();
_playIfNeeded(widget.sources[index]);
_currIndex.value = index;
widget.onPageChanged?.call(index);
}
late final ValueChanged<int>? _onChangePage = widget.sources.length == 1
? null
: (int offset) {
final currPage = _pageController.page?.round() ?? 0;
final nextPage = (currPage + offset).clamp(
0,
widget.sources.length - 1,
);
if (nextPage != currPage) {
_pageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
};
Widget _itemBuilder(BuildContext context, int index) {
final item = widget.sources[index];
final Widget child;
switch (item.sourceType) {
case SourceType.fileImage:
child = Image.file(
key: _key,
File(item.url),
filterQuality: .low,
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
doubleTapGestureRecognizer: _doubleTapGestureRecognizer,
horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
);
case SourceType.networkImage:
final isLongPic = item.isLongPic;
child = Image(
key: _key,
image: CachedNetworkImageProvider(_getActualUrl(item.url)),
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
doubleTapGestureRecognizer: _doubleTapGestureRecognizer,
horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) {
return child;
}
if (frame == null) {
if (widget.quality == _quality) {
return child;
} else {
return Image(
image: ResizeImage.resizeIfNeeded(
_containerSize.width.cacheSize(context),
null,
CachedNetworkImageProvider(
ImageUtils.thumbnailUrl(item.url, widget.quality),
),
),
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
onDragStart: null,
onDragUpdate: null,
onDragEnd: null,
doubleTapGestureRecognizer: _doubleTapGestureRecognizer,
horizontalDragGestureRecognizer:
_horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
);
// final isLongPic = item.isLongPic;
// return CachedNetworkImage(
// fadeInDuration: Duration.zero,
// fadeOutDuration: Duration.zero,
// // fit: isLongPic ? .fitWidth : null,
// // alignment: isLongPic ? .topCenter : .center,
// imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality),
// placeholder: (_, _) => const SizedBox.expand(),
// );
}
}
return child;
},
loadingBuilder: loadingBuilder,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
);
if (isLongPic) {
return child;
}
case SourceType.livePhoto:
child = Obx(
key: _key,
() => _currIndex.value == index && _videoController != null
? Viewer(
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
childSize: _containerSize,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
doubleTapGestureRecognizer: _doubleTapGestureRecognizer,
horizontalDragGestureRecognizer:
_horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
child: FittedBox(
child: SimpleVideo(
controller: _videoController!,
fill: Colors.transparent,
),
),
)
: const SizedBox.shrink(),
);
}
return Hero(tag: item.url, child: child);
}
void _onTap() {
EasyThrottle.throttle(
'VIEWER_TAP',
const Duration(milliseconds: 555),
Get.back,
);
}
void _onLongPress() {
final item = widget.sources[_currIndex.value];
if (item.sourceType == .fileImage) return;
HapticFeedback.mediumImpact();
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformUtils.isMobile)
ListTile(
onTap: () {
Get.back();
ImageUtils.onShareImg(item.url);
},
dense: true,
title: const Text('分享', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
Utils.copyText(item.url);
},
dense: true,
title: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg([item.url]);
},
dense: true,
title: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
if (PlatformUtils.isDesktop)
ListTile(
onTap: () {
Get.back();
PageUtils.launchURL(item.url);
},
dense: true,
title: const Text('网页打开', style: TextStyle(fontSize: 14)),
)
else if (widget.sources.length > 1)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg(
widget.sources.map((item) => item.url).toList(),
);
},
dense: true,
title: const Text('保存全部图片', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
);
},
dense: true,
title: Text(
'保存${Platform.isIOS ? ' Live Photo' : '视频'}',
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
}
void _showDesktopMenu(TapUpDetails details) {
final item = widget.sources[_currIndex.value];
if (item.sourceType == .fileImage) return;
showMenu(
context: context,
position: PageUtils.menuPosition(details.globalPosition),
items: [
PopupMenuItem(
height: 42,
onTap: () => Utils.copyText(item.url),
child: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadImg([item.url]),
child: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => PageUtils.launchURL(item.url),
child: const Text('网页打开', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
),
child: const Text('保存视频', style: TextStyle(fontSize: 14)),
),
],
);
}
Widget loadingBuilder(
BuildContext context,
Widget child,
ImageChunkEvent? loadingProgress,
) {
return Stack(
fit: .expand,
alignment: .center,
clipBehavior: .none,
children: [
child,
if (loadingProgress != null &&
loadingProgress.expectedTotalBytes != null &&
loadingProgress.cumulativeBytesLoaded !=
loadingProgress.expectedTotalBytes)
Center(
child: LoadingIndicator(
size: 39.4,
progress:
loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!,
),
),
],
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/widgets.dart';
Widget fromHero({
required Object tag,
required Widget child,
}) => Hero(
tag: tag,
createRectTween: createEndRectTween,
child: child,
);
RectTween createEndRectTween(Rect? begin, Rect? end) {
if (begin != null && end != null) {
final endWidth = end.width;
final endHeight = end.height;
// TODO: use real image rect
final beginRect = Rect.fromLTWH(
begin.left + (begin.width - endWidth) / 2,
begin.top + (begin.height - endHeight) / 2,
endWidth,
endHeight,
);
return RectTween(begin: beginRect, end: end);
}
return RectTween(begin: begin, end: end);
}

View File

@@ -17,7 +17,7 @@ class HeroDialogRoute<T> extends PageRoute<T> {
bool get opaque => false;
@override
bool get barrierDismissible => true;
bool get barrierDismissible => false;
@override
String? get barrierLabel => null;

View File

@@ -0,0 +1,678 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io' show File;
import 'dart:math' as math;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DoubleTapGestureRecognizer;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
class Image extends StatefulWidget {
const Image({
super.key,
required this.image,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
});
Image.network(
String src, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
WebHtmlElementStrategy webHtmlElementStrategy =
WebHtmlElementStrategy.never,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
NetworkImage(
src,
scale: scale,
headers: headers,
webHtmlElementStrategy: webHtmlElementStrategy,
),
),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.file(
File file, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : assert(
!kIsWeb,
'Image.file is not supported on Flutter Web. '
'Consider using either Image.asset or Image.network instead.',
),
image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
FileImage(file, scale: scale),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.asset(
String name, {
super.key,
AssetBundle? bundle,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
double? scale,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
String? package,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
scale != null
? ExactAssetImage(
name,
bundle: bundle,
scale: scale,
package: package,
)
: AssetImage(name, bundle: bundle, package: package),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.memory(
Uint8List bytes, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
MemoryImage(bytes, scale: scale),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
final ImageFrameBuilder? frameBuilder;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final double? width;
final double? height;
final Color? color;
final Animation<double>? opacity;
final FilterQuality filterQuality;
final BlendMode? colorBlendMode;
final BoxFit? fit;
final AlignmentGeometry alignment;
final ImageRepeat repeat;
final Rect? centerSlice;
final bool matchTextDirection;
final bool gaplessPlayback;
final String? semanticLabel;
final bool excludeFromSemantics;
final bool isAntiAlias;
final double minScale;
final double maxScale;
final Size containerSize;
final ValueChanged<ScaleStartDetails>? onDragStart;
final ValueChanged<ScaleUpdateDetails>? onDragUpdate;
final ValueChanged<ScaleEndDetails>? onDragEnd;
final ValueChanged<int>? onChangePage;
final DoubleTapGestureRecognizer doubleTapGestureRecognizer;
final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer;
@override
State<Image> createState() => _ImageState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<ImageProvider>('image', image))
..add(DiagnosticsProperty<Function>('frameBuilder', frameBuilder))
..add(
DiagnosticsProperty<Function>('loadingBuilder', loadingBuilder),
)
..add(DoubleProperty('width', width, defaultValue: null))
..add(DoubleProperty('height', height, defaultValue: null))
..add(ColorProperty('color', color, defaultValue: null))
..add(
DiagnosticsProperty<Animation<double>?>(
'opacity',
opacity,
defaultValue: null,
),
)
..add(
EnumProperty<BlendMode>(
'colorBlendMode',
colorBlendMode,
defaultValue: null,
),
)
..add(EnumProperty<BoxFit>('fit', fit, defaultValue: null))
..add(
DiagnosticsProperty<AlignmentGeometry>(
'alignment',
alignment,
defaultValue: null,
),
)
..add(
EnumProperty<ImageRepeat>(
'repeat',
repeat,
defaultValue: ImageRepeat.noRepeat,
),
)
..add(
DiagnosticsProperty<Rect>(
'centerSlice',
centerSlice,
defaultValue: null,
),
)
..add(
FlagProperty(
'matchTextDirection',
value: matchTextDirection,
ifTrue: 'match text direction',
),
)
..add(
StringProperty('semanticLabel', semanticLabel, defaultValue: null),
)
..add(
DiagnosticsProperty<bool>(
'this.excludeFromSemantics',
excludeFromSemantics,
),
)
..add(EnumProperty<FilterQuality>('filterQuality', filterQuality));
}
}
class _ImageState extends State<Image> with WidgetsBindingObserver {
ImageStream? _imageStream;
ImageInfo? _imageInfo;
ImageChunkEvent? _loadingProgress;
bool _isListeningToStream = false;
int? _frameNumber;
bool _wasSynchronouslyLoaded = false;
late DisposableBuildContext<State<Image>> _scrollAwareContext;
Object? _lastException;
StackTrace? _lastStack;
ImageStreamCompleterHandle? _completerHandle;
bool _isPaused = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<Image>>(this);
}
@override
void dispose() {
assert(_imageStream != null);
WidgetsBinding.instance.removeObserver(this);
_stopListeningToStream();
_completerHandle?.dispose();
_scrollAwareContext.dispose();
_replaceImage(info: null);
super.dispose();
}
@override
void didChangeDependencies() {
_resolveImage();
_isPaused =
!TickerMode.valuesOf(context).enabled ||
(MediaQuery.maybeDisableAnimationsOf(context) ?? false);
if (_isPaused && _frameNumber != null) {
_stopListeningToStream(keepStreamAlive: true);
} else {
_listenToStream();
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(Image oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isListeningToStream &&
(widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) {
final ImageStreamListener oldListener = _getListener();
_imageStream!.addListener(_getListener(recreateListener: true));
_imageStream!.removeListener(oldListener);
}
if (widget.image != oldWidget.image) {
_resolveImage();
_listenToStream();
}
}
@override
void reassemble() {
_resolveImage();
super.reassemble();
}
void _resolveImage() {
final provider = ScrollAwareImageProvider<Object>(
context: _scrollAwareContext,
imageProvider: widget.image,
);
final ImageStream newStream = provider.resolve(
createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
),
);
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if (_imageStreamListener == null || recreateListener) {
_lastException = null;
_lastStack = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
onChunk: widget.loadingBuilder == null ? null : _handleImageChunk,
onError: widget.errorBuilder != null || kDebugMode
? (Object error, StackTrace? stackTrace) {
setState(() {
_lastException = error;
_lastStack = stackTrace;
});
assert(() {
if (widget.errorBuilder == null) {
throw error;
}
return true;
}());
}
: null,
);
}
return _imageStreamListener!;
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_replaceImage(info: imageInfo);
_loadingProgress = null;
_lastException = null;
_lastStack = null;
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
});
if (_isPaused) {
_stopListeningToStream(keepStreamAlive: true);
}
}
void _handleImageChunk(ImageChunkEvent event) {
assert(widget.loadingBuilder != null);
setState(() {
_loadingProgress = event;
_lastException = null;
_lastStack = null;
});
}
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
if (oldImageInfo != null) {
SchedulerBinding.instance.addPostFrameCallback(
(Duration duration) => oldImageInfo.dispose(),
debugLabel: 'Image.disposeOldInfo',
);
}
_imageInfo = info;
}
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) {
return;
}
if (_isListeningToStream) {
_imageStream!.removeListener(_getListener());
}
if (!widget.gaplessPlayback) {
setState(() {
_replaceImage(info: null);
});
}
setState(() {
_loadingProgress = null;
_frameNumber = null;
_wasSynchronouslyLoaded = false;
});
_imageStream = newStream;
if (_isListeningToStream) {
_imageStream!.addListener(_getListener());
}
}
void _listenToStream() {
if (_isListeningToStream) {
return;
}
_isListeningToStream = true;
_imageStream!.addListener(_getListener());
_completerHandle?.dispose();
_completerHandle = null;
}
void _stopListeningToStream({bool keepStreamAlive = false}) {
if (!_isListeningToStream) {
return;
}
if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
if (_imageStream!.completer != null && widget.errorBuilder != null) {
_imageStream!.completer!.addEphemeralErrorListener(
(
Object exception,
StackTrace? stackTrace,
) {},
);
}
_imageStream!.removeListener(_getListener());
_isListeningToStream = false;
}
// Widget _debugBuildErrorWidget(BuildContext context, Object error) {
// return Stack(
// alignment: Alignment.center,
// children: <Widget>[
// const Positioned.fill(child: Placeholder(color: Color(0xCF8D021F))),
// Padding(
// padding: const EdgeInsets.all(4.0),
// child: FittedBox(
// child: Text(
// '$error',
// textAlign: TextAlign.center,
// textDirection: TextDirection.ltr,
// style: const TextStyle(
// shadows: <Shadow>[Shadow(blurRadius: 1.0)],
// ),
// ),
// ),
// ),
// ],
// );
// }
@override
Widget build(BuildContext context) {
if (_lastException != null) {
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, _lastException!, _lastStack);
}
// if (kDebugMode) {
// return _debugBuildErrorWidget(context, _lastException!);
// }
}
final Size childSize;
final bool isLongPic;
double? minScale, maxScale;
if (_imageInfo != null) {
final imgWidth = _imageInfo!.image.width.toDouble();
final imgHeight = _imageInfo!.image.height.toDouble();
final imgRatio = imgHeight / imgWidth;
isLongPic =
imgRatio > StyleString.imgMaxRatio &&
imgHeight > widget.containerSize.height;
if (isLongPic) {
final compatWidth = math.min(650.0, widget.containerSize.width);
minScale = compatWidth / widget.containerSize.height * imgRatio;
maxScale = math.max(widget.maxScale, minScale * 3);
}
childSize = Size(imgWidth, imgHeight);
} else {
childSize = .zero;
isLongPic = false;
}
Widget result = Viewer(
minScale: minScale ?? widget.minScale,
maxScale: maxScale ?? widget.maxScale,
isLongPic: isLongPic,
containerSize: widget.containerSize,
childSize: childSize,
onDragStart: widget.onDragStart,
onDragUpdate: widget.onDragUpdate,
onDragEnd: widget.onDragEnd,
doubleTapGestureRecognizer: widget.doubleTapGestureRecognizer,
horizontalDragGestureRecognizer: widget.horizontalDragGestureRecognizer,
onChangePage: widget.onChangePage,
child: RawImage(image: _imageInfo?.image),
);
if (!widget.excludeFromSemantics) {
result = Semantics(
container: widget.semanticLabel != null,
image: true,
label: widget.semanticLabel ?? '',
child: result,
);
}
if (widget.frameBuilder != null) {
result = widget.frameBuilder!(
context,
result,
_frameNumber,
_wasSynchronouslyLoaded,
);
}
if (widget.loadingBuilder != null) {
result = widget.loadingBuilder!(context, result, _loadingProgress);
}
return result;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description
..add(DiagnosticsProperty<ImageStream>('stream', _imageStream))
..add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo))
..add(
DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress',
_loadingProgress,
),
)
..add(DiagnosticsProperty<int>('frameNumber', _frameNumber))
..add(
DiagnosticsProperty<bool>(
'wasSynchronouslyLoaded',
_wasSynchronouslyLoaded,
),
);
}
}

View File

@@ -0,0 +1,149 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show Platform;
import 'dart:math' show pi;
import 'package:flutter/material.dart';
///
/// created by dom on 2026/02/14
///
class LoadingIndicator extends LeafRenderObjectWidget {
const LoadingIndicator({
super.key,
required this.size,
required this.progress,
});
final double size;
final double progress;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderLoadingIndicator(
preferredSize: size,
progress: progress,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderLoadingIndicator renderObject,
) {
renderObject
..preferredSize = size
..progress = progress;
}
}
class RenderLoadingIndicator extends RenderBox {
RenderLoadingIndicator({
required double preferredSize,
required double progress,
}) : _preferredSize = preferredSize,
_progress = progress;
double _preferredSize;
double get preferredSize => _preferredSize;
set preferredSize(double value) {
if (_preferredSize == value) return;
_preferredSize = value;
markNeedsLayout();
}
double _progress;
double get progress => _progress;
set progress(double value) {
if (_progress == value) return;
_progress = value;
markNeedsPaint();
}
@override
void performLayout() {
size = constraints.constrainDimensions(_preferredSize, _preferredSize);
}
@override
void paint(PaintingContext context, Offset offset) {
if (_progress == 0) {
return;
}
const padding = 8.0;
const strokeWidth = 1.4;
const startAngle = -pi / 2;
final paint = Paint()..isAntiAlias = true;
final size = this.size;
final radius = size.width / 2 - strokeWidth;
final center = size.center(.zero);
// TODO: remove
// https://github.com/flutter/flutter/issues/182708
// https://github.com/flutter/flutter/issues/183083
if (Platform.isIOS) {
context.canvas
..drawCircle(
center,
radius,
paint..color = Colors.white,
)
..drawCircle(
center,
radius - strokeWidth,
paint..color = const Color(0x80000000),
)
..drawArc(
Rect.fromCircle(center: center, radius: radius - padding),
startAngle,
progress * 2 * pi,
true,
paint..color = Colors.white,
);
} else {
context.canvas
..drawCircle(
center,
radius,
paint
..style = .fill
..color = const Color(0x80000000),
)
..drawCircle(
center,
radius,
paint
..style = .stroke
..strokeWidth = strokeWidth
..color = Colors.white,
)
..drawArc(
Rect.fromCircle(center: center, radius: radius - padding),
startAngle,
progress * 2 * pi,
true,
paint..style = .fill,
);
}
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -0,0 +1,481 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart'
show touchSlopH;
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart' show FrictionSimulation;
import 'package:flutter/services.dart' show HardwareKeyboard;
///
/// created by dom on 2026/02/14
///
class Viewer extends StatefulWidget {
const Viewer({
super.key,
required this.minScale,
required this.maxScale,
this.isLongPic = false,
required this.containerSize,
required this.childSize,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.doubleTapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
required this.child,
});
final double minScale;
final double maxScale;
final bool isLongPic;
final Size containerSize;
final Size childSize;
final Widget child;
final ValueChanged<ScaleStartDetails>? onDragStart;
final ValueChanged<ScaleUpdateDetails>? onDragUpdate;
final ValueChanged<ScaleEndDetails>? onDragEnd;
final ValueChanged<int>? onChangePage;
final DoubleTapGestureRecognizer doubleTapGestureRecognizer;
final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer;
@override
State<StatefulWidget> createState() => _ViewerState();
}
class _ViewerState extends State<Viewer> with SingleTickerProviderStateMixin {
double get _interactionEndFrictionCoefficient => 0.0001 * _scale; // 0.0000135
static const double _scaleFactor = kDefaultMouseScrollToScaleFactor;
_GestureType? _gestureType;
final Matrix4 _matrix = Matrix4.identity();
late double __scale;
double get _scale => __scale;
set _scale(double value) {
__scale = value;
_matrix[0] = _matrix[5] = _matrix[10] = value;
}
late Offset __position;
Offset get _position => __position;
set _position(Offset value) {
__position = value;
_matrix
..[12] = value.dx
..[13] = value.dy;
}
Offset? _scalePos;
double? _scaleStart;
Offset? _referenceFocalPoint;
late Size _imageSize;
late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer;
late final ImageHorizontalDragGestureRecognizer
_horizontalDragGestureRecognizer;
late final ScaleGestureRecognizer _scaleGestureRecognizer;
Offset? _downPos;
late final AnimationController _animationController;
late double _scaleFrom, _scaleTo;
late Offset _positionFrom, _positionTo;
void _listener() {
final t = Curves.easeOut.transform(_animationController.value);
_scale = t.lerp(_scaleFrom, _scaleTo);
_position = Offset.lerp(_positionFrom, _positionTo, t)!;
setState(() {});
}
void _reset() {
_scale = widget.minScale;
_position = .zero;
}
void _initSize() {
_reset();
_imageSize = applyBoxFit(
.scaleDown,
widget.childSize,
widget.containerSize,
).destination;
if (widget.isLongPic) {
final containerWidth = widget.containerSize.width;
final containerHeight = widget.containerSize.height;
final imageHeight = _imageSize.height * _scale;
_position = Offset(
(1 - _scale) * containerWidth / 2,
(imageHeight - _scale * containerHeight) / 2,
);
}
}
@override
void initState() {
super.initState();
_initSize();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(_listener);
_doubleTapGestureRecognizer = widget.doubleTapGestureRecognizer;
_horizontalDragGestureRecognizer = widget.horizontalDragGestureRecognizer;
_scaleGestureRecognizer = ScaleGestureRecognizer(debugOwner: this)
..dragStartBehavior = .start
..onStart = _onScaleStart
..onUpdate = _onScaleUpdate
..onEnd = _onScaleEnd
..gestureSettings = DeviceGestureSettings(touchSlop: touchSlopH);
}
@override
void didUpdateWidget(Viewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.containerSize != widget.containerSize ||
oldWidget.childSize != widget.childSize) {
_initSize();
}
}
@override
void dispose() {
_animationController
..removeListener(_listener)
..dispose();
_scaleGestureRecognizer.dispose();
super.dispose();
}
Offset _toScene(Offset localFocalPoint) {
return (localFocalPoint - _position) / _scale;
}
Offset _clampPosition(Offset offset, double scale) {
final containerSize = widget.containerSize;
final imageWidth = _imageSize.width * scale;
final imageHeight = _imageSize.height * scale;
final center = containerSize * (1 - scale) / 2;
final dxOffset = (imageWidth - containerSize.width) / 2;
final dyOffset = (imageHeight - containerSize.height) / 2;
return Offset(
imageWidth > containerSize.width
? clampDouble(
offset.dx,
center.width - dxOffset,
center.width + dxOffset,
)
: center.width,
imageHeight > containerSize.height
? clampDouble(
offset.dy,
center.height - dyOffset,
center.height + dyOffset,
)
: center.height,
);
}
Offset _matrixTranslate(Offset translation) {
if (translation == .zero) {
return _position;
}
return _clampPosition(_position + translation * _scale, _scale);
}
void _onDoubleTapDown(TapDownDetails details) {
_downPos = details.localPosition;
}
void _onDoubleTap() {
if (!mounted) return;
if (_animationController.isAnimating) return;
EasyThrottle.throttle(
'VIEWER_TAP',
const Duration(milliseconds: 555),
_handleDoubleTap,
);
}
void _handleDoubleTap() {
if (!mounted) return;
if (_animationController.isAnimating) return;
_scaleFrom = _scale;
_positionFrom = _position;
double endScale;
if (_scale == widget.minScale) {
endScale = widget.maxScale * 0.6;
if (endScale <= widget.minScale) {
endScale = widget.maxScale;
}
} else {
endScale = widget.minScale;
}
final position = _clampPosition(
Offset.lerp(_downPos!, _position, endScale / _scale)!,
endScale,
);
_scaleTo = endScale;
_positionTo = position;
_animationController
..duration = const Duration(milliseconds: 300)
..forward(from: 0);
}
static bool _calc(Offset initialPosition, Offset lastPosition) {
final offset = lastPosition - initialPosition;
return offset.dy.abs() > offset.dx.abs();
}
void _onScaleStart(ScaleStartDetails details) {
if (_animationController.isAnimating) {
_animationController.stop();
}
if (details.pointerCount == 1) {
if (widget.isLongPic) {
final imageHeight = _scale * _imageSize.height;
final containerHeight = widget.containerSize.height;
if (_scalePos != null && _calc(_scalePos!, details.focalPoint)) {
final bool drag;
if (details.focalPoint.dy > _scalePos!.dy) {
drag = _position.dy.equals(
(imageHeight - _scale * containerHeight) / 2,
1e-6,
);
} else {
drag = _position.dy.equals(containerHeight - imageHeight, 1e-6);
}
if (drag) {
_gestureType = .drag;
widget.onDragStart?.call(details);
return;
}
}
} else if (_scale == widget.minScale) {
_gestureType = .drag;
widget.onDragStart?.call(details);
return;
}
}
_scaleStart = _scale;
_referenceFocalPoint = _toScene(details.localFocalPoint);
}
void _onScaleUpdate(ScaleUpdateDetails details) {
if (_gestureType == .drag) {
widget.onDragUpdate?.call(details);
return;
}
if (details.scale != 1.0) {
_gestureType = .scale;
_scale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
);
final Offset focalPointSceneScaled = _toScene(details.localFocalPoint);
_position = _matrixTranslate(
focalPointSceneScaled - _referenceFocalPoint!,
);
setState(() {});
} else {
_gestureType = .pan;
final Offset focalPointScene = _toScene(details.localFocalPoint);
final Offset translationChange = focalPointScene - _referenceFocalPoint!;
_position = _matrixTranslate(translationChange);
_referenceFocalPoint = _toScene(details.localFocalPoint);
setState(() {});
}
}
/// ref [InteractiveViewer]
void _onScaleEnd(ScaleEndDetails details) {
switch (_gestureType) {
case _GestureType.pan:
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
return;
}
final drag = _interactionEndFrictionCoefficient;
final FrictionSimulation frictionSimulationX = FrictionSimulation(
drag,
_position.dx,
details.velocity.pixelsPerSecond.dx,
);
final FrictionSimulation frictionSimulationY = FrictionSimulation(
drag,
_position.dy,
details.velocity.pixelsPerSecond.dy,
);
final double tFinal = _getFinalTime(
details.velocity.pixelsPerSecond.distance,
drag,
);
final position = _clampPosition(
Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
_scale,
);
_scaleFrom = _scaleTo = _scale;
_positionFrom = _position;
_positionTo = position;
_animationController
..duration = Duration(milliseconds: (tFinal * 1000).round())
..forward(from: 0);
case _GestureType.scale:
// if (details.scaleVelocity.abs() < 0.1) {
// return;
// }
// final double scale = _scale;
// final FrictionSimulation frictionSimulation = FrictionSimulation(
// _interactionEndFrictionCoefficient * _scaleFactor,
// scale,
// details.scaleVelocity / 10,
// );
// final double tFinal = _getFinalTime(
// details.scaleVelocity.abs(),
// _interactionEndFrictionCoefficient,
// effectivelyMotionless: 0.1,
// );
// _scaleAnimation = _scaleController.drive(
// Tween<double>(
// begin: scale,
// end: frictionSimulation.x(tFinal),
// ).chain(CurveTween(curve: Curves.decelerate)),
// )..addListener(_handleScaleAnimation);
// _animationController
// ..duration = Duration(milliseconds: (tFinal * 1000).round())
// ..forward(from: 0);
break;
case _GestureType.drag:
widget.onDragEnd?.call(details);
case null:
}
_scalePos = null;
_gestureType = null;
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: .opaque,
onPointerDown: _onPointerDown,
onPointerPanZoomStart: _onPointerPanZoomStart,
onPointerSignal: _onPointerSignal,
child: ClipRect(
child: Transform(
transform: _matrix,
child: widget.child,
),
),
);
}
void _onPointerDown(PointerDownEvent event) {
_scalePos = event.position;
_doubleTapGestureRecognizer
..onDoubleTapDown = _onDoubleTapDown
..onDoubleTap = _onDoubleTap;
_horizontalDragGestureRecognizer
..isBoundaryAllowed = _isBoundaryAllowed
..addPointer(event);
_scaleGestureRecognizer.addPointer(event);
}
void _onPointerPanZoomStart(PointerPanZoomStartEvent event) {
_scaleGestureRecognizer.addPointerPanZoom(event);
}
bool _isBoundaryAllowed(Offset? initialPosition, OffsetPair lastPosition) {
if (initialPosition == null) {
return true;
}
if (_scale <= widget.minScale) {
return true;
}
final containerWidth = widget.containerSize.width;
final imageWidth = _imageSize.width * _scale;
if (imageWidth <= containerWidth) {
return true;
}
final dx = (1 - _scale) * containerWidth / 2;
final dxOffset = (imageWidth - containerWidth) / 2;
if (initialPosition.dx < lastPosition.global.dx) {
return _position.dx.equals(dx + dxOffset, 1e-6);
} else {
return _position.dx.equals(dx - dxOffset, 1e-6);
}
}
void _onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
if (widget.onChangePage != null &&
!HardwareKeyboard.instance.isControlPressed) {
widget.onChangePage!.call(event.scrollDelta.dy < 0 ? -1 : 1);
return;
}
final double scaleChange = math.exp(-event.scrollDelta.dy / _scaleFactor);
final Offset local = event.localPosition;
final Offset focalPointScene = _toScene(local);
_scale = clampDouble(
_scale * scaleChange,
widget.minScale,
widget.maxScale,
);
final Offset focalPointSceneScaled = _toScene(local);
_position = _matrixTranslate(focalPointSceneScaled - focalPointScene);
setState(() {});
}
}
}
enum _GestureType { pan, scale, drag }
double _getFinalTime(
double velocity,
double drag, {
double effectivelyMotionless = 10,
}) {
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}

View File

@@ -1,238 +0,0 @@
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer.dart'
as custom;
import 'package:flutter/material.dart';
/// https://github.com/qq326646683/interactiveviewer_gallery
/// A callback for the [InteractiveViewerBoundary] that is called when the scale
/// changed.
typedef ScaleChanged = void Function(double scale);
/// Builds an [InteractiveViewer] and provides callbacks that are called when a
/// horizontal boundary has been hit.
///
/// The callbacks are called when an interaction ends by listening to the
/// [InteractiveViewer.onInteractionEnd] callback.
class InteractiveViewerBoundary extends StatefulWidget {
const InteractiveViewerBoundary({
super.key,
required this.child,
required this.boundaryWidth,
required this.controller,
this.onScaleChanged,
this.onLeftBoundaryHit,
this.onRightBoundaryHit,
this.onNoBoundaryHit,
required this.maxScale,
required this.minScale,
this.onDismissed,
this.onReset,
this.dismissThreshold = 0.2,
});
final VoidCallback? onReset;
final double dismissThreshold;
final VoidCallback? onDismissed;
final Widget child;
/// The max width this widget can have.
///
/// If the [InteractiveViewer] can take up the entire screen width, this
/// should be set to `MediaQuery.of(context).size.width`.
final double boundaryWidth;
/// The [TransformationController] for the [InteractiveViewer].
final TransformationController controller;
/// Called when the scale changed after an interaction ended.
final ScaleChanged? onScaleChanged;
/// Called when the left boundary has been hit after an interaction ended.
final VoidCallback? onLeftBoundaryHit;
/// Called when the right boundary has been hit after an interaction ended.
final VoidCallback? onRightBoundaryHit;
/// Called when no boundary has been hit after an interaction ended.
final VoidCallback? onNoBoundaryHit;
final double maxScale;
final double minScale;
@override
InteractiveViewerBoundaryState createState() =>
InteractiveViewerBoundaryState();
}
class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
with SingleTickerProviderStateMixin {
late TransformationController _controller;
double? _scale;
late AnimationController _animateController;
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
late Animation<Decoration> _opacityAnimation;
Offset _offset = Offset.zero;
bool _dragging = false;
bool get _isActive => _dragging || _animateController.isAnimating;
@override
void initState() {
super.initState();
_controller = widget.controller;
_animateController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_updateMoveAnimation();
}
@override
void dispose() {
_animateController.dispose();
super.dispose();
}
void _updateMoveAnimation() {
final double endX = _offset.dx.sign * (_offset.dx.abs() / _offset.dy.abs());
final double endY = _offset.dy.sign;
_slideAnimation = _animateController.drive(
Tween<Offset>(
begin: Offset.zero,
end: Offset(endX, endY),
),
);
_scaleAnimation = _animateController.drive(
Tween<double>(
begin: 1.0,
end: 0.25,
),
);
_opacityAnimation = _animateController.drive(
DecorationTween(
begin: const BoxDecoration(
color: Colors.black,
),
end: const BoxDecoration(
color: Colors.transparent,
),
),
);
}
void _handleDragStart(ScaleStartDetails details) {
_dragging = true;
if (_animateController.isAnimating) {
_animateController.stop();
} else {
_offset = Offset.zero;
_animateController.value = 0.0;
}
setState(_updateMoveAnimation);
}
void _handleDragUpdate(ScaleUpdateDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
_offset += details.focalPointDelta;
setState(_updateMoveAnimation);
if (!_animateController.isAnimating) {
_animateController.value = _offset.dy.abs() / context.size!.height;
}
}
void _handleDragEnd(ScaleEndDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
_dragging = false;
if (_animateController.isCompleted) {
return;
}
if (!_animateController.isDismissed) {
// if the dragged value exceeded the dismissThreshold, call onDismissed
// else animate back to initial position.
if (_animateController.value > widget.dismissThreshold) {
widget.onDismissed?.call();
} else {
_animateController.reverse();
}
}
}
void _updateBoundaryDetection() {
final double scale = _controller.value.row0[0];
if (_scale != scale) {
// the scale changed
_scale = scale;
widget.onScaleChanged?.call(scale);
}
if (scale <= 1.01) {
// cant hit any boundaries when the child is not scaled
return;
}
final double xOffset = _controller.value.row0[3];
final double boundaryWidth = widget.boundaryWidth;
final double boundaryEnd = boundaryWidth * scale;
final double xPos = boundaryEnd + xOffset;
if (boundaryEnd.round() == xPos.round()) {
// left boundary hit
widget.onLeftBoundaryHit?.call();
} else if (boundaryWidth.round() == xPos.round()) {
// right boundary hit
widget.onRightBoundaryHit?.call();
} else {
widget.onNoBoundaryHit?.call();
}
}
Widget get content => DecoratedBoxTransition(
decoration: _opacityAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
),
);
@override
Widget build(BuildContext context) {
return custom.InteractiveViewer(
maxScale: widget.maxScale,
minScale: widget.minScale,
transformationController: _controller,
onInteractionEnd: (_) => _updateBoundaryDetection(),
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onPanEnd: _handleDragEnd,
onReset: widget.onReset,
isAnimating: () => _animateController.value != 0,
child: content,
);
}
}

View File

@@ -1,528 +0,0 @@
import 'dart:io';
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/string_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// https://github.com/qq326646683/interactiveviewer_gallery
/// Builds a carousel controlled by a [PageView] for the tweet media sources.
///
/// Used for showing a full screen view of the [TweetMedia] sources.
///
/// The sources can be panned and zoomed interactively using an
/// [InteractiveViewer].
/// An [InteractiveViewerBoundary] is used to detect when the boundary of the
/// source is hit after zooming in to disable or enable the swiping gesture of
/// the [PageView].
///
typedef IndexedFocusedWidgetBuilder =
Widget Function(
BuildContext context,
int index,
bool isFocus,
bool enablePageView,
);
typedef IndexedTagStringBuilder = String Function(int index);
class InteractiveviewerGallery extends StatefulWidget {
const InteractiveviewerGallery({
super.key,
required this.sources,
required this.initIndex,
this.itemBuilder,
this.maxScale = 8,
this.minScale = 1.0,
required this.quality,
this.onClose,
});
final int quality;
/// The sources to show.
final List<SourceModel> sources;
/// The index of the first source in [sources] to show.
final int initIndex;
/// The item content
final IndexedFocusedWidgetBuilder? itemBuilder;
final double maxScale;
final double minScale;
final VoidCallback? onClose;
@override
State<InteractiveviewerGallery> createState() =>
_InteractiveviewerGalleryState();
}
class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
with SingleTickerProviderStateMixin {
late final PageController _pageController;
late final TransformationController _transformationController;
/// The controller to animate the transformation value of the
/// [InteractiveViewer] when it should reset.
late AnimationController _animationController;
Animation<Matrix4>? _animation;
/// `true` when an source is zoomed in and not at the at a horizontal boundary
/// to disable the [PageView].
bool _enablePageView = true;
late Offset _doubleTapLocalPosition;
late final RxInt currentIndex = widget.initIndex.obs;
late final int _quality = Pref.previewQ;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: widget.initIndex);
_transformationController = TransformationController();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(listener);
final item = widget.sources[currentIndex.value];
if (item.sourceType == SourceType.livePhoto) {
_onPlay(item.liveUrl!);
}
}
void listener() {
_transformationController.value = _animation?.value ?? Matrix4.identity();
}
@override
void dispose() {
widget.onClose?.call();
_player?.dispose();
_pageController.dispose();
_animationController
..removeListener(listener)
..dispose();
_transformationController.dispose();
if (widget.quality != _quality) {
for (final item in widget.sources) {
if (item.sourceType == SourceType.networkImage) {
CachedNetworkImageProvider(_getActualUrl(item.url)).evict();
}
}
}
super.dispose();
}
/// When the source gets scaled up, the swipe up / down to dismiss gets
/// disabled.
///
/// When the scale resets, the dismiss and the page view swiping gets enabled.
void _onScaleChanged(double scale) {
final bool initialScale = scale <= widget.minScale;
if (initialScale) {
if (!_enablePageView) {
setState(() {
_enablePageView = true;
});
}
} else {
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
}
}
/// When the left boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onLeftBoundaryHit() {
if (!_enablePageView && _pageController.page!.floor() > 0) {
setState(() {
_enablePageView = true;
});
}
}
/// When the right boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onRightBoundaryHit() {
if (!_enablePageView &&
_pageController.page!.floor() < widget.sources.length - 1) {
setState(() {
_enablePageView = true;
});
}
}
/// When the source has been scaled up and no horizontal boundary has been hit,
/// the page view swiping gets disabled.
void _onNoBoundaryHit() {
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
}
void _onPlay(String liveUrl) {
_player ??= Player();
_videoController ??= VideoController(_player!);
_player!.open(Media(liveUrl));
}
/// When the page view changed its page, the source will animate back into the
/// original scale if it was scaled up.
///
/// Additionally the swipe up / down to dismiss gets enabled.
void _onPageChanged(int page) {
_player?.pause();
currentIndex.value = page;
final item = widget.sources[page];
if (item.sourceType == SourceType.livePhoto) {
_onPlay(item.liveUrl!);
}
if (_transformationController.value != Matrix4.identity()) {
// animate the reset for the transformation of the interactive viewer
_animation = _animationController.drive(
Matrix4Tween(
begin: _transformationController.value,
end: Matrix4.identity(),
).chain(CurveTween(curve: Curves.easeOut)),
);
_animationController.forward(from: 0);
}
}
String _getActualUrl(String url) {
return _quality != 100
? ImageUtils.thumbnailUrl(url, _quality)
: url.http2https;
}
Player? _player;
VideoController? _videoController;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
InteractiveViewerBoundary(
controller: _transformationController,
boundaryWidth: MediaQuery.widthOf(context),
onScaleChanged: _onScaleChanged,
onLeftBoundaryHit: _onLeftBoundaryHit,
onRightBoundaryHit: _onRightBoundaryHit,
onNoBoundaryHit: _onNoBoundaryHit,
maxScale: widget.maxScale,
minScale: widget.minScale,
onDismissed: Get.back,
onReset: () {
if (!_enablePageView) {
setState(() {
_enablePageView = true;
});
}
},
child: PageView.builder(
onPageChanged: _onPageChanged,
controller: _pageController,
physics: _enablePageView
? null
: const NeverScrollableScrollPhysics(),
itemCount: widget.sources.length,
itemBuilder: (BuildContext context, int index) {
final item = widget.sources[index];
final isFileImg = item.sourceType == SourceType.fileImage;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => EasyThrottle.throttle(
'preview',
const Duration(milliseconds: 555),
Get.back,
),
onDoubleTapDown: (TapDownDetails details) {
_doubleTapLocalPosition = details.localPosition;
},
onDoubleTap: () => EasyThrottle.throttle(
'preview',
const Duration(milliseconds: 555),
onDoubleTap,
),
onLongPress: !isFileImg ? () => onLongPress(item) : null,
onSecondaryTapUp: PlatformUtils.isDesktop && !isFileImg
? (e) => _showDesktopMenu(e.globalPosition, item)
: null,
child: widget.itemBuilder != null
? widget.itemBuilder!(
context,
index,
index == currentIndex.value,
_enablePageView,
)
: _itemBuilder(index, item),
);
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding:
MediaQuery.viewPaddingOf(context) +
const EdgeInsets.fromLTRB(12, 8, 20, 8),
decoration: _enablePageView
? BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
],
),
)
: null,
alignment: Alignment.center,
child: Obx(
() => Text(
"${currentIndex.value + 1}/${widget.sources.length}",
style: const TextStyle(color: Colors.white),
),
),
),
),
],
);
}
Widget _itemBuilder(int index, SourceModel item) {
return Center(
child: Hero(
tag: item.url,
child: switch (item.sourceType) {
SourceType.fileImage => Image.file(
File(item.url),
filterQuality: FilterQuality.low,
),
SourceType.networkImage => CachedNetworkImage(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageUrl: _getActualUrl(item.url),
placeholderFadeInDuration: Duration.zero,
placeholder: (context, url) {
if (widget.quality == _quality) {
return const SizedBox.expand();
}
return CachedNetworkImage(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality),
placeholder: (_, _) => const SizedBox.expand(),
);
},
),
SourceType.livePhoto => Obx(
() => currentIndex.value == index
? IgnorePointer(
child: Video(
controller: _videoController!,
fill: Colors.transparent,
),
)
: const SizedBox.shrink(),
),
},
),
);
}
void onDoubleTap() {
Matrix4 matrix = _transformationController.value.clone();
double currentScale = matrix.storage[0];
double targetScale = widget.minScale;
if (currentScale <= widget.minScale) {
targetScale = widget.maxScale * 0.4;
}
double offSetX = targetScale == 1.0
? 0.0
: -_doubleTapLocalPosition.dx * (targetScale - 1);
double offSetY = targetScale == 1.0
? 0.0
: -_doubleTapLocalPosition.dy * (targetScale - 1);
matrix = Matrix4.fromList([
targetScale,
matrix.row1.x,
matrix.row2.x,
matrix.row3.x,
matrix.row0.y,
targetScale,
matrix.row2.y,
matrix.row3.y,
matrix.row0.z,
matrix.row1.z,
targetScale,
matrix.row3.z,
offSetX,
offSetY,
matrix.row2.w,
matrix.row3.w,
]);
_animation = _animationController.drive(
Matrix4Tween(
begin: _transformationController.value,
end: matrix,
).chain(CurveTween(curve: Curves.easeOut)),
);
_animationController
.forward(from: 0)
.whenComplete(() => _onScaleChanged(targetScale));
}
void onLongPress(SourceModel item) {
HapticFeedback.mediumImpact();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformUtils.isMobile)
ListTile(
onTap: () {
Get.back();
ImageUtils.onShareImg(item.url);
},
dense: true,
title: const Text('分享', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
Utils.copyText(item.url);
},
dense: true,
title: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg([item.url]);
},
dense: true,
title: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
if (PlatformUtils.isDesktop)
ListTile(
onTap: () {
Get.back();
PageUtils.launchURL(item.url);
},
dense: true,
title: const Text('网页打开', style: TextStyle(fontSize: 14)),
)
else if (widget.sources.length > 1)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg(
widget.sources.map((item) => item.url).toList(),
);
},
dense: true,
title: const Text('保存全部图片', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
);
},
dense: true,
title: Text(
'保存${Platform.isIOS ? ' Live Photo' : '视频'}',
style: const TextStyle(fontSize: 14),
),
),
],
),
);
},
);
}
void _showDesktopMenu(Offset offset, SourceModel item) {
showMenu(
context: context,
position: PageUtils.menuPosition(offset),
items: [
PopupMenuItem(
height: 42,
onTap: () => Utils.copyText(item.url),
child: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadImg([item.url]),
child: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => PageUtils.launchURL(item.url),
child: const Text('网页打开', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
),
child: const Text('保存视频', style: TextStyle(fontSize: 14)),
),
],
);
}
}

View File

@@ -3,17 +3,19 @@ import 'package:flutter_svg/flutter_svg.dart';
class HttpError extends StatelessWidget {
const HttpError({
super.key,
this.isSliver = true,
this.errMsg,
this.onReload,
this.btnText,
super.key,
this.safeArea = true,
});
final bool isSliver;
final String? errMsg;
final VoidCallback? onReload;
final String? btnText;
final bool safeArea;
@override
Widget build(BuildContext context) {
@@ -57,7 +59,8 @@ class HttpError extends StatelessWidget {
style: TextStyle(color: theme.colorScheme.primary),
),
),
SizedBox(height: 40 + MediaQuery.viewPaddingOf(context).bottom),
if (safeArea)
SizedBox(height: 40 + MediaQuery.viewPaddingOf(context).bottom),
],
);
}

View File

@@ -226,13 +226,14 @@ abstract class MarqueeRender extends RenderBox
if (_distance > 0) {
updateSize();
_ticker.initIfNeeded(_onTick);
markNeedsCompositingBitsUpdate();
} else {
_ticker.cancel();
}
}
@override
bool get isRepaintBoundary => true;
bool get isRepaintBoundary => _ticker._ticker != null;
void paintCenter(PaintingContext context, Offset offset) {
if (_direction == Axis.horizontal) {
@@ -448,7 +449,7 @@ class ContextSingleTicker implements TickerProvider {
onTick,
debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null,
);
_tickerModeNotifier = TickerMode.getNotifier(context)
_tickerModeNotifier = TickerMode.getValuesNotifier(context)
..addListener(updateTicker);
updateTicker(); // Sets _ticker.mute correctly.
return _ticker!;
@@ -467,9 +468,15 @@ class ContextSingleTicker implements TickerProvider {
_tickerModeNotifier = null;
}
ValueListenable<bool>? _tickerModeNotifier;
ValueListenable<TickerModeData>? _tickerModeNotifier;
void updateTicker() => _ticker?.muted = !_tickerModeNotifier!.value;
void updateTicker() {
if (_tickerModeNotifier != null && _ticker != null) {
final TickerModeData values = _tickerModeNotifier!.value;
_ticker!.muted = !values.enabled;
_ticker!.forceFrames = values.forceFrames;
}
}
set muted(bool value) => _ticker?.muted = value;
}

View File

@@ -1,17 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderProxyBox;
import 'package:flutter/scheduler.dart';
typedef LayoutCallback = void Function(Size size);
class OnlyLayoutWidget extends SingleChildRenderObjectWidget {
const OnlyLayoutWidget({
super.key,
super.child,
required this.onPerformLayout,
});
final LayoutCallback onPerformLayout;
@override
RenderObject createRenderObject(BuildContext context) => Layout();
RenderObject createRenderObject(BuildContext context) =>
NoRenderLayoutBox(onPerformLayout: onPerformLayout);
@override
void updateRenderObject(
BuildContext context,
NoRenderLayoutBox renderObject,
) {
super.updateRenderObject(context, renderObject);
renderObject.onPerformLayout = onPerformLayout;
}
}
class Layout extends RenderProxyBox {
class NoRenderLayoutBox extends RenderProxyBox {
NoRenderLayoutBox({required this.onPerformLayout});
LayoutCallback onPerformLayout;
@override
void performLayout() {
super.performLayout();
SchedulerBinding.instance.addPostFrameCallback((_) {
onPerformLayout(size);
});
}
@override
void paint(PaintingContext context, Offset offset) {}
}

View File

@@ -15,11 +15,13 @@ class PendantAvatar extends StatelessWidget {
final String? garbPendantImage;
final int? roomId;
final VoidCallback? onTap;
final bool isMemberAvatar;
const PendantAvatar({
super.key,
required this.avatar,
this.size = 80,
required this.size,
this.isMemberAvatar = false,
double? badgeSize,
bool isVip = false,
int? officialType,
@@ -42,13 +44,12 @@ class PendantAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isMemberAvatar = size == 80;
Widget? pendant;
if (showDynDecorate && !garbPendantImage.isNullOrEmpty) {
final pendantSize = size * 1.75;
pendant = Positioned(
// -(size * 1.75 - size) / 2
top: -0.375 * size + (size == 80 ? 2 : 0),
top: -0.375 * size + (isMemberAvatar ? 2 : 0),
child: IgnorePointer(
child: NetworkImgLayer(
type: .emote,

View File

@@ -0,0 +1,129 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show
ContainerRenderObjectMixin,
MultiChildLayoutParentData,
RenderBoxContainerDefaultsMixin,
BoxHitTestResult;
class PlayerBar extends MultiChildRenderObjectWidget {
const PlayerBar({
super.key,
super.children,
});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderBottomBar();
}
}
class RenderBottomBar extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
Matrix4? _transform;
@override
void performLayout() {
_transform = null;
final c = constraints.copyWith(maxWidth: .infinity);
final RenderBox first = firstChild!..layout(c, parentUsesSize: true);
final RenderBox last = lastChild!..layout(c, parentUsesSize: true);
final firstSize = first.size;
final lastSize = last.size;
final firstParentData = first.parentData as MultiChildLayoutParentData;
final lastParentData = last.parentData as MultiChildLayoutParentData;
final firstWidth = firstSize.width;
final lastWidth = lastSize.width;
final totalWidth = firstWidth + lastWidth;
final maxWidth = constraints.maxWidth;
final height = math.max(firstSize.height, lastSize.height);
size = constraints.constrainDimensions(maxWidth, height);
firstParentData.offset = Offset(0.0, (height - firstSize.height) / 2);
if (totalWidth <= maxWidth) {
lastParentData.offset = Offset(
maxWidth - lastWidth,
(height - lastSize.height) / 2,
);
} else {
final scale = maxWidth / totalWidth;
_transform = Matrix4.identity()
..translateByDouble(0.0, height * (1 - scale) / 2, 0.0, 1.0)
..scaleByDouble(scale, scale, scale, 1.0);
lastParentData.offset = Offset(
(maxWidth - lastWidth * scale) / scale,
(height - lastSize.height) / 2,
);
}
}
@override
void paint(PaintingContext context, Offset offset) {
if (_transform != null) {
context.pushTransform(
needsCompositing,
offset,
_transform!,
defaultPaint,
);
} else {
defaultPaint(context, offset);
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return result.addWithPaintTransform(
transform: _transform,
position: position,
hitTest: (BoxHitTestResult result, Offset position) {
return defaultHitTestChildren(result, position: position);
},
);
}
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
final childParentData = child.parentData! as MultiChildLayoutParentData;
final Offset offset = childParentData.offset;
if (_transform != null) {
transform
..translateByDouble(offset.dx * _transform!.storage[0], offset.dy, 0, 1)
..multiply(_transform!);
} else {
transform.translateByDouble(offset.dx, offset.dy, 0, 1);
}
}
}

View File

@@ -15,26 +15,31 @@
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:flutter/foundation.dart' show listEquals, kDebugMode;
import 'dart:ui' as ui;
import 'package:PiliPlus/utils/extension/iterable_ext.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/gestures.dart' show TapGestureRecognizer;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show BoxHitTestEntry;
@immutable
sealed class BaseSegment {
final double start;
final double end;
BaseSegment({
required this.start,
const BaseSegment({
required this.end,
});
}
@immutable
class Segment extends BaseSegment {
final double start;
final Color color;
Segment({
required super.start,
const Segment({
required this.start,
required super.end,
required this.color,
});
@@ -54,14 +59,14 @@ class Segment extends BaseSegment {
int get hashCode => Object.hash(start, end, color);
}
@immutable
class ViewPointSegment extends BaseSegment {
final String? title;
final String? url;
final int? from;
final int? to;
ViewPointSegment({
required super.start,
const ViewPointSegment({
required super.end,
this.title,
this.url,
@@ -75,8 +80,7 @@ class ViewPointSegment extends BaseSegment {
return true;
}
if (other is ViewPointSegment) {
return start == other.start &&
end == other.end &&
return end == other.end &&
title == other.title &&
url == other.url &&
from == other.from &&
@@ -86,13 +90,13 @@ class ViewPointSegment extends BaseSegment {
}
@override
int get hashCode => Object.hash(start, end, title, url, from, to);
int get hashCode => Object.hash(end, title, url, from, to);
}
class SegmentProgressBar extends BaseSegmentProgressBar<Segment> {
const SegmentProgressBar({
super.key,
super.height = 3.5,
super.height,
required super.segments,
});
@@ -119,17 +123,17 @@ class RenderProgressBar extends BaseRenderProgressBar<Segment> {
for (final segment in segments) {
paint.color = segment.color;
final segmentStart = segment.start * size.width;
final segmentEnd = segment.end * size.width;
final segmentStart = offset.dx + segment.start * size.width;
final segmentEnd = offset.dx + segment.end * size.width;
if (segmentEnd > segmentStart ||
(segmentEnd == segmentStart && segmentStart > 0)) {
canvas.drawRect(
Rect.fromLTRB(
segmentStart,
0,
offset.dy,
segmentEnd == segmentStart ? segmentStart + 2 : segmentEnd,
size.height,
size.height + offset.dy,
),
paint,
);
@@ -142,7 +146,7 @@ class ViewPointSegmentProgressBar
extends BaseSegmentProgressBar<ViewPointSegment> {
const ViewPointSegmentProgressBar({
super.key,
super.height = 3.5,
super.height,
required super.segments,
this.onSeek,
});
@@ -189,6 +193,31 @@ class RenderViewPointProgressBar
}
static const double _barHeight = 15.0;
static const double _dividerWidth = 2.0;
static ui.Paragraph _getParagraph(String title, double size) {
final builder =
ui.ParagraphBuilder(
ui.ParagraphStyle(
textDirection: .ltr,
strutStyle: ui.StrutStyle(
leading: 0,
height: 1,
fontSize: size,
),
),
)
..pushStyle(
ui.TextStyle(
color: Colors.white,
fontSize: size,
height: 1,
),
)
..addText(title);
return builder.build()
..layout(const ui.ParagraphConstraints(width: double.infinity));
}
@override
void paint(PaintingContext context, Offset offset) {
@@ -196,6 +225,14 @@ class RenderViewPointProgressBar
final canvas = context.canvas;
final paint = Paint()..style = PaintingStyle.fill;
if (offset != .zero) {
canvas
..save()
..translate(offset.dx, offset.dy);
}
assert(segments.isSortedBy((i) => i.end));
canvas.drawRect(
Rect.fromLTRB(0, 0, size.width, _barHeight),
paint..color = Colors.grey[600]!.withValues(alpha: 0.45),
@@ -203,62 +240,49 @@ class RenderViewPointProgressBar
paint.color = Colors.black.withValues(alpha: 0.5);
for (int index = 0; index < segments.length; index++) {
final isFirst = index == 0;
final item = segments[index];
final segmentStart = item.start * size.width;
final segmentEnd = item.end * size.width;
double prevEnd = 0.0;
for (final segment in segments) {
final segmentEnd = segment.end * size.width;
canvas.drawRect(
Rect.fromLTRB(
segmentEnd,
0,
segmentEnd + _dividerWidth,
_barHeight + height,
),
paint,
);
final title = segment.title;
if (title != null && title.isNotEmpty) {
final segmentWidth = segmentEnd - prevEnd;
final paragraph = _getParagraph(title, 10);
final textWidth = paragraph.maxIntrinsicWidth;
final textHeight = paragraph.height;
if (segmentEnd > segmentStart ||
(segmentEnd == segmentStart && segmentStart > 0)) {
double fontSize = 10;
TextPainter getTextPainter() => TextPainter(
text: TextSpan(
text: item.title,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
),
),
strutStyle: StrutStyle(leading: 0, height: 1, fontSize: fontSize),
textDirection: TextDirection.ltr,
)..layout();
TextPainter textPainter = getTextPainter();
late double prevStart;
if (!isFirst) {
prevStart = segments[index - 1].start * size.width;
final isOverflow = textWidth > segmentWidth;
final Offset offset;
if (isOverflow) {
final scale = segmentWidth / textWidth;
canvas
..save()
..translate(prevEnd, (_barHeight - textHeight * scale) / 2)
..scale(scale);
offset = Offset.zero;
} else {
offset = Offset(
(segmentWidth - textWidth) / 2 + prevEnd,
(_barHeight - textHeight) / 2,
);
}
final width = isFirst ? segmentStart : segmentStart - prevStart;
while (textPainter.width > width - 2 && fontSize >= 2) {
fontSize -= 0.5;
textPainter.dispose();
textPainter = getTextPainter();
canvas.drawParagraph(paragraph, offset);
paragraph.dispose();
if (isOverflow) {
canvas.restore();
}
canvas.drawRect(
Rect.fromLTRB(
segmentStart,
0,
segmentEnd == segmentStart ? segmentStart + 2 : segmentEnd,
_barHeight + height,
),
paint,
);
final textX = isFirst
? (segmentStart - textPainter.width) / 2
: (segmentStart - prevStart - textPainter.width) / 2 +
prevStart +
1;
final textY = (_barHeight - textPainter.height) / 2;
textPainter.paint(canvas, Offset(textX, textY));
}
prevEnd = segmentEnd + _dividerWidth;
}
if (offset != .zero) canvas.restore();
}
ValueSetter<Duration>? _onSeek;
@@ -292,19 +316,16 @@ class RenderViewPointProgressBar
}
}
@pragma('vm:notify-debugger-on-exception')
void _onTapUp(TapUpDetails details) {
try {
final seg = details.localPosition.dx / size.width;
final item = _segments
.where((item) => item.start >= seg)
.reduce((a, b) => a.start < b.start ? a : b);
final item = _segments[_segments.lowerBoundByKey((i) => i.end, seg)];
if (item.from case final from?) {
_onSeek?.call(Duration(seconds: from));
}
// if (kDebugMode) debugPrint('${item.title},,${item.from}');
} catch (e) {
if (kDebugMode) rethrow;
}
} catch (_) {}
}
}
@@ -357,7 +378,4 @@ class BaseRenderProgressBar<T extends BaseSegment> extends RenderBox {
void performLayout() {
size = constraints.constrainDimensions(constraints.maxWidth, height);
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -119,7 +119,9 @@ class RenderProgressBar extends RenderBox {
@override
void paint(PaintingContext context, Offset offset) {
final size = this.size;
final canvas = context.canvas;
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
final paint = Paint()..style = .fill;
canvas.clipRect(
@@ -147,8 +149,6 @@ class RenderProgressBar extends RenderBox {
..drawRect(left, paint..color = _color)
..drawRect(right, paint..color = _backgroundColor);
}
canvas.restore();
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -1,3 +1,4 @@
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:flutter/material.dart';
class RadioWidget<T> extends StatefulWidget {
@@ -34,6 +35,9 @@ class RadioWidgetState<T> extends State<RadioWidget<T>> with RadioClient<T> {
@override
bool get tristate => widget.tristate;
@override
bool get enabled => registry != null;
@override
void dispose() {
registry = null;
@@ -61,17 +65,13 @@ class RadioWidgetState<T> extends State<RadioWidget<T>> with RadioClient<T> {
final child = Row(
mainAxisSize: widget.mainAxisSize,
children: [
Focus(
parentNode: focusNode,
canRequestFocus: false,
skipTraversal: true,
includeSemantics: true,
descendantsAreFocusable: false,
descendantsAreTraversable: false,
ExcludeFocus(
child: Radio<T>(
value: radioValue,
groupRegistry: _radioRegistry,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
materialTapTargetSize: PlatformUtils.isDesktop
? .padded
: .shrinkWrap,
),
),
Text(widget.title),

View File

@@ -18,7 +18,7 @@ class ScaledWidgetsFlutterBinding extends WidgetsFlutterBinding {
double _scaleFactor;
/// Update scaleFactor callback, then rebuild layout
void setScaleFactor(double scaleFactor) {
set scaleFactor(double scaleFactor) {
if (_scaleFactor == scaleFactor) return;
_scaleFactor = scaleFactor;
handleMetricsChanged();

View File

@@ -3,23 +3,13 @@ import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recogniz
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.dart' hide TabBarView;
Widget videoTabBarView({
required List<Widget> children,
TabController? controller,
}) => TabBarView<CustomHorizontalDragGestureRecognizer>(
controller: controller,
physics: const CustomTabBarViewScrollPhysics(parent: ClampingScrollPhysics()),
horizontalDragGestureRecognizer: CustomHorizontalDragGestureRecognizer(),
children: children,
);
Widget tabBarView({
required List<Widget> children,
TabController? controller,
}) => TabBarView<CustomHorizontalDragGestureRecognizer>(
physics: const CustomTabBarViewScrollPhysics(),
controller: controller,
horizontalDragGestureRecognizer: CustomHorizontalDragGestureRecognizer(),
physics: clampingScrollPhysics,
horizontalDragGestureRecognizer: CustomHorizontalDragGestureRecognizer.new,
children: children,
);
@@ -32,6 +22,10 @@ SpringDescription _customSpringDescription() {
);
}
const clampingScrollPhysics = CustomTabBarViewScrollPhysics(
parent: ClampingScrollPhysics(),
);
class CustomTabBarViewScrollPhysics extends ScrollPhysics {
const CustomTabBarViewScrollPhysics({super.parent});

View File

@@ -23,22 +23,17 @@ class SelfSizedHorizontalList extends StatefulWidget {
}
class _SelfSizedHorizontalListState extends State<SelfSizedHorizontalList> {
final _key = GlobalKey();
double? _height;
@override
Widget build(BuildContext context) {
if (_height == null) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
_height = (_key.currentContext!.findRenderObject() as RenderBox)
.size
.height;
return OnlyLayoutWidget(
onPerformLayout: (Size size) {
if (!mounted) return;
_height = size.height;
setState(() {});
},
);
return OnlyLayoutWidget(
key: _key,
child: Padding(
padding: widget.padding ?? .zero,
child: widget.itemBuilder(context, 0),

View File

@@ -0,0 +1,157 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show RenderSliverSingleBoxAdapter, SliverGeometry;
/// ref [SliverFloatingHeader]
class SliverFloatingHeaderWidget extends SingleChildRenderObjectWidget {
const SliverFloatingHeaderWidget({
super.key,
required Widget super.child,
required this.backgroundColor,
});
final Color backgroundColor;
@override
RenderObject createRenderObject(BuildContext context) =>
RenderSliverFloatingHeader(backgroundColor: backgroundColor);
@override
void updateRenderObject(
BuildContext context,
RenderSliverFloatingHeader renderObject,
) {
renderObject.backgroundColor = backgroundColor;
}
}
class RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
RenderSliverFloatingHeader({
required Color backgroundColor,
}) : _backgroundColor = backgroundColor;
Color _backgroundColor;
set backgroundColor(Color value) {
if (_backgroundColor == value) return;
_backgroundColor = value;
markNeedsPaint();
}
double? _childPosition;
double? lastScrollOffset;
late double effectiveScrollOffset;
bool get floatingHeaderNeedsToBeUpdated {
return lastScrollOffset != null &&
(constraints.scrollOffset < lastScrollOffset! ||
effectiveScrollOffset < child!.size.height);
}
@override
void performLayout() {
if (!floatingHeaderNeedsToBeUpdated) {
effectiveScrollOffset = constraints.scrollOffset;
} else {
double delta =
lastScrollOffset! -
constraints.scrollOffset; // > 0 when the header is growing
if (constraints.userScrollDirection == .forward) {
final childExtent = child!.size.height;
if (effectiveScrollOffset > childExtent) {
effectiveScrollOffset =
childExtent; // The header is now just above the start edge of viewport.
}
} else {
// delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
delta = clampDouble(delta, -double.infinity, 0);
}
effectiveScrollOffset = clampDouble(
effectiveScrollOffset - delta,
0.0,
constraints.scrollOffset,
);
}
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final childExtent = child!.size.height;
final double paintExtent = childExtent - effectiveScrollOffset;
final double layoutExtent = childExtent - constraints.scrollOffset;
geometry = SliverGeometry(
paintOrigin: math.min(constraints.overlap, 0.0),
scrollExtent: childExtent,
paintExtent: clampDouble(
paintExtent,
0.0,
constraints.remainingPaintExtent,
),
layoutExtent: clampDouble(
layoutExtent,
0.0,
constraints.remainingPaintExtent,
),
maxPaintExtent: childExtent,
hasVisualOverflow: false,
);
_childPosition = math.min(0.0, paintExtent - childExtent);
lastScrollOffset = constraints.scrollOffset;
}
@override
double childMainAxisPosition(covariant RenderObject child) {
return _childPosition ?? 0;
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
applyPaintTransformForBoxChild(child as RenderBox, transform);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
offset += Offset(0.0, childMainAxisPosition(child!));
final size = child!.size;
context.canvas.drawRect(
Rect.fromLTWH(
offset.dx,
offset.dy - 2,
size.width,
size.height + 2,
),
Paint()..color = _backgroundColor,
);
context.paintChild(child!, offset);
}
}
@override
bool hitTestSelf({
required double mainAxisPosition,
required double crossAxisPosition,
}) => true;
}

View File

@@ -0,0 +1,123 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/rendering.dart'
show RenderSliverSingleBoxAdapter, SliverConstraints, SliverGeometry;
import 'package:flutter/widgets.dart';
/// ref [SliverPersistentHeader]
class SliverPinnedDynamicHeader extends SingleChildRenderObjectWidget {
const SliverPinnedDynamicHeader({
super.key,
required Widget super.child,
required this.minExtent,
required this.maxExtent,
});
final double minExtent;
final double maxExtent;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverPinnedDynamicHeader(
minExtent: minExtent,
maxExtent: maxExtent,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderSliverPinnedDynamicHeader renderObject,
) {
renderObject
..minExtent = minExtent
..maxExtent = maxExtent;
}
}
class RenderSliverPinnedDynamicHeader extends RenderSliverSingleBoxAdapter {
RenderSliverPinnedDynamicHeader({
required double minExtent,
required double maxExtent,
}) : _minExtent = minExtent,
_maxExtent = maxExtent;
double _minExtent;
double get minExtent => _minExtent;
set minExtent(double value) {
if (_minExtent == value) return;
_minExtent = value;
markNeedsLayout();
}
double _maxExtent;
double get maxExtent => _maxExtent;
set maxExtent(double value) {
// removed
// if (_maxExtent == value) return;
_maxExtent = value;
markNeedsLayout();
}
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
final double shrinkOffset = math.min(constraints.scrollOffset, maxExtent);
child!.layout(
constraints.asBoxConstraints(
maxExtent: math.max(minExtent, maxExtent - shrinkOffset),
),
parentUsesSize: true,
);
final double childExtent = child!.size.height;
final double effectiveRemainingPaintExtent = math.max(
0,
constraints.remainingPaintExtent - constraints.overlap,
);
final double layoutExtent = clampDouble(
maxExtent - constraints.scrollOffset,
0.0,
effectiveRemainingPaintExtent,
);
geometry = SliverGeometry(
scrollExtent: maxExtent,
paintOrigin: constraints.overlap,
paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
layoutExtent: layoutExtent,
maxPaintExtent: maxExtent,
maxScrollObstructionExtent: minExtent,
cacheExtent: layoutExtent > 0.0
? -constraints.cacheOrigin + layoutExtent
: layoutExtent,
hasVisualOverflow: false,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
context.paintChild(child!, offset);
}
}
@override
double childMainAxisPosition(RenderBox child) => 0.0;
}

View File

@@ -0,0 +1,118 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/rendering.dart'
show RenderSliverSingleBoxAdapter, SliverGeometry;
import 'package:flutter/widgets.dart';
/// ref [SliverPersistentHeader]
class SliverPinnedHeader extends SingleChildRenderObjectWidget {
const SliverPinnedHeader({
super.key,
required Widget super.child,
this.backgroundColor,
});
final Color? backgroundColor;
@override
RenderObject createRenderObject(BuildContext context) =>
RenderSliverPinnedHeader(backgroundColor: backgroundColor);
@override
void updateRenderObject(
BuildContext context,
RenderSliverPinnedHeader renderObject,
) {
renderObject.backgroundColor = backgroundColor;
}
}
class RenderSliverPinnedHeader extends RenderSliverSingleBoxAdapter {
RenderSliverPinnedHeader({
required Color? backgroundColor,
}) : _backgroundColor = backgroundColor;
Color? _backgroundColor;
set backgroundColor(Color? value) {
if (_backgroundColor == value) return;
_backgroundColor = value;
if (_isPinned) markNeedsPaint();
}
bool _isPinned = false;
@override
void performLayout() {
final constraints = this.constraints;
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double childExtent = child!.size.height;
final double effectiveRemainingPaintExtent = math.max(
0,
constraints.remainingPaintExtent - constraints.overlap,
);
final double layoutExtent = clampDouble(
childExtent - constraints.scrollOffset,
0.0,
effectiveRemainingPaintExtent,
);
_isPinned = constraints.overlap > 0.0 || constraints.scrollOffset > 0.0;
geometry = SliverGeometry(
scrollExtent: childExtent,
paintOrigin: constraints.overlap,
paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
layoutExtent: layoutExtent,
maxPaintExtent: childExtent,
maxScrollObstructionExtent: childExtent,
cacheExtent: layoutExtent > 0.0
? -constraints.cacheOrigin + layoutExtent
: layoutExtent,
hasVisualOverflow: false,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
if (_isPinned && _backgroundColor != null) {
final size = child!.size;
context.canvas.drawRect(
Rect.fromLTWH(
offset.dx,
offset.dy - 2,
size.width,
size.height + 2,
),
Paint()..color = _backgroundColor!,
);
}
context.paintChild(child!, offset);
}
}
@override
double childMainAxisPosition(RenderBox child) => 0.0;
@override
bool hitTestSelf({
required double mainAxisPosition,
required double crossAxisPosition,
}) => true;
}

View File

@@ -0,0 +1,343 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class SliverFixedWrap extends SliverMultiBoxAdaptorWidget {
final double mainAxisExtent;
final double spacing;
final double runSpacing;
const SliverFixedWrap({
super.key,
required super.delegate,
required this.mainAxisExtent,
this.spacing = 0,
this.runSpacing = 0,
});
@override
SliverWrapElement createElement() =>
SliverWrapElement(this, replaceMovedChildren: true);
@override
RenderSliverFixedWrap createRenderObject(BuildContext context) {
return RenderSliverFixedWrap(
childManager: context as SliverWrapElement,
mainAxisExtent: mainAxisExtent,
spacing: spacing,
runSpacing: runSpacing,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderSliverFixedWrap renderObject,
) {
renderObject
..mainAxisExtent = mainAxisExtent
..spacing = spacing
..runSpacing = runSpacing;
}
}
class SliverWrapParentData extends SliverMultiBoxAdaptorParentData {
double crossAxisOffset = 0.0;
@override
String toString() => 'crossAxisOffset=$crossAxisOffset; ${super.toString()}';
}
class _Row {
final int startIndex;
final int endIndex;
final List<double> childWidths;
_Row({
required this.startIndex,
required this.endIndex,
required this.childWidths,
});
}
class RenderSliverFixedWrap extends RenderSliverMultiBoxAdaptor {
RenderSliverFixedWrap({
required super.childManager,
required double mainAxisExtent,
double spacing = 0.0,
double runSpacing = 0.0,
}) : _mainAxisExtent = mainAxisExtent,
_spacing = spacing,
_runSpacing = runSpacing {
assert(mainAxisExtent > 0.0 && mainAxisExtent.isFinite);
}
double _mainAxisExtent;
double get mainAxisExtent => _mainAxisExtent;
set mainAxisExtent(double value) {
if (_mainAxisExtent == value) return;
_mainAxisExtent = value;
markRowsDirty();
markNeedsLayout();
}
double _spacing;
double get spacing => _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markRowsDirty();
markNeedsLayout();
}
double _runSpacing;
double get runSpacing => _runSpacing;
set runSpacing(double value) {
if (_runSpacing == value) return;
_runSpacing = value;
markNeedsLayout();
}
final List<_Row> _rows = [];
void markRowsDirty() {
_rows.clear();
}
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverWrapParentData) {
child.parentData = SliverWrapParentData();
}
}
@override
double childCrossAxisPosition(RenderBox child) {
return (child.parentData as SliverWrapParentData).crossAxisOffset;
}
double _childCrossExtent(RenderBox child) {
assert(child.hasSize);
return switch (constraints.axis) {
Axis.horizontal => child.size.height,
Axis.vertical => child.size.width,
};
}
RenderBox _getOrCreateChildAtIndex(
int index,
BoxConstraints constraints,
RenderBox? child,
) {
assert(firstChild != null);
if (index < indexOf(firstChild!)) {
do {
child = insertAndLayoutLeadingChild(constraints, parentUsesSize: true);
assert(child != null);
} while (indexOf(child!) > index);
assert(indexOf(child) == index);
return child;
} else if (index > indexOf(lastChild!)) {
do {
child = insertAndLayoutChild(
constraints,
after: lastChild,
parentUsesSize: true,
);
assert(child != null);
} while (indexOf(child!) < index);
assert(indexOf(child) == index);
return child;
} else {
child = firstChild;
while (indexOf(child!) < index) {
child = childAfter(child);
}
if (indexOf(child) == index) {
child.layout(constraints, parentUsesSize: true);
return child;
}
throw RangeError.value(index, 'index', 'Value not included in children');
}
}
bool _buildNextRow(int start, BoxConstraints constraints) {
final int childCount = childManager.childCount;
if (start >= childCount) {
return false;
}
final crossAxisExtent = this.constraints.crossAxisExtent;
final List<double> widths = [];
int idx = start;
RenderBox? child;
for (var totalWidth = -_spacing; idx < childCount; idx++) {
child = _getOrCreateChildAtIndex(idx, constraints, child);
final childWidth = _childCrossExtent(child);
totalWidth += childWidth + _spacing;
if (totalWidth <= crossAxisExtent) {
widths.add(childWidth);
} else {
break;
}
}
_rows.add(_Row(startIndex: start, endIndex: idx - 1, childWidths: widths));
return true;
}
@override
void performLayout() {
childManager
..didStartLayout()
..setDidUnderflow(false);
final constraints = this.constraints;
final childCount = childManager.childCount;
final rowHeight = _mainAxisExtent + _runSpacing;
final scrollOffset = constraints.scrollOffset;
final firstCacheOffset = scrollOffset + constraints.cacheOrigin;
final lastCacheOffset = scrollOffset + constraints.remainingCacheExtent;
final firstNeededRow = math.max(0, firstCacheOffset ~/ rowHeight);
final lastNeededRow = math.max(0, lastCacheOffset ~/ rowHeight);
final childConstraints = constraints.toFixedConstraints(_mainAxisExtent);
if (firstChild == null) {
if (!addInitialChild()) {
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
firstChild!.layout(childConstraints, parentUsesSize: true);
}
while (_rows.length <= lastNeededRow) {
final int startIndex = _rows.isEmpty ? 0 : _rows.last.endIndex + 1;
if (!_buildNextRow(startIndex, childConstraints)) {
break;
}
}
assert(firstNeededRow >= 0);
final int firstKeptRow = firstNeededRow.clamp(0, _rows.length - 1);
final int lastKeptRow = lastNeededRow.clamp(0, _rows.length - 1);
final int firstKeptIndex = _rows[firstKeptRow].startIndex;
final int lastKeptIndex = _rows[lastKeptRow].endIndex;
collectGarbage(
calculateLeadingGarbage(firstIndex: firstKeptIndex),
calculateTrailingGarbage(lastIndex: lastKeptIndex),
);
RenderBox? child;
for (var r = firstKeptRow; r <= lastKeptRow; r++) {
final row = _rows[r];
final rowStartOffset = r * rowHeight;
double crossOffset = 0.0;
for (var i = row.startIndex; i <= row.endIndex; i++) {
child = _getOrCreateChildAtIndex(i, childConstraints, child);
(child.parentData as SliverWrapParentData)
..layoutOffset = rowStartOffset
..crossAxisOffset = crossOffset;
crossOffset += row.childWidths[i - row.startIndex] + _spacing;
}
}
final endOffset = _rows.last.endIndex == childCount - 1
? (_rows.length * rowHeight)
: (_rows.last.startIndex + 1) * rowHeight;
final double estimatedMaxScrollOffset;
if (_rows.length <= lastNeededRow || childCount == 0) {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstKeptIndex,
lastIndex: lastKeptIndex,
leadingScrollOffset: firstKeptRow * rowHeight,
trailingScrollOffset: endOffset,
);
} else {
estimatedMaxScrollOffset = _rows.length * rowHeight;
}
final double paintExtent = calculatePaintOffset(
constraints,
from: firstKeptRow * rowHeight,
to: endOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: firstCacheOffset,
to: lastCacheOffset,
);
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
hasVisualOverflow:
endOffset >
constraints.scrollOffset + constraints.remainingPaintExtent,
);
if (estimatedMaxScrollOffset <= endOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
@override
void dispose() {
markRowsDirty();
super.dispose();
}
}
class SliverWrapElement extends SliverMultiBoxAdaptorElement {
SliverWrapElement(SliverFixedWrap super.widget, {super.replaceMovedChildren});
@override
void performRebuild() {
(renderObject as RenderSliverFixedWrap).markRowsDirty();
super.performRebuild();
}
}
extension on SliverConstraints {
BoxConstraints toFixedConstraints(double mainAxisExtent) {
switch (axis) {
case Axis.horizontal:
return BoxConstraints(
minHeight: 0,
maxHeight: crossAxisExtent,
minWidth: mainAxisExtent,
maxWidth: mainAxisExtent,
);
case Axis.vertical:
return BoxConstraints(
minWidth: 0,
maxWidth: crossAxisExtent,
minHeight: mainAxisExtent,
maxHeight: mainAxisExtent,
);
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart' as material;
Future<material.TimeOfDay?> showTimePicker({
required material.BuildContext context,
required material.TimeOfDay initialTime,
}) => material.showTimePicker(
context: context,
initialTime: initialTime,
builder: (context, child) => material.DialogTheme(
data: material.DialogTheme.of(
context,
).copyWith(constraints: const material.BoxConstraints(minWidth: 280)),
child: child,
),
);

View File

@@ -1,5 +1,6 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:PiliPlus/common/widgets/image/image_save.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/progress_bar/video_progress_indicator.dart';
@@ -15,7 +16,7 @@ import 'package:PiliPlus/utils/date_utils.dart';
import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide LayoutBuilder;
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
// 视频卡片 - 水平布局

View File

@@ -1,5 +1,6 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:PiliPlus/common/widgets/image/image_save.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/stat/stat.dart';
@@ -15,7 +16,7 @@ import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide LayoutBuilder;
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:intl/intl.dart';

View File

@@ -102,19 +102,17 @@ class VideoPopupMenu extends StatelessWidget {
if (res != null && context.mounted) {
showDialog(
context: context,
builder: (context) {
return Dialog(
child: Padding(
padding: const .symmetric(vertical: 14),
child: AiConclusionPanel.buildContent(
context,
Theme.of(context),
res,
tap: false,
),
builder: (context) => Dialog(
child: Padding(
padding: const .symmetric(vertical: 14),
child: AiConclusionPanel.buildContent(
context,
Theme.of(context),
res,
tap: false,
),
);
},
),
),
);
}
},
@@ -242,74 +240,70 @@ class VideoPopupMenu extends StatelessWidget {
} else {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 5),
const Text("web端暂不支持精细选择"),
const SizedBox(height: 5),
Wrap(
spacing: 5.0,
runSpacing: 2.0,
children: [
FilledButton.tonal(
onPressed: () async {
Get.back();
SmartDialog.showLoading(
msg: '正在提交',
);
final res =
await VideoHttp.dislikeVideo(
bvid: videoItem.bvid!,
type: true,
);
SmartDialog.dismiss();
if (res.isSuccess) {
SmartDialog.showToast('点踩成功');
onRemove?.call();
} else {
res.toast();
}
},
style: FilledButton.styleFrom(
visualDensity:
VisualDensity.compact,
),
child: const Text("点踩"),
builder: (context) => AlertDialog(
content: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 5),
const Text("web端暂不支持精细选择"),
const SizedBox(height: 5),
Wrap(
spacing: 5.0,
runSpacing: 2.0,
children: [
FilledButton.tonal(
onPressed: () async {
Get.back();
SmartDialog.showLoading(
msg: '正在提交',
);
final res =
await VideoHttp.dislikeVideo(
bvid: videoItem.bvid!,
type: true,
);
SmartDialog.dismiss();
if (res.isSuccess) {
SmartDialog.showToast('点踩成功');
onRemove?.call();
} else {
res.toast();
}
},
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
FilledButton.tonal(
onPressed: () async {
Get.back();
SmartDialog.showLoading(
msg: '正在提交',
);
final res =
await VideoHttp.dislikeVideo(
bvid: videoItem.bvid!,
type: false,
);
SmartDialog.dismiss();
SmartDialog.showToast(
res.isSuccess
? '取消踩'
: res.toString(),
);
},
style: FilledButton.styleFrom(
visualDensity:
VisualDensity.compact,
),
child: const Text("撤销"),
child: const Text("点踩"),
),
FilledButton.tonal(
onPressed: () async {
Get.back();
SmartDialog.showLoading(
msg: '正在提交',
);
final res =
await VideoHttp.dislikeVideo(
bvid: videoItem.bvid!,
type: false,
);
SmartDialog.dismiss();
SmartDialog.showToast(
res.isSuccess
? '取消踩'
: res.toString(),
);
},
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
],
),
],
),
child: const Text("撤销"),
),
],
),
],
),
);
},
),
),
);
}
},

View File

@@ -54,11 +54,12 @@ abstract final class ReplyGrpc {
oid: Int64(oid),
type: Int64(type),
rpid: Int64.ZERO,
cursor: CursorReq(
mode: mode,
next: cursorNext,
),
// pagination: FeedPagination(offset: offset ?? ''),
// cursor: CursorReq(
// mode: mode,
// next: cursorNext,
// ),
mode: mode,
pagination: offset == null ? null : FeedPagination(offset: offset),
),
MainListReply.fromBuffer,
);
@@ -119,7 +120,7 @@ abstract final class ReplyGrpc {
type: Int64(type),
root: Int64(root),
dialog: Int64(dialog),
pagination: FeedPagination(offset: offset ?? ''),
pagination: offset == null ? null : FeedPagination(offset: offset),
),
DialogListReply.fromBuffer,
);

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