Compare commits
425 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae4157384 | ||
|
|
6e1ceb1277 | ||
|
|
71a170deb5 | ||
|
|
9482a706da | ||
|
|
0804484a49 | ||
|
|
cdb9bb3dbc | ||
|
|
6ca0de96f4 | ||
|
|
d908f58528 | ||
|
|
1368733a24 | ||
|
|
32e71dbf65 | ||
|
|
c9ce1af2c6 | ||
|
|
416f9e6a8d | ||
|
|
3ae3955f53 | ||
|
|
464f008023 | ||
|
|
52498b3e34 | ||
|
|
57c57b02a5 | ||
|
|
b8c6868043 | ||
|
|
8200fbf512 | ||
|
|
8650c96b7b | ||
|
|
15fe7787ba | ||
|
|
d83076cb07 | ||
|
|
8b3b4c28a5 | ||
|
|
740c001e2f | ||
|
|
096b057f81 | ||
|
|
a161fa5e58 | ||
|
|
bebf34db23 | ||
|
|
b95061434a | ||
|
|
f2a05bb970 | ||
|
|
6c361a047b | ||
|
|
3fb9e22378 | ||
|
|
b2fb4c9afe | ||
|
|
0862c0fc87 | ||
|
|
77ec78e3fe | ||
|
|
fb59c208e3 | ||
|
|
112a06f92a | ||
|
|
c10c4a6f89 | ||
|
|
669c807b23 | ||
|
|
c9de79532a | ||
|
|
32ce2b87db | ||
|
|
4cfcf18bc9 | ||
|
|
14ae61f891 | ||
|
|
a2d5ecc51e | ||
|
|
84f972a3ab | ||
|
|
5249ceccdb | ||
|
|
5035495043 | ||
|
|
25483d71e9 | ||
|
|
c3fa976b26 | ||
|
|
43beb518f4 | ||
|
|
11edabb890 | ||
|
|
019cd9fda0 | ||
|
|
9d747c8e2c | ||
|
|
4cf1c25b36 | ||
|
|
6c6ed46aea | ||
|
|
e1473a453e | ||
|
|
9f6ef0281a | ||
|
|
84d5a24bc3 | ||
|
|
ed8c39aa76 | ||
|
|
23d235b8f4 | ||
|
|
8bea09b78a | ||
|
|
897fda875a | ||
|
|
510bfe01be | ||
|
|
f6ca007815 | ||
|
|
35b34cb2d4 | ||
|
|
5197cca69c | ||
|
|
e5f0742bf6 | ||
|
|
88d207cc24 | ||
|
|
931fcb6f8f | ||
|
|
e4a960ecf9 | ||
|
|
e44419e088 | ||
|
|
16f577f3fd | ||
|
|
a65edab7d1 | ||
|
|
c0bbf8400a | ||
|
|
1dc2da68ac | ||
|
|
3d49529272 | ||
|
|
41768656b4 | ||
|
|
c7e7b3f9c5 | ||
|
|
e0b0a98f0f | ||
|
|
ca0eb1716f | ||
|
|
06d8296939 | ||
|
|
322885f284 | ||
|
|
4553b86cb4 | ||
|
|
904756b6ea | ||
|
|
2bfa1bb6c2 | ||
|
|
8439a3d85c | ||
|
|
454d6b9de1 | ||
|
|
44c7c44a27 | ||
|
|
40e5e2f372 | ||
|
|
138739781c | ||
|
|
355d897ef0 | ||
|
|
a06aef2b25 | ||
|
|
6ef9a24ed1 | ||
|
|
4df2bb0073 | ||
|
|
f93753ccfd | ||
|
|
52373dc540 | ||
|
|
203a997583 | ||
|
|
b22a406471 | ||
|
|
a441759eb6 | ||
|
|
9057401b16 | ||
|
|
6d0017c256 | ||
|
|
12b27b1d8d | ||
|
|
884bb53d6f | ||
|
|
aa356b5376 | ||
|
|
2aa9b46433 | ||
|
|
42f5a42dd9 | ||
|
|
74f0fb471c | ||
|
|
c31e772a63 | ||
|
|
32f6d97256 | ||
|
|
a28db0dd98 | ||
|
|
aba9493ae0 | ||
|
|
4973176868 | ||
|
|
a000e2262c | ||
|
|
a5715868b3 | ||
|
|
a928e48159 | ||
|
|
16c152d306 | ||
|
|
5747dee03d | ||
|
|
06c545acd4 | ||
|
|
54c3c314e1 | ||
|
|
11c4fae547 | ||
|
|
8f87d248a1 | ||
|
|
ec37af5900 | ||
|
|
1b213793d4 | ||
|
|
aaa8998cb1 | ||
|
|
94760a4136 | ||
|
|
bdbd6cd377 | ||
|
|
d69d81912d | ||
|
|
198a38b103 | ||
|
|
750e67d835 | ||
|
|
5d5adbc73f | ||
|
|
8c7db34e5a | ||
|
|
a18863f292 | ||
|
|
15ee6a679e | ||
|
|
4dfeb284e7 | ||
|
|
eae075c380 | ||
|
|
d9bff6237d | ||
|
|
35df23194f | ||
|
|
12a68257a3 | ||
|
|
022108607f | ||
|
|
4e15422d2d | ||
|
|
e1944b0c8d | ||
|
|
0fd3f3ffd1 | ||
|
|
ed9be72172 | ||
|
|
5d95e624db | ||
|
|
929c51e059 | ||
|
|
15b05cc454 | ||
|
|
260742dc4b | ||
|
|
d833f3651c | ||
|
|
698e11885a | ||
|
|
c103551f6a | ||
|
|
299ee09749 | ||
|
|
06b258cff1 | ||
|
|
be03909fdc | ||
|
|
19f7720fb2 | ||
|
|
89e6d5c160 | ||
|
|
1d723b704b | ||
|
|
05636b33c0 | ||
|
|
2817c8f5b1 | ||
|
|
fe0c636ad6 | ||
|
|
5465492d70 | ||
|
|
862a9fa731 | ||
|
|
0aebadb005 | ||
|
|
24be7a9cf2 | ||
|
|
9d5f4ad977 | ||
|
|
22c57bf468 | ||
|
|
046412b709 | ||
|
|
b3622ef61d | ||
|
|
138b7935f3 | ||
|
|
328034f3ed | ||
|
|
e1f748d7e4 | ||
|
|
5f8dc76891 | ||
|
|
2031604ea2 | ||
|
|
efbf392677 | ||
|
|
fb8c5f50ba | ||
|
|
09920f9fb5 | ||
|
|
1e2618a33f | ||
|
|
fb79fd9b9d | ||
|
|
8f65a5d202 | ||
|
|
b32c1871ae | ||
|
|
8e26a7bc9d | ||
|
|
2333736a72 | ||
|
|
7fedfb8963 | ||
|
|
670f788558 | ||
|
|
c7e3d9dbc1 | ||
|
|
0ebb2afe39 | ||
|
|
e3e6bb0e39 | ||
|
|
ee8af925be | ||
|
|
34bdfe1641 | ||
|
|
d33e2071b6 | ||
|
|
59fd89ae5d | ||
|
|
93e64a0988 | ||
|
|
86a79a9733 | ||
|
|
d961b6d7a9 | ||
|
|
a799b15155 | ||
|
|
33c8d69a58 | ||
|
|
7637c44645 | ||
|
|
5fd3d32200 | ||
|
|
4ae3bd2845 | ||
|
|
67c25bd130 | ||
|
|
05cd631439 | ||
|
|
000fa9fe5c | ||
|
|
61bc4ce5b1 | ||
|
|
11fbd2ebed | ||
|
|
6ced89f30b | ||
|
|
ec1bdb243f | ||
|
|
1345fd36e4 | ||
|
|
6b4fb0d611 | ||
|
|
b9eaa368b1 | ||
|
|
66752093e4 | ||
|
|
4ca9dfecb4 | ||
|
|
2efb04f77e | ||
|
|
f4e3484b01 | ||
|
|
9f715ddd5b | ||
|
|
787be7ac11 | ||
|
|
c54d77f393 | ||
|
|
96539cc64c | ||
|
|
96586f130f | ||
|
|
36de899a35 | ||
|
|
0745d83e4b | ||
|
|
9b171e04be | ||
|
|
d62d0eddc2 | ||
|
|
51c605f5d0 | ||
|
|
099c7b4dff | ||
|
|
6559aa767d | ||
|
|
c3bcd323fb | ||
|
|
5ec04e3a53 | ||
|
|
7a4098434f | ||
|
|
9c552a89e1 | ||
|
|
2d625e0241 | ||
|
|
fef6a8c22a | ||
|
|
cef7e478f5 | ||
|
|
64e893e36f | ||
|
|
a6182b20c0 | ||
|
|
b2e3273dba | ||
|
|
ffd1747bb3 | ||
|
|
59bbe51702 | ||
|
|
1824c83cd0 | ||
|
|
f05cd0322a | ||
|
|
e165666155 | ||
|
|
b043dc38c4 | ||
|
|
d9a0db03f8 | ||
|
|
8a3cf34cb1 | ||
|
|
470140a068 | ||
|
|
720591b8fe | ||
|
|
349a4dfc0b | ||
|
|
3897efd82f | ||
|
|
7ca6d2958f | ||
|
|
efac7f7700 | ||
|
|
9e4a32e3e4 | ||
|
|
37fb63c3b1 | ||
|
|
b9a55ccbce | ||
|
|
d3f4ba4b4a | ||
|
|
0f2908dbc1 | ||
|
|
d0c108538d | ||
|
|
b6352a6a43 | ||
|
|
d6bff33d29 | ||
|
|
da17725616 | ||
|
|
f0f5224677 | ||
|
|
bd0c620097 | ||
|
|
7a4fc6f7e2 | ||
|
|
d6b87b48f5 | ||
|
|
d285f00086 | ||
|
|
e02835ddc4 | ||
|
|
b6de7ef40d | ||
|
|
594a891abd | ||
|
|
215ed665de | ||
|
|
89f72df2b9 | ||
|
|
0659ec9d8a | ||
|
|
a74cbe215e | ||
|
|
5bf09b98f4 | ||
|
|
224bd88473 | ||
|
|
6e6ff369d3 | ||
|
|
7e1e42181c | ||
|
|
172389b12b | ||
|
|
e8a674ca2a | ||
|
|
f0828ea18c | ||
|
|
f0c4d3412d | ||
|
|
28a58ade84 | ||
|
|
04830c7789 | ||
|
|
a635767561 | ||
|
|
2b1759bd10 | ||
|
|
5a6c73b8cf | ||
|
|
cf4ad87b20 | ||
|
|
47ad1adfdc | ||
|
|
f50965862d | ||
|
|
0a7f18a035 | ||
|
|
f8226fcade | ||
|
|
498ab2818e | ||
|
|
8d94c0405f | ||
|
|
80fa0240e9 | ||
|
|
e6af0ef15b | ||
|
|
3cbfd158e1 | ||
|
|
2956b43f42 | ||
|
|
86346d568e | ||
|
|
fc6f51787b | ||
|
|
e72203afdb | ||
|
|
6741333367 | ||
|
|
6e1bc8d0e7 | ||
|
|
477b59ce89 | ||
|
|
70881ead22 | ||
|
|
b09a41af24 | ||
|
|
08a33d9ce5 | ||
|
|
84f7f14a29 | ||
|
|
331c9877a3 | ||
|
|
ac26022da1 | ||
|
|
7a5662c6ca | ||
|
|
659cff875f | ||
|
|
06a5c2c63b | ||
|
|
077293854c | ||
|
|
cf24f851e8 | ||
|
|
01a8631e00 | ||
|
|
5f8313901b | ||
|
|
56ffc2781f | ||
|
|
51d7e454de | ||
|
|
63419d5b1c | ||
|
|
91627df804 | ||
|
|
fb8a06787b | ||
|
|
dc7fe2cb3b | ||
|
|
1f22dcd73f | ||
|
|
09b0f19775 | ||
|
|
8498ea0618 | ||
|
|
a366b8a9e4 | ||
|
|
461e91239e | ||
|
|
4bba675063 | ||
|
|
08d64be5d4 | ||
|
|
7d30c9c66a | ||
|
|
fe191ef934 | ||
|
|
db8b5f5e66 | ||
|
|
6c52db1c6c | ||
|
|
f942b2a7ee | ||
|
|
288d554de9 | ||
|
|
a274f5ae8b | ||
|
|
ad0d9ecee0 | ||
|
|
ee819bb260 | ||
|
|
b77403f03f | ||
|
|
3c34e43827 | ||
|
|
6009668427 | ||
|
|
16a3e21db4 | ||
|
|
d69649f1b6 | ||
|
|
faaffd0f30 | ||
|
|
a9f1e3cf09 | ||
|
|
9e72fea67c | ||
|
|
8fc8bd99e5 | ||
|
|
4d3a74f2e0 | ||
|
|
272cfcb829 | ||
|
|
c7437225eb | ||
|
|
d4a1568b28 | ||
|
|
824ee53025 | ||
|
|
ee142e5e1d | ||
|
|
571bdb5eae | ||
|
|
2e5cb324a1 | ||
|
|
ed191e20b4 | ||
|
|
ba14e56ceb | ||
|
|
b6ce93cbd2 | ||
|
|
76f1d0129b | ||
|
|
e096ebcbba | ||
|
|
6c8baa5be5 | ||
|
|
4f2bfb8126 | ||
|
|
33738c90bc | ||
|
|
7ff95c00d2 | ||
|
|
fc4f92e0c0 | ||
|
|
ed57697fdc | ||
|
|
08c3789321 | ||
|
|
43fa00848d | ||
|
|
8c38699334 | ||
|
|
dcc5f51e6a | ||
|
|
dc6b76812c | ||
|
|
470545337d | ||
|
|
ab610e9da5 | ||
|
|
5420712bda | ||
|
|
55733d30c5 | ||
|
|
2090fd2312 | ||
|
|
f3bad60fb6 | ||
|
|
d805306d20 | ||
|
|
831a3052fa | ||
|
|
52151765f8 | ||
|
|
422b413778 | ||
|
|
1943b65788 | ||
|
|
629be129ff | ||
|
|
6ff256637a | ||
|
|
34e9afd7ad | ||
|
|
0cd57c9bb0 | ||
|
|
22d9fbddf9 | ||
|
|
65746ae2bd | ||
|
|
685852c0a4 | ||
|
|
b2100f3872 | ||
|
|
86125d5ecd | ||
|
|
086c93d24f | ||
|
|
aea1992f5d | ||
|
|
6b38322c3b | ||
|
|
865ddad147 | ||
|
|
6709fa4d21 | ||
|
|
705417f65b | ||
|
|
690c4f5786 | ||
|
|
e00c176bdf | ||
|
|
8d14f42fd8 | ||
|
|
6688fcf3e9 | ||
|
|
308bd26172 | ||
|
|
a94493705d | ||
|
|
e251eaf811 | ||
|
|
1826b6a059 | ||
|
|
be5a1af040 | ||
|
|
17b7eb7e0f | ||
|
|
60c25e4b65 | ||
|
|
2c92845af0 | ||
|
|
4a4aa569ec | ||
|
|
95f1d1485d | ||
|
|
e7f27e4913 | ||
|
|
dc61d9007f | ||
|
|
88c2ba8059 | ||
|
|
309c871919 | ||
|
|
745a510ffa | ||
|
|
8fbc8fda3d | ||
|
|
dbde90459b | ||
|
|
b788794f4b | ||
|
|
06b433aa60 | ||
|
|
6093848811 | ||
|
|
34c5d6812f | ||
|
|
aaad7fc6dc | ||
|
|
fac37e59aa | ||
|
|
11c6745fd7 | ||
|
|
30aa29598b | ||
|
|
85c72731f6 | ||
|
|
27c9c266c1 | ||
|
|
720f3e10e8 | ||
|
|
162a79145f | ||
|
|
9e31326bf5 |
32
.github/ISSUE_TEMPLATE/bug-反馈.yml
vendored
@@ -9,10 +9,24 @@ body:
|
||||
attributes:
|
||||
label: 检查清单
|
||||
options:
|
||||
- label: 之前没有人提交过类似或相同的 bug report。
|
||||
- label: 搜索了 [历史 issue](https://github.com/bggRGjQaUbCoE/PiliPlus/issues?q=is%3Aissue) ,并未发现相同问题
|
||||
required: true
|
||||
- label: 正在使用最新版本。
|
||||
required: true
|
||||
- label: 已排除网络问题
|
||||
required: true
|
||||
- label: 已排除账号问题
|
||||
required: true
|
||||
- label: 已排除设置问题
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: assign
|
||||
attributes:
|
||||
label: Assign
|
||||
options:
|
||||
- label: self-assign
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: version
|
||||
@@ -21,14 +35,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请提供一个清晰而简明的问题描述。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
@@ -45,6 +51,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 请描述实际的行为或结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/功能请求.yml
vendored
@@ -9,10 +9,20 @@ body:
|
||||
attributes:
|
||||
label: 检查清单
|
||||
options:
|
||||
- label: 之前没有人提交过类似或相同的功能请求。
|
||||
- label: 搜索了 [历史 issue](https://github.com/bggRGjQaUbCoE/PiliPlus/issues?q=is%3Aissue) ,并未发现相同功能请求
|
||||
required: true
|
||||
- label: 正在使用最新版本。
|
||||
required: true
|
||||
- label: 设置中未搜索到该功能
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: assign
|
||||
attributes:
|
||||
label: Assign
|
||||
options:
|
||||
- label: self-assign
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: desc
|
||||
@@ -22,14 +32,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: propose
|
||||
attributes:
|
||||
label: 目标
|
||||
description: 请描述你希望通过这个功能实现的目标。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
|
||||
79
.github/workflows/android.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Android Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 代码迁出
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 构建Java环境
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "17"
|
||||
|
||||
- name: 检查缓存
|
||||
uses: actions/cache@v4
|
||||
id: cache-flutter
|
||||
with:
|
||||
path: /root/flutter-sdk # Flutter SDK 的路径
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
|
||||
- name: 安装Flutter
|
||||
if: steps.cache-flutter.outputs.cache-hit != 'true'
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version-file: pubspec.yaml
|
||||
|
||||
- name: 下载项目依赖
|
||||
run: flutter pub get
|
||||
|
||||
- name: 更新版本号
|
||||
run: |
|
||||
version_name=$(yq e .version pubspec.yaml | cut -d "+" -f 1)
|
||||
sed -i "s/version: .*/version: $version_name-$(git rev-parse --short HEAD)+$(git rev-list --count HEAD)/g" pubspec.yaml
|
||||
|
||||
- name: Write key
|
||||
run: |
|
||||
if [ ! -z "${{ secrets.SIGN_KEYSTORE_BASE64 }}" ]; then
|
||||
echo "${{ secrets.SIGN_KEYSTORE_BASE64 }}" | base64 --decode > android/app/key.jks
|
||||
echo storeFile='key.jks' >> android/key.properties
|
||||
echo storePassword='${{ secrets.KEYSTORE_PASSWORD }}' >> android/key.properties
|
||||
echo keyAlias='${{ secrets.KEY_ALIAS }}' >> android/key.properties
|
||||
echo keyPassword='${{ secrets.KEY_PASSWORD }}' >> android/key.properties
|
||||
fi
|
||||
|
||||
- name: flutter build apk
|
||||
run: |
|
||||
chmod +x lib/scripts/build.sh
|
||||
lib/scripts/build.sh
|
||||
flutter build apk --release --split-per-abi
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-arm64-v8a
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-armeabi-v7a
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-x86_64
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||
181
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,181 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_android:
|
||||
description: "Build Android"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
build_ios:
|
||||
description: "Build iOS"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
build_mac:
|
||||
description: "Build Mac"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
build_win_x64:
|
||||
description: "Build Win-x64"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
build_linux_x64:
|
||||
description: "Build Linux-x64"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
build_linux_arm64:
|
||||
description: "Build Linux-arm64"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
android:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_android == 'true' }}
|
||||
name: Release Android
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
|
||||
steps:
|
||||
- name: 代码迁出
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 构建Java环境
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "17"
|
||||
cache: "gradle"
|
||||
cache-dependency-path: |
|
||||
android/*.gradle*
|
||||
android/**/gradle-wrapper.properties
|
||||
|
||||
- name: 安装Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
id: flutter-action
|
||||
with:
|
||||
channel: stable
|
||||
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
|
||||
continue-on-error: true
|
||||
|
||||
- name: Write key
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ ! -z "${{ secrets.SIGN_KEYSTORE_BASE64 }}" ]; then
|
||||
echo "${{ secrets.SIGN_KEYSTORE_BASE64 }}" | base64 --decode > android/app/key.jks
|
||||
echo storeFile='key.jks' >> android/key.properties
|
||||
echo storePassword='${{ secrets.KEYSTORE_PASSWORD }}' >> android/key.properties
|
||||
echo keyAlias='${{ secrets.KEY_ALIAS }}' >> android/key.properties
|
||||
echo keyPassword='${{ secrets.KEY_PASSWORD }}' >> android/key.properties
|
||||
fi
|
||||
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1 android
|
||||
|
||||
- name: flutter build apk
|
||||
run: flutter build apk --release --split-per-abi --dart-define-from-file=pili_release.json --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|')
|
||||
mv "$file" "PiliPlus_android_${{ env.version }}_${abi}.apk"
|
||||
done
|
||||
shell: bash
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_android_*.apk
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Android_arm64-v8a
|
||||
path: |
|
||||
PiliPlus_android_*_arm64-v8a.apk
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Android_armeabi-v7a
|
||||
path: |
|
||||
PiliPlus_android_*_armeabi-v7a.apk
|
||||
|
||||
- name: 上传
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Android_x86_64
|
||||
path: |
|
||||
PiliPlus_android_*_x86_64.apk
|
||||
|
||||
ios:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_ios == 'true' }}
|
||||
uses: ./.github/workflows/ios.yml
|
||||
permissions: write-all
|
||||
with:
|
||||
tag: github.event.inputs.tag
|
||||
|
||||
mac:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_mac == 'true' }}
|
||||
uses: ./.github/workflows/mac.yml
|
||||
permissions: write-all
|
||||
with:
|
||||
tag: github.event.inputs.tag
|
||||
|
||||
win_x64:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_win_x64 == 'true' }}
|
||||
uses: ./.github/workflows/win_x64.yml
|
||||
permissions: write-all
|
||||
with:
|
||||
tag: github.event.inputs.tag
|
||||
|
||||
linux_x64:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_linux_x64 == 'true' }}
|
||||
uses: ./.github/workflows/linux_x64.yml
|
||||
permissions: write-all
|
||||
with:
|
||||
tag: github.event.inputs.tag
|
||||
|
||||
linux_arm64:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event.inputs.build_linux_arm64 == 'true' }}
|
||||
uses: ./.github/workflows/linux_arm64.yml
|
||||
permissions: write-all
|
||||
with:
|
||||
tag: github.event.inputs.tag
|
||||
38
.github/workflows/ios.yml
vendored
@@ -1,11 +1,14 @@
|
||||
name: Build for iOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-macos-app:
|
||||
@@ -13,9 +16,8 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup flutter
|
||||
@@ -24,21 +26,27 @@ jobs:
|
||||
channel: stable
|
||||
flutter-version-file: pubspec.yaml
|
||||
|
||||
- name: 更新版本号
|
||||
run: |
|
||||
version_name=$(yq e '.version' pubspec.yaml | cut -d "+" -f 1)
|
||||
sed -i '' "s/version: .*/version: $version_name+$(git rev-list --count HEAD)/" pubspec.yaml
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
chmod +x lib/scripts/build.sh
|
||||
lib/scripts/build.sh
|
||||
flutter build ios --release --no-codesign
|
||||
flutter build ios --release --no-codesign --dart-define-from-file=pili_release.json
|
||||
ln -sf ./build/ios/iphoneos Payload
|
||||
zip -r9 ios-release-no-sign.ipa Payload/runner.app
|
||||
zip -r9 PiliPlus_ios_${{env.version}}.ipa Payload/runner.app
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_ios_*.ipa
|
||||
|
||||
- name: Upload ios release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-release
|
||||
path: ios-release-no-sign.ipa
|
||||
name: iOS-release
|
||||
path: PiliPlus_ios_*.ipa
|
||||
|
||||
210
.github/workflows/linux_arm64.yml
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
name: Build for Linux Arm64
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-linux-app:
|
||||
name: Release Linux Arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang cmake libgtk-3-dev ninja-build libayatana-appindicator3-dev unzip webkit2gtk-4.1 libasound2-dev rpm patchelf
|
||||
sudo apt-get install -y gcc g++ autoconf automake debhelper glslang-dev ladspa-sdk xutils-dev libasound2-dev \
|
||||
libarchive-dev libbluray-dev libbs2b-dev libcaca-dev libcdio-paranoia-dev libdrm-dev \
|
||||
libdav1d-dev libdvdnav-dev libegl1-mesa-dev libepoxy-dev libfontconfig-dev libfreetype6-dev \
|
||||
libfribidi-dev libgl1-mesa-dev libgbm-dev libgme-dev libgsm1-dev libharfbuzz-dev libjpeg-dev \
|
||||
libbrotli-dev liblcms2-dev libmodplug-dev libmp3lame-dev libopenal-dev \
|
||||
libopus-dev libopencore-amrnb-dev libopencore-amrwb-dev libpulse-dev librtmp-dev \
|
||||
libsdl2-dev libsixel-dev libssh-dev libsoxr-dev libspeex-dev libtool \
|
||||
libv4l-dev libva-dev libvdpau-dev libvorbis-dev libvo-amrwbenc-dev \
|
||||
libunwind-dev libvpx-dev libwayland-dev libx11-dev libxext-dev \
|
||||
libxkbcommon-dev libxrandr-dev libxss-dev libxv-dev libxvidcore-dev \
|
||||
linux-libc-dev nasm ninja-build pkg-config python3 python3-docutils wayland-protocols \
|
||||
x11proto-core-dev zlib1g-dev libfdk-aac-dev libtheora-dev libwebp-dev \
|
||||
unixodbc-dev libpq-dev libxxhash-dev libaom-dev \
|
||||
libgtk-3-0 libblkid1 liblzma5 libmpv-dev
|
||||
shell: bash
|
||||
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: master
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1
|
||||
|
||||
#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
|
||||
|
||||
- name: Package .tar.gz
|
||||
run: tar -zcvf PiliPlus_linux_${{ env.version }}_arm64.tar.gz -C build/linux/arm64/release/bundle .
|
||||
|
||||
- name: Packege deb
|
||||
run: |
|
||||
printf "建立构建目录...\n"
|
||||
mkdir "PiliPlus_linux_${{ env.version }}_arm64"
|
||||
pushd "PiliPlus_linux_${{ env.version }}_arm64"
|
||||
mkdir -p opt/PiliPlus
|
||||
mkdir -p usr/share/applications
|
||||
mkdir -p usr/share/icons/hicolor/512x512/apps
|
||||
|
||||
printf "复制文件...\n"
|
||||
cp -r ../build/linux/arm64/release/bundle/* opt/PiliPlus
|
||||
cp -r ../assets/linux/DEBIAN .
|
||||
cp ../assets/linux/piliplus.desktop usr/share/applications
|
||||
cp ../assets/images/logo/logo.png usr/share/icons/hicolor/512x512/apps/piliplus.png
|
||||
|
||||
printf "修改控制文件...\n"
|
||||
# 替换版本号
|
||||
sed -i "2s/version_need_change/${{ env.version }}/g" DEBIAN/control
|
||||
# 计算安装大小并替换
|
||||
SIZE_KB=$(du -s -b --apparent-size . | awk '{print int($1)}')
|
||||
SIZE_KB=$(($SIZE_KB - $(du -s -b --apparent-size DEBIAN | awk '{print int($1)}')))
|
||||
SIZE_KB=$(echo $SIZE_KB | awk '{print int($1/1024 + 0.999)}')
|
||||
printf "\t安装大小: %s KB\n" "$SIZE_KB"
|
||||
sed -i "9s/size_need_change/${SIZE_KB}/g" DEBIAN/control
|
||||
|
||||
printf "生成并写入 md5sums ...\n"
|
||||
md5sum opt/PiliPlus/piliplus >> DEBIAN/md5sums
|
||||
md5sum opt/PiliPlus/lib/* >> DEBIAN/md5sums
|
||||
md5sum opt/PiliPlus/data/icudtl.dat >> DEBIAN/md5sums
|
||||
|
||||
printf "设置权限...\n"
|
||||
chmod 0644 DEBIAN/control
|
||||
chmod 0644 DEBIAN/md5sums
|
||||
chmod 0755 DEBIAN/postinst
|
||||
chmod 0755 DEBIAN/postrm
|
||||
chmod 0755 DEBIAN/prerm
|
||||
|
||||
printf "打包 deb 文件...\n"
|
||||
popd
|
||||
dpkg-deb --build --verbose --root-owner-group "PiliPlus_linux_${{ env.version }}_arm64"
|
||||
printf "完成: PiliPlus_linux_%s_arm64.deb\n" "${{ env.version }}"
|
||||
shell: bash
|
||||
|
||||
- name: Packege rpm
|
||||
run: |
|
||||
printf "建立 RPM 构建目录...\n"
|
||||
RPM_BUILD_ROOT="$PWD/rpm_build"
|
||||
mkdir -p "$RPM_BUILD_ROOT/BUILD" "$RPM_BUILD_ROOT/RPMS" "$RPM_BUILD_ROOT/SOURCES" "$RPM_BUILD_ROOT/SPECS" "$RPM_BUILD_ROOT/SRPMS"
|
||||
|
||||
printf "准备源码归档(仅包含运行时与元数据)...\n"
|
||||
DATE="$(date '+%a %b %d %Y')"
|
||||
SRC_DIR="$PWD/piliplus-${{ env.version }}"
|
||||
mkdir -p "$SRC_DIR/bundle" "$SRC_DIR/assets"
|
||||
cp -r build/linux/arm64/release/bundle/* "$SRC_DIR/bundle/"
|
||||
cp assets/linux/piliplus.desktop "$SRC_DIR/assets/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 }}"
|
||||
|
||||
printf "生成 spec 文件...\n"
|
||||
cat > "$RPM_BUILD_ROOT/SPECS/piliplus.spec" <<EOF
|
||||
Name: piliplus
|
||||
Version: ${{ env.version }}
|
||||
Release: 1%{?dist}
|
||||
Summary: PiliPlus Linux Version
|
||||
License: GPL-3.0
|
||||
Source0: piliplus-${{ env.version }}.tar.gz
|
||||
Requires: desktop-file-utils, hicolor-icon-theme
|
||||
|
||||
%description
|
||||
使用 Flutter 开发的 BiliBili 第三方客户端
|
||||
|
||||
%prep
|
||||
%setup -q -n piliplus-${{ env.version }}
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}/opt/PiliPlus
|
||||
cp -r bundle/* %{buildroot}/opt/PiliPlus/
|
||||
|
||||
# 二进制权限与命令行入口
|
||||
chmod 755 %{buildroot}/opt/PiliPlus/piliplus
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
ln -sf /opt/PiliPlus/piliplus %{buildroot}/usr/bin/piliplus
|
||||
|
||||
# 桌面集成
|
||||
mkdir -p %{buildroot}/usr/share/applications
|
||||
install -m 644 assets/piliplus.desktop %{buildroot}/usr/share/applications/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
|
||||
|
||||
%post
|
||||
update-desktop-database -q || true
|
||||
gtk-update-icon-cache -q -t -f %{_datadir}/icons/hicolor || true
|
||||
|
||||
%postun
|
||||
update-desktop-database -q || true
|
||||
gtk-update-icon-cache -q -t -f %{_datadir}/icons/hicolor || true
|
||||
|
||||
%files
|
||||
/opt/PiliPlus
|
||||
/usr/bin/piliplus
|
||||
/usr/share/applications/piliplus.desktop
|
||||
/usr/share/icons/hicolor/512x512/apps/piliplus.png
|
||||
|
||||
%changelog
|
||||
* DATE - ${{ env.version }}-1
|
||||
- Initial RPM release
|
||||
EOF
|
||||
|
||||
sed -i "s/DATE/${DATE}/g" "$RPM_BUILD_ROOT/SPECS/piliplus.spec"
|
||||
|
||||
printf "构建 RPM 包...\n"
|
||||
rpmbuild --define "_topdir $RPM_BUILD_ROOT" -bb "$RPM_BUILD_ROOT/SPECS/piliplus.spec"
|
||||
|
||||
printf "移动生成的 RPM...\n"
|
||||
find "$RPM_BUILD_ROOT/RPMS" -name "*.rpm" -exec mv {} "PiliPlus_linux_${{ env.version }}_arm64.rpm" \;
|
||||
|
||||
printf "完成: PiliPlus_linux_%s_arm64.rpm\n" "${{ env.version }}"
|
||||
shell: bash
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_linux_*.tar.gz
|
||||
PiliPlus_linux_*.deb
|
||||
PiliPlus_linux_*.rpm
|
||||
|
||||
- name: Upload linux targz package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_targz_arm64_packege
|
||||
path: PiliPlus_linux_*.tar.gz
|
||||
|
||||
- name: Upload linux deb package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_deb_arm64_package
|
||||
path: PiliPlus_linux_*.deb
|
||||
|
||||
- name: Upload linux rpm package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_rpm_arm64_package
|
||||
path: PiliPlus_linux_*.rpm
|
||||
210
.github/workflows/linux_x64.yml
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
name: Build for Linux x64
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-linux-app:
|
||||
name: Release Linux x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang cmake libgtk-3-dev ninja-build libayatana-appindicator3-dev unzip webkit2gtk-4.1 libasound2-dev rpm patchelf
|
||||
sudo apt-get install -y gcc g++ autoconf automake debhelper glslang-dev ladspa-sdk xutils-dev libasound2-dev \
|
||||
libarchive-dev libbluray-dev libbs2b-dev libcaca-dev libcdio-paranoia-dev libdrm-dev \
|
||||
libdav1d-dev libdvdnav-dev libegl1-mesa-dev libepoxy-dev libfontconfig-dev libfreetype6-dev \
|
||||
libfribidi-dev libgl1-mesa-dev libgbm-dev libgme-dev libgsm1-dev libharfbuzz-dev libjpeg-dev \
|
||||
libbrotli-dev liblcms2-dev libmodplug-dev libmp3lame-dev libopenal-dev \
|
||||
libopus-dev libopencore-amrnb-dev libopencore-amrwb-dev libpulse-dev librtmp-dev \
|
||||
libsdl2-dev libsixel-dev libssh-dev libsoxr-dev libspeex-dev libtool \
|
||||
libv4l-dev libva-dev libvdpau-dev libvorbis-dev libvo-amrwbenc-dev \
|
||||
libunwind-dev libvpx-dev libwayland-dev libx11-dev libxext-dev \
|
||||
libxkbcommon-dev libxrandr-dev libxss-dev libxv-dev libxvidcore-dev \
|
||||
linux-libc-dev nasm ninja-build pkg-config python3 python3-docutils wayland-protocols \
|
||||
x11proto-core-dev zlib1g-dev libfdk-aac-dev libtheora-dev libwebp-dev \
|
||||
unixodbc-dev libpq-dev libxxhash-dev libaom-dev \
|
||||
libgtk-3-0 libblkid1 liblzma5 libmpv-dev
|
||||
shell: bash
|
||||
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1
|
||||
|
||||
#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
|
||||
|
||||
- name: Package .tar.gz
|
||||
run: tar -zcvf PiliPlus_linux_${{ env.version }}_amd64.tar.gz -C build/linux/x64/release/bundle .
|
||||
|
||||
- name: Packege deb
|
||||
run: |
|
||||
printf "建立构建目录...\n"
|
||||
mkdir "PiliPlus_linux_${{ env.version }}_amd64"
|
||||
pushd "PiliPlus_linux_${{ env.version }}_amd64"
|
||||
mkdir -p opt/PiliPlus
|
||||
mkdir -p usr/share/applications
|
||||
mkdir -p usr/share/icons/hicolor/512x512/apps
|
||||
|
||||
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/images/logo/logo.png usr/share/icons/hicolor/512x512/apps/piliplus.png
|
||||
|
||||
printf "修改控制文件...\n"
|
||||
# 替换版本号
|
||||
sed -i "2s/version_need_change/${{ env.version }}/g" DEBIAN/control
|
||||
# 计算安装大小并替换
|
||||
SIZE_KB=$(du -s -b --apparent-size . | awk '{print int($1)}')
|
||||
SIZE_KB=$(($SIZE_KB - $(du -s -b --apparent-size DEBIAN | awk '{print int($1)}')))
|
||||
SIZE_KB=$(echo $SIZE_KB | awk '{print int($1/1024 + 0.999)}')
|
||||
printf "\t安装大小: %s KB\n" "$SIZE_KB"
|
||||
sed -i "9s/size_need_change/${SIZE_KB}/g" DEBIAN/control
|
||||
|
||||
printf "生成并写入 md5sums ...\n"
|
||||
md5sum opt/PiliPlus/piliplus >> DEBIAN/md5sums
|
||||
md5sum opt/PiliPlus/lib/* >> DEBIAN/md5sums
|
||||
md5sum opt/PiliPlus/data/icudtl.dat >> DEBIAN/md5sums
|
||||
|
||||
printf "设置权限...\n"
|
||||
chmod 0644 DEBIAN/control
|
||||
chmod 0644 DEBIAN/md5sums
|
||||
chmod 0755 DEBIAN/postinst
|
||||
chmod 0755 DEBIAN/postrm
|
||||
chmod 0755 DEBIAN/prerm
|
||||
|
||||
printf "打包 deb 文件...\n"
|
||||
popd
|
||||
dpkg-deb --build --verbose --root-owner-group "PiliPlus_linux_${{ env.version }}_amd64"
|
||||
printf "完成: PiliPlus_linux_%s_amd64.deb\n" "${{ env.version }}"
|
||||
shell: bash
|
||||
|
||||
- name: Packege rpm
|
||||
run: |
|
||||
printf "建立 RPM 构建目录...\n"
|
||||
RPM_BUILD_ROOT="$PWD/rpm_build"
|
||||
mkdir -p "$RPM_BUILD_ROOT/BUILD" "$RPM_BUILD_ROOT/RPMS" "$RPM_BUILD_ROOT/SOURCES" "$RPM_BUILD_ROOT/SPECS" "$RPM_BUILD_ROOT/SRPMS"
|
||||
|
||||
printf "准备源码归档(仅包含运行时与元数据)...\n"
|
||||
DATE="$(date '+%a %b %d %Y')"
|
||||
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/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 }}"
|
||||
|
||||
printf "生成 spec 文件...\n"
|
||||
cat > "$RPM_BUILD_ROOT/SPECS/piliplus.spec" <<EOF
|
||||
Name: piliplus
|
||||
Version: ${{ env.version }}
|
||||
Release: 1%{?dist}
|
||||
Summary: PiliPlus Linux Version
|
||||
License: GPL-3.0
|
||||
Source0: piliplus-${{ env.version }}.tar.gz
|
||||
Requires: desktop-file-utils, hicolor-icon-theme
|
||||
|
||||
%description
|
||||
使用 Flutter 开发的 BiliBili 第三方客户端
|
||||
|
||||
%prep
|
||||
%setup -q -n piliplus-${{ env.version }}
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}/opt/PiliPlus
|
||||
cp -r bundle/* %{buildroot}/opt/PiliPlus/
|
||||
|
||||
# 二进制权限与命令行入口
|
||||
chmod 755 %{buildroot}/opt/PiliPlus/piliplus
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
ln -sf /opt/PiliPlus/piliplus %{buildroot}/usr/bin/piliplus
|
||||
|
||||
# 桌面集成
|
||||
mkdir -p %{buildroot}/usr/share/applications
|
||||
install -m 644 assets/piliplus.desktop %{buildroot}/usr/share/applications/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
|
||||
|
||||
%post
|
||||
update-desktop-database -q || true
|
||||
gtk-update-icon-cache -q -t -f %{_datadir}/icons/hicolor || true
|
||||
|
||||
%postun
|
||||
update-desktop-database -q || true
|
||||
gtk-update-icon-cache -q -t -f %{_datadir}/icons/hicolor || true
|
||||
|
||||
%files
|
||||
/opt/PiliPlus
|
||||
/usr/bin/piliplus
|
||||
/usr/share/applications/piliplus.desktop
|
||||
/usr/share/icons/hicolor/512x512/apps/piliplus.png
|
||||
|
||||
%changelog
|
||||
* DATE - ${{ env.version }}-1
|
||||
- Initial RPM release
|
||||
EOF
|
||||
|
||||
sed -i "s/DATE/${DATE}/g" "$RPM_BUILD_ROOT/SPECS/piliplus.spec"
|
||||
|
||||
printf "构建 RPM 包...\n"
|
||||
rpmbuild --define "_topdir $RPM_BUILD_ROOT" -bb "$RPM_BUILD_ROOT/SPECS/piliplus.spec"
|
||||
|
||||
printf "移动生成的 RPM...\n"
|
||||
find "$RPM_BUILD_ROOT/RPMS" -name "*.rpm" -exec mv {} "PiliPlus_linux_${{ env.version }}_amd64.rpm" \;
|
||||
|
||||
printf "完成: PiliPlus_linux_%s_amd64.rpm\n" "${{ env.version }}"
|
||||
shell: bash
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_linux_*.tar.gz
|
||||
PiliPlus_linux_*.deb
|
||||
PiliPlus_linux_*.rpm
|
||||
|
||||
- name: Upload linux targz package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_targz_amd64_packege
|
||||
path: PiliPlus_linux_*.tar.gz
|
||||
|
||||
- name: Upload linux deb package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_deb_amd64_package
|
||||
path: PiliPlus_linux_*.deb
|
||||
|
||||
- name: Upload linux rpm package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux_rpm_amd64_package
|
||||
path: PiliPlus_linux_*.rpm
|
||||
58
.github/workflows/mac.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Build for Mac
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-mac-app:
|
||||
name: Release Mac
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version-file: pubspec.yaml
|
||||
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1
|
||||
|
||||
- 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
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rename DMG
|
||||
run: mv PiliPlus*.dmg PiliPlus_macos_${{ env.version }}.dmg
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_macos_*.dmg
|
||||
|
||||
- name: Upload macos release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macOS-release
|
||||
path: PiliPlus_macos_*.dmg
|
||||
80
.github/workflows/win_x64.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Build for Windows x64
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "tag"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-windows-app:
|
||||
name: Release Windows x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version-file: pubspec.yaml
|
||||
|
||||
- name: Add fastforge and Inno Setup
|
||||
run: |
|
||||
dart pub global activate fastforge
|
||||
choco install innosetup
|
||||
|
||||
- name: Add Chinese language file for Inno Setup
|
||||
run: |
|
||||
Copy-Item "windows/packaging/exe/ChineseSimplified.isl" "C:\Program Files (x86)\Inno Setup 6\Languages\ChineseSimplified.isl"
|
||||
shell: pwsh
|
||||
|
||||
- name: Set and Extract version
|
||||
shell: pwsh
|
||||
run: lib/scripts/build.ps1
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
fastforge package --platform windows --targets exe --flutter-build-args="dart-define-from-file=pili_release.json"
|
||||
|
||||
- name: Prepare Upload
|
||||
run: |
|
||||
mkdir -p Release/PiliPlus-Win
|
||||
mkdir -p PiliPlus-Win-Setup
|
||||
mv build/windows/x64/runner/Release/* Release/PiliPlus-Win/
|
||||
mv dist/**/*.exe PiliPlus-Win-Setup/PiliPlus_windows_${{env.version}}_x64_setup.exe
|
||||
|
||||
- name: Compress
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
run: |
|
||||
Compress-Archive -Path "Release/PiliPlus-Win" -DestinationPath "PiliPlus_windows_${{env.version}}_x64.zip"
|
||||
shell: pwsh
|
||||
|
||||
- name: Release
|
||||
if: ${{ github.event.inputs.tag != '' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: ${{ github.event.inputs.tag }}
|
||||
files: |
|
||||
PiliPlus_windows_*.zip
|
||||
PiliPlus-Win-Setup/PiliPlus_windows_*.exe
|
||||
|
||||
- name: Upload windows file release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Windows-file-x64-release
|
||||
path: Release
|
||||
|
||||
- name: Upload windows setup release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Windows-setup-x64-release
|
||||
path: PiliPlus-Win-Setup
|
||||
15
.gitignore
vendored
@@ -19,7 +19,7 @@ migrate_working_dir/
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
.vscode/
|
||||
|
||||
# Flutter repo-specific
|
||||
/bin/cache/
|
||||
@@ -134,7 +134,16 @@ app.*.symbols
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
!/dev/ci/**/Gemfile.lock
|
||||
!.vscode/settings.json
|
||||
|
||||
/lib/build_config.dart
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
pili_release.json
|
||||
|
||||
dist
|
||||
|
||||
test.dart
|
||||
8
.vscode/launch.json
vendored
@@ -1,22 +1,22 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "piliplus",
|
||||
"name": "PiliPlus",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "piliplus (profile mode)",
|
||||
"name": "PiliPlus (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "piliplus (release mode)",
|
||||
"name": "PiliPlus (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
|
||||
45
README.md
@@ -22,22 +22,18 @@
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
## 开发环境
|
||||
|
||||
```bash
|
||||
[✓] Flutter (Channel stable, 3.24.0, on Microsoft Windows [版本 10.0.19045.4046], locale zh-CN)
|
||||
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
|
||||
[✓] Xcode - develop for iOS and macOS (Xcode 15.1)
|
||||
[✓] Chrome - develop for the web
|
||||
[✓] Android Studio (version 2022.3)
|
||||
[✓] VS Code (version 1.85.1)
|
||||
[✓] Connected device (3 available)
|
||||
[✓] Network resources
|
||||
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 适配平台
|
||||
|
||||
- [x] Android
|
||||
- [x] iOS
|
||||
- [x] Pad
|
||||
- [x] Windows
|
||||
- [x] Linux
|
||||
|
||||
[](https://repology.org/project/piliplus/versions)
|
||||
|
||||
## refactor
|
||||
|
||||
@@ -47,6 +43,14 @@
|
||||
|
||||
## feat
|
||||
|
||||
- [x] 移动端支持点击弹幕悬停,点赞、复制、举报 by [@My-Responsitories](https://github.com/My-Responsitories)
|
||||
- [x] 播放音频
|
||||
- [x] 跳过番剧片头/片尾
|
||||
- [x] 安卓端 `loudnorm` 适配 by [@My-Responsitories](https://github.com/My-Responsitories)
|
||||
- [x] Win/Mac 支持极验、短信登录 by [@My-Responsitories](https://github.com/My-Responsitories)
|
||||
- [x] 视频截取动图 by [@My-Responsitories](https://github.com/My-Responsitories)
|
||||
- [x] AI 原声翻译
|
||||
- [x] SuperChat
|
||||
- [x] 播放课堂视频
|
||||
- [x] 发起投票
|
||||
- [x] 发布动态/评论支持`富文本编辑`/`表情显示`/`@用户`
|
||||
@@ -135,11 +139,6 @@
|
||||
|
||||
## 功能
|
||||
|
||||
目前着重移动端(Android、iOS)和Pad端,暂时没有适配桌面端、手表端等
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
- [x] 推荐视频列表(app端)
|
||||
- [x] 最热视频列表
|
||||
- [x] 热门直播
|
||||
@@ -239,3 +238,13 @@
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#bggRGjQaUbCoE/PiliPlus&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=bggRGjQaUbCoE/PiliPlus&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=bggRGjQaUbCoE/PiliPlus&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=bggRGjQaUbCoE/PiliPlus&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -61,5 +61,7 @@ linter:
|
||||
- use_decorated_box
|
||||
- use_named_constants
|
||||
- use_null_aware_elements
|
||||
- unnecessary_lambdas
|
||||
- use_is_even_rather_than_modulo
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
3
android/.gitignore
vendored
@@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
/build
|
||||
/.kotlin
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
def keystoreProperties = new Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
def _filePath = System.getenv("KEYSTORE") ?: keystoreProperties["storeFile"]
|
||||
def _storeFile = _filePath != null ? file(_filePath) : null
|
||||
def _storePassword = System.getenv("KEYSTORE_PASSWORD") ?: keystoreProperties["storePassword"]
|
||||
def _keyAlias = System.getenv("KEY_ALIAS") ?: keystoreProperties["keyAlias"]
|
||||
def _keyPassword = System.getenv("KEY_PASSWORD") ?: keystoreProperties["keyPassword"]
|
||||
|
||||
android {
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
|
||||
namespace 'com.example.piliplus'
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.example.piliplus"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// 添加签名配置
|
||||
if(_storeFile != null) {
|
||||
release {
|
||||
// 配置密钥库文件的位置、别名、密码等信息
|
||||
storeFile _storeFile
|
||||
storePassword _storePassword
|
||||
keyAlias _keyAlias
|
||||
keyPassword _keyPassword
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig _storeFile != null ? signingConfigs.release : signingConfigs.debug
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
project.android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
output.versionCodeOverride = variant.versionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
}
|
||||
77
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,77 @@
|
||||
import com.android.build.gradle.internal.api.ApkVariantOutputImpl
|
||||
import org.jetbrains.kotlin.konan.properties.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.piliplus"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.piliplus"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
packagingOptions.jniLibs.useLegacyPackaging = true
|
||||
|
||||
val keyProperties = Properties().also {
|
||||
val properties = rootProject.file("key.properties")
|
||||
if (properties.exists())
|
||||
it.load(properties.inputStream())
|
||||
}
|
||||
|
||||
val config = keyProperties.getProperty("storeFile")?.let {
|
||||
signingConfigs.create("release") {
|
||||
storeFile = file(it)
|
||||
storePassword = keyProperties.getProperty("storePassword")
|
||||
keyAlias = keyProperties.getProperty("keyAlias")
|
||||
keyPassword = keyProperties.getProperty("keyPassword")
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
all {
|
||||
signingConfig = config ?: signingConfigs["debug"]
|
||||
}
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs.forEach { output ->
|
||||
(output as ApkVariantOutputImpl).versionCodeOverride = flutter.versionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
4
android/app/proguard-rules.pro
vendored
@@ -1 +1,3 @@
|
||||
-keep class com.yalantis.ucrop.util.RectUtils { *; }
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.OpenSSLProvider
|
||||
3
android/app/src/debug/res/values/string.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">PiliPlus debug</string>
|
||||
</resources>
|
||||
@@ -36,11 +36,11 @@
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="PiliPlus"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
tools:replace="android:allowBackup">
|
||||
@@ -109,6 +109,7 @@
|
||||
<data android:host="uper" />
|
||||
<data android:host="article"
|
||||
android:pathPattern="/readlist" />
|
||||
<data android:host="opus" />
|
||||
<data android:host="advertise" android:path="/home" />
|
||||
<data android:host="clip" />
|
||||
<data android:host="search" android:pathPattern=".*" />
|
||||
@@ -176,7 +177,6 @@
|
||||
|
||||
<activity
|
||||
android:name="com.yalantis.ucrop.UCropActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Ucrop.CropTheme"/>
|
||||
|
||||
<receiver
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.example.piliplus
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager.LayoutParams
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "PiliPlus")
|
||||
methodChannel.setMethodCallHandler { call, result ->
|
||||
if (call.method == "back") {
|
||||
back()
|
||||
} else if (call.method == "biliSendCommAntifraud") {
|
||||
try {
|
||||
val action = call.argument<Int>("action") ?: 0
|
||||
val oid = call.argument<Number>("oid") ?: 0L
|
||||
val type = call.argument<Int>("type") ?: 0
|
||||
val rpid = call.argument<Number>("rpid") ?: 0L
|
||||
val root = call.argument<Number>("root") ?: 0L
|
||||
val parent = call.argument<Number>("parent") ?: 0L
|
||||
val ctime = call.argument<Number>("ctime") ?: 0L
|
||||
val commentText = call.argument<String>("comment_text") ?: ""
|
||||
val pictures = call.argument<String?>("pictures")
|
||||
val sourceId = call.argument<String>("source_id") ?: ""
|
||||
val uid = call.argument<Number>("uid") ?: 0L
|
||||
val cookies = call.argument<List<String>>("cookies") ?: emptyList<String>()
|
||||
|
||||
val intent = Intent().apply {
|
||||
component = ComponentName("icu.freedomIntrovert.biliSendCommAntifraud", "icu.freedomIntrovert.biliSendCommAntifraud.ByXposedLaunchedActivity")
|
||||
putExtra("action", action)
|
||||
putExtra("oid", oid.toLong())
|
||||
putExtra("type", type)
|
||||
putExtra("rpid", rpid.toLong())
|
||||
putExtra("root", root.toLong())
|
||||
putExtra("parent", parent.toLong())
|
||||
putExtra("ctime", ctime.toLong())
|
||||
putExtra("comment_text", commentText)
|
||||
if(pictures != null)
|
||||
putExtra("pictures", pictures)
|
||||
putExtra("source_id", sourceId)
|
||||
putExtra("uid", uid.toLong())
|
||||
putStringArrayListExtra("cookies", ArrayList(cookies))
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {}
|
||||
} else if (call.method == "linkVerifySettings") {
|
||||
try {
|
||||
val intent = Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
|
||||
Uri.parse("package:" + context.packageName))
|
||||
context.startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
try {
|
||||
val intent = Intent("android.intent.action.MAIN", Uri.parse("package:" + context.packageName))
|
||||
intent.setClassName("com.android.settings", "com.android.settings.applications.InstalledAppOpenByDefaultActivity")
|
||||
context.startActivity(intent)
|
||||
} catch (t2: Throwable) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:" + context.packageName))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun back() {
|
||||
val intent = Intent(Intent.ACTION_MAIN).apply {
|
||||
addCategory(Intent.CATEGORY_HOME)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
methodChannel.invokeMethod("onUserLeaveHint", null)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration?) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
MethodChannel(
|
||||
flutterEngine!!.getDartExecutor()!!.getBinaryMessenger(),
|
||||
"floating"
|
||||
).invokeMethod("onPipChanged", isInPictureInPictureMode)
|
||||
}
|
||||
}
|
||||
153
android/app/src/main/kotlin/com/example/piliplus/MainActivity.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package com.example.piliplus
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.SearchManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager.LayoutParams
|
||||
import androidx.core.net.toUri
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "PiliPlus")
|
||||
methodChannel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"back" -> back();
|
||||
"biliSendCommAntifraud" -> {
|
||||
try {
|
||||
val action = call.argument<Int>("action") ?: 0
|
||||
val oid = call.argument<Number>("oid") ?: 0L
|
||||
val type = call.argument<Int>("type") ?: 0
|
||||
val rpid = call.argument<Number>("rpid") ?: 0L
|
||||
val root = call.argument<Number>("root") ?: 0L
|
||||
val parent = call.argument<Number>("parent") ?: 0L
|
||||
val ctime = call.argument<Number>("ctime") ?: 0L
|
||||
val commentText = call.argument<String>("comment_text") ?: ""
|
||||
val pictures = call.argument<String?>("pictures")
|
||||
val sourceId = call.argument<String>("source_id") ?: ""
|
||||
val uid = call.argument<Number>("uid") ?: 0L
|
||||
val cookies = call.argument<List<String>>("cookies") ?: emptyList<String>()
|
||||
|
||||
val intent = Intent().apply {
|
||||
component = ComponentName("icu.freedomIntrovert.biliSendCommAntifraud", "icu.freedomIntrovert.biliSendCommAntifraud.ByXposedLaunchedActivity")
|
||||
putExtra("action", action)
|
||||
putExtra("oid", oid.toLong())
|
||||
putExtra("type", type)
|
||||
putExtra("rpid", rpid.toLong())
|
||||
putExtra("root", root.toLong())
|
||||
putExtra("parent", parent.toLong())
|
||||
putExtra("ctime", ctime.toLong())
|
||||
putExtra("comment_text", commentText)
|
||||
if(pictures != null)
|
||||
putExtra("pictures", pictures)
|
||||
putExtra("source_id", sourceId)
|
||||
putExtra("uid", uid.toLong())
|
||||
putStringArrayListExtra("cookies", ArrayList(cookies))
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
"linkVerifySettings" -> {
|
||||
val uri = ("package:" + context.packageName).toUri()
|
||||
try {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Intent(Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, uri)
|
||||
} else {
|
||||
Intent("android.intent.action.MAIN", uri).setClassName("com.android.settings",
|
||||
"com.android.settings.applications.InstalledAppOpenByDefaultActivity")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Throwable) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
"music" -> {
|
||||
val title = call.argument<String>("title")
|
||||
val intent = Intent(MediaStore.INTENT_ACTION_MEDIA_SEARCH).apply {
|
||||
putExtra(SearchManager.QUERY, title)
|
||||
putExtra(MediaStore.EXTRA_MEDIA_TITLE, title)
|
||||
call.argument<String?>("artist")?.let { putExtra(MediaStore.EXTRA_MEDIA_ARTIST, it) }
|
||||
call.argument<String?>("album")?.let { putExtra(MediaStore.EXTRA_MEDIA_ALBUM, it) }
|
||||
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
}
|
||||
try {
|
||||
if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
|
||||
startActivity(intent)
|
||||
result.success(true)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
try {
|
||||
intent.action = MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH
|
||||
if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
|
||||
startActivity(intent)
|
||||
result.success(true)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
result.success(false)
|
||||
}
|
||||
"setPipAutoEnterEnabled" -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(call.argument<Boolean>("autoEnable") ?: false)
|
||||
.build()
|
||||
setPictureInPictureParams(params)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun back() {
|
||||
val intent = Intent(Intent.ACTION_MAIN).apply {
|
||||
addCategory(Intent.CATEGORY_HOME)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
methodChannel.invokeMethod("onUserLeaveHint", null)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration?) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
MethodChannel(
|
||||
flutterEngine!!.dartExecutor.binaryMessenger,
|
||||
"floating"
|
||||
).invokeMethod("onPipChanged", isInPictureInPictureMode)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Ucrop.CropTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/string.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">PiliPlus</string>
|
||||
</resources>
|
||||
@@ -1,59 +0,0 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
afterEvaluate { project ->
|
||||
if (project.hasProperty('android')) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Integer pluginCompileSdk = project.android.compileSdk
|
||||
if (pluginCompileSdk != null) {
|
||||
if (pluginCompileSdk < 31) {
|
||||
project.logger.error(
|
||||
"Warning: Overriding compileSdk version in Flutter plugin: "
|
||||
+ project.name
|
||||
+ " from "
|
||||
+ pluginCompileSdk
|
||||
+ " to 31 (to work around https://issuetracker.google.com/issues/199180389)."
|
||||
+ "\nIf there is not a new version of " + project.name + ", consider filing an issue against "
|
||||
+ project.name
|
||||
+ " to increase their compileSdk to the latest (otherwise try updating to the latest version)."
|
||||
)
|
||||
project.android {
|
||||
compileSdk 31
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
67
android/build.gradle.kts
Normal file
@@ -0,0 +1,67 @@
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
afterEvaluate {
|
||||
if (project.extensions.findByName("android") != null) {
|
||||
val androidExtension =
|
||||
project.extensions.getByName("android") as com.android.build.gradle.BaseExtension
|
||||
|
||||
if (androidExtension.namespace == null) {
|
||||
androidExtension.namespace = project.group.toString()
|
||||
}
|
||||
|
||||
androidExtension.compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
project.tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
val pluginCompileSdkStr = androidExtension.compileSdkVersion
|
||||
val pluginCompileSdk = pluginCompileSdkStr
|
||||
?.removePrefix("android-")
|
||||
?.toIntOrNull()
|
||||
if (pluginCompileSdk != null && pluginCompileSdk < 31) {
|
||||
project.logger.error(
|
||||
"Warning: Overriding compileSdk version in Flutter plugin: ${project.name} " +
|
||||
"from $pluginCompileSdk to 31 (to work around https://issuetracker.google.com/issues/199180389).\n" +
|
||||
"If there is not a new version of ${project.name}, consider filing an issue against ${project.name} " +
|
||||
"to increase their compileSdk to the latest (otherwise try updating to the latest version)."
|
||||
)
|
||||
androidExtension.setCompileSdkVersion(31)
|
||||
}
|
||||
}
|
||||
|
||||
project.buildDir = File(rootProject.buildDir, project.name)
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
distributionUrl=https\://downloads.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.4.1' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.22" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
26
android/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.12.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
Before Width: | Height: | Size: 31 KiB |
BIN
assets/images/loading.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/images/logo/app_icon.ico
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
assets/images/logo/logo_large.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
16
assets/linux/DEBIAN/control
Normal file
@@ -0,0 +1,16 @@
|
||||
Package: PiliPlus
|
||||
Version: version_need_change
|
||||
Maintainer: gh-MzA4Nzk <githubaccount2333@proton.me>
|
||||
Original-Maintainer: bggRGjQaUbCoE <githubaccount56556@proton.me>
|
||||
Section: x11
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Essential: no
|
||||
Installed-Size: size_need_change
|
||||
Description: third-party Bilibili client developed in Flutter
|
||||
Homepage: https://github.com/bggRGjQaUbCoE/PiliPlus
|
||||
Depends: libgtk-3-0t64,
|
||||
libmpv2,
|
||||
gir1.2-ayatanaappindicator3-0.1,
|
||||
libayatana-appindicator3-1
|
||||
|
||||
21
assets/linux/DEBIAN/postinst
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ln -sf /opt/PiliPlus/piliplus /usr/bin/piliplus
|
||||
chmod +x /usr/bin/piliplus
|
||||
|
||||
if [ $1 == "config" ] && [ -x /usr/binupdate-mime-database ]; then
|
||||
echo "updating mime database..."
|
||||
update-mime-database /usr/share/mime || true
|
||||
fi
|
||||
|
||||
if [ $1 == "config" ] && [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||
echo "updating icon cache..."
|
||||
gtk-update-icon-cache -q -f -t /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
if [ $1 == "config" ] && [ -x /usr/bin/update-desktop-database ]; then
|
||||
echo "updating desktop database..."
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
26
assets/linux/DEBIAN/postrm
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
rm /usr/bin/piliplus
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
if [ -x /usr/bin/update-desktop-database ]; then
|
||||
echo "updating desktop database..."
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
|
||||
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||
echo "updating icon cache..."
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
if [ -x /usr/bin/update-mime-database ]; then
|
||||
echo "updating mime database..."
|
||||
update-mime-database /usr/share/mime || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $1 = "purge" ]; then
|
||||
echo "Removing user data..."
|
||||
rm -rf /home/*/.local/share/com.example.PiliPlus || true
|
||||
rm -rf /root/.local/share/com.example.PiliPlus || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
8
assets/linux/DEBIAN/prerm
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then
|
||||
echo "Stopping PiliPlus if running..."
|
||||
pkill -x piliplus || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
9
assets/linux/piliplus.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PiliPlus
|
||||
Comment=A third-party Bilibili Client developed in Flutter
|
||||
Comment[zh_CN]=使用 Flutter 开发的 BiliBili 第三方客户端
|
||||
Exec=piliplus
|
||||
Icon=piliplus
|
||||
Terminal=false
|
||||
Categories=Video;AudioVideo;Player;
|
||||
1
distribute_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
output: dist/
|
||||
@@ -1,7 +1,7 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
|
||||
@UIApplicationMain
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
|
||||
16
lib/build_config.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
class BuildConfig {
|
||||
static const int versionCode = int.fromEnvironment(
|
||||
'pili.code',
|
||||
defaultValue: 1,
|
||||
);
|
||||
static const String versionName = String.fromEnvironment(
|
||||
'pili.name',
|
||||
defaultValue: 'SNAPSHOT',
|
||||
);
|
||||
|
||||
static const int buildTime = int.fromEnvironment('pili.time');
|
||||
static const String commitHash = String.fromEnvironment(
|
||||
'pili.hash',
|
||||
defaultValue: 'N/A',
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,15 @@ class StyleString {
|
||||
static const BorderRadius mdRadius = BorderRadius.all(imgRadius);
|
||||
static const Radius imgRadius = Radius.circular(10);
|
||||
static const double aspectRatio = 16 / 10;
|
||||
static const bottomSheetRadius = BorderRadius.only(
|
||||
topLeft: Radius.circular(18),
|
||||
topRight: Radius.circular(18),
|
||||
static const bottomSheetRadius = BorderRadius.vertical(
|
||||
top: Radius.circular(18),
|
||||
);
|
||||
}
|
||||
|
||||
class Constants {
|
||||
static const appName = 'PiliPlus';
|
||||
static const sourceCodeUrl = 'https://github.com/bggRGjQaUbCoE/PiliPlus';
|
||||
|
||||
// 27eb53fc9058f8c3 移动端 Android
|
||||
// 4409e2ce8ffd12b8 HD版
|
||||
static const String appKey = 'dfca71928277209b';
|
||||
@@ -75,221 +77,221 @@ class Constants {
|
||||
];
|
||||
|
||||
//内容来自 https://passport.bilibili.com/web/generic/country/list
|
||||
static List<Map<String, dynamic>> get internationalDialingPrefix => [
|
||||
{"id": 1, "cname": "中国大陆", "country_id": "86"},
|
||||
{"id": 5, "cname": "中国香港特别行政区", "country_id": "852"},
|
||||
{"id": 2, "cname": "中国澳门特别行政区", "country_id": "853"},
|
||||
{"id": 3, "cname": "中国台湾", "country_id": "886"},
|
||||
{"id": 4, "cname": "美国", "country_id": "1"},
|
||||
{"id": 6, "cname": "比利时", "country_id": "32"},
|
||||
{"id": 7, "cname": "澳大利亚", "country_id": "61"},
|
||||
{"id": 8, "cname": "法国", "country_id": "33"},
|
||||
{"id": 9, "cname": "加拿大", "country_id": "1"},
|
||||
{"id": 10, "cname": "日本", "country_id": "81"},
|
||||
{"id": 11, "cname": "新加坡", "country_id": "65"},
|
||||
{"id": 12, "cname": "韩国", "country_id": "82"},
|
||||
{"id": 13, "cname": "马来西亚", "country_id": "60"},
|
||||
{"id": 14, "cname": "英国", "country_id": "44"},
|
||||
{"id": 15, "cname": "意大利", "country_id": "39"},
|
||||
{"id": 16, "cname": "德国", "country_id": "49"},
|
||||
{"id": 18, "cname": "俄罗斯", "country_id": "7"},
|
||||
{"id": 19, "cname": "新西兰", "country_id": "64"}, //common:1-19
|
||||
{"id": 153, "cname": "瓦利斯群岛和富图纳群岛", "country_id": "1681"},
|
||||
{"id": 152, "cname": "葡萄牙", "country_id": "351"},
|
||||
{"id": 151, "cname": "帕劳", "country_id": "680"},
|
||||
{"id": 150, "cname": "诺福克岛", "country_id": "672"},
|
||||
{"id": 149, "cname": "挪威", "country_id": "47"},
|
||||
{"id": 148, "cname": "纽埃岛", "country_id": "683"},
|
||||
{"id": 147, "cname": "尼日利亚", "country_id": "234"},
|
||||
{"id": 146, "cname": "尼日尔", "country_id": "227"},
|
||||
{"id": 145, "cname": "尼加拉瓜", "country_id": "505"},
|
||||
{"id": 144, "cname": "尼泊尔", "country_id": "977"},
|
||||
{"id": 143, "cname": "瑙鲁", "country_id": "674"},
|
||||
{"id": 154, "cname": "格鲁吉亚", "country_id": "995"},
|
||||
{"id": 155, "cname": "瑞典", "country_id": "46"},
|
||||
{"id": 165, "cname": "沙特阿拉伯", "country_id": "966"},
|
||||
{"id": 164, "cname": "桑给巴尔岛", "country_id": "259"},
|
||||
{"id": 163, "cname": "塞舌尔共和国", "country_id": "248"},
|
||||
{"id": 162, "cname": "塞浦路斯", "country_id": "357"},
|
||||
{"id": 161, "cname": "塞内加尔", "country_id": "221"},
|
||||
{"id": 160, "cname": "塞拉利昂", "country_id": "232"},
|
||||
{"id": 159, "cname": "萨摩亚,东部", "country_id": "684"},
|
||||
{"id": 158, "cname": "萨摩亚,西部", "country_id": "685"},
|
||||
{"id": 157, "cname": "萨尔瓦多", "country_id": "503"},
|
||||
{"id": 156, "cname": "瑞士", "country_id": "41"},
|
||||
{"id": 166, "cname": "圣多美和普林西比", "country_id": "239"},
|
||||
{"id": 142, "cname": "塞尔维亚", "country_id": "381"},
|
||||
{"id": 141, "cname": "南非", "country_id": "27"},
|
||||
{"id": 128, "cname": "毛里塔尼亚", "country_id": "222"},
|
||||
{"id": 127, "cname": "毛里求斯", "country_id": "230"},
|
||||
{"id": 126, "cname": "马歇尔岛", "country_id": "692"},
|
||||
{"id": 125, "cname": "马提尼克岛", "country_id": "596"},
|
||||
{"id": 124, "cname": "马其顿", "country_id": "389"},
|
||||
{"id": 123, "cname": "马里亚纳岛", "country_id": "1670"},
|
||||
{"id": 122, "cname": "马里", "country_id": "223"},
|
||||
{"id": 121, "cname": "马拉维", "country_id": "265"},
|
||||
{"id": 120, "cname": "马耳他", "country_id": "356"},
|
||||
{"id": 119, "cname": "马尔代夫", "country_id": "960"},
|
||||
{"id": 129, "cname": "蒙古", "country_id": "976"},
|
||||
{"id": 130, "cname": "蒙特塞拉特岛", "country_id": "1664"},
|
||||
{"id": 140, "cname": "纳米比亚", "country_id": "264"},
|
||||
{"id": 139, "cname": "墨西哥", "country_id": "52"},
|
||||
{"id": 138, "cname": "莫桑比克", "country_id": "258"},
|
||||
{"id": 137, "cname": "摩纳哥", "country_id": "377"},
|
||||
{"id": 136, "cname": "摩洛哥", "country_id": "212"},
|
||||
{"id": 135, "cname": "摩尔多瓦", "country_id": "373"},
|
||||
{"id": 134, "cname": "缅甸", "country_id": "95"},
|
||||
{"id": 133, "cname": "密克罗尼西亚", "country_id": "691"},
|
||||
{"id": 132, "cname": "秘鲁", "country_id": "51"},
|
||||
{"id": 131, "cname": "孟加拉国", "country_id": "880"},
|
||||
{"id": 118, "cname": "马达加斯加", "country_id": "261"},
|
||||
{"id": 167, "cname": "圣卢西亚", "country_id": "1784"},
|
||||
{"id": 216, "cname": "智利", "country_id": "56"},
|
||||
{"id": 203, "cname": "牙买加", "country_id": "1876"},
|
||||
{"id": 202, "cname": "叙利亚", "country_id": "963"},
|
||||
{"id": 201, "cname": "匈牙利", "country_id": "36"},
|
||||
{"id": 200, "cname": "科特迪瓦", "country_id": "225"},
|
||||
{"id": 199, "cname": "希腊", "country_id": "30"},
|
||||
{"id": 198, "cname": "西班牙", "country_id": "34"},
|
||||
{"id": 197, "cname": "乌兹别克斯坦", "country_id": "998"},
|
||||
{"id": 196, "cname": "乌拉圭", "country_id": "598"},
|
||||
{"id": 195, "cname": "乌克兰", "country_id": "380"},
|
||||
{"id": 194, "cname": "乌干达", "country_id": "256"},
|
||||
{"id": 204, "cname": "亚美尼亚", "country_id": "374"},
|
||||
{"id": 205, "cname": "也门", "country_id": "967"},
|
||||
{"id": 215, "cname": "直布罗陀", "country_id": "350"},
|
||||
{"id": 214, "cname": "乍得", "country_id": "235"},
|
||||
{"id": 213, "cname": "赞比亚", "country_id": "260"},
|
||||
{"id": 212, "cname": "越南", "country_id": "84"},
|
||||
{"id": 211, "cname": "约旦", "country_id": "962"},
|
||||
{"id": 210, "cname": "印尼", "country_id": "62"},
|
||||
{"id": 209, "cname": "印度", "country_id": "91"},
|
||||
{"id": 208, "cname": "以色列", "country_id": "972"},
|
||||
{"id": 207, "cname": "伊朗", "country_id": "98"},
|
||||
{"id": 206, "cname": "伊拉克", "country_id": "964"},
|
||||
{"id": 193, "cname": "文莱", "country_id": "673"},
|
||||
{"id": 192, "cname": "委内瑞拉", "country_id": "58"},
|
||||
{"id": 191, "cname": "维珍群岛(英属)", "country_id": "1284"},
|
||||
{"id": 178, "cname": "泰国", "country_id": "66"},
|
||||
{"id": 177, "cname": "索马里", "country_id": "252"},
|
||||
{"id": 176, "cname": "所罗门群岛", "country_id": "677"},
|
||||
{"id": 175, "cname": "苏里南", "country_id": "597"},
|
||||
{"id": 174, "cname": "苏丹", "country_id": "249"},
|
||||
{"id": 173, "cname": "斯威士兰", "country_id": "268"},
|
||||
{"id": 172, "cname": "斯洛文尼亚", "country_id": "386"},
|
||||
{"id": 171, "cname": "斯洛伐克", "country_id": "421"},
|
||||
{"id": 170, "cname": "斯里兰卡", "country_id": "94"},
|
||||
{"id": 169, "cname": "圣皮埃尔和密克隆群岛", "country_id": "508"},
|
||||
{"id": 179, "cname": "坦桑尼亚", "country_id": "255"},
|
||||
{"id": 180, "cname": "汤加", "country_id": "676"},
|
||||
{"id": 190, "cname": "维珍群岛(美属)", "country_id": "1340"},
|
||||
{"id": 189, "cname": "瓦努阿图", "country_id": "678"},
|
||||
{"id": 188, "cname": "托克劳岛", "country_id": "690"},
|
||||
{"id": 187, "cname": "土库曼斯坦", "country_id": "993"},
|
||||
{"id": 186, "cname": "土耳其", "country_id": "90"},
|
||||
{"id": 185, "cname": "图瓦卢", "country_id": "688"},
|
||||
{"id": 184, "cname": "突尼斯", "country_id": "216"},
|
||||
{"id": 183, "cname": "阿森松岛", "country_id": "247"},
|
||||
{"id": 182, "cname": "特立尼达和多巴哥", "country_id": "1868"},
|
||||
{"id": 181, "cname": "特克斯和凯科斯", "country_id": "1649"},
|
||||
{"id": 168, "cname": "圣马力诺", "country_id": "378"},
|
||||
{"id": 67, "cname": "法属圭亚那", "country_id": "594"},
|
||||
{"id": 54, "cname": "不丹", "country_id": "975"},
|
||||
{"id": 53, "cname": "博茨瓦纳", "country_id": "267"},
|
||||
{"id": 52, "cname": "伯利兹", "country_id": "501"},
|
||||
{"id": 51, "cname": "玻利维亚", "country_id": "591"},
|
||||
{"id": 50, "cname": "波兰", "country_id": "48"},
|
||||
{"id": 49, "cname": "波黑", "country_id": "387"},
|
||||
{"id": 48, "cname": "波多黎各", "country_id": "1787"},
|
||||
{"id": 47, "cname": "冰岛", "country_id": "354"},
|
||||
{"id": 46, "cname": "贝宁", "country_id": "229"},
|
||||
{"id": 45, "cname": "保加利亚", "country_id": "359"},
|
||||
{"id": 55, "cname": "布基纳法索", "country_id": "226"},
|
||||
{"id": 56, "cname": "布隆迪", "country_id": "257"},
|
||||
{"id": 66, "cname": "法属波利尼西亚", "country_id": "689"},
|
||||
{"id": 65, "cname": "法罗岛", "country_id": "298"},
|
||||
{"id": 64, "cname": "厄立特里亚", "country_id": "291"},
|
||||
{"id": 63, "cname": "厄瓜多尔", "country_id": "593"},
|
||||
{"id": 62, "cname": "多米尼加代表", "country_id": "1809"},
|
||||
{"id": 61, "cname": "多米尼加", "country_id": "1767"},
|
||||
{"id": 60, "cname": "多哥", "country_id": "228"},
|
||||
{"id": 59, "cname": "迪戈加西亚岛", "country_id": "246"},
|
||||
{"id": 58, "cname": "丹麦", "country_id": "45"},
|
||||
{"id": 57, "cname": "赤道几内亚", "country_id": "240"},
|
||||
{"id": 44, "cname": "百慕大群岛", "country_id": "1441"},
|
||||
{"id": 43, "cname": "白俄罗斯", "country_id": "375"},
|
||||
{"id": 42, "cname": "巴西", "country_id": "55"},
|
||||
{"id": 29, "cname": "爱尔兰", "country_id": "353"},
|
||||
{"id": 28, "cname": "埃塞俄比亚", "country_id": "251"},
|
||||
{"id": 27, "cname": "埃及", "country_id": "20"},
|
||||
{"id": 26, "cname": "阿塞拜疆", "country_id": "994"},
|
||||
{"id": 25, "cname": "阿曼", "country_id": "968"},
|
||||
{"id": 24, "cname": "阿联酋", "country_id": "971"},
|
||||
{"id": 23, "cname": "阿根廷", "country_id": "54"},
|
||||
{"id": 22, "cname": "阿富汗", "country_id": "93"},
|
||||
{"id": 21, "cname": "阿尔及利亚", "country_id": "213"},
|
||||
{"id": 20, "cname": "阿尔巴尼亚", "country_id": "355"},
|
||||
{"id": 30, "cname": "爱沙尼亚", "country_id": "372"},
|
||||
{"id": 31, "cname": "安道尔", "country_id": "376"},
|
||||
{"id": 41, "cname": "巴拿马", "country_id": "507"},
|
||||
{"id": 40, "cname": "巴林", "country_id": "973"},
|
||||
{"id": 39, "cname": "巴拉圭", "country_id": "595"},
|
||||
{"id": 38, "cname": "巴基斯坦", "country_id": "92"},
|
||||
{"id": 37, "cname": "巴哈马群岛", "country_id": "1242"},
|
||||
{"id": 36, "cname": "巴布亚新几内亚", "country_id": "675"},
|
||||
{"id": 35, "cname": "巴巴多斯", "country_id": "1246"},
|
||||
{"id": 34, "cname": "奥地利", "country_id": "43"},
|
||||
{"id": 33, "cname": "安提瓜岛和巴布达", "country_id": "1268"},
|
||||
{"id": 32, "cname": "安哥拉", "country_id": "244"},
|
||||
{"id": 68, "cname": "非洲中部", "country_id": "236"},
|
||||
{"id": 117, "cname": "罗马尼亚", "country_id": "40"},
|
||||
{"id": 104, "cname": "科威特", "country_id": "965"},
|
||||
{"id": 103, "cname": "科摩罗", "country_id": "269"},
|
||||
{"id": 102, "cname": "开曼群岛", "country_id": "1345"},
|
||||
{"id": 101, "cname": "卡塔尔", "country_id": "974"},
|
||||
{"id": 100, "cname": "喀麦隆", "country_id": "237"},
|
||||
{"id": 99, "cname": "聚会岛", "country_id": "262"},
|
||||
{"id": 98, "cname": "津巴布韦", "country_id": "263"},
|
||||
{"id": 97, "cname": "捷克", "country_id": "420"},
|
||||
{"id": 96, "cname": "柬埔寨", "country_id": "855"},
|
||||
{"id": 95, "cname": "加蓬", "country_id": "241"},
|
||||
{"id": 105, "cname": "克罗地亚", "country_id": "385"},
|
||||
{"id": 106, "cname": "肯尼亚", "country_id": "254"},
|
||||
{"id": 116, "cname": "卢旺达", "country_id": "250"},
|
||||
{"id": 115, "cname": "卢森堡", "country_id": "352"},
|
||||
{"id": 114, "cname": "利比亚", "country_id": "218"},
|
||||
{"id": 113, "cname": "利比里亚", "country_id": "231"},
|
||||
{"id": 112, "cname": "立陶宛", "country_id": "370"},
|
||||
{"id": 111, "cname": "黎巴嫩", "country_id": "961"},
|
||||
{"id": 110, "cname": "老挝", "country_id": "856"},
|
||||
{"id": 109, "cname": "莱索托", "country_id": "266"},
|
||||
{"id": 108, "cname": "拉脱维亚", "country_id": "371"},
|
||||
{"id": 107, "cname": "库克岛", "country_id": "682"},
|
||||
{"id": 94, "cname": "加纳", "country_id": "233"},
|
||||
{"id": 93, "cname": "几内亚比绍", "country_id": "245"},
|
||||
{"id": 92, "cname": "几内亚", "country_id": "224"},
|
||||
{"id": 79, "cname": "格林纳达", "country_id": "1473"},
|
||||
{"id": 78, "cname": "哥斯达黎加", "country_id": "506"},
|
||||
{"id": 77, "cname": "哥伦比亚", "country_id": "57"},
|
||||
{"id": 76, "cname": "刚果(金)", "country_id": "243"},
|
||||
{"id": 75, "cname": "刚果", "country_id": "242"},
|
||||
{"id": 74, "cname": "冈比亚", "country_id": "220"},
|
||||
{"id": 73, "cname": "福克兰岛", "country_id": "500"},
|
||||
{"id": 72, "cname": "佛得角", "country_id": "238"},
|
||||
{"id": 71, "cname": "芬兰", "country_id": "358"},
|
||||
{"id": 70, "cname": "斐济", "country_id": "679"},
|
||||
{"id": 80, "cname": "格陵兰岛", "country_id": "299"},
|
||||
{"id": 81, "cname": "古巴", "country_id": "53"},
|
||||
{"id": 91, "cname": "吉尔吉斯斯坦", "country_id": "996"},
|
||||
{"id": 90, "cname": "吉布提", "country_id": "253"},
|
||||
{"id": 89, "cname": "基里巴斯", "country_id": "686"},
|
||||
{"id": 88, "cname": "维克岛", "country_id": "1808"},
|
||||
{"id": 87, "cname": "洪都拉斯", "country_id": "504"},
|
||||
{"id": 86, "cname": "荷兰", "country_id": "31"},
|
||||
{"id": 85, "cname": "朝鲜", "country_id": "850"},
|
||||
{"id": 84, "cname": "海地", "country_id": "509"},
|
||||
{"id": 83, "cname": "关岛", "country_id": "1671"},
|
||||
{"id": 82, "cname": "瓜德罗普岛", "country_id": "590"},
|
||||
{"id": 69, "cname": "菲律宾", "country_id": "63"},
|
||||
static const internationalDialingPrefix = [
|
||||
(id: 1, cname: "中国大陆", countryId: 86),
|
||||
(id: 5, cname: "中国香港特别行政区", countryId: 852),
|
||||
(id: 2, cname: "中国澳门特别行政区", countryId: 853),
|
||||
(id: 3, cname: "中国台湾", countryId: 886),
|
||||
(id: 4, cname: "美国", countryId: 1),
|
||||
(id: 6, cname: "比利时", countryId: 32),
|
||||
(id: 7, cname: "澳大利亚", countryId: 61),
|
||||
(id: 8, cname: "法国", countryId: 33),
|
||||
(id: 9, cname: "加拿大", countryId: 1),
|
||||
(id: 10, cname: "日本", countryId: 81),
|
||||
(id: 11, cname: "新加坡", countryId: 65),
|
||||
(id: 12, cname: "韩国", countryId: 82),
|
||||
(id: 13, cname: "马来西亚", countryId: 60),
|
||||
(id: 14, cname: "英国", countryId: 44),
|
||||
(id: 15, cname: "意大利", countryId: 39),
|
||||
(id: 16, cname: "德国", countryId: 49),
|
||||
(id: 18, cname: "俄罗斯", countryId: 7),
|
||||
(id: 19, cname: "新西兰", countryId: 64),
|
||||
(id: 153, cname: "瓦利斯群岛和富图纳群岛", countryId: 1681),
|
||||
(id: 152, cname: "葡萄牙", countryId: 351),
|
||||
(id: 151, cname: "帕劳", countryId: 680),
|
||||
(id: 150, cname: "诺福克岛", countryId: 672),
|
||||
(id: 149, cname: "挪威", countryId: 47),
|
||||
(id: 148, cname: "纽埃岛", countryId: 683),
|
||||
(id: 147, cname: "尼日利亚", countryId: 234),
|
||||
(id: 146, cname: "尼日尔", countryId: 227),
|
||||
(id: 145, cname: "尼加拉瓜", countryId: 505),
|
||||
(id: 144, cname: "尼泊尔", countryId: 977),
|
||||
(id: 143, cname: "瑙鲁", countryId: 674),
|
||||
(id: 154, cname: "格鲁吉亚", countryId: 995),
|
||||
(id: 155, cname: "瑞典", countryId: 46),
|
||||
(id: 165, cname: "沙特阿拉伯", countryId: 966),
|
||||
(id: 164, cname: "桑给巴尔岛", countryId: 259),
|
||||
(id: 163, cname: "塞舌尔共和国", countryId: 248),
|
||||
(id: 162, cname: "塞浦路斯", countryId: 357),
|
||||
(id: 161, cname: "塞内加尔", countryId: 221),
|
||||
(id: 160, cname: "塞拉利昂", countryId: 232),
|
||||
(id: 159, cname: "萨摩亚,东部", countryId: 684),
|
||||
(id: 158, cname: "萨摩亚,西部", countryId: 685),
|
||||
(id: 157, cname: "萨尔瓦多", countryId: 503),
|
||||
(id: 156, cname: "瑞士", countryId: 41),
|
||||
(id: 166, cname: "圣多美和普林西比", countryId: 239),
|
||||
(id: 142, cname: "塞尔维亚", countryId: 381),
|
||||
(id: 141, cname: "南非", countryId: 27),
|
||||
(id: 128, cname: "毛里塔尼亚", countryId: 222),
|
||||
(id: 127, cname: "毛里求斯", countryId: 230),
|
||||
(id: 126, cname: "马歇尔岛", countryId: 692),
|
||||
(id: 125, cname: "马提尼克岛", countryId: 596),
|
||||
(id: 124, cname: "马其顿", countryId: 389),
|
||||
(id: 123, cname: "马里亚纳岛", countryId: 1670),
|
||||
(id: 122, cname: "马里", countryId: 223),
|
||||
(id: 121, cname: "马拉维", countryId: 265),
|
||||
(id: 120, cname: "马耳他", countryId: 356),
|
||||
(id: 119, cname: "马尔代夫", countryId: 960),
|
||||
(id: 129, cname: "蒙古", countryId: 976),
|
||||
(id: 130, cname: "蒙特塞拉特岛", countryId: 1664),
|
||||
(id: 140, cname: "纳米比亚", countryId: 264),
|
||||
(id: 139, cname: "墨西哥", countryId: 52),
|
||||
(id: 138, cname: "莫桑比克", countryId: 258),
|
||||
(id: 137, cname: "摩纳哥", countryId: 377),
|
||||
(id: 136, cname: "摩洛哥", countryId: 212),
|
||||
(id: 135, cname: "摩尔多瓦", countryId: 373),
|
||||
(id: 134, cname: "缅甸", countryId: 95),
|
||||
(id: 133, cname: "密克罗尼西亚", countryId: 691),
|
||||
(id: 132, cname: "秘鲁", countryId: 51),
|
||||
(id: 131, cname: "孟加拉国", countryId: 880),
|
||||
(id: 118, cname: "马达加斯加", countryId: 261),
|
||||
(id: 167, cname: "圣卢西亚", countryId: 1784),
|
||||
(id: 216, cname: "智利", countryId: 56),
|
||||
(id: 203, cname: "牙买加", countryId: 1876),
|
||||
(id: 202, cname: "叙利亚", countryId: 963),
|
||||
(id: 201, cname: "匈牙利", countryId: 36),
|
||||
(id: 200, cname: "科特迪瓦", countryId: 225),
|
||||
(id: 199, cname: "希腊", countryId: 30),
|
||||
(id: 198, cname: "西班牙", countryId: 34),
|
||||
(id: 197, cname: "乌兹别克斯坦", countryId: 998),
|
||||
(id: 196, cname: "乌拉圭", countryId: 598),
|
||||
(id: 195, cname: "乌克兰", countryId: 380),
|
||||
(id: 194, cname: "乌干达", countryId: 256),
|
||||
(id: 204, cname: "亚美尼亚", countryId: 374),
|
||||
(id: 205, cname: "也门", countryId: 967),
|
||||
(id: 215, cname: "直布罗陀", countryId: 350),
|
||||
(id: 214, cname: "乍得", countryId: 235),
|
||||
(id: 213, cname: "赞比亚", countryId: 260),
|
||||
(id: 212, cname: "越南", countryId: 84),
|
||||
(id: 211, cname: "约旦", countryId: 962),
|
||||
(id: 210, cname: "印尼", countryId: 62),
|
||||
(id: 209, cname: "印度", countryId: 91),
|
||||
(id: 208, cname: "以色列", countryId: 972),
|
||||
(id: 207, cname: "伊朗", countryId: 98),
|
||||
(id: 206, cname: "伊拉克", countryId: 964),
|
||||
(id: 193, cname: "文莱", countryId: 673),
|
||||
(id: 192, cname: "委内瑞拉", countryId: 58),
|
||||
(id: 191, cname: "维珍群岛(英属)", countryId: 1284),
|
||||
(id: 178, cname: "泰国", countryId: 66),
|
||||
(id: 177, cname: "索马里", countryId: 252),
|
||||
(id: 176, cname: "所罗门群岛", countryId: 677),
|
||||
(id: 175, cname: "苏里南", countryId: 597),
|
||||
(id: 174, cname: "苏丹", countryId: 249),
|
||||
(id: 173, cname: "斯威士兰", countryId: 268),
|
||||
(id: 172, cname: "斯洛文尼亚", countryId: 386),
|
||||
(id: 171, cname: "斯洛伐克", countryId: 421),
|
||||
(id: 170, cname: "斯里兰卡", countryId: 94),
|
||||
(id: 169, cname: "圣皮埃尔和密克隆群岛", countryId: 508),
|
||||
(id: 179, cname: "坦桑尼亚", countryId: 255),
|
||||
(id: 180, cname: "汤加", countryId: 676),
|
||||
(id: 190, cname: "维珍群岛(美属)", countryId: 1340),
|
||||
(id: 189, cname: "瓦努阿图", countryId: 678),
|
||||
(id: 188, cname: "托克劳岛", countryId: 690),
|
||||
(id: 187, cname: "土库曼斯坦", countryId: 993),
|
||||
(id: 186, cname: "土耳其", countryId: 90),
|
||||
(id: 185, cname: "图瓦卢", countryId: 688),
|
||||
(id: 184, cname: "突尼斯", countryId: 216),
|
||||
(id: 183, cname: "阿森松岛", countryId: 247),
|
||||
(id: 182, cname: "特立尼达和多巴哥", countryId: 1868),
|
||||
(id: 181, cname: "特克斯和凯科斯", countryId: 1649),
|
||||
(id: 168, cname: "圣马力诺", countryId: 378),
|
||||
(id: 67, cname: "法属圭亚那", countryId: 594),
|
||||
(id: 54, cname: "不丹", countryId: 975),
|
||||
(id: 53, cname: "博茨瓦纳", countryId: 267),
|
||||
(id: 52, cname: "伯利兹", countryId: 501),
|
||||
(id: 51, cname: "玻利维亚", countryId: 591),
|
||||
(id: 50, cname: "波兰", countryId: 48),
|
||||
(id: 49, cname: "波黑", countryId: 387),
|
||||
(id: 48, cname: "波多黎各", countryId: 1787),
|
||||
(id: 47, cname: "冰岛", countryId: 354),
|
||||
(id: 46, cname: "贝宁", countryId: 229),
|
||||
(id: 45, cname: "保加利亚", countryId: 359),
|
||||
(id: 55, cname: "布基纳法索", countryId: 226),
|
||||
(id: 56, cname: "布隆迪", countryId: 257),
|
||||
(id: 66, cname: "法属波利尼西亚", countryId: 689),
|
||||
(id: 65, cname: "法罗岛", countryId: 298),
|
||||
(id: 64, cname: "厄立特里亚", countryId: 291),
|
||||
(id: 63, cname: "厄瓜多尔", countryId: 593),
|
||||
(id: 62, cname: "多米尼加代表", countryId: 1809),
|
||||
(id: 61, cname: "多米尼加", countryId: 1767),
|
||||
(id: 60, cname: "多哥", countryId: 228),
|
||||
(id: 59, cname: "迪戈加西亚岛", countryId: 246),
|
||||
(id: 58, cname: "丹麦", countryId: 45),
|
||||
(id: 57, cname: "赤道几内亚", countryId: 240),
|
||||
(id: 44, cname: "百慕大群岛", countryId: 1441),
|
||||
(id: 43, cname: "白俄罗斯", countryId: 375),
|
||||
(id: 42, cname: "巴西", countryId: 55),
|
||||
(id: 29, cname: "爱尔兰", countryId: 353),
|
||||
(id: 28, cname: "埃塞俄比亚", countryId: 251),
|
||||
(id: 27, cname: "埃及", countryId: 20),
|
||||
(id: 26, cname: "阿塞拜疆", countryId: 994),
|
||||
(id: 25, cname: "阿曼", countryId: 968),
|
||||
(id: 24, cname: "阿联酋", countryId: 971),
|
||||
(id: 23, cname: "阿根廷", countryId: 54),
|
||||
(id: 22, cname: "阿富汗", countryId: 93),
|
||||
(id: 21, cname: "阿尔及利亚", countryId: 213),
|
||||
(id: 20, cname: "阿尔巴尼亚", countryId: 355),
|
||||
(id: 30, cname: "爱沙尼亚", countryId: 372),
|
||||
(id: 31, cname: "安道尔", countryId: 376),
|
||||
(id: 41, cname: "巴拿马", countryId: 507),
|
||||
(id: 40, cname: "巴林", countryId: 973),
|
||||
(id: 39, cname: "巴拉圭", countryId: 595),
|
||||
(id: 38, cname: "巴基斯坦", countryId: 92),
|
||||
(id: 37, cname: "巴哈马群岛", countryId: 1242),
|
||||
(id: 36, cname: "巴布亚新几内亚", countryId: 675),
|
||||
(id: 35, cname: "巴巴多斯", countryId: 1246),
|
||||
(id: 34, cname: "奥地利", countryId: 43),
|
||||
(id: 33, cname: "安提瓜岛和巴布达", countryId: 1268),
|
||||
(id: 32, cname: "安哥拉", countryId: 244),
|
||||
(id: 68, cname: "非洲中部", countryId: 236),
|
||||
(id: 117, cname: "罗马尼亚", countryId: 40),
|
||||
(id: 104, cname: "科威特", countryId: 965),
|
||||
(id: 103, cname: "科摩罗", countryId: 269),
|
||||
(id: 102, cname: "开曼群岛", countryId: 1345),
|
||||
(id: 101, cname: "卡塔尔", countryId: 974),
|
||||
(id: 100, cname: "喀麦隆", countryId: 237),
|
||||
(id: 99, cname: "聚会岛", countryId: 262),
|
||||
(id: 98, cname: "津巴布韦", countryId: 263),
|
||||
(id: 97, cname: "捷克", countryId: 420),
|
||||
(id: 96, cname: "柬埔寨", countryId: 855),
|
||||
(id: 95, cname: "加蓬", countryId: 241),
|
||||
(id: 105, cname: "克罗地亚", countryId: 385),
|
||||
(id: 106, cname: "肯尼亚", countryId: 254),
|
||||
(id: 116, cname: "卢旺达", countryId: 250),
|
||||
(id: 115, cname: "卢森堡", countryId: 352),
|
||||
(id: 114, cname: "利比亚", countryId: 218),
|
||||
(id: 113, cname: "利比里亚", countryId: 231),
|
||||
(id: 112, cname: "立陶宛", countryId: 370),
|
||||
(id: 111, cname: "黎巴嫩", countryId: 961),
|
||||
(id: 110, cname: "老挝", countryId: 856),
|
||||
(id: 109, cname: "莱索托", countryId: 266),
|
||||
(id: 108, cname: "拉脱维亚", countryId: 371),
|
||||
(id: 107, cname: "库克岛", countryId: 682),
|
||||
(id: 94, cname: "加纳", countryId: 233),
|
||||
(id: 93, cname: "几内亚比绍", countryId: 245),
|
||||
(id: 92, cname: "几内亚", countryId: 224),
|
||||
(id: 79, cname: "格林纳达", countryId: 1473),
|
||||
(id: 78, cname: "哥斯达黎加", countryId: 506),
|
||||
(id: 77, cname: "哥伦比亚", countryId: 57),
|
||||
(id: 76, cname: "刚果(金)", countryId: 243),
|
||||
(id: 75, cname: "刚果", countryId: 242),
|
||||
(id: 74, cname: "冈比亚", countryId: 220),
|
||||
(id: 73, cname: "福克兰岛", countryId: 500),
|
||||
(id: 72, cname: "佛得角", countryId: 238),
|
||||
(id: 71, cname: "芬兰", countryId: 358),
|
||||
(id: 70, cname: "斐济", countryId: 679),
|
||||
(id: 80, cname: "格陵兰岛", countryId: 299),
|
||||
(id: 81, cname: "古巴", countryId: 53),
|
||||
(id: 91, cname: "吉尔吉斯斯坦", countryId: 996),
|
||||
(id: 90, cname: "吉布提", countryId: 253),
|
||||
(id: 89, cname: "基里巴斯", countryId: 686),
|
||||
(id: 88, cname: "维克岛", countryId: 1808),
|
||||
(id: 87, cname: "洪都拉斯", countryId: 504),
|
||||
(id: 86, cname: "荷兰", countryId: 31),
|
||||
(id: 85, cname: "朝鲜", countryId: 850),
|
||||
(id: 84, cname: "海地", countryId: 509),
|
||||
(id: 83, cname: "关岛", countryId: 1671),
|
||||
(id: 82, cname: "瓜德罗普岛", countryId: 590),
|
||||
(id: 69, cname: "菲律宾", countryId: 63),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:PiliPlus/common/skeleton/skeleton.dart';
|
||||
import 'package:PiliPlus/utils/global_data.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DynamicCardSkeleton extends StatelessWidget {
|
||||
@@ -20,6 +21,7 @@ class DynamicCardSkeleton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -50,46 +52,38 @@ class DynamicCardSkeleton extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
color: color,
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
color: color,
|
||||
width: double.infinity,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: double.infinity,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 300,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 250,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 100,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
color: color,
|
||||
width: double.infinity,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 300,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 250,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
Container(
|
||||
color: color,
|
||||
width: 100,
|
||||
height: 13,
|
||||
margin: const EdgeInsets.only(bottom: 7),
|
||||
),
|
||||
if (GlobalData().dynamicsWaterfallFlow) const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
|
||||
@@ -19,15 +19,11 @@ class VideoCardHSkeleton extends StatelessWidget {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: StyleString.aspectRatio,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, boxConstraints) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: StyleString.mdRadius,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: StyleString.mdRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -10,18 +10,15 @@ class VideoCardVSkeleton extends StatelessWidget {
|
||||
final color = Theme.of(context).colorScheme.onInverseSurface;
|
||||
return Skeleton(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: StyleString.aspectRatio,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, boxConstraints) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: StyleString.mdRadius,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: StyleString.mdRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -30,9 +30,8 @@ class VideoReplySkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4,
|
||||
left: 57,
|
||||
right: 6,
|
||||
|
||||
@@ -33,7 +33,7 @@ class MultiSelectAppBarWidget extends StatelessWidget
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () => ctr.handleSelect(true),
|
||||
onPressed: () => ctr.handleSelect(checked: true),
|
||||
child: const Text('全选'),
|
||||
),
|
||||
...?children,
|
||||
|
||||
@@ -75,10 +75,11 @@ class PBadge extends StatelessWidget {
|
||||
bgColor = Colors.transparent;
|
||||
borderColor = theme.secondary;
|
||||
case PBadgeType.free:
|
||||
bgColor = Get.isDarkMode
|
||||
? const Color(0xFFD66011)
|
||||
: const Color(0xFFFF7F24);
|
||||
bgColor = theme.freeColor;
|
||||
color = Colors.white;
|
||||
case PBadgeType.shop:
|
||||
bgColor = theme.secondaryContainer.withValues(alpha: 0.5);
|
||||
color = theme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
late EdgeInsets paddingStyle = const EdgeInsets.symmetric(
|
||||
|
||||
@@ -1,50 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Widget iconButton({
|
||||
required BuildContext context,
|
||||
BuildContext? context,
|
||||
String? tooltip,
|
||||
required IconData icon,
|
||||
required Widget icon,
|
||||
required VoidCallback? onPressed,
|
||||
double size = 36,
|
||||
double? iconSize,
|
||||
Color? bgColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
late final theme = Theme.of(context);
|
||||
Color? backgroundColor = bgColor;
|
||||
Color? foregroundColor = iconColor;
|
||||
if (context != null) {
|
||||
final colorScheme = ColorScheme.of(context);
|
||||
backgroundColor = colorScheme.secondaryContainer;
|
||||
foregroundColor = colorScheme.onSecondaryContainer;
|
||||
}
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: IconButton(
|
||||
icon: icon,
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: iconSize ?? size / 2,
|
||||
color: iconColor ?? theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: bgColor ?? theme.colorScheme.secondaryContainer,
|
||||
iconSize: iconSize ?? size / 2,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget mediumButton({
|
||||
String? tooltip,
|
||||
IconData? icon,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: Icon(icon),
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(EdgeInsets.zero),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
34
lib/common/widgets/button/more_btn.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Widget moreTextButton({
|
||||
String text = '查看更多',
|
||||
required VoidCallback onTap,
|
||||
EdgeInsets? padding,
|
||||
Color? color,
|
||||
}) {
|
||||
Widget child = Text.rich(
|
||||
style: TextStyle(color: color, height: 1),
|
||||
strutStyle: const StrutStyle(leading: 0, height: 1),
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: text),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Icon(
|
||||
size: 22,
|
||||
color: color,
|
||||
Icons.keyboard_arrow_right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (padding != null) {
|
||||
child = Padding(padding: padding, child: child);
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class ToolbarIconButton extends StatelessWidget {
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.outline,
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(EdgeInsets.zero),
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
selected ? theme.colorScheme.secondaryContainer : null,
|
||||
),
|
||||
|
||||
@@ -15,11 +15,10 @@ class ColorPalette extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final Hct hct = Hct.fromInt(color.value);
|
||||
final Hct hct = Hct.fromInt(color.toARGB32());
|
||||
final primary = Color(Hct.from(hct.hue, 20.0, 90.0).toInt());
|
||||
final tertiary = Color(Hct.from(hct.hue + 50, 20.0, 85.0).toInt());
|
||||
final primaryContainer = Color(Hct.from(hct.hue, 30.0, 50.0).toInt());
|
||||
final checkbox = Color(Hct.from(hct.hue, 30.0, 40.0).toInt());
|
||||
Widget coloredBox(Color color) => Expanded(
|
||||
child: ColoredBox(
|
||||
color: color,
|
||||
@@ -51,7 +50,7 @@ class ColorPalette extends StatelessWidget {
|
||||
width: 23,
|
||||
height: 23,
|
||||
decoration: BoxDecoration(
|
||||
color: checkbox,
|
||||
color: Color(Hct.from(hct.hue, 30.0, 40.0).toInt()),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class CustomIcon {
|
||||
class CustomIcons {
|
||||
static const IconData coin = _CustomIconData(0xe800);
|
||||
static const IconData dm_off = _CustomIconData(0xe801);
|
||||
static const IconData dm_on = _CustomIconData(0xe802);
|
||||
@@ -10,19 +10,24 @@ class CustomIcon {
|
||||
static const IconData dyn = _CustomIconData(0xe804);
|
||||
static const IconData fav = _CustomIconData(0xe805);
|
||||
static const IconData live_reserve = _CustomIconData(0xe806);
|
||||
static const IconData share = _CustomIconData(0xe807);
|
||||
static const IconData share_line = _CustomIconData(0xe808);
|
||||
static const IconData share_node = _CustomIconData(0xe809);
|
||||
static const IconData star_favorite_line = _CustomIconData(0xe80a);
|
||||
static const IconData star_favorite_solid = _CustomIconData(0xe80b);
|
||||
static const IconData thumbs_down = _CustomIconData(0xe80c);
|
||||
static const IconData thumbs_down_outline = _CustomIconData(0xe80d);
|
||||
static const IconData thumbs_up = _CustomIconData(0xe80e);
|
||||
static const IconData thumbs_up_fill = _CustomIconData(0xe80f);
|
||||
static const IconData thumbs_up_line = _CustomIconData(0xe810);
|
||||
static const IconData thumbs_up_outline = _CustomIconData(0xe811);
|
||||
static const IconData topic_tag = _CustomIconData(0xe812);
|
||||
static const IconData watch_later = _CustomIconData(0xe813);
|
||||
static const IconData player_dm_tip_back = _CustomIconData(0xe807);
|
||||
static const IconData player_dm_tip_copy = _CustomIconData(0xe808);
|
||||
static const IconData player_dm_tip_like = _CustomIconData(0xe809);
|
||||
static const IconData player_dm_tip_like_solid = _CustomIconData(0xe80a);
|
||||
static const IconData player_dm_tip_recall = _CustomIconData(0xe80b);
|
||||
static const IconData share = _CustomIconData(0xe80c);
|
||||
static const IconData share_line = _CustomIconData(0xe80d);
|
||||
static const IconData share_node = _CustomIconData(0xe80e);
|
||||
static const IconData star_favorite_line = _CustomIconData(0xe80f);
|
||||
static const IconData star_favorite_solid = _CustomIconData(0xe810);
|
||||
static const IconData thumbs_down = _CustomIconData(0xe811);
|
||||
static const IconData thumbs_down_outline = _CustomIconData(0xe812);
|
||||
static const IconData thumbs_up = _CustomIconData(0xe813);
|
||||
static const IconData thumbs_up_fill = _CustomIconData(0xe814);
|
||||
static const IconData thumbs_up_line = _CustomIconData(0xe815);
|
||||
static const IconData thumbs_up_outline = _CustomIconData(0xe816);
|
||||
static const IconData topic_tag = _CustomIconData(0xe817);
|
||||
static const IconData watch_later = _CustomIconData(0xe818);
|
||||
}
|
||||
|
||||
class _CustomIconData extends IconData {
|
||||
|
||||
462
lib/common/widgets/custom_layout.dart
Normal file
@@ -0,0 +1,462 @@
|
||||
// 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 'package:flutter/widgets.dart';
|
||||
///
|
||||
/// @docImport 'stack.dart';
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class CustomMultiChildLayout extends MultiChildRenderObjectWidget {
|
||||
/// Creates a custom multi-child layout.
|
||||
const CustomMultiChildLayout({
|
||||
super.key,
|
||||
required this.delegate,
|
||||
super.children,
|
||||
});
|
||||
|
||||
/// The delegate that controls the layout of the children.
|
||||
final MultiChildLayoutDelegate delegate;
|
||||
|
||||
@override
|
||||
RenderCustomMultiChildLayoutBox createRenderObject(BuildContext context) {
|
||||
return RenderCustomMultiChildLayoutBox(delegate: delegate);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
RenderCustomMultiChildLayoutBox renderObject,
|
||||
) {
|
||||
renderObject.delegate = delegate;
|
||||
}
|
||||
}
|
||||
|
||||
/// A delegate that controls the layout of multiple children.
|
||||
///
|
||||
/// Used with [CustomMultiChildLayout] (in the widgets library) and
|
||||
/// [RenderCustomMultiChildLayoutBox] (in the rendering library).
|
||||
///
|
||||
/// Delegates must be idempotent. Specifically, if two delegates are equal, then
|
||||
/// they must produce the same layout. To change the layout, replace the
|
||||
/// delegate with a different instance whose [shouldRelayout] returns true when
|
||||
/// given the previous instance.
|
||||
///
|
||||
/// Override [getSize] to control the overall size of the layout. The size of
|
||||
/// the layout cannot depend on layout properties of the children. This was
|
||||
/// a design decision to simplify the delegate implementations: This way,
|
||||
/// the delegate implementations do not have to also handle various intrinsic
|
||||
/// sizing functions if the parent's size depended on the children.
|
||||
/// If you want to build a custom layout where you define the size of that widget
|
||||
/// based on its children, then you will have to create a custom render object.
|
||||
/// See [MultiChildRenderObjectWidget] with [ContainerRenderObjectMixin] and
|
||||
/// [RenderBoxContainerDefaultsMixin] to get started or [RenderStack] for an
|
||||
/// example implementation.
|
||||
///
|
||||
/// Override [performLayout] to size and position the children. An
|
||||
/// implementation of [performLayout] must call [layoutChild] exactly once for
|
||||
/// each child, but it may call [layoutChild] on children in an arbitrary order.
|
||||
/// Typically a delegate will use the size returned from [layoutChild] on one
|
||||
/// child to determine the constraints for [performLayout] on another child or
|
||||
/// to determine the offset for [positionChild] for that child or another child.
|
||||
///
|
||||
/// Override [shouldRelayout] to determine when the layout of the children needs
|
||||
/// to be recomputed when the delegate changes.
|
||||
///
|
||||
/// The most efficient way to trigger a relayout is to supply a `relayout`
|
||||
/// argument to the constructor of the [MultiChildLayoutDelegate]. The custom
|
||||
/// layout will listen to this value and relayout whenever the Listenable
|
||||
/// notifies its listeners, such as when an [Animation] ticks. This allows
|
||||
/// the custom layout to avoid the build phase of the pipeline.
|
||||
///
|
||||
/// Each child must be wrapped in a [LayoutId] widget to assign the id that
|
||||
/// identifies it to the delegate. The [LayoutId.id] needs to be unique among
|
||||
/// the children that the [CustomMultiChildLayout] manages.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
///
|
||||
/// Below is an example implementation of [performLayout] that causes one widget
|
||||
/// (the follower) to be the same size as another (the leader):
|
||||
///
|
||||
/// ```dart
|
||||
/// // Define your own slot numbers, depending upon the id assigned by LayoutId.
|
||||
/// // Typical usage is to define an enum like the one below, and use those
|
||||
/// // values as the ids.
|
||||
/// enum _Slot {
|
||||
/// leader,
|
||||
/// follower,
|
||||
/// }
|
||||
///
|
||||
/// class FollowTheLeader extends MultiChildLayoutDelegate {
|
||||
/// @override
|
||||
/// void performLayout(Size size) {
|
||||
/// Size leaderSize = Size.zero;
|
||||
///
|
||||
/// if (hasChild(_Slot.leader)) {
|
||||
/// leaderSize = layoutChild(_Slot.leader, BoxConstraints.loose(size));
|
||||
/// positionChild(_Slot.leader, Offset.zero);
|
||||
/// }
|
||||
///
|
||||
/// if (hasChild(_Slot.follower)) {
|
||||
/// layoutChild(_Slot.follower, BoxConstraints.tight(leaderSize));
|
||||
/// positionChild(_Slot.follower, Offset(size.width - leaderSize.width,
|
||||
/// size.height - leaderSize.height));
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
|
||||
/// }
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// The delegate gives the leader widget loose constraints, which means the
|
||||
/// child determines what size to be (subject to fitting within the given size).
|
||||
/// The delegate then remembers the size of that child and places it in the
|
||||
/// upper left corner.
|
||||
///
|
||||
/// The delegate then gives the follower widget tight constraints, forcing it to
|
||||
/// match the size of the leader widget. The delegate then places the follower
|
||||
/// widget in the bottom right corner.
|
||||
///
|
||||
/// The leader and follower widget will paint in the order they appear in the
|
||||
/// child list, regardless of the order in which [layoutChild] is called on
|
||||
/// them.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [CustomMultiChildLayout], the widget that uses this delegate.
|
||||
/// * [RenderCustomMultiChildLayoutBox], render object that uses this
|
||||
/// delegate.
|
||||
abstract class MultiChildLayoutDelegate {
|
||||
/// Creates a layout delegate.
|
||||
///
|
||||
/// The layout will update whenever [relayout] notifies its listeners.
|
||||
MultiChildLayoutDelegate({Listenable? relayout}) : _relayout = relayout;
|
||||
|
||||
final Listenable? _relayout;
|
||||
|
||||
Map<Object, RenderBox>? _idToChild;
|
||||
Set<RenderBox>? _debugChildrenNeedingLayout;
|
||||
|
||||
/// True if a non-null LayoutChild was provided for the specified id.
|
||||
///
|
||||
/// Call this from the [performLayout] method to determine which children
|
||||
/// are available, if the child list might vary.
|
||||
///
|
||||
/// This method cannot be called from [getSize] as the size is not allowed
|
||||
/// to depend on the children.
|
||||
bool hasChild(Object childId) => _idToChild![childId] != null;
|
||||
|
||||
/// Ask the child to update its layout within the limits specified by
|
||||
/// the constraints parameter. The child's size is returned.
|
||||
///
|
||||
/// Call this from your [performLayout] function to lay out each
|
||||
/// child. Every child must be laid out using this function exactly
|
||||
/// once each time the [performLayout] function is called.
|
||||
Size layoutChild(Object childId, BoxConstraints constraints) {
|
||||
final RenderBox? child = _idToChild![childId];
|
||||
assert(() {
|
||||
if (child == null) {
|
||||
throw FlutterError(
|
||||
'The $this custom multichild layout delegate tried to lay out a non-existent child.\n'
|
||||
'There is no child with the id "$childId".',
|
||||
);
|
||||
}
|
||||
if (!_debugChildrenNeedingLayout!.remove(child)) {
|
||||
throw FlutterError(
|
||||
'The $this custom multichild layout delegate tried to lay out the child with id "$childId" more than once.\n'
|
||||
'Each child must be laid out exactly once.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assert(constraints.debugAssertIsValid(isAppliedConstraint: true));
|
||||
} on AssertionError catch (exception) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary(
|
||||
'The $this custom multichild layout delegate provided invalid box constraints for the child with id "$childId".',
|
||||
),
|
||||
DiagnosticsProperty<AssertionError>(
|
||||
'Exception',
|
||||
exception,
|
||||
showName: false,
|
||||
),
|
||||
ErrorDescription(
|
||||
'The minimum width and height must be greater than or equal to zero.\n'
|
||||
'The maximum width must be greater than or equal to the minimum width.\n'
|
||||
'The maximum height must be greater than or equal to the minimum height.',
|
||||
),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
child!.layout(constraints, parentUsesSize: true);
|
||||
return child.size;
|
||||
}
|
||||
|
||||
/// Specify the child's origin relative to this origin.
|
||||
///
|
||||
/// Call this from your [performLayout] function to position each
|
||||
/// child. If you do not call this for a child, its position will
|
||||
/// remain unchanged. Children initially have their position set to
|
||||
/// (0,0), i.e. the top left of the [RenderCustomMultiChildLayoutBox].
|
||||
void positionChild(Object childId, Offset offset) {
|
||||
final RenderBox? child = _idToChild![childId];
|
||||
assert(() {
|
||||
if (child == null) {
|
||||
throw FlutterError(
|
||||
'The $this custom multichild layout delegate tried to position out a non-existent child:\n'
|
||||
'There is no child with the id "$childId".',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
final MultiChildLayoutParentData childParentData =
|
||||
child!.parentData! as MultiChildLayoutParentData;
|
||||
childParentData.offset = offset;
|
||||
}
|
||||
|
||||
DiagnosticsNode _debugDescribeChild(RenderBox child) {
|
||||
final MultiChildLayoutParentData childParentData =
|
||||
child.parentData! as MultiChildLayoutParentData;
|
||||
return DiagnosticsProperty<RenderBox>('${childParentData.id}', child);
|
||||
}
|
||||
|
||||
void _callPerformLayout(Size size, RenderBox? firstChild) {
|
||||
// A particular layout delegate could be called reentrantly, e.g. if it used
|
||||
// by both a parent and a child. So, we must restore the _idToChild map when
|
||||
// we return.
|
||||
final Map<Object, RenderBox>? previousIdToChild = _idToChild;
|
||||
|
||||
Set<RenderBox>? debugPreviousChildrenNeedingLayout;
|
||||
assert(() {
|
||||
debugPreviousChildrenNeedingLayout = _debugChildrenNeedingLayout;
|
||||
_debugChildrenNeedingLayout = <RenderBox>{};
|
||||
return true;
|
||||
}());
|
||||
|
||||
try {
|
||||
_idToChild = <Object, RenderBox>{};
|
||||
RenderBox? child = firstChild;
|
||||
while (child != null) {
|
||||
final MultiChildLayoutParentData childParentData =
|
||||
child.parentData! as MultiChildLayoutParentData;
|
||||
assert(() {
|
||||
if (childParentData.id == null) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary(
|
||||
'Every child of a RenderCustomMultiChildLayoutBox must have an ID in its parent data.',
|
||||
),
|
||||
child!.describeForError('The following child has no ID'),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
_idToChild![childParentData.id!] = child;
|
||||
assert(() {
|
||||
_debugChildrenNeedingLayout!.add(child!);
|
||||
return true;
|
||||
}());
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
performLayout(size);
|
||||
assert(() {
|
||||
if (_debugChildrenNeedingLayout!.isNotEmpty) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('Each child must be laid out exactly once.'),
|
||||
DiagnosticsBlock(
|
||||
name:
|
||||
'The $this custom multichild layout delegate forgot '
|
||||
'to lay out the following '
|
||||
'${_debugChildrenNeedingLayout!.length > 1 ? 'children' : 'child'}',
|
||||
properties: _debugChildrenNeedingLayout!
|
||||
.map<DiagnosticsNode>(_debugDescribeChild)
|
||||
.toList(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
} finally {
|
||||
_idToChild = previousIdToChild;
|
||||
assert(() {
|
||||
_debugChildrenNeedingLayout = debugPreviousChildrenNeedingLayout;
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
/// Override this method to return the size of this object given the
|
||||
/// incoming constraints.
|
||||
///
|
||||
/// The size cannot reflect the sizes of the children. If this layout has a
|
||||
/// fixed width or height the returned size can reflect that; the size will be
|
||||
/// constrained to the given constraints.
|
||||
///
|
||||
/// By default, attempts to size the box to the biggest size
|
||||
/// possible given the constraints.
|
||||
Size getSize(BoxConstraints constraints) => constraints.biggest;
|
||||
|
||||
/// Override this method to lay out and position all children given this
|
||||
/// widget's size.
|
||||
///
|
||||
/// This method must call [layoutChild] for each child. It should also specify
|
||||
/// the final position of each child with [positionChild].
|
||||
void performLayout(Size size);
|
||||
|
||||
/// Override this method to return true when the children need to be
|
||||
/// laid out.
|
||||
///
|
||||
/// This should compare the fields of the current delegate and the given
|
||||
/// `oldDelegate` and return true if the fields are such that the layout would
|
||||
/// be different.
|
||||
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);
|
||||
|
||||
/// Override this method to include additional information in the
|
||||
/// debugging data printed by [debugDumpRenderTree] and friends.
|
||||
///
|
||||
/// By default, returns the [runtimeType] of the class.
|
||||
@override
|
||||
String toString() => objectRuntimeType(this, 'MultiChildLayoutDelegate');
|
||||
}
|
||||
|
||||
/// Defers the layout of multiple children to a delegate.
|
||||
///
|
||||
/// The delegate can determine the layout constraints for each child and can
|
||||
/// decide where to position each child. The delegate can also determine the
|
||||
/// size of the parent, but the size of the parent cannot depend on the sizes of
|
||||
/// the children.
|
||||
class RenderCustomMultiChildLayoutBox extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
|
||||
/// Creates a render object that customizes the layout of multiple children.
|
||||
RenderCustomMultiChildLayoutBox({
|
||||
List<RenderBox>? children,
|
||||
required MultiChildLayoutDelegate delegate,
|
||||
}) : _delegate = delegate {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! MultiChildLayoutParentData) {
|
||||
child.parentData = MultiChildLayoutParentData();
|
||||
}
|
||||
}
|
||||
|
||||
/// The delegate that controls the layout of the children.
|
||||
MultiChildLayoutDelegate get delegate => _delegate;
|
||||
MultiChildLayoutDelegate _delegate;
|
||||
set delegate(MultiChildLayoutDelegate newDelegate) {
|
||||
if (_delegate == newDelegate) {
|
||||
return;
|
||||
}
|
||||
final MultiChildLayoutDelegate oldDelegate = _delegate;
|
||||
if (newDelegate.runtimeType != oldDelegate.runtimeType ||
|
||||
newDelegate.shouldRelayout(oldDelegate)) {
|
||||
markNeedsLayout();
|
||||
}
|
||||
_delegate = newDelegate;
|
||||
if (attached) {
|
||||
oldDelegate._relayout?.removeListener(markNeedsLayout);
|
||||
newDelegate._relayout?.addListener(markNeedsLayout);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_delegate._relayout?.addListener(markNeedsLayout);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_delegate._relayout?.removeListener(markNeedsLayout);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
Size _getSize(BoxConstraints constraints) {
|
||||
assert(constraints.debugAssertIsValid());
|
||||
return constraints.constrain(_delegate.getSize(constraints));
|
||||
}
|
||||
|
||||
// TODO(ianh): It's a bit dubious to be using the getSize function from the delegate to
|
||||
// figure out the intrinsic dimensions. We really should either not support intrinsics,
|
||||
// or we should expose intrinsic delegate callbacks and throw if they're not implemented.
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) {
|
||||
final double width = _getSize(
|
||||
BoxConstraints.tightForFinite(height: height),
|
||||
).width;
|
||||
if (width.isFinite) {
|
||||
return width;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) {
|
||||
final double width = _getSize(
|
||||
BoxConstraints.tightForFinite(height: height),
|
||||
).width;
|
||||
if (width.isFinite) {
|
||||
return width;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) {
|
||||
final double height = _getSize(
|
||||
BoxConstraints.tightForFinite(width: width),
|
||||
).height;
|
||||
if (height.isFinite) {
|
||||
return height;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) {
|
||||
final double height = _getSize(
|
||||
BoxConstraints.tightForFinite(width: width),
|
||||
).height;
|
||||
if (height.isFinite) {
|
||||
return height;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
@protected
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
return _getSize(constraints);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = _getSize(constraints);
|
||||
delegate._callPerformLayout(size, firstChild);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomSliverPersistentHeaderDelegate
|
||||
extends SliverPersistentHeaderDelegate {
|
||||
CustomSliverPersistentHeaderDelegate({
|
||||
const CustomSliverPersistentHeaderDelegate({
|
||||
required this.child,
|
||||
required this.bgColor,
|
||||
double extent = 45,
|
||||
this.needRebuild,
|
||||
this.needRebuild = false,
|
||||
}) : _minExtent = extent,
|
||||
_maxExtent = extent;
|
||||
final double _minExtent;
|
||||
final double _maxExtent;
|
||||
final Widget child;
|
||||
final Color? bgColor;
|
||||
final bool? needRebuild;
|
||||
final bool needRebuild;
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
@@ -28,12 +30,14 @@ class CustomSliverPersistentHeaderDelegate
|
||||
? DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: bgColor!,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
boxShadow: Platform.isIOS
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: bgColor!,
|
||||
offset: const Offset(0, -1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
)
|
||||
@@ -51,6 +55,6 @@ class CustomSliverPersistentHeaderDelegate
|
||||
@override
|
||||
bool shouldRebuild(CustomSliverPersistentHeaderDelegate oldDelegate) {
|
||||
return oldDelegate.bgColor != bgColor ||
|
||||
(needRebuild == true && oldDelegate.child != child);
|
||||
(needRebuild && oldDelegate.child != child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class CustomToast extends StatelessWidget {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: EdgeInsets.only(
|
||||
bottom: MediaQuery.paddingOf(context).bottom + 30,
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom + 30,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -46,7 +46,7 @@ class LoadingWidget extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dialogBackgroundColor,
|
||||
color: theme.dialogTheme.backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show clampDouble;
|
||||
|
||||
import 'package:PiliPlus/utils/utils.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -148,11 +149,21 @@ class CustomTooltipState extends State<CustomTooltip>
|
||||
@protected
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget result = Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: widget.child,
|
||||
);
|
||||
Widget result;
|
||||
if (Utils.isMobile) {
|
||||
result = Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: widget.child,
|
||||
);
|
||||
} else {
|
||||
result = MouseRegion(
|
||||
cursor: MouseCursor.defer,
|
||||
onEnter: (_) => _scheduleShowTooltip(),
|
||||
onExit: (_) => _scheduleDismissTooltip(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
return OverlayPortal(
|
||||
controller: _overlayController,
|
||||
overlayChildBuilder: _buildCustomTooltipOverlay,
|
||||
@@ -184,30 +195,34 @@ class _CustomTooltipOverlay extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onDismiss,
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _CustomMultiTooltipPositionDelegate(
|
||||
type: type,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontslOffset: horizontslOffset,
|
||||
preferBelow: false,
|
||||
),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: 'overlay',
|
||||
child: overlayWidget(),
|
||||
),
|
||||
if (indicator != null)
|
||||
LayoutId(
|
||||
id: 'indicator',
|
||||
child: indicator!(),
|
||||
),
|
||||
],
|
||||
Widget child = CustomMultiChildLayout(
|
||||
delegate: _CustomMultiTooltipPositionDelegate(
|
||||
type: type,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontslOffset: horizontslOffset,
|
||||
preferBelow: false,
|
||||
),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: 'overlay',
|
||||
child: overlayWidget(),
|
||||
),
|
||||
if (indicator != null)
|
||||
LayoutId(
|
||||
id: 'indicator',
|
||||
child: indicator!(),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (Utils.isMobile) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onDismiss,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
void showConfirmDialog({
|
||||
Future<void> showConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
dynamic content,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
showDialog(
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:PiliPlus/common/widgets/radio_widget.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
void autoWrapReportDialog(
|
||||
Future<void> autoWrapReportDialog(
|
||||
BuildContext context,
|
||||
Map<String, Map<int, String>> options,
|
||||
Future<Map> Function(int reasonType, String? reasonDesc, bool banUid)
|
||||
@@ -14,144 +15,130 @@ void autoWrapReportDialog(
|
||||
String? reasonDesc;
|
||||
bool banUid = false;
|
||||
late final key = GlobalKey<FormState>();
|
||||
showDialog(
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('举报'),
|
||||
titlePadding: const EdgeInsets.only(left: 22, top: 16, right: 22),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 5),
|
||||
actionsPadding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 10,
|
||||
),
|
||||
content: Form(
|
||||
key: key,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('举报'),
|
||||
titlePadding: const EdgeInsets.only(left: 22, top: 16, right: 22),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 5),
|
||||
actionsPadding: const EdgeInsets.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: EdgeInsets.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(),
|
||||
),
|
||||
),
|
||||
if (reasonType == 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 22,
|
||||
top: 5,
|
||||
right: 22,
|
||||
bottom: 5,
|
||||
),
|
||||
child: Text('请选择举报的理由:'),
|
||||
),
|
||||
...options.entries.map(
|
||||
(entry) => WrapRadioOptionsGroup<int>(
|
||||
groupTitle: entry.key,
|
||||
options: entry.value,
|
||||
selectedValue: reasonType,
|
||||
onChanged: (value) =>
|
||||
setState(() => reasonType = value),
|
||||
child: Form(
|
||||
key: key,
|
||||
child: TextFormField(
|
||||
autofocus: true,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
initialValue: reasonDesc,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '为帮助审核人员更快处理,请补充问题类型和出现位置等详细信息',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.all(10),
|
||||
),
|
||||
onChanged: (value) => reasonDesc = value,
|
||||
validator: (value) =>
|
||||
value.isNullOrEmpty ? '理由不能为空' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reasonType == 0)
|
||||
ReasonField(
|
||||
onChanged: (value) => reasonDesc = value,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (reasonType == null ||
|
||||
(reasonType == 0 && key.currentState?.validate() != true)) {
|
||||
return;
|
||||
}
|
||||
SmartDialog.showLoading();
|
||||
try {
|
||||
final data = await onSuccess(reasonType!, reasonDesc, banUid);
|
||||
SmartDialog.dismiss();
|
||||
if (data['code'] == 0) {
|
||||
Get.back();
|
||||
SmartDialog.showToast('举报成功');
|
||||
} else {
|
||||
SmartDialog.showToast(data['message']);
|
||||
}
|
||||
} catch (e) {
|
||||
SmartDialog.dismiss();
|
||||
SmartDialog.showToast('提交失败:$e');
|
||||
}
|
||||
},
|
||||
child: const Text('确定'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 14, top: 6),
|
||||
child: CheckBoxText(
|
||||
text: '拉黑该用户',
|
||||
onChanged: (value) => banUid = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ReasonField extends StatefulWidget {
|
||||
final ValueChanged<String> onChanged;
|
||||
String? _validator(String? value) => value.isNullOrEmpty ? '理由不能为空' : null;
|
||||
|
||||
const ReasonField({super.key, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<ReasonField> createState() => _ReasonFieldState();
|
||||
}
|
||||
|
||||
class _ReasonFieldState extends State<ReasonField> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 5, right: 22),
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
minLines: 4,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '为帮助审核人员更快处理,请补充问题类型和出现位置等详细信息',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.all(10),
|
||||
),
|
||||
onChanged: widget.onChanged,
|
||||
validator: widget._validator,
|
||||
),
|
||||
);
|
||||
}
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (reasonType == null ||
|
||||
(reasonType == 0 && key.currentState?.validate() != true)) {
|
||||
return;
|
||||
}
|
||||
SmartDialog.showLoading();
|
||||
try {
|
||||
final data = await onSuccess(reasonType!, reasonDesc, banUid);
|
||||
SmartDialog.dismiss();
|
||||
if (data['code'] == 0) {
|
||||
Get.back();
|
||||
SmartDialog.showToast('举报成功');
|
||||
} else {
|
||||
SmartDialog.showToast(data['message'].toString());
|
||||
}
|
||||
} catch (e) {
|
||||
SmartDialog.dismiss();
|
||||
SmartDialog.showToast('提交失败:$e');
|
||||
if (kDebugMode) rethrow;
|
||||
}
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class CheckBoxText extends StatefulWidget {
|
||||
@@ -186,8 +173,8 @@ class _CheckBoxTextState extends State<CheckBoxText> {
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selected = !_selected;
|
||||
widget.onChanged(_selected);
|
||||
});
|
||||
widget.onChanged(_selected);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
@@ -246,4 +233,34 @@ class ReportOptions {
|
||||
0: '其他',
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, Map<int, String>> get danmakuReport => const {
|
||||
'': {
|
||||
1: '违法违禁',
|
||||
2: '色情低俗',
|
||||
3: '赌博诈骗',
|
||||
4: '人身攻击',
|
||||
5: '侵犯隐私',
|
||||
6: '垃圾广告',
|
||||
7: '引战',
|
||||
8: '剧透',
|
||||
9: '恶意刷屏',
|
||||
10: '视频无关',
|
||||
12: '青少年不良信息',
|
||||
13: '违法信息外链',
|
||||
0: '其它', // 11
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, Map<int, String>> get liveDanmakuReport => const {
|
||||
'': {
|
||||
1: '违法违规',
|
||||
2: '低俗色情',
|
||||
3: '垃圾广告',
|
||||
4: '辱骂引战',
|
||||
5: '政治敏感',
|
||||
6: '青少年不良信息',
|
||||
7: '其他', // avoid show form
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,126 +1,122 @@
|
||||
import 'package:PiliPlus/common/widgets/radio_widget.dart';
|
||||
import 'package:PiliPlus/http/member.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class MemberReportPanel extends StatefulWidget {
|
||||
const MemberReportPanel({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.mid,
|
||||
});
|
||||
Future<void> showMemberReportDialog(
|
||||
BuildContext context, {
|
||||
required Object? name,
|
||||
required Object mid,
|
||||
}) {
|
||||
final Set<int> reason = {};
|
||||
int? reasonV2;
|
||||
|
||||
final dynamic name;
|
||||
final dynamic mid;
|
||||
|
||||
@override
|
||||
State<MemberReportPanel> createState() => _MemberReportPanelState();
|
||||
}
|
||||
|
||||
class _MemberReportPanelState extends State<MemberReportPanel> {
|
||||
final List<bool> _reasonList = List.generate(3, (_) => false);
|
||||
final Set<int> _reason = {};
|
||||
int? _reasonV2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'举报: ${widget.name}',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('uid: ${widget.mid}'),
|
||||
const SizedBox(height: 10),
|
||||
const Text('举报内容(必选,可多选)'),
|
||||
...List.generate(
|
||||
3,
|
||||
(index) => _checkBoxWidget(
|
||||
_reasonList[index],
|
||||
(value) {
|
||||
setState(() => _reasonList[index] = value);
|
||||
if (value) {
|
||||
_reason.add(index + 1);
|
||||
} else {
|
||||
_reason.remove(index + 1);
|
||||
}
|
||||
},
|
||||
const ['头像违规', '昵称违规', '签名违规'][index],
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
titleTextStyle: theme.textTheme.bodyMedium,
|
||||
title: Column(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
'举报: $name',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const Text('举报理由(单选,非必选)'),
|
||||
...List.generate(
|
||||
5,
|
||||
(index) => RadioWidget<int>(
|
||||
value: index,
|
||||
groupValue: _reasonV2,
|
||||
onChanged: (value) {
|
||||
setState(() => _reasonV2 = value);
|
||||
},
|
||||
title: const ['色情低俗', '不实信息', '违禁', '人身攻击', '赌博诈骗'][index],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
Text('uid: $mid'),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: theme.colorScheme.outline),
|
||||
const Text('举报内容(必选,可多选)'),
|
||||
...List.generate(
|
||||
3,
|
||||
(index) => Builder(
|
||||
builder: (context) => CheckboxListTile(
|
||||
dense: true,
|
||||
value: reason.contains(index + 1),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) {
|
||||
if (value!) {
|
||||
reason.add(index + 1);
|
||||
} else {
|
||||
reason.remove(index + 1);
|
||||
}
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
title: Text(const ['头像违规', '昵称违规', '签名违规'][index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (_reason.isEmpty) {
|
||||
SmartDialog.showToast('至少选择一项作为举报内容');
|
||||
} else {
|
||||
Get.back();
|
||||
var result = await MemberHttp.reportMember(
|
||||
widget.mid,
|
||||
reason: _reason.join(','),
|
||||
reasonV2: _reasonV2 != null ? _reasonV2! + 1 : null,
|
||||
);
|
||||
if (result['msg'] is String && result['msg'].isNotEmpty) {
|
||||
SmartDialog.showToast(result['msg']);
|
||||
} else {
|
||||
SmartDialog.showToast('举报失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('确定'),
|
||||
const Text('举报理由(单选,非必选)'),
|
||||
Builder(
|
||||
builder: (context) => RadioGroup<int>(
|
||||
onChanged: (v) {
|
||||
reasonV2 = v;
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
groupValue: reasonV2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(
|
||||
5,
|
||||
(index) => RadioListTile<int>(
|
||||
toggleable: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: const EdgeInsets.only(left: 4),
|
||||
dense: true,
|
||||
value: index,
|
||||
title: Text(
|
||||
const ['色情低俗', '不实信息', '违禁', '人身攻击', '赌博诈骗'][index],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _checkBoxWidget(
|
||||
bool defValue,
|
||||
ValueChanged onChanged,
|
||||
String title,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: () => onChanged(!defValue),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: defValue,
|
||||
onChanged: onChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
Text(title),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (reason.isEmpty) {
|
||||
SmartDialog.showToast('至少选择一项作为举报内容');
|
||||
} else {
|
||||
Get.back();
|
||||
var result = await MemberHttp.reportMember(
|
||||
mid,
|
||||
reason: reason.join(','),
|
||||
reasonV2: reasonV2 != null ? reasonV2! + 1 : null,
|
||||
);
|
||||
if (result['msg'] is String && result['msg'].isNotEmpty) {
|
||||
SmartDialog.showToast(result['msg']);
|
||||
} else {
|
||||
SmartDialog.showToast('举报失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
789
lib/common/widgets/dyn/button.dart
Normal file
@@ -0,0 +1,789 @@
|
||||
// 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/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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1418
lib/common/widgets/dyn/ink_well.dart
Normal file
676
lib/common/widgets/dyn/text_button.dart
Normal file
@@ -0,0 +1,676 @@
|
||||
// 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/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
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// https://github.com/flutter/flutter/issues/18345#issuecomment-1627644396
|
||||
class DynamicSliverAppBar extends StatefulWidget {
|
||||
const DynamicSliverAppBar({
|
||||
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.callback,
|
||||
});
|
||||
|
||||
final ValueChanged<double>? callback;
|
||||
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<DynamicSliverAppBar> createState() => _DynamicSliverAppBarState();
|
||||
}
|
||||
|
||||
class _DynamicSliverAppBarState extends State<DynamicSliverAppBar> {
|
||||
final GlobalKey _childKey = GlobalKey();
|
||||
|
||||
// As long as the height is 0 instead of the sliver app bar a sliver to box adapter will be used
|
||||
// to calculate dynamically the size for the sliver app bar
|
||||
double _height = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateHeight();
|
||||
}
|
||||
|
||||
void _updateHeight() {
|
||||
// Gets the new height and updates the sliver app bar. Needs to be called after the last frame has been rebuild
|
||||
// otherwise this will throw an error
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
if (_childKey.currentContext == null) return;
|
||||
setState(() {
|
||||
_height = (_childKey.currentContext!.findRenderObject()! as RenderBox)
|
||||
.size
|
||||
.height;
|
||||
widget.callback?.call(_height);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_height = 0;
|
||||
_updateHeight();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//Needed to lay out the flexibleSpace the first time, so we can calculate its intrinsic height
|
||||
if (_height == 0) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
key: _childKey,
|
||||
child: widget.flexibleSpace ?? const SizedBox(height: kToolbarHeight),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
MediaQuery.orientationOf(context);
|
||||
|
||||
return SliverAppBar(
|
||||
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,
|
||||
collapsedHeight: widget.collapsedHeight,
|
||||
floating: widget.floating,
|
||||
pinned: widget.pinned,
|
||||
snap: widget.snap,
|
||||
stretch: widget.stretch,
|
||||
stretchTriggerOffset: widget.stretchTriggerOffset,
|
||||
onStretchTrigger: widget.onStretchTrigger,
|
||||
shape: widget.shape,
|
||||
toolbarHeight: widget.toolbarHeight,
|
||||
expandedHeight: _height,
|
||||
leadingWidth: widget.leadingWidth,
|
||||
toolbarTextStyle: widget.toolbarTextStyle,
|
||||
titleTextStyle: widget.titleTextStyle,
|
||||
systemOverlayStyle: widget.systemOverlayStyle,
|
||||
forceMaterialTransparency: widget.forceMaterialTransparency,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
flexibleSpace: FlexibleSpaceBar(background: widget.flexibleSpace),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,12 +99,6 @@ class _DynamicSliverAppBarMediumState extends State<DynamicSliverAppBarMedium> {
|
||||
// to calculate dynamically the size for the sliver app bar
|
||||
double _height = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateHeight();
|
||||
}
|
||||
|
||||
void _updateHeight() {
|
||||
// Gets the new height and updates the sliver app bar. Needs to be called after the last frame has been rebuild
|
||||
// otherwise this will throw an error
|
||||
@@ -119,27 +113,36 @@ class _DynamicSliverAppBarMediumState extends State<DynamicSliverAppBarMedium> {
|
||||
});
|
||||
}
|
||||
|
||||
Orientation? _orientation;
|
||||
double? _width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final orientation = MediaQuery.orientationOf(context);
|
||||
if (orientation != _orientation) {
|
||||
_orientation = orientation;
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final width = MediaQuery.widthOf(context);
|
||||
if (_width != width) {
|
||||
_width = width;
|
||||
_height = 0;
|
||||
_updateHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//Needed to lay out the flexibleSpace the first time, so we can calculate its intrinsic height
|
||||
if (_height == 0) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
key: _childKey,
|
||||
child: widget.flexibleSpace ?? const SizedBox(height: kToolbarHeight),
|
||||
child: UnconstrainedBox(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
key: _childKey,
|
||||
width: _width,
|
||||
child: widget.flexibleSpace,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final padding = MediaQuery.viewPaddingOf(context).top;
|
||||
return SliverAppBar.medium(
|
||||
leading: widget.leading,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
@@ -159,7 +162,6 @@ class _DynamicSliverAppBarMediumState extends State<DynamicSliverAppBarMedium> {
|
||||
centerTitle: widget.centerTitle,
|
||||
excludeHeaderSemantics: widget.excludeHeaderSemantics,
|
||||
titleSpacing: widget.titleSpacing,
|
||||
collapsedHeight: widget.collapsedHeight,
|
||||
floating: widget.floating,
|
||||
pinned: widget.pinned,
|
||||
snap: widget.snap,
|
||||
@@ -167,8 +169,9 @@ class _DynamicSliverAppBarMediumState extends State<DynamicSliverAppBarMedium> {
|
||||
stretchTriggerOffset: widget.stretchTriggerOffset,
|
||||
onStretchTrigger: widget.onStretchTrigger,
|
||||
shape: widget.shape,
|
||||
toolbarHeight: widget.toolbarHeight,
|
||||
expandedHeight: _height - MediaQuery.paddingOf(context).top,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
collapsedHeight: kToolbarHeight + padding + 1,
|
||||
expandedHeight: _height - padding,
|
||||
leadingWidth: widget.leadingWidth,
|
||||
toolbarTextStyle: widget.toolbarTextStyle,
|
||||
titleTextStyle: widget.titleTextStyle,
|
||||
|
||||
180
lib/common/widgets/gesture/immediate_tap_gesture_recognizer.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
class ImmediateTapGestureRecognizer extends OneSequenceGestureRecognizer {
|
||||
ImmediateTapGestureRecognizer({
|
||||
super.debugOwner,
|
||||
super.supportedDevices,
|
||||
super.allowedButtonsFilter,
|
||||
required this.onTapDown,
|
||||
required this.onTapUp,
|
||||
required this.onTapCancel,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final GestureTapDownCallback onTapDown;
|
||||
|
||||
final GestureTapUpCallback onTapUp;
|
||||
|
||||
final GestureTapCancelCallback onTapCancel;
|
||||
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
PointerUpEvent? _up;
|
||||
int _activePointer = 0;
|
||||
bool _sentTapDown = false;
|
||||
bool _wonArena = false;
|
||||
|
||||
@override
|
||||
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) => false;
|
||||
|
||||
@override
|
||||
bool isPointerAllowed(PointerDownEvent event) =>
|
||||
_activePointer == 0 && super.isPointerAllowed(event);
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
super.addAllowedPointer(event);
|
||||
|
||||
_activePointer = event.pointer;
|
||||
_sentTapDown = false;
|
||||
_wonArena = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (event.pointer != _activePointer) {
|
||||
stopTrackingPointer(event.pointer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
_handleTapDown(event);
|
||||
} else if (event is PointerMoveEvent) {
|
||||
_handlePointerMove(event);
|
||||
} else if (event is PointerUpEvent) {
|
||||
_up = event;
|
||||
_handlePointerUp(event);
|
||||
}
|
||||
|
||||
stopTrackingIfPointerNoLongerDown(event);
|
||||
}
|
||||
|
||||
void _handleTapDown(PointerDownEvent event) {
|
||||
if (_sentTapDown) return;
|
||||
|
||||
_sentTapDown = true;
|
||||
final details = TapDownDetails(
|
||||
globalPosition: event.position,
|
||||
localPosition: event.localPosition,
|
||||
kind: event.kind,
|
||||
);
|
||||
invokeCallback<void>('onTapDown', () => onTapDown(details));
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (event.delta.distanceSquared > 2.0) {
|
||||
_cancelGesture('pointer moved');
|
||||
stopTrackingPointer(event.pointer);
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
if (_wonArena && _sentTapDown) {
|
||||
_handleTapUp(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTapUp(PointerUpEvent event) {
|
||||
if (_sentTapDown) {
|
||||
final details = TapUpDetails(
|
||||
globalPosition: event.position,
|
||||
localPosition: event.localPosition,
|
||||
kind: event.kind,
|
||||
);
|
||||
invokeCallback<void>('onTapUp', () => onTapUp(details));
|
||||
|
||||
if (onTap != null) {
|
||||
invokeCallback<void>('onTap', onTap!);
|
||||
}
|
||||
}
|
||||
|
||||
_reset();
|
||||
}
|
||||
|
||||
void _cancelGesture(String reason) {
|
||||
if (_sentTapDown) {
|
||||
invokeCallback<void>('onTapCancel: $reason', onTapCancel);
|
||||
}
|
||||
_reset();
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_activePointer = 0;
|
||||
_up = null;
|
||||
_sentTapDown = false;
|
||||
_wonArena = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void acceptGesture(int pointer) {
|
||||
super.acceptGesture(pointer);
|
||||
|
||||
if (pointer == _activePointer) {
|
||||
_wonArena = true;
|
||||
|
||||
if (_up != null && _sentTapDown) {
|
||||
_handleTapUp(_up!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
super.rejectGesture(pointer);
|
||||
|
||||
if (pointer == _activePointer) {
|
||||
_cancelGesture('gesture rejected by arena');
|
||||
stopTrackingPointer(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
// wait for arena
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_sentTapDown) {
|
||||
_cancelGesture('disposed');
|
||||
}
|
||||
_reset();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
String get debugDescription => 'immediate tap';
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
..add(IntProperty('activePointer', _activePointer))
|
||||
..add(
|
||||
FlagProperty(
|
||||
'sentTapDown',
|
||||
value: _sentTapDown,
|
||||
ifTrue: 'has sentTapDown',
|
||||
),
|
||||
)
|
||||
..add(FlagProperty('wonArena', value: _wonArena, ifTrue: 'wonArena'))
|
||||
..add(
|
||||
DiagnosticsProperty<PointerUpEvent>(
|
||||
'pointerUpEvent',
|
||||
_up,
|
||||
defaultValue: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
905
lib/common/widgets/gesture/mouse_interactive_viewer.dart
Normal file
@@ -0,0 +1,905 @@
|
||||
// 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 Platform;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:vector_math/vector_math_64.dart' show Quad, Vector3;
|
||||
|
||||
class MouseInteractiveViewer extends StatefulWidget {
|
||||
const MouseInteractiveViewer({
|
||||
super.key,
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
this.panAxis = PanAxis.free,
|
||||
this.boundaryMargin = EdgeInsets.zero,
|
||||
this.constrained = true,
|
||||
this.maxScale = 2.5,
|
||||
this.minScale = 0.8,
|
||||
this.interactionEndFrictionCoefficient = _kDrag,
|
||||
this.pointerSignalFallback,
|
||||
this.onPointerPanZoomUpdate,
|
||||
this.onPointerPanZoomEnd,
|
||||
this.onPointerDown,
|
||||
this.onInteractionEnd,
|
||||
this.onInteractionStart,
|
||||
this.onInteractionUpdate,
|
||||
this.panEnabled = true,
|
||||
this.scaleEnabled = true,
|
||||
this.scaleFactor = kDefaultMouseScrollToScaleFactor,
|
||||
this.transformationController,
|
||||
this.alignment,
|
||||
this.trackpadScrollCausesScale = false,
|
||||
|
||||
required this.childKey,
|
||||
required this.child,
|
||||
}) : assert(minScale > 0),
|
||||
assert(interactionEndFrictionCoefficient > 0),
|
||||
assert(maxScale > 0),
|
||||
assert(maxScale >= minScale);
|
||||
|
||||
final Alignment? alignment;
|
||||
final Clip clipBehavior;
|
||||
final PanAxis panAxis;
|
||||
final EdgeInsets boundaryMargin;
|
||||
final Widget child;
|
||||
final bool constrained;
|
||||
final bool panEnabled;
|
||||
final bool scaleEnabled;
|
||||
final bool trackpadScrollCausesScale;
|
||||
final double scaleFactor;
|
||||
final double maxScale;
|
||||
final double minScale;
|
||||
final double interactionEndFrictionCoefficient;
|
||||
final PointerSignalEventListener? pointerSignalFallback;
|
||||
final PointerPanZoomUpdateEventListener? onPointerPanZoomUpdate;
|
||||
final PointerPanZoomEndEventListener? onPointerPanZoomEnd;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final GestureScaleEndCallback? onInteractionEnd;
|
||||
final GestureScaleStartCallback? onInteractionStart;
|
||||
final GestureScaleUpdateCallback? onInteractionUpdate;
|
||||
final TransformationController? transformationController;
|
||||
final GlobalKey childKey;
|
||||
|
||||
static const double _kDrag = 0.0000135;
|
||||
|
||||
@override
|
||||
State<MouseInteractiveViewer> createState() => _MouseInteractiveViewerState();
|
||||
}
|
||||
|
||||
class _MouseInteractiveViewerState extends State<MouseInteractiveViewer>
|
||||
with TickerProviderStateMixin {
|
||||
late TransformationController _transformer =
|
||||
widget.transformationController ?? TransformationController();
|
||||
|
||||
final GlobalKey _parentKey = GlobalKey();
|
||||
Animation<Offset>? _animation;
|
||||
Animation<double>? _scaleAnimation;
|
||||
late Offset _scaleAnimationFocalPoint;
|
||||
late AnimationController _controller;
|
||||
late AnimationController _scaleController;
|
||||
Axis? _currentAxis;
|
||||
Offset? _referenceFocalPoint;
|
||||
double? _scaleStart;
|
||||
double? _rotationStart = 0.0;
|
||||
double _currentRotation = 0.0;
|
||||
_GestureType? _gestureType;
|
||||
|
||||
static final gestureSettings = DeviceGestureSettings(
|
||||
touchSlop: Platform.isIOS ? 9 : 4,
|
||||
);
|
||||
|
||||
late final _scaleGestureRecognizer =
|
||||
ScaleGestureRecognizer(
|
||||
debugOwner: this,
|
||||
allowedButtonsFilter: (buttons) => buttons == kPrimaryButton,
|
||||
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
|
||||
trackpadScrollCausesScale: widget.trackpadScrollCausesScale,
|
||||
)
|
||||
..gestureSettings = gestureSettings
|
||||
..onStart = _onScaleStart
|
||||
..onUpdate = _onScaleUpdate
|
||||
..onEnd = _onScaleEnd;
|
||||
|
||||
final bool _rotateEnabled = false;
|
||||
|
||||
Rect get _boundaryRect {
|
||||
assert(widget.childKey.currentContext != null);
|
||||
final RenderBox childRenderBox =
|
||||
widget.childKey.currentContext!.findRenderObject()! as RenderBox;
|
||||
final Size childSize = childRenderBox.size;
|
||||
final Rect boundaryRect = widget.boundaryMargin.inflateRect(
|
||||
Offset.zero & childSize,
|
||||
);
|
||||
assert(
|
||||
!boundaryRect.isEmpty,
|
||||
"InteractiveViewer's child must have nonzero dimensions.",
|
||||
);
|
||||
assert(
|
||||
boundaryRect.isFinite ||
|
||||
(boundaryRect.left.isInfinite &&
|
||||
boundaryRect.top.isInfinite &&
|
||||
boundaryRect.right.isInfinite &&
|
||||
boundaryRect.bottom.isInfinite),
|
||||
'boundaryRect must either be infinite in all directions or finite in all directions.',
|
||||
);
|
||||
return boundaryRect;
|
||||
}
|
||||
|
||||
Rect get _viewport {
|
||||
assert(_parentKey.currentContext != null);
|
||||
final RenderBox parentRenderBox =
|
||||
_parentKey.currentContext!.findRenderObject()! as RenderBox;
|
||||
return Offset.zero & parentRenderBox.size;
|
||||
}
|
||||
|
||||
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
||||
if (translation == Offset.zero) {
|
||||
return matrix.clone();
|
||||
}
|
||||
|
||||
final Offset alignedTranslation;
|
||||
|
||||
if (_currentAxis != null) {
|
||||
alignedTranslation = switch (widget.panAxis) {
|
||||
PanAxis.horizontal => _alignAxis(translation, Axis.horizontal),
|
||||
PanAxis.vertical => _alignAxis(translation, Axis.vertical),
|
||||
PanAxis.aligned => _alignAxis(translation, _currentAxis!),
|
||||
PanAxis.free => translation,
|
||||
};
|
||||
} else {
|
||||
alignedTranslation = translation;
|
||||
}
|
||||
|
||||
final Matrix4 nextMatrix = matrix.clone()
|
||||
..translateByDouble(alignedTranslation.dx, alignedTranslation.dy, 0, 1);
|
||||
|
||||
final Quad nextViewport = _transformViewport(nextMatrix, _viewport);
|
||||
|
||||
if (_boundaryRect.isInfinite) {
|
||||
return nextMatrix;
|
||||
}
|
||||
|
||||
final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation(
|
||||
_boundaryRect,
|
||||
_currentRotation,
|
||||
);
|
||||
|
||||
final Offset offendingDistance = _exceedsBy(
|
||||
boundariesAabbQuad,
|
||||
nextViewport,
|
||||
);
|
||||
if (offendingDistance == Offset.zero) {
|
||||
return nextMatrix;
|
||||
}
|
||||
|
||||
final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix);
|
||||
final double currentScale = matrix.getMaxScaleOnAxis();
|
||||
final Offset correctedTotalTranslation = Offset(
|
||||
nextTotalTranslation.dx - offendingDistance.dx * currentScale,
|
||||
nextTotalTranslation.dy - offendingDistance.dy * currentScale,
|
||||
);
|
||||
final Matrix4 correctedMatrix = matrix.clone()
|
||||
..setTranslation(
|
||||
Vector3(
|
||||
correctedTotalTranslation.dx,
|
||||
correctedTotalTranslation.dy,
|
||||
0.0,
|
||||
),
|
||||
);
|
||||
|
||||
final Quad correctedViewport = _transformViewport(
|
||||
correctedMatrix,
|
||||
_viewport,
|
||||
);
|
||||
final Offset offendingCorrectedDistance = _exceedsBy(
|
||||
boundariesAabbQuad,
|
||||
correctedViewport,
|
||||
);
|
||||
if (offendingCorrectedDistance == Offset.zero) {
|
||||
return correctedMatrix;
|
||||
}
|
||||
|
||||
if (offendingCorrectedDistance.dx != 0.0 &&
|
||||
offendingCorrectedDistance.dy != 0.0) {
|
||||
return matrix.clone();
|
||||
}
|
||||
|
||||
final Offset unidirectionalCorrectedTotalTranslation = Offset(
|
||||
offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0,
|
||||
offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0,
|
||||
);
|
||||
return matrix.clone()..setTranslation(
|
||||
Vector3(
|
||||
unidirectionalCorrectedTotalTranslation.dx,
|
||||
unidirectionalCorrectedTotalTranslation.dy,
|
||||
0.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
|
||||
if (scale == 1.0) {
|
||||
return matrix.clone();
|
||||
}
|
||||
assert(scale != 0.0);
|
||||
|
||||
final double currentScale = _transformer.value.getMaxScaleOnAxis();
|
||||
final double totalScale = math.max(
|
||||
currentScale * scale,
|
||||
math.max(
|
||||
_viewport.width / _boundaryRect.width,
|
||||
_viewport.height / _boundaryRect.height,
|
||||
),
|
||||
);
|
||||
final double clampedTotalScale = clampDouble(
|
||||
totalScale,
|
||||
widget.minScale,
|
||||
widget.maxScale,
|
||||
);
|
||||
final double clampedScale = clampedTotalScale / currentScale;
|
||||
return matrix.clone()
|
||||
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
|
||||
}
|
||||
|
||||
Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) {
|
||||
if (rotation == 0) {
|
||||
return matrix.clone();
|
||||
}
|
||||
final Offset focalPointScene = _transformer.toScene(focalPoint);
|
||||
return matrix.clone()
|
||||
..translateByDouble(focalPointScene.dx, focalPointScene.dy, 0, 1)
|
||||
..rotateZ(-rotation)
|
||||
..translateByDouble(-focalPointScene.dx, -focalPointScene.dy, 0, 1);
|
||||
}
|
||||
|
||||
bool _gestureIsSupported(_GestureType? gestureType) {
|
||||
return switch (gestureType) {
|
||||
_GestureType.rotate => _rotateEnabled,
|
||||
_GestureType.scale => widget.scaleEnabled,
|
||||
_GestureType.pan || null => widget.panEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
_GestureType _getGestureType(ScaleUpdateDetails details) {
|
||||
final double scale = !widget.scaleEnabled ? 1.0 : details.scale;
|
||||
final double rotation = !_rotateEnabled ? 0.0 : details.rotation;
|
||||
if ((scale - 1).abs() > rotation.abs()) {
|
||||
return _GestureType.scale;
|
||||
} else if (rotation != 0.0) {
|
||||
return _GestureType.rotate;
|
||||
} else {
|
||||
return _GestureType.pan;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the start of a gesture. All of pan, scale, and rotate are handled
|
||||
// with GestureDetector's scale gesture.
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
widget.onInteractionStart?.call(details);
|
||||
|
||||
if (_controller.isAnimating) {
|
||||
_controller
|
||||
..stop()
|
||||
..reset();
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
}
|
||||
if (_scaleController.isAnimating) {
|
||||
_scaleController
|
||||
..stop()
|
||||
..reset();
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
}
|
||||
|
||||
_gestureType = null;
|
||||
_currentAxis = null;
|
||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
_rotationStart = _currentRotation;
|
||||
}
|
||||
|
||||
// Handle an update to an ongoing gesture. All of pan, scale, and rotate are
|
||||
// handled with GestureDetector's scale gesture.
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
_scaleAnimationFocalPoint = details.localFocalPoint;
|
||||
final Offset focalPointScene = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
|
||||
if (_gestureType == _GestureType.pan) {
|
||||
// When a gesture first starts, it sometimes has no change in scale and
|
||||
// rotation despite being a two-finger gesture. Here the gesture is
|
||||
// allowed to be reinterpreted as its correct type after originally
|
||||
// being marked as a pan.
|
||||
_gestureType = _getGestureType(details);
|
||||
} else {
|
||||
_gestureType ??= _getGestureType(details);
|
||||
}
|
||||
if (!_gestureIsSupported(_gestureType)) {
|
||||
widget.onInteractionUpdate?.call(details);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_gestureType!) {
|
||||
case _GestureType.scale:
|
||||
assert(_scaleStart != null);
|
||||
// details.scale gives us the amount to change the scale as of the
|
||||
// start of this gesture, so calculate the amount to scale as of the
|
||||
// previous call to _onScaleUpdate.
|
||||
final double desiredScale = _scaleStart! * details.scale;
|
||||
final double scaleChange = desiredScale / scale;
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// While scaling, translate such that the user's two fingers stay on
|
||||
// the same places in the scene. That means that the focal point of
|
||||
// the scale should be on the same place in the scene before and after
|
||||
// the scale.
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - _referenceFocalPoint!,
|
||||
);
|
||||
|
||||
// details.localFocalPoint should now be at the same location as the
|
||||
// original _referenceFocalPoint point. If it's not, that's because
|
||||
// the translate came in contact with a boundary. In that case, update
|
||||
// _referenceFocalPoint so subsequent updates happen in relation to
|
||||
// the new effective focal point.
|
||||
final Offset focalPointSceneCheck = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) {
|
||||
_referenceFocalPoint = focalPointSceneCheck;
|
||||
}
|
||||
|
||||
case _GestureType.rotate:
|
||||
if (details.rotation == 0.0) {
|
||||
widget.onInteractionUpdate?.call(details);
|
||||
return;
|
||||
}
|
||||
final double desiredRotation = _rotationStart! + details.rotation;
|
||||
_transformer.value = _matrixRotate(
|
||||
_transformer.value,
|
||||
_currentRotation - desiredRotation,
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_currentRotation = desiredRotation;
|
||||
|
||||
case _GestureType.pan:
|
||||
assert(_referenceFocalPoint != null);
|
||||
// details may have a change in scale here when scaleEnabled is false.
|
||||
// In an effort to keep the behavior similar whether or not scaleEnabled
|
||||
// is true, these gestures are thrown away.
|
||||
if (details.scale != 1.0) {
|
||||
widget.onInteractionUpdate?.call(details);
|
||||
return;
|
||||
}
|
||||
_currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene);
|
||||
// Translate so that the same point in the scene is underneath the
|
||||
// focal point before and after the movement.
|
||||
final Offset translationChange =
|
||||
focalPointScene - _referenceFocalPoint!;
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
translationChange,
|
||||
);
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
}
|
||||
widget.onInteractionUpdate?.call(details);
|
||||
}
|
||||
|
||||
// Handle the end of a gesture of _GestureType. All of pan, scale, and rotate
|
||||
// are handled with GestureDetector's scale gesture.
|
||||
void _onScaleEnd(ScaleEndDetails details) {
|
||||
widget.onInteractionEnd?.call(details);
|
||||
_scaleStart = null;
|
||||
_rotationStart = null;
|
||||
_referenceFocalPoint = null;
|
||||
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_controller.reset();
|
||||
_scaleController.reset();
|
||||
|
||||
if (!_gestureIsSupported(_gestureType)) {
|
||||
_currentAxis = null;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_gestureType) {
|
||||
case _GestureType.pan:
|
||||
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
|
||||
_currentAxis = null;
|
||||
return;
|
||||
}
|
||||
final Vector3 translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation = Offset(
|
||||
translationVector.x,
|
||||
translationVector.y,
|
||||
);
|
||||
final FrictionSimulation frictionSimulationX = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
translation.dx,
|
||||
details.velocity.pixelsPerSecond.dx,
|
||||
);
|
||||
final FrictionSimulation frictionSimulationY = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
translation.dy,
|
||||
details.velocity.pixelsPerSecond.dy,
|
||||
);
|
||||
final double tFinal = _getFinalTime(
|
||||
details.velocity.pixelsPerSecond.distance,
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
);
|
||||
_animation =
|
||||
Tween<Offset>(
|
||||
begin: translation,
|
||||
end: Offset(
|
||||
frictionSimulationX.finalX,
|
||||
frictionSimulationY.finalX,
|
||||
),
|
||||
).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.decelerate),
|
||||
)
|
||||
..addListener(_handleInertiaAnimation);
|
||||
_controller
|
||||
..duration = Duration(milliseconds: (tFinal * 1000).round())
|
||||
..forward();
|
||||
case _GestureType.scale:
|
||||
if (details.scaleVelocity.abs() < 0.1) {
|
||||
_currentAxis = null;
|
||||
return;
|
||||
}
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
||||
scale,
|
||||
details.scaleVelocity / 10,
|
||||
);
|
||||
final double tFinal = _getFinalTime(
|
||||
details.scaleVelocity.abs(),
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
effectivelyMotionless: 0.1,
|
||||
);
|
||||
_scaleAnimation =
|
||||
Tween<double>(
|
||||
begin: scale,
|
||||
end: frictionSimulation.x(tFinal),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _scaleController,
|
||||
curve: Curves.decelerate,
|
||||
),
|
||||
)
|
||||
..addListener(_handleScaleAnimation);
|
||||
_scaleController
|
||||
..duration = Duration(milliseconds: (tFinal * 1000).round())
|
||||
..forward();
|
||||
case _GestureType.rotate || null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _receivedPointerSignal(PointerSignalEvent event) {
|
||||
final Offset local = event.localPosition;
|
||||
final Offset global = event.position;
|
||||
final double scaleChange;
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) {
|
||||
widget.onInteractionStart?.call(
|
||||
ScaleStartDetails(focalPoint: global, localFocalPoint: local),
|
||||
);
|
||||
|
||||
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
|
||||
untransformedEndPosition: global + event.scrollDelta,
|
||||
untransformedDelta: event.scrollDelta,
|
||||
transform: event.transform,
|
||||
);
|
||||
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
final Offset newFocalPointScene = _transformer.toScene(
|
||||
local - localDelta,
|
||||
);
|
||||
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
newFocalPointScene - focalPointScene,
|
||||
);
|
||||
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: global - event.scrollDelta,
|
||||
localFocalPoint: local - localDelta,
|
||||
focalPointDelta: -localDelta,
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
return;
|
||||
}
|
||||
_handlePointerScrollEvent(event);
|
||||
return;
|
||||
} else if (event is PointerScaleEvent) {
|
||||
scaleChange = event.scale;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
widget.onInteractionStart?.call(
|
||||
ScaleStartDetails(focalPoint: global, localFocalPoint: local),
|
||||
);
|
||||
|
||||
if (!_gestureIsSupported(_GestureType.scale)) {
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: scaleChange,
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// After scaling, translate such that the event's position is at the
|
||||
// same scene point before and after the scale.
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(local);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - focalPointScene,
|
||||
);
|
||||
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: scaleChange,
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
}
|
||||
|
||||
void _handlePointerScrollEvent(PointerScrollEvent event) {
|
||||
final Offset local = event.localPosition;
|
||||
final Offset global = event.position;
|
||||
|
||||
if (_gestureIsSupported(_GestureType.scale)) {
|
||||
late final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (HardwareKeyboard.instance.isControlPressed) {
|
||||
_handleMouseWheelScale(event, local, global);
|
||||
return;
|
||||
} else if (shift || HardwareKeyboard.instance.isAltPressed) {
|
||||
_handleMouseWheelPanAsScale(event, local, global, shift);
|
||||
return;
|
||||
} else {
|
||||
widget.pointerSignalFallback?.call(event);
|
||||
}
|
||||
}
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: math.exp(-event.scrollDelta.dy / widget.scaleFactor),
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
}
|
||||
|
||||
void _handleMouseWheelScale(
|
||||
PointerScrollEvent event,
|
||||
Offset local,
|
||||
Offset global,
|
||||
) {
|
||||
final double scaleChange = math.exp(
|
||||
-event.scrollDelta.dy / widget.scaleFactor,
|
||||
);
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(local);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - focalPointScene,
|
||||
);
|
||||
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: scaleChange,
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
}
|
||||
|
||||
void _handleMouseWheelPanAsScale(
|
||||
PointerScrollEvent event,
|
||||
Offset local,
|
||||
Offset global,
|
||||
bool flip,
|
||||
) {
|
||||
final Offset translation = flip
|
||||
? event.scrollDelta.flip
|
||||
: event.scrollDelta;
|
||||
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
final Offset newFocalPointScene = _transformer.toScene(local - translation);
|
||||
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
newFocalPointScene - focalPointScene,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleInertiaAnimation() {
|
||||
if (!_controller.isAnimating) {
|
||||
_currentAxis = null;
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
_controller.reset();
|
||||
return;
|
||||
}
|
||||
final Vector3 translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation = Offset(translationVector.x, translationVector.y);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
_transformer.toScene(_animation!.value) -
|
||||
_transformer.toScene(translation),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScaleAnimation() {
|
||||
if (!_scaleController.isAnimating) {
|
||||
_currentAxis = null;
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
_scaleController.reset();
|
||||
return;
|
||||
}
|
||||
final double desiredScale = _scaleAnimation!.value;
|
||||
final double scaleChange =
|
||||
desiredScale / _transformer.value.getMaxScaleOnAxis();
|
||||
final Offset referenceFocalPoint = _transformer.toScene(
|
||||
_scaleAnimationFocalPoint,
|
||||
);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(
|
||||
_scaleAnimationFocalPoint,
|
||||
);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - referenceFocalPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTransformation() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPointerDown(PointerDownEvent event) {
|
||||
widget.onPointerDown?.call(event);
|
||||
_scaleGestureRecognizer.addPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this);
|
||||
_scaleController = AnimationController(vsync: this);
|
||||
|
||||
_transformer.addListener(_handleTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MouseInteractiveViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
final TransformationController? newController =
|
||||
widget.transformationController;
|
||||
if (newController == oldWidget.transformationController) {
|
||||
return;
|
||||
}
|
||||
_transformer.removeListener(_handleTransformation);
|
||||
if (oldWidget.transformationController == null) {
|
||||
_transformer.dispose();
|
||||
}
|
||||
_transformer = newController ?? TransformationController();
|
||||
_transformer.addListener(_handleTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleGestureRecognizer.dispose();
|
||||
_controller.dispose();
|
||||
_scaleController.dispose();
|
||||
_transformer.removeListener(_handleTransformation);
|
||||
if (widget.transformationController == null) {
|
||||
_transformer.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(widget.child.key == widget.childKey);
|
||||
|
||||
return Listener(
|
||||
key: _parentKey,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerSignal: _receivedPointerSignal,
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerPanZoomStart: _scaleGestureRecognizer.addPointerPanZoom,
|
||||
onPointerPanZoomUpdate: widget.onPointerPanZoomUpdate,
|
||||
onPointerPanZoomEnd: widget.onPointerPanZoomEnd,
|
||||
child: _InteractiveViewerBuilt(
|
||||
childKey: widget.childKey,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
constrained: widget.constrained,
|
||||
matrix: _transformer.value,
|
||||
alignment: widget.alignment,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InteractiveViewerBuilt extends StatelessWidget {
|
||||
const _InteractiveViewerBuilt({
|
||||
required this.child,
|
||||
required this.childKey,
|
||||
required this.clipBehavior,
|
||||
required this.constrained,
|
||||
required this.matrix,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final GlobalKey childKey;
|
||||
final Clip clipBehavior;
|
||||
final bool constrained;
|
||||
final Matrix4 matrix;
|
||||
final Alignment? alignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Transform(
|
||||
transform: matrix,
|
||||
alignment: alignment,
|
||||
child: this.child,
|
||||
);
|
||||
|
||||
if (!constrained) {
|
||||
child = OverflowBox(
|
||||
alignment: Alignment.topLeft,
|
||||
minWidth: 0.0,
|
||||
minHeight: 0.0,
|
||||
maxWidth: double.infinity,
|
||||
maxHeight: double.infinity,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
if (clipBehavior != Clip.none) {
|
||||
child = ClipRect(clipBehavior: clipBehavior, child: child);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
enum _GestureType { pan, scale, rotate }
|
||||
|
||||
double _getFinalTime(
|
||||
double velocity,
|
||||
double drag, {
|
||||
double effectivelyMotionless = 10,
|
||||
}) {
|
||||
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
|
||||
}
|
||||
|
||||
Offset _getMatrixTranslation(Matrix4 matrix) {
|
||||
final Vector3 nextTranslation = matrix.getTranslation();
|
||||
return Offset(nextTranslation.x, nextTranslation.y);
|
||||
}
|
||||
|
||||
Quad _transformViewport(Matrix4 matrix, Rect viewport) {
|
||||
final Matrix4 inverseMatrix = matrix.clone()..invert();
|
||||
return Quad.points(
|
||||
inverseMatrix.transform3(
|
||||
Vector3(viewport.topLeft.dx, viewport.topLeft.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(viewport.topRight.dx, viewport.topRight.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(viewport.bottomRight.dx, viewport.bottomRight.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(viewport.bottomLeft.dx, viewport.bottomLeft.dy, 0.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) {
|
||||
final Matrix4 rotationMatrix = Matrix4.identity()
|
||||
..translateByDouble(rect.size.width / 2, rect.size.height / 2, 0, 1)
|
||||
..rotateZ(rotation)
|
||||
..translateByDouble(-rect.size.width / 2, -rect.size.height / 2, 0, 1);
|
||||
final Quad boundariesRotated = Quad.points(
|
||||
rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)),
|
||||
rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)),
|
||||
rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)),
|
||||
rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)),
|
||||
);
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated);
|
||||
}
|
||||
|
||||
Offset _exceedsBy(Quad boundary, Quad viewport) {
|
||||
final List<Vector3> viewportPoints = <Vector3>[
|
||||
viewport.point0,
|
||||
viewport.point1,
|
||||
viewport.point2,
|
||||
viewport.point3,
|
||||
];
|
||||
Offset largestExcess = Offset.zero;
|
||||
for (final Vector3 point in viewportPoints) {
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
final Vector3 pointInside = InteractiveViewer.getNearestPointInside(
|
||||
point,
|
||||
boundary,
|
||||
);
|
||||
final Offset excess = Offset(
|
||||
pointInside.x - point.x,
|
||||
pointInside.y - point.y,
|
||||
);
|
||||
if (excess.dx.abs() > largestExcess.dx.abs()) {
|
||||
largestExcess = Offset(excess.dx, largestExcess.dy);
|
||||
}
|
||||
if (excess.dy.abs() > largestExcess.dy.abs()) {
|
||||
largestExcess = Offset(largestExcess.dx, excess.dy);
|
||||
}
|
||||
}
|
||||
|
||||
return _round(largestExcess);
|
||||
}
|
||||
|
||||
Offset _round(Offset offset) {
|
||||
return Offset(
|
||||
double.parse(offset.dx.toStringAsFixed(9)),
|
||||
double.parse(offset.dy.toStringAsFixed(9)),
|
||||
);
|
||||
}
|
||||
|
||||
Offset _alignAxis(Offset offset, Axis axis) {
|
||||
return switch (axis) {
|
||||
Axis.horizontal => Offset(offset.dx, 0.0),
|
||||
Axis.vertical => Offset(0.0, offset.dy),
|
||||
};
|
||||
}
|
||||
|
||||
Axis? _getPanAxis(Offset point1, Offset point2) {
|
||||
if (point1 == point2) {
|
||||
return null;
|
||||
}
|
||||
final double x = point2.dx - point1.dx;
|
||||
final double y = point2.dy - point1.dy;
|
||||
return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical;
|
||||
}
|
||||
|
||||
extension on Offset {
|
||||
Offset get flip => Offset(dy, dx);
|
||||
}
|
||||
223
lib/common/widgets/image/cached_network_svg_image.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
// code from cached_network_svg_image;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class CachedNetworkSVGImage extends StatefulWidget {
|
||||
CachedNetworkSVGImage(
|
||||
String url, {
|
||||
Key? key,
|
||||
String? cacheKey,
|
||||
Widget? placeholder,
|
||||
Widget? errorWidget,
|
||||
double? width,
|
||||
double? height,
|
||||
Map<String, String>? headers,
|
||||
BoxFit fit = BoxFit.contain,
|
||||
AlignmentGeometry alignment = Alignment.center,
|
||||
bool matchTextDirection = false,
|
||||
bool allowDrawingOutsideViewBox = false,
|
||||
String? semanticsLabel,
|
||||
bool excludeFromSemantics = false,
|
||||
SvgTheme theme = const SvgTheme(),
|
||||
ColorFilter? colorFilter,
|
||||
WidgetBuilder? placeholderBuilder,
|
||||
BaseCacheManager? cacheManager,
|
||||
}) : _url = url,
|
||||
_cacheKey = cacheKey,
|
||||
_placeholder = placeholder,
|
||||
_errorWidget = errorWidget,
|
||||
_width = width,
|
||||
_height = height,
|
||||
_headers = headers,
|
||||
_fit = fit,
|
||||
_alignment = alignment,
|
||||
_matchTextDirection = matchTextDirection,
|
||||
_allowDrawingOutsideViewBox = allowDrawingOutsideViewBox,
|
||||
_semanticsLabel = semanticsLabel,
|
||||
_excludeFromSemantics = excludeFromSemantics,
|
||||
_theme = theme,
|
||||
_colorFilter = colorFilter,
|
||||
_placeholderBuilder = placeholderBuilder,
|
||||
_cacheManager = cacheManager ?? DefaultCacheManager(),
|
||||
super(key: key ?? ValueKey(cacheKey ?? url));
|
||||
|
||||
final String _url;
|
||||
final String? _cacheKey;
|
||||
final Widget? _placeholder;
|
||||
final Widget? _errorWidget;
|
||||
final double? _width;
|
||||
final double? _height;
|
||||
final Map<String, String>? _headers;
|
||||
final BoxFit _fit;
|
||||
final AlignmentGeometry _alignment;
|
||||
final bool _matchTextDirection;
|
||||
final bool _allowDrawingOutsideViewBox;
|
||||
final String? _semanticsLabel;
|
||||
final bool _excludeFromSemantics;
|
||||
final SvgTheme _theme;
|
||||
final ColorFilter? _colorFilter;
|
||||
final WidgetBuilder? _placeholderBuilder;
|
||||
final BaseCacheManager _cacheManager;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
class _CachedNetworkSVGImageState extends State<CachedNetworkSVGImage> {
|
||||
bool _isLoading = false;
|
||||
bool _isError = false;
|
||||
String? _svgString;
|
||||
late final String _cacheKey;
|
||||
double? height;
|
||||
late TextScaler textScaler;
|
||||
|
||||
static final _sizeRegExp = RegExp(
|
||||
r'height="([\d\.]+)([c-x]{2})?"',
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cacheKey =
|
||||
widget._cacheKey ??
|
||||
CachedNetworkSVGImage._generateKeyFromUrl(widget._url);
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
textScaler = MediaQuery.textScalerOf(context);
|
||||
}
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
try {
|
||||
var file = (await widget._cacheManager.getFileFromCache(_cacheKey))?.file;
|
||||
|
||||
file ??= await widget._cacheManager.getSingleFile(
|
||||
widget._url,
|
||||
key: _cacheKey,
|
||||
headers: widget._headers ?? {},
|
||||
);
|
||||
final svg = await file.readAsString();
|
||||
_svgString = svg;
|
||||
if (widget._width == null && widget._height == null) {
|
||||
final match = _sizeRegExp.firstMatch(svg);
|
||||
if (match != null) {
|
||||
double h = double.parse(match.group(1)!);
|
||||
final suffix = match.group(2);
|
||||
if (suffix != null) {
|
||||
h *= switch (suffix) {
|
||||
'em' => textScaler.scale(widget._theme.fontSize),
|
||||
'ex' => textScaler.scale(widget._theme.xHeight),
|
||||
'pt' => 1.25,
|
||||
'pc' => 15.0,
|
||||
'mm' => 3.543307,
|
||||
'cm' => 35.43307,
|
||||
'in' => 90.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
height = h;
|
||||
}
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
|
||||
_setState();
|
||||
} catch (e) {
|
||||
log('CachedNetworkSVGImage: $e');
|
||||
|
||||
_isError = true;
|
||||
_isLoading = false;
|
||||
|
||||
_setState();
|
||||
}
|
||||
}
|
||||
|
||||
void _setState() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
} else {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget._width,
|
||||
height: widget._height,
|
||||
child: _buildImage(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildImage() {
|
||||
if (_isLoading) return _buildPlaceholderWidget();
|
||||
|
||||
if (_isError) return _buildErrorWidget();
|
||||
|
||||
return _buildSVGImage();
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderWidget() => Center(child: widget._placeholder);
|
||||
|
||||
Widget _buildErrorWidget() => Center(child: widget._errorWidget);
|
||||
|
||||
Widget? _buildSVGImage() {
|
||||
if (_svgString == null) {
|
||||
return Center(child: widget._placeholderBuilder?.call(context));
|
||||
}
|
||||
|
||||
return SvgPicture.string(
|
||||
_svgString!,
|
||||
fit: widget._fit,
|
||||
width: widget._width,
|
||||
height: widget._height ?? height,
|
||||
alignment: widget._alignment,
|
||||
matchTextDirection: widget._matchTextDirection,
|
||||
allowDrawingOutsideViewBox: widget._allowDrawingOutsideViewBox,
|
||||
semanticsLabel: widget._semanticsLabel,
|
||||
excludeFromSemantics: widget._excludeFromSemantics,
|
||||
colorFilter: widget._colorFilter,
|
||||
placeholderBuilder: widget._placeholderBuilder,
|
||||
theme: widget._theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
291
lib/common/widgets/image/custom_grid_view.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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' show min;
|
||||
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/common/widgets/badge.dart';
|
||||
import 'package:PiliPlus/common/widgets/custom_layout.dart';
|
||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/common/widgets/marquee.dart' show ContextSingleTicker;
|
||||
import 'package:PiliPlus/models/common/badge_type.dart';
|
||||
import 'package:PiliPlus/models/common/image_preview_type.dart';
|
||||
import 'package:PiliPlus/utils/context_ext.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:PiliPlus/utils/page_utils.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:flutter/material.dart'
|
||||
hide CustomMultiChildLayout, MultiChildLayoutDelegate;
|
||||
|
||||
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) > _maxRatio;
|
||||
bool get isLivePhoto =>
|
||||
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
|
||||
|
||||
static bool enableLivePhoto = Pref.enableLivePhoto;
|
||||
}
|
||||
|
||||
const double _maxRatio = 22 / 9;
|
||||
|
||||
class CustomGridView extends StatelessWidget {
|
||||
const CustomGridView({
|
||||
super.key,
|
||||
this.space = 5,
|
||||
required this.maxWidth,
|
||||
required this.picArr,
|
||||
this.onViewImage,
|
||||
this.onDismissed,
|
||||
this.fullScreen = false,
|
||||
});
|
||||
|
||||
final double maxWidth;
|
||||
final double space;
|
||||
final List<ImageModel> picArr;
|
||||
final VoidCallback? onViewImage;
|
||||
final ValueChanged<int>? onDismissed;
|
||||
final bool fullScreen;
|
||||
|
||||
static bool horizontalPreview = Pref.horizontalPreview;
|
||||
|
||||
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 &&
|
||||
!context.mediaQuerySize.isPortrait) {
|
||||
final scaffoldState = Scaffold.maybeOf(context);
|
||||
if (scaffoldState != null) {
|
||||
PageUtils.onHorizontalPreviewState(
|
||||
scaffoldState,
|
||||
ContextSingleTicker(scaffoldState.context),
|
||||
imgList,
|
||||
index,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onViewImage?.call();
|
||||
PageUtils.imageView(
|
||||
initialPage: index,
|
||||
imgList: imgList,
|
||||
onDismissed: onDismissed,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@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, _maxRatio);
|
||||
}
|
||||
}
|
||||
|
||||
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: Center(
|
||||
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: CustomMultiChildLayout(
|
||||
delegate: _CustomGridViewDelegate(
|
||||
space: space,
|
||||
itemCount: length,
|
||||
column: column,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
),
|
||||
children: List.generate(length, (index) {
|
||||
final item = picArr[index];
|
||||
final radius = borderRadius(column, length, index);
|
||||
return LayoutId(
|
||||
id: index,
|
||||
child: Hero(
|
||||
tag: item.url,
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(context, index),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: NetworkImgLayer(
|
||||
radius: 0,
|
||||
src: item.url,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
isLongPic: item.isLongPic,
|
||||
forceUseCacheWidth: 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 _CustomGridViewDelegate extends MultiChildLayoutDelegate {
|
||||
_CustomGridViewDelegate({
|
||||
required this.space,
|
||||
required this.itemCount,
|
||||
required this.column,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final double space;
|
||||
final int itemCount;
|
||||
final int column;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
final constraints = BoxConstraints.expand(width: width, height: height);
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
layoutChild(i, constraints);
|
||||
positionChild(
|
||||
i,
|
||||
Offset(
|
||||
(space + width) * (i % column),
|
||||
(space + height) * (i ~/ column),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CustomGridViewDelegate oldDelegate) {
|
||||
return space != oldDelegate.space ||
|
||||
itemCount != oldDelegate.itemCount ||
|
||||
column != oldDelegate.column ||
|
||||
width != oldDelegate.width ||
|
||||
height != oldDelegate.height;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/common/widgets/button/icon_button.dart';
|
||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/http/user.dart';
|
||||
import 'package:PiliPlus/utils/image_util.dart';
|
||||
import 'package:PiliPlus/utils/image_utils.dart';
|
||||
import 'package:PiliPlus/utils/utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -18,20 +19,16 @@ void imageSaveDialog({
|
||||
animationType: SmartAnimationType.centerScale_otherSlide,
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
late final iconColor = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
Widget iconBtn({
|
||||
String? tooltip,
|
||||
required IconData icon,
|
||||
required Icon icon,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return iconButton(
|
||||
context: context,
|
||||
onPressed: onPressed,
|
||||
iconSize: 20,
|
||||
icon: icon,
|
||||
bgColor: Colors.transparent,
|
||||
iconColor: iconColor,
|
||||
iconSize: 20,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,12 +64,12 @@ void imageSaveDialog({
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
child: const IconButton(
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(EdgeInsets.zero),
|
||||
padding: WidgetStatePropertyAll(EdgeInsets.zero),
|
||||
),
|
||||
onPressed: SmartDialog.dismiss,
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
@@ -104,21 +101,22 @@ void imageSaveDialog({
|
||||
(res) => SmartDialog.showToast(res['msg']),
|
||||
),
|
||||
},
|
||||
icon: Icons.watch_later_outlined,
|
||||
icon: const Icon(Icons.watch_later_outlined),
|
||||
),
|
||||
if (cover?.isNotEmpty == true) ...[
|
||||
iconBtn(
|
||||
tooltip: '分享',
|
||||
onPressed: () {
|
||||
SmartDialog.dismiss();
|
||||
ImageUtil.onShareImg(cover!);
|
||||
},
|
||||
icon: Icons.share,
|
||||
),
|
||||
if (Utils.isMobile)
|
||||
iconBtn(
|
||||
tooltip: '分享',
|
||||
onPressed: () {
|
||||
SmartDialog.dismiss();
|
||||
ImageUtils.onShareImg(cover!);
|
||||
},
|
||||
icon: const Icon(Icons.share),
|
||||
),
|
||||
iconBtn(
|
||||
tooltip: '保存封面图',
|
||||
onPressed: () async {
|
||||
bool saveStatus = await ImageUtil.downloadImg(
|
||||
bool saveStatus = await ImageUtils.downloadImg(
|
||||
context,
|
||||
[cover!],
|
||||
);
|
||||
@@ -126,7 +124,7 @@ void imageSaveDialog({
|
||||
SmartDialog.dismiss();
|
||||
}
|
||||
},
|
||||
icon: Icons.download,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
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/nine_grid_view.dart';
|
||||
import 'package:PiliPlus/models/common/badge_type.dart';
|
||||
import 'package:PiliPlus/models/common/image_preview_type.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:PiliPlus/utils/page_utils.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:flutter/material.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) > _maxRatio;
|
||||
bool get isLivePhoto =>
|
||||
_isLivePhoto ??= enableLivePhoto && liveUrl?.isNotEmpty == true;
|
||||
|
||||
static bool enableLivePhoto = Pref.enableLivePhoto;
|
||||
}
|
||||
|
||||
const double _maxRatio = 22 / 9;
|
||||
|
||||
Widget imageView(
|
||||
double maxWidth,
|
||||
List<ImageModel> picArr, {
|
||||
VoidCallback? onViewImage,
|
||||
ValueChanged<int>? onDismissed,
|
||||
Function(List<String>, int)? callback,
|
||||
}) {
|
||||
double imageWidth = (maxWidth - 2 * 5) / 3;
|
||||
double imageHeight = imageWidth;
|
||||
if (picArr.length == 1) {
|
||||
dynamic width = picArr[0].width;
|
||||
dynamic height = picArr[0].height;
|
||||
double ratioWH = width / height;
|
||||
double ratioHW = height / width;
|
||||
imageWidth = ratioWH > 1.5
|
||||
? maxWidth
|
||||
: (ratioWH >= 1 || (height > width && ratioHW < 1.5))
|
||||
? 2 * imageWidth
|
||||
: 1.5 * imageWidth;
|
||||
imageHeight = imageWidth * min(ratioHW, _maxRatio);
|
||||
} else if (picArr.length == 2) {
|
||||
imageWidth = imageHeight = 2 * imageWidth;
|
||||
}
|
||||
late final int row = picArr.length == 4 ? 2 : 3;
|
||||
BorderRadius borderRadius(index) {
|
||||
if (picArr.length == 1) {
|
||||
return StyleString.mdRadius;
|
||||
}
|
||||
return BorderRadius.only(
|
||||
topLeft:
|
||||
index - row >= 0 ||
|
||||
((index - 1) >= 0 && (index - 1) % row < index % row)
|
||||
? Radius.zero
|
||||
: StyleString.imgRadius,
|
||||
topRight:
|
||||
index - row >= 0 ||
|
||||
((index + 1) < picArr.length && (index + 1) % row > index % row)
|
||||
? Radius.zero
|
||||
: StyleString.imgRadius,
|
||||
bottomLeft:
|
||||
index + row < picArr.length ||
|
||||
((index - 1) >= 0 && (index - 1) % row < index % row)
|
||||
? Radius.zero
|
||||
: StyleString.imgRadius,
|
||||
bottomRight:
|
||||
index + row < picArr.length ||
|
||||
((index + 1) < picArr.length && (index + 1) % row > index % row)
|
||||
? Radius.zero
|
||||
: StyleString.imgRadius,
|
||||
);
|
||||
}
|
||||
|
||||
int parseSize(size) {
|
||||
return switch (size) {
|
||||
int() => size,
|
||||
double() => size.round(),
|
||||
String() => int.tryParse(size) ?? 1,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
void onTap(int index) {
|
||||
if (callback != null) {
|
||||
callback(picArr.map((item) => item.url).toList(), index);
|
||||
} else {
|
||||
onViewImage?.call();
|
||||
PageUtils.imageView(
|
||||
initialPage: index,
|
||||
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 ? parseSize(item.width) : null,
|
||||
height: isLive ? parseSize(item.height) : null,
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
onDismissed: onDismissed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NineGridView(
|
||||
type: NineGridType.weiBo,
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
bigImageWidth: imageWidth,
|
||||
bigImageHeight: imageHeight,
|
||||
space: 5,
|
||||
height: picArr.length == 1 ? imageHeight : null,
|
||||
width: picArr.length == 1 ? imageWidth : maxWidth,
|
||||
itemCount: picArr.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = picArr[index];
|
||||
return Hero(
|
||||
tag: item.url,
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(index),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: borderRadius(index),
|
||||
child: NetworkImgLayer(
|
||||
radius: 0,
|
||||
src: item.url,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
isLongPic: () => item.isLongPic,
|
||||
callback: () => item.width <= item.height,
|
||||
getPlaceHolder: () {
|
||||
return Container(
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface.withValues(alpha: 0.4),
|
||||
borderRadius: borderRadius(index),
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/images/loading.png',
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
cacheWidth: imageWidth.cacheSize(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/models/common/image_type.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:PiliPlus/utils/image_util.dart';
|
||||
import 'package:PiliPlus/utils/image_utils.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -19,8 +20,8 @@ class NetworkImgLayer extends StatelessWidget {
|
||||
this.semanticsLabel,
|
||||
this.radius,
|
||||
this.imageBuilder,
|
||||
this.isLongPic,
|
||||
this.callback,
|
||||
this.isLongPic = false,
|
||||
this.forceUseCacheWidth = false,
|
||||
this.getPlaceHolder,
|
||||
this.boxFit,
|
||||
});
|
||||
@@ -35,66 +36,84 @@ class NetworkImgLayer extends StatelessWidget {
|
||||
final String? semanticsLabel;
|
||||
final double? radius;
|
||||
final ImageWidgetBuilder? imageBuilder;
|
||||
final Function? isLongPic;
|
||||
final Function? callback;
|
||||
final Function? getPlaceHolder;
|
||||
final bool isLongPic;
|
||||
final bool forceUseCacheWidth;
|
||||
final Widget Function()? getPlaceHolder;
|
||||
final BoxFit? boxFit;
|
||||
|
||||
static Color? reduceLuxColor = Pref.reduceLuxColor;
|
||||
static bool reduce = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return src?.isNotEmpty == true
|
||||
? type == ImageType.avatar
|
||||
? ClipOval(child: _buildImage(context))
|
||||
: radius == 0 || type == ImageType.emote
|
||||
? _buildImage(context)
|
||||
: ClipRRect(
|
||||
borderRadius: radius != null
|
||||
? BorderRadius.circular(radius!)
|
||||
: StyleString.mdRadius,
|
||||
child: _buildImage(context),
|
||||
)
|
||||
: getPlaceHolder?.call() ?? placeholder(context);
|
||||
final noRadius = type == ImageType.emote || radius == 0;
|
||||
final Widget child;
|
||||
|
||||
if (src?.isNotEmpty == true) {
|
||||
child = noRadius
|
||||
? _buildImage(context, noRadius)
|
||||
: type == ImageType.avatar
|
||||
? ClipOval(child: _buildImage(context, noRadius))
|
||||
: ClipRRect(
|
||||
borderRadius: radius != null
|
||||
? BorderRadius.circular(radius!)
|
||||
: StyleString.mdRadius,
|
||||
child: _buildImage(context, noRadius),
|
||||
);
|
||||
} else {
|
||||
child = getPlaceHolder?.call() ?? _placeholder(context, noRadius);
|
||||
}
|
||||
|
||||
return semanticsLabel?.isNotEmpty == true
|
||||
? Semantics(
|
||||
container: true,
|
||||
image: true,
|
||||
excludeSemantics: true,
|
||||
label: semanticsLabel,
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
Widget _buildImage(BuildContext context) {
|
||||
Widget _buildImage(BuildContext context, bool noRadius) {
|
||||
int? memCacheWidth, memCacheHeight;
|
||||
if (height == null || callback?.call() == true || width <= height!) {
|
||||
if (height == null || forceUseCacheWidth || width <= height!) {
|
||||
memCacheWidth = width.cacheSize(context);
|
||||
} else {
|
||||
memCacheHeight = height.cacheSize(context);
|
||||
memCacheHeight = height?.cacheSize(context);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: ImageUtil.thumbnailUrl(src, quality),
|
||||
imageUrl: ImageUtils.thumbnailUrl(src, quality),
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheWidth: memCacheWidth,
|
||||
memCacheHeight: memCacheHeight,
|
||||
fit: boxFit ?? BoxFit.cover,
|
||||
alignment: isLongPic?.call() == true
|
||||
? Alignment.topCenter
|
||||
: Alignment.center,
|
||||
alignment: isLongPic ? Alignment.topCenter : Alignment.center,
|
||||
fadeOutDuration: fadeOutDuration ?? const Duration(milliseconds: 120),
|
||||
fadeInDuration: fadeInDuration ?? const Duration(milliseconds: 120),
|
||||
filterQuality: FilterQuality.low,
|
||||
placeholder: (BuildContext context, String url) =>
|
||||
getPlaceHolder?.call() ?? placeholder(context),
|
||||
getPlaceHolder?.call() ?? _placeholder(context, noRadius),
|
||||
imageBuilder: imageBuilder,
|
||||
errorWidget: (context, url, error) => placeholder(context),
|
||||
errorWidget: (context, url, error) => _placeholder(context, noRadius),
|
||||
colorBlendMode: reduce ? BlendMode.modulate : null,
|
||||
color: reduce ? reduceLuxColor : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget placeholder(BuildContext context) {
|
||||
Widget _placeholder(BuildContext context, bool noRadius) {
|
||||
final isAvatar = type == ImageType.avatar;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
clipBehavior: noRadius ? Clip.none : Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: type == ImageType.avatar ? BoxShape.circle : BoxShape.rectangle,
|
||||
shape: isAvatar ? BoxShape.circle : BoxShape.rectangle,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface.withValues(alpha: 0.4),
|
||||
borderRadius:
|
||||
type == ImageType.avatar || type == ImageType.emote || radius == 0
|
||||
borderRadius: noRadius || isAvatar
|
||||
? null
|
||||
: radius != null
|
||||
? BorderRadius.circular(radius!)
|
||||
@@ -102,12 +121,12 @@ class NetworkImgLayer extends StatelessWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
type == ImageType.avatar
|
||||
? 'assets/images/noface.jpeg'
|
||||
: 'assets/images/loading.png',
|
||||
isAvatar ? 'assets/images/noface.jpeg' : 'assets/images/loading.png',
|
||||
width: width,
|
||||
height: height,
|
||||
cacheWidth: width.cacheSize(context),
|
||||
colorBlendMode: reduce ? BlendMode.modulate : null,
|
||||
color: reduce ? reduceLuxColor : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/**
|
||||
* @Author: Sky24n
|
||||
* @GitHub: https://github.com/Sky24n
|
||||
* @Description: NineGridView.
|
||||
* @Date: 2020/06/16
|
||||
*/
|
||||
|
||||
/// NineGridView Type.
|
||||
enum NineGridType {
|
||||
/// normal NineGridView.
|
||||
normal,
|
||||
|
||||
/// like WeChat NineGridView.
|
||||
weChat,
|
||||
|
||||
/// like WeiBo International NineGridView.
|
||||
weiBo,
|
||||
|
||||
/// like WeChat group.
|
||||
weChatGp,
|
||||
|
||||
/// like DingTalk group.
|
||||
dingTalkGp,
|
||||
|
||||
/// like QQ group.
|
||||
qqGp,
|
||||
}
|
||||
|
||||
/// big images size cache map.
|
||||
Map<String, Rect> ngvBigImageSizeMap = HashMap();
|
||||
|
||||
/// NineGridView.
|
||||
/// like WeChat, WeiBo International, WeChat group, DingTalk group, QQ group.
|
||||
///
|
||||
/// Another [NineGridView](https://github.com/flutterchina/flukit) in [flukit](https://github.com/flutterchina/flukit) UI Kit,using GridView implementation。
|
||||
class NineGridView extends StatefulWidget {
|
||||
/// create NineGridView.
|
||||
/// If you want to show a single big picture.
|
||||
/// It is recommended to use a medium-quality picture, because the original picture is too large and takes time to load.
|
||||
/// 单张大图建议使用中等质量图片,因为原图太大加载耗时。
|
||||
/// you need input (bigImageWidth + bigImageHeight) or (bigImage + bigImageUrl).
|
||||
const NineGridView({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.space = 3,
|
||||
this.arcAngle = 0,
|
||||
this.initIndex = 1,
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.margin = EdgeInsets.zero,
|
||||
this.alignment,
|
||||
this.color,
|
||||
this.decoration,
|
||||
this.type = NineGridType.weChat,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.bigImageWidth,
|
||||
this.bigImageHeight,
|
||||
this.bigImage,
|
||||
this.bigImageUrl,
|
||||
});
|
||||
|
||||
/// View width.
|
||||
final double? width;
|
||||
|
||||
/// View height.
|
||||
final double? height;
|
||||
|
||||
/// The number of logical pixels between each child.
|
||||
final double space;
|
||||
|
||||
/// QQ group arc angle (0 ~ 180).
|
||||
final double arcAngle;
|
||||
|
||||
/// QQ group init index (0 or 1). def 1.
|
||||
final int initIndex;
|
||||
|
||||
/// View padding.
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// View margin.
|
||||
final EdgeInsets margin;
|
||||
|
||||
/// Align the [child] within the container.
|
||||
final AlignmentGeometry? alignment;
|
||||
|
||||
/// The color to paint behind the [child].
|
||||
final Color? color;
|
||||
|
||||
/// The decoration to paint behind the [child].
|
||||
final Decoration? decoration;
|
||||
|
||||
/// NineGridView type.
|
||||
final NineGridType type;
|
||||
|
||||
/// The total number of children this delegate can provide.
|
||||
final int itemCount;
|
||||
|
||||
/// Called to build children for the view.
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
|
||||
/// Single big picture width.
|
||||
final double? bigImageWidth;
|
||||
|
||||
/// Single big picture height.
|
||||
final double? bigImageHeight;
|
||||
|
||||
/// It is recommended to use a medium-quality picture, because the original picture is too large and takes time to load.
|
||||
/// 单张大图建议使用中等质量图片,因为原图太大加载耗时。
|
||||
/// Single big picture Image.
|
||||
final Image? bigImage;
|
||||
|
||||
/// Single big picture url.
|
||||
final String? bigImageUrl;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _NineGridViewState();
|
||||
}
|
||||
}
|
||||
|
||||
/// _NineGridViewState.
|
||||
class _NineGridViewState extends State<NineGridView> {
|
||||
/// init view size.
|
||||
Rect _initSize(BuildContext context) {
|
||||
EdgeInsets padding = widget.padding;
|
||||
if (widget.itemCount == 0) {
|
||||
return Rect.fromLTRB(0, 0, padding.horizontal, padding.vertical);
|
||||
}
|
||||
double width =
|
||||
widget.width ??
|
||||
(MediaQuery.sizeOf(context).width - widget.margin.horizontal);
|
||||
width = width - padding.horizontal;
|
||||
double space = widget.space;
|
||||
double itemW;
|
||||
if (widget.type == NineGridType.weiBo &&
|
||||
(widget.itemCount == 1 || widget.itemCount == 2)) {
|
||||
// || itemCount == 4
|
||||
itemW = (width - space) / 2;
|
||||
} else {
|
||||
itemW = (width - space * 2) / 3;
|
||||
}
|
||||
bool fourGrid =
|
||||
(widget.itemCount == 4 && widget.type != NineGridType.normal);
|
||||
int column = fourGrid ? 2 : math.min(3, widget.itemCount);
|
||||
int row = fourGrid ? 2 : (widget.itemCount / 3).ceil();
|
||||
double realWidth =
|
||||
itemW * column + space * (column - 1) + padding.horizontal;
|
||||
double realHeight = itemW * row + space * (row - 1) + padding.vertical;
|
||||
return Rect.fromLTRB(itemW, 0, realWidth, realHeight);
|
||||
}
|
||||
|
||||
/// build nine grid view.
|
||||
Widget _buildChild(BuildContext context, double itemW) {
|
||||
double space = widget.space;
|
||||
int column = (widget.itemCount == 4 && widget.type != NineGridType.normal)
|
||||
? 2
|
||||
: 3;
|
||||
List<Widget> list = [];
|
||||
for (int i = 0; i < widget.itemCount; i++) {
|
||||
list.add(
|
||||
Positioned(
|
||||
top: (space + itemW) * (i ~/ column),
|
||||
left: (space + itemW) * (i % column),
|
||||
child: SizedBox(
|
||||
width: itemW,
|
||||
height: itemW,
|
||||
child: widget.itemBuilder(context, i),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
|
||||
/// build one child.
|
||||
Widget? _buildOneChild(BuildContext context) {
|
||||
double? bigImgWidth = widget.bigImageWidth?.toDouble();
|
||||
double? bigImgHeight = widget.bigImageHeight?.toDouble();
|
||||
if (!_isZero(bigImgWidth) && !_isZero(bigImgHeight)) {
|
||||
return _getOneChild(context, bigImgWidth!, bigImgHeight!);
|
||||
} else if (widget.bigImage != null) {
|
||||
String bigImageUrl = widget.bigImageUrl!;
|
||||
Rect? bigImgRect = ngvBigImageSizeMap[bigImageUrl];
|
||||
bigImgWidth = bigImgRect?.width;
|
||||
bigImgHeight = bigImgRect?.height;
|
||||
if (!_isZero(bigImgWidth) && !_isZero(bigImgHeight)) {
|
||||
return _getOneChild(context, bigImgWidth!, bigImgHeight!);
|
||||
} else {
|
||||
_ImageUtil()
|
||||
.getImageSize(widget.bigImage)
|
||||
?.then((rect) {
|
||||
ngvBigImageSizeMap[bigImageUrl] = rect;
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
})
|
||||
.catchError((e) {});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// get one child.
|
||||
Widget _getOneChild(BuildContext context, double width, double height) {
|
||||
Rect rect = _getBigImgSize(width, height);
|
||||
return SizedBox(
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
child: widget.itemBuilder(context, 0),
|
||||
);
|
||||
}
|
||||
|
||||
/// build weChat group.
|
||||
Widget _buildWeChatGroup(BuildContext context) {
|
||||
double width = widget.width! - widget.padding.horizontal;
|
||||
double space = widget.space;
|
||||
double itemW;
|
||||
|
||||
int column = widget.itemCount < 5 ? 2 : 3;
|
||||
int row = 0;
|
||||
if (widget.itemCount == 1) {
|
||||
row = 1;
|
||||
itemW = width;
|
||||
} else if (widget.itemCount < 5) {
|
||||
row = widget.itemCount == 2 ? 1 : 2;
|
||||
itemW = (width - space) / 2;
|
||||
} else if (widget.itemCount < 7) {
|
||||
row = 2;
|
||||
itemW = (width - space * 2) / 3;
|
||||
} else {
|
||||
row = 3;
|
||||
itemW = (width - space * 2) / 3;
|
||||
}
|
||||
|
||||
int first = widget.itemCount % column;
|
||||
List<Widget> list = [];
|
||||
for (int i = 0; i < widget.itemCount; i++) {
|
||||
double left;
|
||||
if (first > 0 && i < first) {
|
||||
left =
|
||||
(width - itemW * first - space * (first - 1)) / 2 +
|
||||
(itemW + space) * i;
|
||||
} else {
|
||||
left = (space + itemW) * ((i - first) % column);
|
||||
}
|
||||
|
||||
int itemIndex = (first > 0 && i < first)
|
||||
? 0
|
||||
: (first > 0 ? (i + column - first) : i) ~/ column;
|
||||
|
||||
double top =
|
||||
(width - itemW * row - space * (row - 1)) / 2 +
|
||||
(space + itemW) * itemIndex;
|
||||
|
||||
list.add(
|
||||
Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
child: SizedBox(
|
||||
width: itemW,
|
||||
height: itemW,
|
||||
child: widget.itemBuilder(context, i),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
|
||||
/// build dingTalk group.
|
||||
Widget _buildDingTalkGroup(BuildContext context) {
|
||||
double width = widget.width! - widget.padding.horizontal;
|
||||
int itemCount = math.min(4, widget.itemCount);
|
||||
double itemW = (width - widget.space) / 2;
|
||||
List<Widget> children = [];
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
children.add(
|
||||
Positioned(
|
||||
top: (widget.space + itemW) * (i ~/ 2),
|
||||
left:
|
||||
(widget.space + itemW) *
|
||||
(((itemCount == 3 && i == 2) ? i + 1 : i) % 2),
|
||||
child: SizedBox(
|
||||
width: itemCount == 1 ? width : itemW,
|
||||
height:
|
||||
(itemCount == 1 || itemCount == 2 || (itemCount == 3 && i == 0))
|
||||
? width
|
||||
: itemW,
|
||||
child: widget.itemBuilder(context, i),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ClipOval(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// build QQ group.
|
||||
Widget _buildQQGroup(BuildContext context) {
|
||||
double width = widget.width! - widget.padding.horizontal;
|
||||
int itemCount = math.min(5, widget.itemCount);
|
||||
if (itemCount == 1) {
|
||||
return ClipOval(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: width,
|
||||
child: widget.itemBuilder(context, 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> children = [];
|
||||
double startDegree = 0;
|
||||
double r = 0;
|
||||
double r1 = 0;
|
||||
double centerX = width / 2;
|
||||
double centerY = width / 2;
|
||||
switch (itemCount) {
|
||||
case 2:
|
||||
startDegree = 135;
|
||||
r = width / (2 + 2 * math.sin(math.pi / 4));
|
||||
r1 = r;
|
||||
break;
|
||||
case 3:
|
||||
startDegree = 210;
|
||||
r = width / (2 + 4 * math.sin(math.pi * (3 - 2) / (2 * 3)));
|
||||
r1 = r / math.cos(math.pi * (3 - 2) / (2 * 3));
|
||||
double R =
|
||||
r *
|
||||
(1 + math.sin(math.pi / itemCount)) /
|
||||
math.sin(math.pi / itemCount);
|
||||
double dy = 0.5 * (width - R - r * (1 + 1 / math.tan(math.pi / 3)));
|
||||
centerY = dy + r + r1;
|
||||
break;
|
||||
case 4:
|
||||
startDegree = 180;
|
||||
r = width / 4;
|
||||
r1 = r / math.cos(math.pi / 4);
|
||||
break;
|
||||
case 5:
|
||||
startDegree = 126;
|
||||
r = width / (2 + 4 * math.sin(math.pi * (5 - 2) / (2 * 5)));
|
||||
r1 = r / math.cos(math.pi * (5 - 2) / (2 * 5));
|
||||
double R =
|
||||
r *
|
||||
(1 + math.sin(math.pi / itemCount)) /
|
||||
math.sin(math.pi / itemCount);
|
||||
double dy = 0.5 * (width - R - r * (1 + 1 / math.tan(math.pi / 5)));
|
||||
centerY = dy + r + r1;
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
double degree1 = (itemCount == 2 || itemCount == 4) ? (-math.pi / 4) : 0;
|
||||
double x = centerX + r1 * math.sin(degree1 + i * 2 * math.pi / itemCount);
|
||||
double y = centerY - r1 * math.cos(degree1 + i * 2 * math.pi / itemCount);
|
||||
|
||||
double degree = startDegree + i * 2 * 180 / itemCount;
|
||||
if (degree >= 360) degree = degree % 360;
|
||||
double previousX = r + 2 * r * math.sin(degree / 180 * math.pi);
|
||||
double previousY = r - 2 * r * math.cos(degree / 180 * math.pi);
|
||||
|
||||
Widget child = Positioned.fromRect(
|
||||
rect: Rect.fromCircle(center: Offset(x, y), radius: r),
|
||||
child: ClipPath(
|
||||
clipper: QQClipper(
|
||||
total: itemCount,
|
||||
index: i,
|
||||
initIndex: widget.initIndex,
|
||||
previousX: previousX,
|
||||
previousY: previousY,
|
||||
degree: degree,
|
||||
arcAngle: widget.arcAngle,
|
||||
space: widget.space,
|
||||
),
|
||||
child: widget.itemBuilder(context, i),
|
||||
),
|
||||
);
|
||||
children.add(child);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// double is zero.
|
||||
bool _isZero(double? value) {
|
||||
return value == null || value == 0;
|
||||
}
|
||||
|
||||
/// get big image size.
|
||||
Rect _getBigImgSize(double originalWidth, double originalHeight) {
|
||||
double width =
|
||||
widget.width ??
|
||||
(MediaQuery.sizeOf(context).width - widget.margin.horizontal);
|
||||
width = width - widget.padding.horizontal;
|
||||
double itemW = (width - widget.space * 2) / 3;
|
||||
|
||||
// double devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
double devicePixelRatio = 1.0;
|
||||
double tempWidth = originalWidth / devicePixelRatio;
|
||||
double tempHeight = originalHeight / devicePixelRatio;
|
||||
double maxW = itemW * 2 + widget.space;
|
||||
double minW = width / 2;
|
||||
|
||||
double relWidth = tempWidth >= maxW ? maxW : math.max(minW, tempWidth);
|
||||
|
||||
double relHeight;
|
||||
double ratio = tempWidth / tempHeight;
|
||||
if (tempWidth == tempHeight) {
|
||||
relHeight = relWidth;
|
||||
} else if (tempWidth > tempHeight) {
|
||||
relHeight = relWidth / (math.min(ratio, 4 / 3));
|
||||
} else {
|
||||
relHeight = relWidth / (math.max(ratio, 3 / 4));
|
||||
}
|
||||
return Rect.fromLTRB(0, 0, relWidth, relHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget? child;
|
||||
double? realWidth = widget.width;
|
||||
double? realHeight = widget.height;
|
||||
switch (widget.type) {
|
||||
case NineGridType.normal:
|
||||
case NineGridType.weiBo:
|
||||
case NineGridType.weChat:
|
||||
Rect size = _initSize(context);
|
||||
if (widget.itemCount == 1) {
|
||||
child = _buildOneChild(context);
|
||||
if (child == null) {
|
||||
realWidth = size.right;
|
||||
realHeight = size.bottom;
|
||||
child = _buildChild(context, size.left);
|
||||
}
|
||||
} else {
|
||||
realWidth = size.right;
|
||||
realHeight = size.bottom;
|
||||
child = _buildChild(context, size.left);
|
||||
}
|
||||
break;
|
||||
case NineGridType.weChatGp:
|
||||
child = _buildWeChatGroup(context);
|
||||
break;
|
||||
case NineGridType.dingTalkGp:
|
||||
child = _buildDingTalkGroup(context);
|
||||
break;
|
||||
case NineGridType.qqGp:
|
||||
child = _buildQQGroup(context);
|
||||
break;
|
||||
}
|
||||
return Container(
|
||||
alignment: widget.alignment,
|
||||
color: widget.color,
|
||||
decoration: widget.decoration,
|
||||
margin: widget.margin,
|
||||
padding: widget.padding,
|
||||
width: realWidth,
|
||||
height: realHeight,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// image util.
|
||||
class _ImageUtil {
|
||||
late ImageStreamListener listener;
|
||||
late ImageStream imageStream;
|
||||
|
||||
/// get image size.
|
||||
Future<Rect>? getImageSize(Image? image) {
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
Completer<Rect> completer = Completer<Rect>();
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool synchronousCall) {
|
||||
imageStream.removeListener(listener);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(
|
||||
Rect.fromLTWH(
|
||||
0,
|
||||
0,
|
||||
info.image.width.toDouble(),
|
||||
info.image.height.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (dynamic exception, StackTrace? stackTrace) {
|
||||
imageStream.removeListener(listener);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(exception, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
imageStream = image.image.resolve(ImageConfiguration.empty);
|
||||
imageStream.addListener(listener);
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// QQ Clipper.
|
||||
class QQClipper extends CustomClipper<Path> {
|
||||
QQClipper({
|
||||
this.total = 0,
|
||||
this.index = 0,
|
||||
this.initIndex = 1,
|
||||
this.previousX = 0,
|
||||
this.previousY = 0,
|
||||
this.degree = 0,
|
||||
this.arcAngle = 0,
|
||||
this.space = 0,
|
||||
}) : assert(arcAngle >= 0 && arcAngle <= 180);
|
||||
|
||||
final int total;
|
||||
final int index;
|
||||
final int initIndex;
|
||||
final double previousX;
|
||||
final double previousY;
|
||||
final double degree;
|
||||
final double arcAngle;
|
||||
final double space;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
double r = size.width / 2;
|
||||
Path path = Path();
|
||||
List<Offset> points = [];
|
||||
|
||||
if (total == 2 && index == initIndex) {
|
||||
path.addOval(Rect.fromLTRB(0, 0, size.width, size.height));
|
||||
} else {
|
||||
/// arcAngle and space, prefer to use arcAngle.
|
||||
double spaceA = arcAngle > 0
|
||||
? (arcAngle / 2)
|
||||
: (math.acos((r - math.min(r, space)) / r) / math.pi * 180);
|
||||
double startA = degree + spaceA;
|
||||
double endA = degree - spaceA;
|
||||
for (double i = startA; i <= 360 + endA; i = i + 1) {
|
||||
double x1 = r + r * math.sin(d2r(i));
|
||||
double y1 = r - r * math.cos(d2r(i));
|
||||
points.add(Offset(x1, y1));
|
||||
}
|
||||
|
||||
double spaceB =
|
||||
math.atan(
|
||||
r * math.sin(d2r(spaceA)) / (2 * r - r * math.cos(d2r(spaceA))),
|
||||
) /
|
||||
math.pi *
|
||||
180;
|
||||
double r1 = (2 * r - r * math.cos(d2r(spaceA))) / math.cos(d2r(spaceB));
|
||||
double startB = degree - 180 - spaceB;
|
||||
double endB = degree - 180 + spaceB;
|
||||
List<Offset> pointsB = [];
|
||||
for (double i = startB; i < endB; i = i + 1) {
|
||||
double x1 = previousX + r1 * math.sin(d2r(i));
|
||||
double y1 = previousY - r1 * math.cos(d2r(i));
|
||||
pointsB.add(Offset(x1, y1));
|
||||
}
|
||||
points.addAll(pointsB.reversed);
|
||||
path.addPolygon(points, true);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// degree to radian.
|
||||
double d2r(double degree) {
|
||||
return degree / 180 * math.pi;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) {
|
||||
return this != oldClipper;
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,20 @@
|
||||
// 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 'editable_text.dart';
|
||||
/// @docImport 'scroll_view.dart';
|
||||
/// @docImport 'table.dart';
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3;
|
||||
|
||||
// Examples can assume:
|
||||
// late BuildContext context;
|
||||
// late Offset? _childWasTappedAt;
|
||||
// late TransformationController _transformationController;
|
||||
// Widget child = const Placeholder();
|
||||
|
||||
/// A signature for widget builders that take a [Quad] of the current viewport.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [InteractiveViewer.builder], whose builder is of this type.
|
||||
/// * [WidgetBuilder], which is similar, but takes no viewport.
|
||||
typedef InteractiveViewerWidgetBuilder =
|
||||
Widget Function(BuildContext context, Quad viewport);
|
||||
import 'package:vector_math/vector_math_64.dart' show Quad, Vector3;
|
||||
|
||||
/// A widget that enables pan and zoom interactions with its child.
|
||||
///
|
||||
@@ -428,43 +420,19 @@ class InteractiveViewer extends StatefulWidget {
|
||||
static Quad getAxisAlignedBoundingBox(Quad quad) {
|
||||
final double minX = math.min(
|
||||
quad.point0.x,
|
||||
math.min(
|
||||
quad.point1.x,
|
||||
math.min(
|
||||
quad.point2.x,
|
||||
quad.point3.x,
|
||||
),
|
||||
),
|
||||
math.min(quad.point1.x, math.min(quad.point2.x, quad.point3.x)),
|
||||
);
|
||||
final double minY = math.min(
|
||||
quad.point0.y,
|
||||
math.min(
|
||||
quad.point1.y,
|
||||
math.min(
|
||||
quad.point2.y,
|
||||
quad.point3.y,
|
||||
),
|
||||
),
|
||||
math.min(quad.point1.y, math.min(quad.point2.y, quad.point3.y)),
|
||||
);
|
||||
final double maxX = math.max(
|
||||
quad.point0.x,
|
||||
math.max(
|
||||
quad.point1.x,
|
||||
math.max(
|
||||
quad.point2.x,
|
||||
quad.point3.x,
|
||||
),
|
||||
),
|
||||
math.max(quad.point1.x, math.max(quad.point2.x, quad.point3.x)),
|
||||
);
|
||||
final double maxY = math.max(
|
||||
quad.point0.y,
|
||||
math.max(
|
||||
quad.point1.y,
|
||||
math.max(
|
||||
quad.point2.y,
|
||||
quad.point3.y,
|
||||
),
|
||||
),
|
||||
math.max(quad.point1.y, math.max(quad.point2.y, quad.point3.y)),
|
||||
);
|
||||
return Quad.points(
|
||||
Vector3(minX, minY, 0),
|
||||
@@ -529,7 +497,8 @@ class InteractiveViewer extends StatefulWidget {
|
||||
|
||||
class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
with TickerProviderStateMixin {
|
||||
TransformationController? _transformationController;
|
||||
late TransformationController _transformer =
|
||||
widget.transformationController ?? TransformationController();
|
||||
|
||||
final GlobalKey _childKey = GlobalKey();
|
||||
final GlobalKey _parentKey = GlobalKey();
|
||||
@@ -611,10 +580,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
}
|
||||
|
||||
final Matrix4 nextMatrix = matrix.clone()
|
||||
..translate(
|
||||
alignedTranslation.dx,
|
||||
alignedTranslation.dy,
|
||||
);
|
||||
..translateByDouble(alignedTranslation.dx, alignedTranslation.dy, 0, 1);
|
||||
|
||||
// Transform the viewport to determine where its four corners will be after
|
||||
// the child has been transformed.
|
||||
@@ -712,8 +678,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
|
||||
// Don't allow a scale that results in an overall scale beyond min/max
|
||||
// scale.
|
||||
final double currentScale = _transformationController!.value
|
||||
.getMaxScaleOnAxis();
|
||||
final double currentScale = _transformer.value.getMaxScaleOnAxis();
|
||||
final double totalScale = math.max(
|
||||
currentScale * scale,
|
||||
// Ensure that the scale cannot make the child so big that it can't fit
|
||||
@@ -729,7 +694,8 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
widget.maxScale,
|
||||
);
|
||||
final double clampedScale = clampedTotalScale / currentScale;
|
||||
return matrix.clone()..scale(clampedScale);
|
||||
return matrix.clone()
|
||||
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
|
||||
}
|
||||
|
||||
// Return a new matrix representing the given matrix after applying the given
|
||||
@@ -738,13 +704,11 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
if (rotation == 0) {
|
||||
return matrix.clone();
|
||||
}
|
||||
final Offset focalPointScene = _transformationController!.toScene(
|
||||
focalPoint,
|
||||
);
|
||||
final Offset focalPointScene = _transformer.toScene(focalPoint);
|
||||
return matrix.clone()
|
||||
..translate(focalPointScene.dx, focalPointScene.dy)
|
||||
..translateByDouble(focalPointScene.dx, focalPointScene.dy, 0, 1)
|
||||
..rotateZ(-rotation)
|
||||
..translate(-focalPointScene.dx, -focalPointScene.dy);
|
||||
..translateByDouble(-focalPointScene.dx, -focalPointScene.dy, 0, 1);
|
||||
}
|
||||
|
||||
// Returns true iff the given _GestureType is enabled.
|
||||
@@ -776,8 +740,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// with GestureDetector's scale gesture.
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
if (widget.isAnimating?.call() == true ||
|
||||
(details.pointerCount < 2 &&
|
||||
_transformationController?.value.row0.x == 1.0)) {
|
||||
(details.pointerCount < 2 && _transformer.value.row0.x == 1.0)) {
|
||||
widget.onPanStart?.call(details);
|
||||
return;
|
||||
}
|
||||
@@ -788,23 +751,21 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
_controller
|
||||
..stop()
|
||||
..reset();
|
||||
_animation?.removeListener(_onAnimate);
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
}
|
||||
if (_scaleController.isAnimating) {
|
||||
_scaleController
|
||||
..stop()
|
||||
..reset();
|
||||
_scaleAnimation?.removeListener(_onScaleAnimate);
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
}
|
||||
|
||||
_gestureType = null;
|
||||
_currentAxis = null;
|
||||
_scaleStart = _transformationController!.value.getMaxScaleOnAxis();
|
||||
_referenceFocalPoint = _transformationController!.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
_rotationStart = _currentRotation;
|
||||
}
|
||||
|
||||
@@ -812,15 +773,14 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// handled with GestureDetector's scale gesture.
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
if (widget.isAnimating?.call() == true ||
|
||||
(details.pointerCount < 2 &&
|
||||
_transformationController?.value.row0.x == 1.0)) {
|
||||
(details.pointerCount < 2 && _transformer.value.row0.x == 1.0)) {
|
||||
widget.onPanUpdate?.call(details);
|
||||
return;
|
||||
}
|
||||
|
||||
final double scale = _transformationController!.value.getMaxScaleOnAxis();
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
_scaleAnimationFocalPoint = details.localFocalPoint;
|
||||
final Offset focalPointScene = _transformationController!.toScene(
|
||||
final Offset focalPointScene = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
|
||||
@@ -846,20 +806,17 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// previous call to _onScaleUpdate.
|
||||
final double desiredScale = _scaleStart! * details.scale;
|
||||
final double scaleChange = desiredScale / scale;
|
||||
_transformationController!.value = _matrixScale(
|
||||
_transformationController!.value,
|
||||
scaleChange,
|
||||
);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// While scaling, translate such that the user's two fingers stay on
|
||||
// the same places in the scene. That means that the focal point of
|
||||
// the scale should be on the same place in the scene before and after
|
||||
// the scale.
|
||||
final Offset focalPointSceneScaled = _transformationController!.toScene(
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - _referenceFocalPoint!,
|
||||
);
|
||||
|
||||
@@ -868,7 +825,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// the translate came in contact with a boundary. In that case, update
|
||||
// _referenceFocalPoint so subsequent updates happen in relation to
|
||||
// the new effective focal point.
|
||||
final Offset focalPointSceneCheck = _transformationController!.toScene(
|
||||
final Offset focalPointSceneCheck = _transformer.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) {
|
||||
@@ -881,15 +838,17 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
return;
|
||||
}
|
||||
final double desiredRotation = _rotationStart! + details.rotation;
|
||||
_transformationController!.value = _matrixRotate(
|
||||
_transformationController!.value,
|
||||
_transformer.value = _matrixRotate(
|
||||
_transformer.value,
|
||||
_currentRotation - desiredRotation,
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_currentRotation = desiredRotation;
|
||||
|
||||
case _GestureType.pan:
|
||||
assert(_referenceFocalPoint != null);
|
||||
if (_referenceFocalPoint == null) {
|
||||
return;
|
||||
}
|
||||
// details may have a change in scale here when scaleEnabled is false.
|
||||
// In an effort to keep the behavior similar whether or not scaleEnabled
|
||||
// is true, these gestures are thrown away.
|
||||
@@ -902,13 +861,11 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// focal point before and after the movement.
|
||||
final Offset translationChange =
|
||||
focalPointScene - _referenceFocalPoint!;
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
translationChange,
|
||||
);
|
||||
_referenceFocalPoint = _transformationController!.toScene(
|
||||
details.localFocalPoint,
|
||||
);
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
}
|
||||
widget.onInteractionUpdate?.call(details);
|
||||
}
|
||||
@@ -916,12 +873,11 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
// Handle the end of a gesture of _GestureType. All of pan, scale, and rotate
|
||||
// are handled with GestureDetector's scale gesture.
|
||||
void _onScaleEnd(ScaleEndDetails details) {
|
||||
if (_transformationController?.value.row0.x == 1.0) {
|
||||
if (_transformer.value.row0.x == 1.0) {
|
||||
widget.onReset?.call();
|
||||
}
|
||||
if (widget.isAnimating?.call() == true ||
|
||||
(details.pointerCount < 2 &&
|
||||
_transformationController?.value.row0.x == 1.0)) {
|
||||
(details.pointerCount < 2 && _transformer.value.row0.x == 1.0)) {
|
||||
widget.onPanEnd?.call(details);
|
||||
return;
|
||||
}
|
||||
@@ -931,8 +887,8 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
_rotationStart = null;
|
||||
_referenceFocalPoint = null;
|
||||
|
||||
_animation?.removeListener(_onAnimate);
|
||||
_scaleAnimation?.removeListener(_onScaleAnimate);
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_controller.reset();
|
||||
_scaleController.reset();
|
||||
|
||||
@@ -947,8 +903,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
_currentAxis = null;
|
||||
return;
|
||||
}
|
||||
final Vector3 translationVector = _transformationController!.value
|
||||
.getTranslation();
|
||||
final Vector3 translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation = Offset(
|
||||
translationVector.x,
|
||||
translationVector.y,
|
||||
@@ -975,21 +930,17 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
frictionSimulationY.finalX,
|
||||
),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.decelerate,
|
||||
),
|
||||
CurvedAnimation(parent: _controller, curve: Curves.decelerate),
|
||||
);
|
||||
_controller.duration = Duration(milliseconds: (tFinal * 1000).round());
|
||||
_animation!.addListener(_onAnimate);
|
||||
_animation!.addListener(_handleInertiaAnimation);
|
||||
_controller.forward();
|
||||
case _GestureType.scale:
|
||||
if (details.scaleVelocity.abs() < 0.1) {
|
||||
_currentAxis = null;
|
||||
return;
|
||||
}
|
||||
final double scale = _transformationController!.value
|
||||
.getMaxScaleOnAxis();
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
||||
scale,
|
||||
@@ -1013,7 +964,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
_scaleController.duration = Duration(
|
||||
milliseconds: (tFinal * 1000).round(),
|
||||
);
|
||||
_scaleAnimation!.addListener(_onScaleAnimate);
|
||||
_scaleAnimation!.addListener(_handleScaleAnimation);
|
||||
_scaleController.forward();
|
||||
case _GestureType.rotate || null:
|
||||
break;
|
||||
@@ -1022,20 +973,19 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
|
||||
// Handle mousewheel and web trackpad scroll events.
|
||||
void _receivedPointerSignal(PointerSignalEvent event) {
|
||||
final Offset local = event.localPosition;
|
||||
final Offset global = event.position;
|
||||
final double scaleChange;
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.kind == PointerDeviceKind.trackpad &&
|
||||
!widget.trackpadScrollCausesScale) {
|
||||
// Trackpad scroll, so treat it as a pan.
|
||||
widget.onInteractionStart?.call(
|
||||
ScaleStartDetails(
|
||||
focalPoint: event.position,
|
||||
localFocalPoint: event.localPosition,
|
||||
),
|
||||
ScaleStartDetails(focalPoint: global, localFocalPoint: local),
|
||||
);
|
||||
|
||||
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
|
||||
untransformedEndPosition: event.position + event.scrollDelta,
|
||||
untransformedEndPosition: global + event.scrollDelta,
|
||||
untransformedDelta: event.scrollDelta,
|
||||
transform: event.transform,
|
||||
);
|
||||
@@ -1043,8 +993,8 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
if (!_gestureIsSupported(_GestureType.pan)) {
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: event.position - event.scrollDelta,
|
||||
localFocalPoint: event.localPosition - event.scrollDelta,
|
||||
focalPoint: global - event.scrollDelta,
|
||||
localFocalPoint: local - event.scrollDelta,
|
||||
focalPointDelta: -localDelta,
|
||||
),
|
||||
);
|
||||
@@ -1052,23 +1002,20 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset focalPointScene = _transformationController!.toScene(
|
||||
event.localPosition,
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
final Offset newFocalPointScene = _transformer.toScene(
|
||||
local - localDelta,
|
||||
);
|
||||
|
||||
final Offset newFocalPointScene = _transformationController!.toScene(
|
||||
event.localPosition - localDelta,
|
||||
);
|
||||
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
newFocalPointScene - focalPointScene,
|
||||
);
|
||||
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: event.position - event.scrollDelta,
|
||||
localFocalPoint: event.localPosition - localDelta,
|
||||
focalPoint: global - event.scrollDelta,
|
||||
localFocalPoint: local - localDelta,
|
||||
focalPointDelta: -localDelta,
|
||||
),
|
||||
);
|
||||
@@ -1086,17 +1033,14 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
return;
|
||||
}
|
||||
widget.onInteractionStart?.call(
|
||||
ScaleStartDetails(
|
||||
focalPoint: event.position,
|
||||
localFocalPoint: event.localPosition,
|
||||
),
|
||||
ScaleStartDetails(focalPoint: global, localFocalPoint: local),
|
||||
);
|
||||
|
||||
if (!_gestureIsSupported(_GestureType.scale)) {
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: event.position,
|
||||
localFocalPoint: event.localPosition,
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: scaleChange,
|
||||
),
|
||||
);
|
||||
@@ -1104,95 +1048,75 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset focalPointScene = _transformationController!.toScene(
|
||||
event.localPosition,
|
||||
);
|
||||
|
||||
_transformationController!.value = _matrixScale(
|
||||
_transformationController!.value,
|
||||
scaleChange,
|
||||
);
|
||||
final Offset focalPointScene = _transformer.toScene(local);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// After scaling, translate such that the event's position is at the
|
||||
// same scene point before and after the scale.
|
||||
final Offset focalPointSceneScaled = _transformationController!.toScene(
|
||||
event.localPosition,
|
||||
);
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(local);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - focalPointScene,
|
||||
);
|
||||
|
||||
widget.onInteractionUpdate?.call(
|
||||
ScaleUpdateDetails(
|
||||
focalPoint: event.position,
|
||||
localFocalPoint: event.localPosition,
|
||||
focalPoint: global,
|
||||
localFocalPoint: local,
|
||||
scale: scaleChange,
|
||||
),
|
||||
);
|
||||
widget.onInteractionEnd?.call(ScaleEndDetails());
|
||||
}
|
||||
|
||||
// Handle inertia drag animation.
|
||||
void _onAnimate() {
|
||||
void _handleInertiaAnimation() {
|
||||
if (!_controller.isAnimating) {
|
||||
_currentAxis = null;
|
||||
_animation?.removeListener(_onAnimate);
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
_controller.reset();
|
||||
return;
|
||||
}
|
||||
// Translate such that the resulting translation is _animation.value.
|
||||
final Vector3 translationVector = _transformationController!.value
|
||||
.getTranslation();
|
||||
final Vector3 translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation = Offset(translationVector.x, translationVector.y);
|
||||
final Offset translationScene = _transformationController!.toScene(
|
||||
translation,
|
||||
);
|
||||
final Offset animationScene = _transformationController!.toScene(
|
||||
_animation!.value,
|
||||
);
|
||||
final Offset translationChangeScene = animationScene - translationScene;
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
translationChangeScene,
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
_transformer.toScene(_animation!.value) -
|
||||
_transformer.toScene(translation),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle inertia scale animation.
|
||||
void _onScaleAnimate() {
|
||||
void _handleScaleAnimation() {
|
||||
if (!_scaleController.isAnimating) {
|
||||
_currentAxis = null;
|
||||
_scaleAnimation?.removeListener(_onScaleAnimate);
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
_scaleController.reset();
|
||||
return;
|
||||
}
|
||||
final double desiredScale = _scaleAnimation!.value;
|
||||
final double scaleChange =
|
||||
desiredScale / _transformationController!.value.getMaxScaleOnAxis();
|
||||
final Offset referenceFocalPoint = _transformationController!.toScene(
|
||||
desiredScale / _transformer.value.getMaxScaleOnAxis();
|
||||
final Offset referenceFocalPoint = _transformer.toScene(
|
||||
_scaleAnimationFocalPoint,
|
||||
);
|
||||
_transformationController!.value = _matrixScale(
|
||||
_transformationController!.value,
|
||||
scaleChange,
|
||||
);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// While scaling, translate such that the user's two fingers stay on
|
||||
// the same places in the scene. That means that the focal point of
|
||||
// the scale should be on the same place in the scene before and after
|
||||
// the scale.
|
||||
final Offset focalPointSceneScaled = _transformationController!.toScene(
|
||||
final Offset focalPointSceneScaled = _transformer.toScene(
|
||||
_scaleAnimationFocalPoint,
|
||||
);
|
||||
_transformationController!.value = _matrixTranslate(
|
||||
_transformationController!.value,
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - referenceFocalPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void _onTransformationControllerChange() {
|
||||
void _handleTransformation() {
|
||||
// A change to the TransformationController's value is a change to the
|
||||
// state.
|
||||
setState(() {});
|
||||
@@ -1201,63 +1125,36 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_transformationController =
|
||||
widget.transformationController ?? TransformationController();
|
||||
_transformationController!.addListener(_onTransformationControllerChange);
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
);
|
||||
_controller = AnimationController(vsync: this);
|
||||
_scaleController = AnimationController(vsync: this);
|
||||
|
||||
_transformer.addListener(_handleTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InteractiveViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Handle all cases of needing to dispose and initialize
|
||||
// transformationControllers.
|
||||
if (oldWidget.transformationController == null) {
|
||||
if (widget.transformationController != null) {
|
||||
_transformationController!.removeListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
_transformationController!.dispose();
|
||||
_transformationController = widget.transformationController;
|
||||
_transformationController!.addListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (widget.transformationController == null) {
|
||||
_transformationController!.removeListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
_transformationController = TransformationController();
|
||||
_transformationController!.addListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
} else if (widget.transformationController !=
|
||||
oldWidget.transformationController) {
|
||||
_transformationController!.removeListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
_transformationController = widget.transformationController;
|
||||
_transformationController!.addListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
}
|
||||
|
||||
final TransformationController? newController =
|
||||
widget.transformationController;
|
||||
if (newController == oldWidget.transformationController) {
|
||||
return;
|
||||
}
|
||||
_transformer.removeListener(_handleTransformation);
|
||||
if (oldWidget.transformationController == null) {
|
||||
_transformer.dispose();
|
||||
}
|
||||
_transformer = newController ?? TransformationController();
|
||||
_transformer.addListener(_handleTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scaleController.dispose();
|
||||
_transformationController!.removeListener(
|
||||
_onTransformationControllerChange,
|
||||
);
|
||||
_transformer.removeListener(_handleTransformation);
|
||||
if (widget.transformationController == null) {
|
||||
_transformationController!.dispose();
|
||||
_transformer.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
@@ -1270,7 +1167,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
childKey: _childKey,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
constrained: widget.constrained,
|
||||
matrix: _transformationController!.value,
|
||||
matrix: _transformer.value,
|
||||
alignment: widget.alignment,
|
||||
child: widget.child!,
|
||||
);
|
||||
@@ -1281,7 +1178,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
assert(!widget.constrained);
|
||||
child = LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final Matrix4 matrix = _transformationController!.value;
|
||||
final Matrix4 matrix = _transformer.value;
|
||||
return _InteractiveViewerBuilt(
|
||||
childKey: _childKey,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
@@ -1301,8 +1198,7 @@ class _InteractiveViewerState extends State<InteractiveViewer>
|
||||
key: _parentKey,
|
||||
onPointerSignal: _receivedPointerSignal,
|
||||
child: GestureDetector(
|
||||
behavior:
|
||||
HitTestBehavior.translucent, // Necessary when panning off screen.
|
||||
behavior: HitTestBehavior.opaque, // Necessary when panning off screen.
|
||||
onScaleEnd: _onScaleEnd,
|
||||
onScaleStart: _onScaleStart,
|
||||
onScaleUpdate: _onScaleUpdate,
|
||||
@@ -1338,10 +1234,7 @@ class _InteractiveViewerBuilt extends StatelessWidget {
|
||||
Widget child = Transform(
|
||||
transform: matrix,
|
||||
alignment: alignment,
|
||||
child: KeyedSubtree(
|
||||
key: childKey,
|
||||
child: this.child,
|
||||
),
|
||||
child: KeyedSubtree(key: childKey, child: this.child),
|
||||
);
|
||||
|
||||
if (!constrained) {
|
||||
@@ -1355,83 +1248,13 @@ class _InteractiveViewerBuilt extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRect(
|
||||
clipBehavior: clipBehavior,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A thin wrapper on [ValueNotifier] whose value is a [Matrix4] representing a
|
||||
/// transformation.
|
||||
///
|
||||
/// The [value] defaults to the identity matrix, which corresponds to no
|
||||
/// transformation.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [InteractiveViewer.transformationController] for detailed documentation
|
||||
/// on how to use TransformationController with [InteractiveViewer].
|
||||
class TransformationController extends ValueNotifier<Matrix4> {
|
||||
/// Create an instance of [TransformationController].
|
||||
///
|
||||
/// The [value] defaults to the identity matrix, which corresponds to no
|
||||
/// transformation.
|
||||
TransformationController([Matrix4? value])
|
||||
: super(value ?? Matrix4.identity());
|
||||
|
||||
/// Return the scene point at the given viewport point.
|
||||
///
|
||||
/// A viewport point is relative to the parent while a scene point is relative
|
||||
/// to the child, regardless of transformation. Calling toScene with a
|
||||
/// viewport point essentially returns the scene coordinate that lies
|
||||
/// underneath the viewport point given the transform.
|
||||
///
|
||||
/// The viewport transforms as the inverse of the child (i.e. moving the child
|
||||
/// left is equivalent to moving the viewport right).
|
||||
///
|
||||
/// This method is often useful when determining where an event on the parent
|
||||
/// occurs on the child. This example shows how to determine where a tap on
|
||||
/// the parent occurred on the child.
|
||||
///
|
||||
/// ```dart
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return GestureDetector(
|
||||
/// onTapUp: (TapUpDetails details) {
|
||||
/// _childWasTappedAt = _transformationController.toScene(
|
||||
/// details.localPosition,
|
||||
/// );
|
||||
/// },
|
||||
/// child: InteractiveViewer(
|
||||
/// transformationController: _transformationController,
|
||||
/// child: child,
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
Offset toScene(Offset viewportPoint) {
|
||||
// On viewportPoint, perform the inverse transformation of the scene to get
|
||||
// where the point would be in the scene before the transformation.
|
||||
final Matrix4 inverseMatrix = Matrix4.inverted(value);
|
||||
final Vector3 untransformed = inverseMatrix.transform3(
|
||||
Vector3(
|
||||
viewportPoint.dx,
|
||||
viewportPoint.dy,
|
||||
0,
|
||||
),
|
||||
);
|
||||
return Offset(untransformed.x, untransformed.y);
|
||||
return ClipRect(clipBehavior: clipBehavior, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
// A classification of relevant user gestures. Each contiguous user gesture is
|
||||
// represented by exactly one _GestureType.
|
||||
enum _GestureType {
|
||||
pan,
|
||||
scale,
|
||||
rotate,
|
||||
}
|
||||
enum _GestureType { pan, scale, rotate }
|
||||
|
||||
// Given a velocity and drag, calculate the time at which motion will come to
|
||||
// a stop, within the margin of effectivelyMotionless.
|
||||
@@ -1457,32 +1280,16 @@ Quad _transformViewport(Matrix4 matrix, Rect viewport) {
|
||||
final Matrix4 inverseMatrix = matrix.clone()..invert();
|
||||
return Quad.points(
|
||||
inverseMatrix.transform3(
|
||||
Vector3(
|
||||
viewport.topLeft.dx,
|
||||
viewport.topLeft.dy,
|
||||
0.0,
|
||||
),
|
||||
Vector3(viewport.topLeft.dx, viewport.topLeft.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(
|
||||
viewport.topRight.dx,
|
||||
viewport.topRight.dy,
|
||||
0.0,
|
||||
),
|
||||
Vector3(viewport.topRight.dx, viewport.topRight.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(
|
||||
viewport.bottomRight.dx,
|
||||
viewport.bottomRight.dy,
|
||||
0.0,
|
||||
),
|
||||
Vector3(viewport.bottomRight.dx, viewport.bottomRight.dy, 0.0),
|
||||
),
|
||||
inverseMatrix.transform3(
|
||||
Vector3(
|
||||
viewport.bottomLeft.dx,
|
||||
viewport.bottomLeft.dy,
|
||||
0.0,
|
||||
),
|
||||
Vector3(viewport.bottomLeft.dx, viewport.bottomLeft.dy, 0.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1491,9 +1298,9 @@ Quad _transformViewport(Matrix4 matrix, Rect viewport) {
|
||||
// the given amount.
|
||||
Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) {
|
||||
final Matrix4 rotationMatrix = Matrix4.identity()
|
||||
..translate(rect.size.width / 2, rect.size.height / 2)
|
||||
..translateByDouble(rect.size.width / 2, rect.size.height / 2, 0, 1)
|
||||
..rotateZ(rotation)
|
||||
..translate(-rect.size.width / 2, -rect.size.height / 2);
|
||||
..translateByDouble(-rect.size.width / 2, -rect.size.height / 2, 0, 1);
|
||||
final Quad boundariesRotated = Quad.points(
|
||||
rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)),
|
||||
rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)),
|
||||
@@ -1562,20 +1369,3 @@ Axis? _getPanAxis(Offset point1, Offset point2) {
|
||||
final double y = point2.dy - point1.dy;
|
||||
return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical;
|
||||
}
|
||||
|
||||
/// This enum is used to specify the behavior of the [InteractiveViewer] when
|
||||
/// the user drags the viewport.
|
||||
enum PanAxis {
|
||||
/// The user can only pan the viewport along the horizontal axis.
|
||||
horizontal,
|
||||
|
||||
/// The user can only pan the viewport along the vertical axis.
|
||||
vertical,
|
||||
|
||||
/// The user can pan the viewport along the horizontal and vertical axes
|
||||
/// but not diagonally.
|
||||
aligned,
|
||||
|
||||
/// The user can pan the viewport freely in any direction.
|
||||
free,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class InteractiveViewerBoundary extends StatefulWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.boundaryWidth,
|
||||
this.controller,
|
||||
required this.controller,
|
||||
this.onScaleChanged,
|
||||
this.onLeftBoundaryHit,
|
||||
this.onRightBoundaryHit,
|
||||
@@ -43,7 +43,7 @@ class InteractiveViewerBoundary extends StatefulWidget {
|
||||
final double boundaryWidth;
|
||||
|
||||
/// The [TransformationController] for the [InteractiveViewer].
|
||||
final custom.TransformationController? controller;
|
||||
final TransformationController controller;
|
||||
|
||||
/// Called when the scale changed after an interaction ended.
|
||||
final ScaleChanged? onScaleChanged;
|
||||
@@ -68,7 +68,7 @@ class InteractiveViewerBoundary extends StatefulWidget {
|
||||
|
||||
class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
|
||||
with SingleTickerProviderStateMixin {
|
||||
custom.TransformationController? _controller;
|
||||
late TransformationController _controller;
|
||||
|
||||
double? _scale;
|
||||
|
||||
@@ -85,8 +85,7 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = widget.controller ?? custom.TransformationController();
|
||||
_controller = widget.controller;
|
||||
|
||||
_animateController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -98,9 +97,7 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller!.dispose();
|
||||
_animateController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -183,7 +180,7 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
|
||||
}
|
||||
|
||||
void _updateBoundaryDetection() {
|
||||
final double scale = _controller!.value.row0[0];
|
||||
final double scale = _controller.value.row0[0];
|
||||
|
||||
if (_scale != scale) {
|
||||
// the scale changed
|
||||
@@ -196,7 +193,7 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
|
||||
return;
|
||||
}
|
||||
|
||||
final double xOffset = _controller!.value.row0[3];
|
||||
final double xOffset = _controller.value.row0[3];
|
||||
final double boundaryWidth = widget.boundaryWidth;
|
||||
final double boundaryEnd = boundaryWidth * scale;
|
||||
final double xPos = boundaryEnd + xOffset;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer.dart'
|
||||
as custom;
|
||||
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.dart';
|
||||
import 'package:PiliPlus/utils/image_util.dart';
|
||||
import 'package:PiliPlus/utils/image_utils.dart';
|
||||
import 'package:PiliPlus/utils/page_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';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
@@ -38,7 +36,7 @@ typedef IndexedFocusedWidgetBuilder =
|
||||
|
||||
typedef IndexedTagStringBuilder = String Function(int index);
|
||||
|
||||
class InteractiveviewerGallery<T> extends StatefulWidget {
|
||||
class InteractiveviewerGallery extends StatefulWidget {
|
||||
const InteractiveviewerGallery({
|
||||
super.key,
|
||||
required this.sources,
|
||||
@@ -48,7 +46,6 @@ class InteractiveviewerGallery<T> extends StatefulWidget {
|
||||
this.minScale = 1.0,
|
||||
this.onPageChanged,
|
||||
this.onDismissed,
|
||||
this.setStatusBar = true,
|
||||
this.onClose,
|
||||
required this.quality,
|
||||
});
|
||||
@@ -57,8 +54,6 @@ class InteractiveviewerGallery<T> extends StatefulWidget {
|
||||
|
||||
final ValueChanged<bool>? onClose;
|
||||
|
||||
final bool setStatusBar;
|
||||
|
||||
/// The sources to show.
|
||||
final List<SourceModel> sources;
|
||||
|
||||
@@ -83,8 +78,8 @@ class InteractiveviewerGallery<T> extends StatefulWidget {
|
||||
|
||||
class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
with SingleTickerProviderStateMixin {
|
||||
PageController? _pageController;
|
||||
custom.TransformationController? _transformationController;
|
||||
late final PageController _pageController;
|
||||
late final TransformationController _transformationController;
|
||||
|
||||
/// The controller to animate the transformation value of the
|
||||
/// [InteractiveViewer] when it should reset.
|
||||
@@ -107,55 +102,32 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
|
||||
_pageController = PageController(initialPage: widget.initIndex);
|
||||
|
||||
_transformationController = custom.TransformationController();
|
||||
_transformationController = TransformationController();
|
||||
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
)..addListener(listener);
|
||||
|
||||
if (widget.setStatusBar) {
|
||||
setStatusBar();
|
||||
}
|
||||
|
||||
var item = widget.sources[currentIndex.value];
|
||||
final item = widget.sources[currentIndex.value];
|
||||
if (item.sourceType == SourceType.livePhoto) {
|
||||
_onPlay(item.liveUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
void listener() {
|
||||
_transformationController!.value = _animation?.value ?? Matrix4.identity();
|
||||
}
|
||||
|
||||
SystemUiMode? mode;
|
||||
Future<void> setStatusBar() async {
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
);
|
||||
}
|
||||
if (Platform.isAndroid && (await Utils.sdkInt < 29)) {
|
||||
mode = SystemUiMode.manual;
|
||||
}
|
||||
_transformationController.value = _animation?.value ?? Matrix4.identity();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.onClose?.call(true);
|
||||
_player?.dispose();
|
||||
_pageController?.dispose();
|
||||
_pageController.dispose();
|
||||
_animationController
|
||||
..removeListener(listener)
|
||||
..dispose();
|
||||
if (widget.setStatusBar) {
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
mode ?? SystemUiMode.edgeToEdge,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
}
|
||||
_transformationController.dispose();
|
||||
for (var item in widget.sources) {
|
||||
if (item.sourceType == SourceType.networkImage) {
|
||||
CachedNetworkImageProvider(_getActualUrl(item.url)).evict();
|
||||
@@ -189,7 +161,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
/// 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) {
|
||||
if (!_enablePageView && _pageController.page!.floor() > 0) {
|
||||
setState(() {
|
||||
_enablePageView = true;
|
||||
});
|
||||
@@ -200,7 +172,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
/// view swiping gets enabled if it has a page to swipe to.
|
||||
void _onRightBoundaryHit() {
|
||||
if (!_enablePageView &&
|
||||
_pageController!.page!.floor() < widget.sources.length - 1) {
|
||||
_pageController.page!.floor() < widget.sources.length - 1) {
|
||||
setState(() {
|
||||
_enablePageView = true;
|
||||
});
|
||||
@@ -235,12 +207,12 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
_onPlay(item.liveUrl!);
|
||||
}
|
||||
widget.onPageChanged?.call(page);
|
||||
if (_transformationController!.value != Matrix4.identity()) {
|
||||
if (_transformationController.value != Matrix4.identity()) {
|
||||
// animate the reset for the transformation of the interactive viewer
|
||||
|
||||
_animation =
|
||||
Matrix4Tween(
|
||||
begin: _transformationController!.value,
|
||||
begin: _transformationController.value,
|
||||
end: Matrix4.identity(),
|
||||
).animate(
|
||||
CurveTween(curve: Curves.easeOut).animate(_animationController),
|
||||
@@ -252,7 +224,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
|
||||
String _getActualUrl(String url) {
|
||||
return _quality != 100
|
||||
? ImageUtil.thumbnailUrl(url, _quality)
|
||||
? ImageUtils.thumbnailUrl(url, _quality)
|
||||
: url.http2https;
|
||||
}
|
||||
|
||||
@@ -261,7 +233,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
widget.onClose!(false);
|
||||
} else {
|
||||
Get.back();
|
||||
widget.onDismissed?.call(_pageController!.page!.floor());
|
||||
widget.onDismissed?.call(_pageController.page!.floor());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +247,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
children: [
|
||||
InteractiveViewerBoundary(
|
||||
controller: _transformationController,
|
||||
boundaryWidth: MediaQuery.sizeOf(context).width,
|
||||
boundaryWidth: MediaQuery.widthOf(context),
|
||||
onScaleChanged: _onScaleChanged,
|
||||
onLeftBoundaryHit: _onLeftBoundaryHit,
|
||||
onRightBoundaryHit: _onRightBoundaryHit,
|
||||
@@ -299,6 +271,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
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(
|
||||
@@ -314,9 +287,10 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
const Duration(milliseconds: 555),
|
||||
onDoubleTap,
|
||||
),
|
||||
onLongPress: item.sourceType == SourceType.fileImage
|
||||
? null
|
||||
: () => onLongPress(item),
|
||||
onLongPress: !isFileImg ? () => onLongPress(item) : null,
|
||||
onSecondaryTap: !isFileImg && !Utils.isMobile
|
||||
? () => onLongPress(item)
|
||||
: null,
|
||||
child: widget.itemBuilder != null
|
||||
? widget.itemBuilder!(
|
||||
context,
|
||||
@@ -335,7 +309,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding:
|
||||
MediaQuery.paddingOf(context) +
|
||||
MediaQuery.viewPaddingOf(context) +
|
||||
const EdgeInsets.fromLTRB(12, 8, 20, 8),
|
||||
decoration: _enablePageView
|
||||
? BoxDecoration(
|
||||
@@ -349,77 +323,12 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: onClose,
|
||||
),
|
||||
),
|
||||
if (widget.sources.length > 1)
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Obx(
|
||||
() => Text(
|
||||
"${currentIndex.value + 1}/${widget.sources.length}",
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.sources[currentIndex.value].sourceType !=
|
||||
SourceType.fileImage)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
final item = widget.sources[currentIndex.value];
|
||||
return [
|
||||
PopupMenuItem(
|
||||
onTap: () => ImageUtil.onShareImg(item.url),
|
||||
child: const Text("分享图片"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => Utils.copyText(item.url),
|
||||
child: const Text("复制链接"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => ImageUtil.downloadImg(
|
||||
this.context,
|
||||
[item.url],
|
||||
),
|
||||
child: const Text("保存图片"),
|
||||
),
|
||||
if (widget.sources.length > 1)
|
||||
PopupMenuItem(
|
||||
onTap: () => ImageUtil.downloadImg(
|
||||
this.context,
|
||||
widget.sources.map((item) => item.url).toList(),
|
||||
),
|
||||
child: const Text("保存全部"),
|
||||
),
|
||||
if (item.sourceType == SourceType.livePhoto)
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
ImageUtil.downloadLivePhoto(
|
||||
context: this.context,
|
||||
url: item.url,
|
||||
liveUrl: item.liveUrl!,
|
||||
width: item.width!,
|
||||
height: item.height!,
|
||||
);
|
||||
},
|
||||
child: const Text("保存 Live Photo"),
|
||||
),
|
||||
];
|
||||
},
|
||||
child: const Icon(Icons.more_horiz, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment: Alignment.center,
|
||||
child: Obx(
|
||||
() => Text(
|
||||
"${currentIndex.value + 1}/${widget.sources.length}",
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -445,7 +354,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
return CachedNetworkImage(
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
imageUrl: ImageUtil.thumbnailUrl(item.url, widget.quality),
|
||||
imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -465,7 +374,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
}
|
||||
|
||||
void onDoubleTap() {
|
||||
Matrix4 matrix = _transformationController!.value.clone();
|
||||
Matrix4 matrix = _transformationController.value.clone();
|
||||
double currentScale = matrix.row0.x;
|
||||
|
||||
double targetScale = widget.minScale;
|
||||
@@ -502,7 +411,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
|
||||
_animation =
|
||||
Matrix4Tween(
|
||||
begin: _transformationController!.value,
|
||||
begin: _transformationController.value,
|
||||
end: matrix,
|
||||
).animate(
|
||||
CurveTween(curve: Curves.easeOut).animate(_animationController),
|
||||
@@ -522,14 +431,15 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
ImageUtil.onShareImg(item.url);
|
||||
},
|
||||
dense: true,
|
||||
title: const Text('分享', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
if (Utils.isMobile)
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
ImageUtils.onShareImg(item.url);
|
||||
},
|
||||
dense: true,
|
||||
title: const Text('分享', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
@@ -541,7 +451,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
ImageUtil.downloadImg(
|
||||
ImageUtils.downloadImg(
|
||||
this.context,
|
||||
[item.url],
|
||||
);
|
||||
@@ -549,11 +459,20 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
dense: true,
|
||||
title: const Text('保存图片', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
if (widget.sources.length > 1)
|
||||
if (Utils.isDesktop)
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
ImageUtil.downloadImg(
|
||||
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(
|
||||
this.context,
|
||||
widget.sources.map((item) => item.url).toList(),
|
||||
);
|
||||
@@ -565,7 +484,7 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
|
||||
ListTile(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
ImageUtil.downloadLivePhoto(
|
||||
ImageUtils.downloadLivePhoto(
|
||||
context: this.context,
|
||||
url: item.url,
|
||||
liveUrl: item.liveUrl!,
|
||||
|
||||
@@ -22,6 +22,14 @@ class _KeepAliveWrapperState extends State<KeepAliveWrapper>
|
||||
return widget.builder(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(KeepAliveWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.wantKeepAlive != widget.wantKeepAlive) {
|
||||
updateKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.wantKeepAlive;
|
||||
}
|
||||
|
||||
1874
lib/common/widgets/list_tile.dart
Normal file
54
lib/common/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class LoadingWidget extends StatelessWidget {
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.msg = 'loading...',
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
///loading msg
|
||||
final String msg;
|
||||
final RxDouble progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dialogTheme.backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
//loading animation
|
||||
RepaintBoundary.wrap(
|
||||
Obx(
|
||||
() => CustomPaint(
|
||||
size: const Size.square(40),
|
||||
painter: ArcPainter(
|
||||
color: onSurfaceVariant,
|
||||
strokeWidth: 3,
|
||||
sweepAngle: progress.value * 2 * pi,
|
||||
),
|
||||
),
|
||||
),
|
||||
0,
|
||||
),
|
||||
//msg
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: Text(msg, style: TextStyle(color: onSurfaceVariant)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,7 @@ class HttpError extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return isSliver
|
||||
? SliverToBoxAdapter(child: content(context))
|
||||
: SizedBox(
|
||||
width: double.infinity,
|
||||
child: content(context),
|
||||
);
|
||||
: SizedBox(width: double.infinity, child: content(context));
|
||||
}
|
||||
|
||||
Widget content(BuildContext context) {
|
||||
@@ -60,7 +57,7 @@ class HttpError extends StatelessWidget {
|
||||
style: TextStyle(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom),
|
||||
SizedBox(height: 40 + MediaQuery.viewPaddingOf(context).bottom),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
475
lib/common/widgets/marquee.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class MarqueeText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final double spacing;
|
||||
final double velocity;
|
||||
final ContextSingleTicker? provider;
|
||||
|
||||
const MarqueeText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.spacing = 0,
|
||||
this.velocity = 25,
|
||||
this.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NormalMarquee(
|
||||
velocity: velocity,
|
||||
spacing: spacing,
|
||||
provider: provider,
|
||||
child: Text(
|
||||
text,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Marquee extends SingleChildRenderObjectWidget {
|
||||
final Axis direction;
|
||||
final Clip clipBehavior;
|
||||
final double spacing;
|
||||
final double velocity;
|
||||
final ContextSingleTicker? provider;
|
||||
|
||||
const Marquee({
|
||||
super.key,
|
||||
required this.velocity,
|
||||
required super.child,
|
||||
this.direction = Axis.horizontal,
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
this.spacing = 0,
|
||||
this.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
covariant MarqueeRender renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..direction = direction
|
||||
..clipBehavior = clipBehavior
|
||||
..velocity = velocity
|
||||
..spacing = spacing;
|
||||
|
||||
if (provider != null) {
|
||||
renderObject.provider = provider!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NormalMarquee extends Marquee {
|
||||
const NormalMarquee({
|
||||
super.key,
|
||||
required super.velocity,
|
||||
required super.child,
|
||||
super.direction,
|
||||
super.clipBehavior,
|
||||
super.spacing,
|
||||
super.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _NormalMarqueeRender(
|
||||
direction: direction,
|
||||
velocity: velocity,
|
||||
clipBehavior: clipBehavior,
|
||||
spacing: spacing,
|
||||
provider: provider ?? ContextSingleTicker(context),
|
||||
);
|
||||
}
|
||||
|
||||
class BounceMarquee extends Marquee {
|
||||
const BounceMarquee({
|
||||
super.key,
|
||||
required super.velocity,
|
||||
required super.child,
|
||||
super.direction,
|
||||
super.clipBehavior,
|
||||
super.spacing,
|
||||
super.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _BounceMarqueeRender(
|
||||
direction: direction,
|
||||
velocity: velocity,
|
||||
clipBehavior: clipBehavior,
|
||||
spacing: spacing,
|
||||
provider: provider ?? ContextSingleTicker(context),
|
||||
);
|
||||
}
|
||||
|
||||
abstract class MarqueeRender extends RenderBox
|
||||
with RenderObjectWithChildMixin<RenderBox> {
|
||||
MarqueeRender({
|
||||
required Axis direction,
|
||||
required double velocity,
|
||||
required double spacing,
|
||||
required this.clipBehavior,
|
||||
required ContextSingleTicker provider,
|
||||
}) : _ticker = provider,
|
||||
_spacing = spacing,
|
||||
_velocity = velocity,
|
||||
_direction = direction,
|
||||
assert(spacing.isFinite && !spacing.isNaN);
|
||||
|
||||
Clip clipBehavior;
|
||||
|
||||
Axis _direction;
|
||||
Axis get direction => _direction;
|
||||
set direction(Axis value) {
|
||||
if (_direction == value) return;
|
||||
_direction = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
ContextSingleTicker _ticker;
|
||||
set provider(ContextSingleTicker value) {
|
||||
if (_ticker == value) return;
|
||||
if (_ticker._ticker != null) {
|
||||
if (value._ticker != null) {
|
||||
value._ticker!.absorbTicker(_ticker._ticker!);
|
||||
} else {
|
||||
value
|
||||
..createTicker(_onTick)
|
||||
..initStart();
|
||||
}
|
||||
}
|
||||
_ticker.cancel();
|
||||
_ticker = value;
|
||||
}
|
||||
|
||||
double _velocity;
|
||||
set velocity(double value) {
|
||||
if (_velocity == value) return;
|
||||
_velocity = value;
|
||||
_simulation = _simulation?.copyWith(initialValue: _delta, velocity: value);
|
||||
_ticker.reset();
|
||||
}
|
||||
|
||||
double _spacing;
|
||||
set spacing(double value) {
|
||||
if (value.isNegative) {
|
||||
value *= _direction == Axis.horizontal ? -size.width : -size.height;
|
||||
}
|
||||
if (_spacing == value) return;
|
||||
|
||||
_simulation = _simulation?.copyWith(
|
||||
initialValue: _delta,
|
||||
addSize: value - _spacing,
|
||||
);
|
||||
_spacing = value;
|
||||
_ticker.reset();
|
||||
}
|
||||
|
||||
double _delta = 0;
|
||||
set delta(double value) {
|
||||
if (_delta == value) return;
|
||||
_delta = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_ticker.updateTicker();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
late double _distance;
|
||||
|
||||
_MarqueeSimulation? _simulation;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final child = this.child;
|
||||
if (child == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_direction == Axis.horizontal) {
|
||||
child.layout(
|
||||
BoxConstraints(maxHeight: constraints.maxHeight),
|
||||
parentUsesSize: true,
|
||||
);
|
||||
size = constraints.constrain(child.size);
|
||||
_distance = child.size.width - size.width;
|
||||
if (_spacing.isNegative) _spacing *= -size.width;
|
||||
} else {
|
||||
child.layout(
|
||||
BoxConstraints(maxWidth: constraints.maxWidth),
|
||||
parentUsesSize: true,
|
||||
);
|
||||
size = constraints.constrain(child.size);
|
||||
_distance = child.size.height - size.height;
|
||||
if (_spacing.isNegative) _spacing *= -size.height;
|
||||
}
|
||||
|
||||
if (_distance > 0) {
|
||||
updateSize();
|
||||
_ticker.initIfNeeded(_onTick);
|
||||
} else {
|
||||
_ticker.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
|
||||
void paintCenter(PaintingContext context, Offset offset) {
|
||||
if (_direction == Axis.horizontal) {
|
||||
context.paintChild(child!, Offset(offset.dx - _distance / 2, offset.dy));
|
||||
} else {
|
||||
context.paintChild(child!, Offset(offset.dx, offset.dy - _distance / 2));
|
||||
}
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
delta = _simulation!.x(
|
||||
elapsed.inMicroseconds.toDouble() / Duration.microsecondsPerSecond,
|
||||
);
|
||||
}
|
||||
|
||||
void updateSize();
|
||||
}
|
||||
|
||||
class _BounceMarqueeRender extends MarqueeRender {
|
||||
_BounceMarqueeRender({
|
||||
required super.direction,
|
||||
required super.velocity,
|
||||
required super.clipBehavior,
|
||||
required super.spacing,
|
||||
required super.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
void updateSize() {
|
||||
final size = _distance + _spacing;
|
||||
if (size == _simulation?.size) return;
|
||||
_simulation = _MarqueeSimulation(_delta, size, false, _velocity);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (child == null) return;
|
||||
|
||||
if (_distance > 0) {
|
||||
final delta = _spacing / 2.0 - _delta;
|
||||
void paintChild() {
|
||||
if (_direction == Axis.horizontal) {
|
||||
context.paintChild(child!, Offset(offset.dx + delta, offset.dy));
|
||||
} else {
|
||||
context.paintChild(child!, Offset(offset.dx, offset.dy + delta));
|
||||
}
|
||||
}
|
||||
|
||||
if (clipBehavior == Clip.none) {
|
||||
paintChild();
|
||||
} else {
|
||||
final rect = Rect.fromLTRB(0, 0, size.width, size.height);
|
||||
context.clipRectAndPaint(rect, clipBehavior, rect, paintChild);
|
||||
}
|
||||
} else {
|
||||
paintCenter(context, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NormalMarqueeRender extends MarqueeRender {
|
||||
_NormalMarqueeRender({
|
||||
required super.direction,
|
||||
required super.velocity,
|
||||
required super.clipBehavior,
|
||||
required super.spacing,
|
||||
required super.provider,
|
||||
});
|
||||
|
||||
@override
|
||||
void updateSize() {
|
||||
final size =
|
||||
(_direction == Axis.horizontal
|
||||
? child!.size.width
|
||||
: child!.size.height) +
|
||||
_spacing;
|
||||
if (size == _simulation?.size) return;
|
||||
_simulation = _MarqueeSimulation(_delta, size, true, _velocity);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final child = this.child;
|
||||
if (child == null) return;
|
||||
|
||||
if (_distance > 0) {
|
||||
void paintChild() {
|
||||
if (_direction == Axis.horizontal) {
|
||||
final dx = _delta;
|
||||
context.paintChild(child, Offset(offset.dx - dx, offset.dy));
|
||||
if (dx > _distance) {
|
||||
context.paintChild(
|
||||
child,
|
||||
Offset(offset.dx + _simulation!.size - dx, offset.dy),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final dy = _delta;
|
||||
context.paintChild(child, Offset(offset.dx, offset.dy - dy));
|
||||
if (dy > _distance) {
|
||||
context.paintChild(
|
||||
child,
|
||||
Offset(offset.dx, offset.dy + _simulation!.size - dy),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clipBehavior == Clip.none) {
|
||||
paintChild();
|
||||
} else {
|
||||
final rect = Rect.fromLTRB(0, 0, size.width, size.height);
|
||||
context.clipRectAndPaint(rect, clipBehavior, rect, paintChild);
|
||||
}
|
||||
} else {
|
||||
paintCenter(context, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MarqueeSimulation extends Simulation {
|
||||
_MarqueeSimulation(
|
||||
this.initialValue,
|
||||
this.size,
|
||||
this.notBounce,
|
||||
this.velocity,
|
||||
);
|
||||
|
||||
final double initialValue;
|
||||
final double size;
|
||||
final bool notBounce;
|
||||
final double velocity;
|
||||
|
||||
@override
|
||||
double x(double timeInSeconds) {
|
||||
assert(timeInSeconds >= 0.0);
|
||||
final totalX = initialValue + velocity * timeInSeconds;
|
||||
if (notBounce) return totalX % size;
|
||||
|
||||
final doublePeriod = 2.0 * size;
|
||||
final doubleX = totalX % doublePeriod;
|
||||
return doubleX < size ? doubleX : doublePeriod - doubleX;
|
||||
}
|
||||
|
||||
@override
|
||||
double dx(double timeInSeconds) => velocity;
|
||||
|
||||
@override
|
||||
bool isDone(double timeInSeconds) => false;
|
||||
|
||||
_MarqueeSimulation copyWith({
|
||||
final double? initialValue,
|
||||
final double? addSize,
|
||||
final bool? notBounce,
|
||||
final double? velocity,
|
||||
}) => _MarqueeSimulation(
|
||||
initialValue ?? this.initialValue,
|
||||
addSize == null ? size : size + addSize,
|
||||
notBounce ?? this.notBounce,
|
||||
velocity ?? this.velocity,
|
||||
);
|
||||
}
|
||||
|
||||
class ContextSingleTicker implements TickerProvider {
|
||||
Ticker? _ticker;
|
||||
BuildContext context;
|
||||
final bool autoStart;
|
||||
|
||||
ContextSingleTicker(this.context, {this.autoStart = true});
|
||||
|
||||
void initStart() {
|
||||
if (autoStart) {
|
||||
_ticker?.start();
|
||||
}
|
||||
}
|
||||
|
||||
void startIfNeeded() {
|
||||
if (_ticker case final ticker?) {
|
||||
if (!ticker.isActive) {
|
||||
ticker.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void initIfNeeded(TickerCallback onTick) {
|
||||
if (_ticker == null) {
|
||||
createTicker(onTick);
|
||||
initStart();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Ticker createTicker(TickerCallback onTick) {
|
||||
assert(() {
|
||||
if (_ticker == null) {
|
||||
return true;
|
||||
}
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary(
|
||||
'$runtimeType is a SingleTickerProviderStateMixin but multiple tickers were created.',
|
||||
),
|
||||
ErrorDescription(
|
||||
'A SingleTickerProviderStateMixin can only be used as a TickerProvider once.',
|
||||
),
|
||||
ErrorHint(
|
||||
'If a State is used for multiple AnimationController objects, or if it is passed to other '
|
||||
'objects and those objects might use it more than one time in total, then instead of '
|
||||
'mixing in a SingleTickerProviderStateMixin, use a regular TickerProviderStateMixin.',
|
||||
),
|
||||
]);
|
||||
}());
|
||||
_ticker = Ticker(
|
||||
onTick,
|
||||
debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null,
|
||||
);
|
||||
_tickerModeNotifier = TickerMode.getNotifier(context)
|
||||
..addListener(updateTicker);
|
||||
updateTicker(); // Sets _ticker.mute correctly.
|
||||
return _ticker!;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_ticker
|
||||
?..stop()
|
||||
..start();
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_ticker?.dispose();
|
||||
_ticker = null;
|
||||
_tickerModeNotifier?.removeListener(updateTicker);
|
||||
_tickerModeNotifier = null;
|
||||
}
|
||||
|
||||
ValueListenable<bool>? _tickerModeNotifier;
|
||||
|
||||
void updateTicker() => _ticker?.muted = !_tickerModeNotifier!.value;
|
||||
|
||||
set muted(bool value) => _ticker?.muted = value;
|
||||
}
|
||||
27
lib/common/widgets/mouse_back.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MouseBackDetector extends StatelessWidget {
|
||||
const MouseBackDetector({
|
||||
super.key,
|
||||
required this.onTapDown,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
final VoidCallback onTapDown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
if (event.buttons == kBackMouseButton) {
|
||||
onTapDown();
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -665,10 +664,6 @@ class CustomScrollableState extends State<CustomScrollable>
|
||||
vsync: this,
|
||||
reverseDuration: const Duration(milliseconds: 500),
|
||||
);
|
||||
_anim = Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(0, 1),
|
||||
).animate(_animController);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -782,11 +777,11 @@ class CustomScrollableState extends State<CustomScrollable>
|
||||
bool? _lastCanDrag;
|
||||
Axis? _lastAxisDirection;
|
||||
|
||||
late bool _isRTL = false;
|
||||
Offset? _downPos;
|
||||
bool? _isSliding;
|
||||
|
||||
late AnimationController _animController;
|
||||
late Animation<Offset> _anim;
|
||||
|
||||
@override
|
||||
@protected
|
||||
@@ -893,7 +888,12 @@ class CustomScrollableState extends State<CustomScrollable>
|
||||
ScrollHoldController? _hold;
|
||||
|
||||
void _handleDragDown(DragDownDetails details) {
|
||||
if (details.localPosition.dx <= 30) {
|
||||
final dx = details.localPosition.dx;
|
||||
const offset = 30;
|
||||
final isLTR = dx <= offset;
|
||||
final isRTL = dx >= _maxWidth - offset;
|
||||
if (isLTR || isRTL) {
|
||||
_isRTL = isRTL;
|
||||
_downPos = details.localPosition;
|
||||
return;
|
||||
}
|
||||
@@ -915,19 +915,24 @@ class CustomScrollableState extends State<CustomScrollable>
|
||||
_downPos = null;
|
||||
_isSliding = false;
|
||||
}
|
||||
} else {
|
||||
_downPos = null;
|
||||
_isSliding = false;
|
||||
}
|
||||
} else if (_isSliding == true) {
|
||||
if (localPosition.dx < 0) {
|
||||
return;
|
||||
}
|
||||
final from = _downPos!.dx;
|
||||
final to = localPosition.dx;
|
||||
_animController.value =
|
||||
max(0, (localPosition.dx - _downPos!.dx)) / _maxWidth;
|
||||
math.max(0, _isRTL ? from - to : to - from) / _maxWidth;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDismiss() {
|
||||
if (_isSliding == true) {
|
||||
if (_animController.value * _maxWidth + _downPos!.dx >= 100) {
|
||||
final dx = _downPos!.dx;
|
||||
if (_animController.value * _maxWidth +
|
||||
(_isRTL ? (_maxWidth - dx) : dx) >=
|
||||
100) {
|
||||
Get.back();
|
||||
} else {
|
||||
_animController.reverse();
|
||||
@@ -1172,24 +1177,31 @@ class CustomScrollableState extends State<CustomScrollable>
|
||||
);
|
||||
}
|
||||
|
||||
return SlideTransition(
|
||||
position: _anim,
|
||||
child: Material(
|
||||
color: widget.bgColor,
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constrains) {
|
||||
_maxWidth = constrains.maxWidth;
|
||||
return widget.header != null
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_maxWidth = constraints.maxWidth;
|
||||
return AnimatedBuilder(
|
||||
animation: _animController,
|
||||
builder: (context, child) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
heightFactor: 1 - _animController.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: widget.bgColor,
|
||||
child: widget.header != null
|
||||
? Column(
|
||||
children: [
|
||||
widget.header!,
|
||||
Expanded(child: result),
|
||||
],
|
||||
)
|
||||
: result;
|
||||
},
|
||||
),
|
||||
),
|
||||
: result,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -244,9 +244,7 @@ class _CustomTabBarViewState extends State<CustomTabBarView> {
|
||||
);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_updateChildren();
|
||||
});
|
||||
setState(_updateChildren);
|
||||
}
|
||||
return Future<void>.value();
|
||||
}
|
||||
@@ -286,9 +284,7 @@ class _CustomTabBarViewState extends State<CustomTabBarView> {
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_updateChildren();
|
||||
});
|
||||
setState(_updateChildren);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/models/common/avatar_badge_type.dart';
|
||||
import 'package:PiliPlus/models/common/image_type.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:PiliPlus/utils/image_util.dart';
|
||||
import 'package:PiliPlus/utils/image_utils.dart';
|
||||
import 'package:PiliPlus/utils/page_utils.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -22,13 +22,13 @@ class PendantAvatar extends StatelessWidget {
|
||||
required this.avatar,
|
||||
this.size = 80,
|
||||
double? badgeSize,
|
||||
bool? isVip,
|
||||
bool isVip = false,
|
||||
int? officialType,
|
||||
this.garbPendantImage,
|
||||
this.roomId,
|
||||
this.onTap,
|
||||
}) : _badgeType = officialType == null || officialType < 0
|
||||
? isVip == true
|
||||
? isVip
|
||||
? BadgeType.vip
|
||||
: BadgeType.none
|
||||
: officialType == 0
|
||||
@@ -43,16 +43,17 @@ class PendantAvatar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isMemberAvatar = size == 80;
|
||||
return Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
onTap == null
|
||||
? _buildAvatar(colorScheme)
|
||||
? _buildAvatar(colorScheme, isMemberAvatar)
|
||||
: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: _buildAvatar(colorScheme),
|
||||
child: _buildAvatar(colorScheme, isMemberAvatar),
|
||||
),
|
||||
if (showDynDecorate && !garbPendantImage.isNullOrEmpty)
|
||||
Positioned(
|
||||
@@ -63,7 +64,7 @@ class PendantAvatar extends StatelessWidget {
|
||||
child: CachedNetworkImage(
|
||||
width: size * 1.75,
|
||||
height: size * 1.75,
|
||||
imageUrl: ImageUtil.thumbnailUrl(garbPendantImage),
|
||||
imageUrl: ImageUtils.thumbnailUrl(garbPendantImage),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -100,12 +101,13 @@ class PendantAvatar extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else if (_badgeType != BadgeType.none)
|
||||
_buildBadge(colorScheme),
|
||||
_buildBadge(colorScheme, isMemberAvatar),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme colorScheme) => size == 80
|
||||
Widget _buildAvatar(ColorScheme colorScheme, bool isMemberAvatar) =>
|
||||
isMemberAvatar
|
||||
? DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
@@ -131,7 +133,7 @@ class PendantAvatar extends StatelessWidget {
|
||||
type: ImageType.avatar,
|
||||
);
|
||||
|
||||
Widget _buildBadge(ColorScheme colorScheme) {
|
||||
Widget _buildBadge(ColorScheme colorScheme, bool isMemberAvatar) {
|
||||
final child = switch (_badgeType) {
|
||||
BadgeType.vip => Image.asset(
|
||||
'assets/images/big-vip.png',
|
||||
@@ -145,9 +147,10 @@ class PendantAvatar extends StatelessWidget {
|
||||
semanticLabel: _badgeType.desc,
|
||||
),
|
||||
};
|
||||
final offset = isMemberAvatar ? 2.0 : 0.0;
|
||||
return Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
right: offset,
|
||||
bottom: offset,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -5,52 +5,6 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
/// This is where the current time and total time labels should appear in
|
||||
/// relation to the progress bar.
|
||||
enum TimeLabelLocation {
|
||||
/// The time is displayed above the progress bar.
|
||||
///
|
||||
/// | 01:23 05:00 |
|
||||
/// | -------O---------------- |
|
||||
above,
|
||||
|
||||
/// The time is displayed below the progress bar.
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
/// | 01:23 05:00 |
|
||||
below,
|
||||
|
||||
/// The time is displayed on the sides of the progress bar.
|
||||
///
|
||||
/// | 01:23 -------O---------------- 05:00 |
|
||||
sides,
|
||||
|
||||
/// The time is not displayed.
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
none,
|
||||
}
|
||||
|
||||
/// The time label on the right hand side can be shown as the [totalTime] or as
|
||||
/// the [remainingTime]. If the choice is [remainingTime] then this will be
|
||||
/// shown as a negative number.
|
||||
///
|
||||
///
|
||||
enum TimeLabelType {
|
||||
/// The time label on the right shows the total time.
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
/// | 01:23 05:00 |
|
||||
totalTime,
|
||||
|
||||
/// The time label on the right shows the remaining time as a
|
||||
/// negative number.
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
/// | 01:23 -03:37 |
|
||||
remainingTime,
|
||||
}
|
||||
|
||||
/// The shape of the progress bar at the left and right ends.
|
||||
enum BarCapShape {
|
||||
/// The left and right ends of the bar are round.
|
||||
@@ -83,19 +37,15 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
this.onDragUpdate,
|
||||
this.onDragEnd,
|
||||
this.barHeight = 5.0,
|
||||
this.baseBarColor,
|
||||
this.progressBarColor,
|
||||
this.bufferedBarColor,
|
||||
required this.baseBarColor,
|
||||
required this.progressBarColor,
|
||||
required this.bufferedBarColor,
|
||||
this.barCapShape = BarCapShape.round,
|
||||
this.thumbRadius = 10.0,
|
||||
this.thumbColor,
|
||||
this.thumbGlowColor,
|
||||
required this.thumbColor,
|
||||
required this.thumbGlowColor,
|
||||
this.thumbGlowRadius = 30.0,
|
||||
this.thumbCanPaintOutsideBar = true,
|
||||
this.timeLabelLocation,
|
||||
this.timeLabelType,
|
||||
this.timeLabelTextStyle,
|
||||
this.timeLabelPadding = 0.0,
|
||||
});
|
||||
|
||||
/// The elapsed playing time of the media.
|
||||
@@ -172,20 +122,20 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
/// The color of the progress bar before playback has started.
|
||||
///
|
||||
/// By default it is a transparent version of your theme's primary color.
|
||||
final Color? baseBarColor;
|
||||
final Color baseBarColor;
|
||||
|
||||
/// The color of the progress bar to the left of the current playing
|
||||
/// [progress].
|
||||
///
|
||||
/// By default it is your theme's primary color.
|
||||
final Color? progressBarColor;
|
||||
final Color progressBarColor;
|
||||
|
||||
/// The color of the progress bar between the [progress] location and the
|
||||
/// [buffered] location.
|
||||
///
|
||||
/// By default it is a transparent version of your theme's primary color,
|
||||
/// a shade darker than [baseBarColor].
|
||||
final Color? bufferedBarColor;
|
||||
final Color bufferedBarColor;
|
||||
|
||||
/// The shape of the bar at the left and right ends.
|
||||
///
|
||||
@@ -199,12 +149,12 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
/// The color of the circle for the moveable progress bar thumb.
|
||||
///
|
||||
/// By default it is your theme's primary color.
|
||||
final Color? thumbColor;
|
||||
final Color thumbColor;
|
||||
|
||||
/// The color of the pressed-down effect of the moveable progress bar thumb.
|
||||
///
|
||||
/// By default it is [thumbColor] with an alpha value of 80.
|
||||
final Color? thumbGlowColor;
|
||||
final Color thumbGlowColor;
|
||||
|
||||
/// The radius of the circle for the pressed-down effect of the moveable
|
||||
/// progress bar thumb.
|
||||
@@ -229,35 +179,8 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
/// is happening during this time, though.
|
||||
final bool thumbCanPaintOutsideBar;
|
||||
|
||||
/// The location for the [progress] and [total] duration text labels.
|
||||
///
|
||||
/// By default the labels appear under the progress bar but you can also
|
||||
/// put them above, on the sides, or remove them altogether.
|
||||
final TimeLabelLocation? timeLabelLocation;
|
||||
|
||||
/// What to display for the time label on the right
|
||||
///
|
||||
/// The right time label can show the total time or the remaining time as a
|
||||
/// negative number. The default is [TimeLabelType.totalTime].
|
||||
final TimeLabelType? timeLabelType;
|
||||
|
||||
/// The [TextStyle] used by the time labels.
|
||||
///
|
||||
/// By default it is [TextTheme.bodyLarge].
|
||||
final TextStyle? timeLabelTextStyle;
|
||||
|
||||
/// The extra space between the time labels and the progress bar.
|
||||
///
|
||||
/// The default is 0.0. A positive number will move the labels further from
|
||||
/// the progress bar and a negative number will move them closer.
|
||||
final double timeLabelPadding;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primaryColor = theme.colorScheme.primary;
|
||||
final textStyle = timeLabelTextStyle ?? theme.textTheme.bodyLarge;
|
||||
final textScaleFactor = MediaQuery.textScalerOf(context).scale(1);
|
||||
return _RenderProgressBar(
|
||||
progress: progress,
|
||||
total: total,
|
||||
@@ -267,56 +190,38 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
onDragUpdate: onDragUpdate,
|
||||
onDragEnd: onDragEnd,
|
||||
barHeight: barHeight,
|
||||
baseBarColor: baseBarColor ?? primaryColor.withValues(alpha: 0.24),
|
||||
progressBarColor: progressBarColor ?? primaryColor,
|
||||
bufferedBarColor:
|
||||
bufferedBarColor ?? primaryColor.withValues(alpha: 0.24),
|
||||
baseBarColor: baseBarColor,
|
||||
progressBarColor: progressBarColor,
|
||||
bufferedBarColor: bufferedBarColor,
|
||||
barCapShape: barCapShape,
|
||||
thumbRadius: thumbRadius,
|
||||
thumbColor: thumbColor ?? primaryColor,
|
||||
thumbGlowColor:
|
||||
thumbGlowColor ?? (thumbColor ?? primaryColor).withAlpha(80),
|
||||
thumbColor: thumbColor,
|
||||
thumbGlowColor: thumbGlowColor,
|
||||
thumbGlowRadius: thumbGlowRadius,
|
||||
thumbCanPaintOutsideBar: thumbCanPaintOutsideBar,
|
||||
timeLabelLocation: timeLabelLocation ?? TimeLabelLocation.below,
|
||||
timeLabelType: timeLabelType ?? TimeLabelType.totalTime,
|
||||
timeLabelTextStyle: textStyle,
|
||||
timeLabelPadding: timeLabelPadding,
|
||||
textScaleFactor: textScaleFactor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
final theme = Theme.of(context);
|
||||
final primaryColor = theme.colorScheme.primary;
|
||||
final textStyle = timeLabelTextStyle ?? theme.textTheme.bodyLarge;
|
||||
final textScaleFactor = MediaQuery.textScalerOf(context).scale(1);
|
||||
(renderObject as _RenderProgressBar)
|
||||
..progress = progress
|
||||
..total = total
|
||||
..progress = progress
|
||||
..buffered = buffered ?? Duration.zero
|
||||
..onSeek = onSeek
|
||||
..onDragStart = onDragStart
|
||||
..onDragUpdate = onDragUpdate
|
||||
..onDragEnd = onDragEnd
|
||||
..barHeight = barHeight
|
||||
..baseBarColor = baseBarColor ?? primaryColor.withValues(alpha: 0.24)
|
||||
..progressBarColor = progressBarColor ?? primaryColor
|
||||
..bufferedBarColor =
|
||||
bufferedBarColor ?? primaryColor.withValues(alpha: 0.24)
|
||||
..baseBarColor = baseBarColor
|
||||
..progressBarColor = progressBarColor
|
||||
..bufferedBarColor = bufferedBarColor
|
||||
..barCapShape = barCapShape
|
||||
..thumbRadius = thumbRadius
|
||||
..thumbColor = thumbColor ?? primaryColor
|
||||
..thumbGlowColor =
|
||||
thumbGlowColor ?? (thumbColor ?? primaryColor).withAlpha(80)
|
||||
..thumbColor = thumbColor
|
||||
..thumbGlowColor = thumbGlowColor
|
||||
..thumbGlowRadius = thumbGlowRadius
|
||||
..thumbCanPaintOutsideBar = thumbCanPaintOutsideBar
|
||||
..timeLabelLocation = timeLabelLocation ?? TimeLabelLocation.below
|
||||
..timeLabelType = timeLabelType ?? TimeLabelType.totalTime
|
||||
..timeLabelTextStyle = textStyle
|
||||
..timeLabelPadding = timeLabelPadding
|
||||
..textScaleFactor = textScaleFactor;
|
||||
..thumbCanPaintOutsideBar = thumbCanPaintOutsideBar;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -371,11 +276,7 @@ class ProgressBar extends LeafRenderObjectWidget {
|
||||
ifFalse: 'false',
|
||||
showName: true,
|
||||
),
|
||||
)
|
||||
..add(StringProperty('timeLabelLocation', timeLabelLocation.toString()))
|
||||
..add(StringProperty('timeLabelType', timeLabelType.toString()))
|
||||
..add(DiagnosticsProperty('timeLabelTextStyle', timeLabelTextStyle))
|
||||
..add(DoubleProperty('timeLabelPadding', timeLabelPadding));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,11 +346,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
required Color thumbGlowColor,
|
||||
double thumbGlowRadius = 30.0,
|
||||
bool thumbCanPaintOutsideBar = true,
|
||||
required TimeLabelLocation timeLabelLocation,
|
||||
required TimeLabelType timeLabelType,
|
||||
TextStyle? timeLabelTextStyle,
|
||||
double timeLabelPadding = 0.0,
|
||||
double textScaleFactor = 1.0,
|
||||
}) : _total = total,
|
||||
_buffered = buffered,
|
||||
_onSeek = onSeek,
|
||||
@@ -465,12 +361,8 @@ class _RenderProgressBar extends RenderBox {
|
||||
_thumbColor = thumbColor,
|
||||
_thumbGlowColor = thumbGlowColor,
|
||||
_thumbGlowRadius = thumbGlowRadius,
|
||||
_thumbCanPaintOutsideBar = thumbCanPaintOutsideBar,
|
||||
_timeLabelLocation = timeLabelLocation,
|
||||
_timeLabelType = timeLabelType,
|
||||
_timeLabelTextStyle = timeLabelTextStyle,
|
||||
_timeLabelPadding = timeLabelPadding,
|
||||
_textScaleFactor = textScaleFactor {
|
||||
_paintThumbGlow = thumbGlowRadius > thumbRadius,
|
||||
_thumbCanPaintOutsideBar = thumbCanPaintOutsideBar {
|
||||
_drag = _EagerHorizontalDragGestureRecognizer()
|
||||
..onStart = _onDragStart
|
||||
..onUpdate = _onDragUpdate
|
||||
@@ -482,6 +374,12 @@ class _RenderProgressBar extends RenderBox {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_drag?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// This is the gesture recognizer used to move the thumb.
|
||||
_EagerHorizontalDragGestureRecognizer? _drag;
|
||||
|
||||
@@ -495,14 +393,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
// time as a [progress] update there won't be a conflict.
|
||||
bool _userIsDraggingThumb = false;
|
||||
|
||||
// This padding is always used between the time labels and the progress bar
|
||||
// when the time labels are on the sides. Any user defined [timeLabelPadding]
|
||||
// is in addition to this.
|
||||
double get _defaultSidePadding {
|
||||
const minPadding = 5.0;
|
||||
return (_thumbCanPaintOutsideBar) ? thumbRadius + minPadding : minPadding;
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
if (onDragStart == null) {
|
||||
return;
|
||||
@@ -557,20 +447,12 @@ class _RenderProgressBar extends RenderBox {
|
||||
// only one place to make changes.
|
||||
void _updateThumbPosition(Offset localPosition) {
|
||||
final dx = localPosition.dx;
|
||||
double lengthBefore = 0.0;
|
||||
double lengthAfter = 0.0;
|
||||
if (_timeLabelLocation == TimeLabelLocation.sides) {
|
||||
lengthBefore =
|
||||
_leftLabelSize.width + _defaultSidePadding + _timeLabelPadding;
|
||||
lengthAfter =
|
||||
_rightLabelSize.width + _defaultSidePadding + _timeLabelPadding;
|
||||
}
|
||||
// The paint used to draw the bar line draws half of the cap before the
|
||||
// start of the line (and after the end of the line). The cap radius is
|
||||
// equal to half of the line width, which in this case is the bar height.
|
||||
final barCapRadius = _barHeight / 2;
|
||||
double barStart = lengthBefore + barCapRadius;
|
||||
double barEnd = size.width - lengthAfter - barCapRadius;
|
||||
double barStart = barCapRadius;
|
||||
double barEnd = size.width - barCapRadius;
|
||||
final barWidth = barEnd - barStart;
|
||||
final position = (dx - barStart).clamp(0.0, barWidth);
|
||||
_thumbValue = (position / barWidth);
|
||||
@@ -588,9 +470,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
if (_progress == clamp) {
|
||||
return;
|
||||
}
|
||||
if (_labelLengthDifferent(_progress, clamp)) {
|
||||
_clearLabelCache();
|
||||
}
|
||||
if (!_userIsDraggingThumb) {
|
||||
_progress = clamp;
|
||||
_thumbValue = _proportionOfTotal(clamp);
|
||||
@@ -598,58 +477,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
bool _labelLengthDifferent(Duration first, Duration second) {
|
||||
return (first.inMinutes < 10 && second.inMinutes >= 10) ||
|
||||
(first.inMinutes >= 10 && second.inMinutes < 10) ||
|
||||
(first.inHours == 0 && second.inHours != 0) ||
|
||||
(first.inHours != 0 && second.inHours == 0) ||
|
||||
(first.inHours < 10 && second.inHours >= 10) ||
|
||||
(first.inHours >= 10 && second.inHours < 10);
|
||||
}
|
||||
|
||||
TextPainter? _cachedLeftLabel;
|
||||
Size get _leftLabelSize {
|
||||
_cachedLeftLabel ??= _leftTimeLabel();
|
||||
return _cachedLeftLabel!.size;
|
||||
}
|
||||
|
||||
TextPainter? _cachedRightLabel;
|
||||
Size get _rightLabelSize {
|
||||
_cachedRightLabel ??= _rightTimeLabel();
|
||||
return _cachedRightLabel!.size;
|
||||
}
|
||||
|
||||
void _clearLabelCache() {
|
||||
_cachedLeftLabel = null;
|
||||
_cachedRightLabel = null;
|
||||
}
|
||||
|
||||
TextPainter _leftTimeLabel() {
|
||||
final text = _getTimeString(progress);
|
||||
return _layoutText(text);
|
||||
}
|
||||
|
||||
TextPainter _rightTimeLabel() {
|
||||
switch (timeLabelType) {
|
||||
case TimeLabelType.totalTime:
|
||||
final text = _getTimeString(total);
|
||||
return _layoutText(text);
|
||||
case TimeLabelType.remainingTime:
|
||||
final remaining = total - progress;
|
||||
final text = '-${_getTimeString(remaining)}';
|
||||
return _layoutText(text);
|
||||
}
|
||||
}
|
||||
|
||||
TextPainter _layoutText(String text) {
|
||||
TextPainter textPainter = TextPainter(
|
||||
text: TextSpan(text: text, style: _timeLabelTextStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaler: TextScaler.linear(textScaleFactor),
|
||||
)..layout(minWidth: 0, maxWidth: double.infinity);
|
||||
return textPainter;
|
||||
}
|
||||
|
||||
/// The total time length of the media.
|
||||
Duration get total => _total;
|
||||
Duration _total;
|
||||
@@ -658,9 +485,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
if (_total == clamp) {
|
||||
return;
|
||||
}
|
||||
if (_labelLengthDifferent(_total, clamp)) {
|
||||
_clearLabelCache();
|
||||
}
|
||||
_total = clamp;
|
||||
if (!_userIsDraggingThumb) {
|
||||
_thumbValue = _proportionOfTotal(progress);
|
||||
@@ -798,11 +622,13 @@ class _RenderProgressBar extends RenderBox {
|
||||
}
|
||||
|
||||
/// The length of the radius of the pressed-down effect of the moveable thumb.
|
||||
bool _paintThumbGlow;
|
||||
double get thumbGlowRadius => _thumbGlowRadius;
|
||||
double _thumbGlowRadius;
|
||||
set thumbGlowRadius(double value) {
|
||||
if (_thumbGlowRadius == value) return;
|
||||
_thumbGlowRadius = value;
|
||||
_paintThumbGlow = value > _thumbRadius;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -815,59 +641,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
/// The position of the duration text labels for the progress and total time.
|
||||
TimeLabelLocation get timeLabelLocation => _timeLabelLocation;
|
||||
TimeLabelLocation _timeLabelLocation;
|
||||
set timeLabelLocation(TimeLabelLocation value) {
|
||||
if (_timeLabelLocation == value) return;
|
||||
_timeLabelLocation = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// What to display for the time label on the right
|
||||
///
|
||||
/// The right time label can show the total time or the remaining time as a
|
||||
/// negative number. The default is [TimeLabelType.totalTime].
|
||||
TimeLabelType get timeLabelType => _timeLabelType;
|
||||
TimeLabelType _timeLabelType;
|
||||
set timeLabelType(TimeLabelType value) {
|
||||
if (_timeLabelType == value) return;
|
||||
_timeLabelType = value;
|
||||
_clearLabelCache();
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// The text style for the duration text labels. By default this style is
|
||||
/// taken from the theme's [textStyle.bodyText1].
|
||||
TextStyle? get timeLabelTextStyle => _timeLabelTextStyle;
|
||||
TextStyle? _timeLabelTextStyle;
|
||||
set timeLabelTextStyle(TextStyle? value) {
|
||||
if (_timeLabelTextStyle == value) return;
|
||||
_timeLabelTextStyle = value;
|
||||
_clearLabelCache();
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// The length of the radius for the circular thumb.
|
||||
double get timeLabelPadding => _timeLabelPadding;
|
||||
double _timeLabelPadding;
|
||||
set timeLabelPadding(double value) {
|
||||
if (_timeLabelPadding == value) return;
|
||||
_timeLabelPadding = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// The text scale factor for the `progress` and `total` text labels.
|
||||
/// By default the value is 1.0.
|
||||
double get textScaleFactor => _textScaleFactor;
|
||||
double _textScaleFactor;
|
||||
set textScaleFactor(double value) {
|
||||
if (_textScaleFactor == value) return;
|
||||
_textScaleFactor = value;
|
||||
_clearLabelCache();
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
// The smallest that this widget would ever want to be.
|
||||
static const _minDesiredWidth = 100.0;
|
||||
|
||||
@@ -878,10 +651,10 @@ class _RenderProgressBar extends RenderBox {
|
||||
double computeMaxIntrinsicWidth(double height) => _minDesiredWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => _calculateDesiredHeight();
|
||||
double computeMinIntrinsicHeight(double width) => _heightWhenNoLabels();
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => _calculateDesiredHeight();
|
||||
double computeMaxIntrinsicHeight(double width) => _heightWhenNoLabels();
|
||||
|
||||
@override
|
||||
bool hitTestSelf(Offset position) => true;
|
||||
@@ -902,41 +675,15 @@ class _RenderProgressBar extends RenderBox {
|
||||
@override
|
||||
Size computeDryLayout(BoxConstraints constraints) {
|
||||
final desiredWidth = constraints.maxWidth;
|
||||
final desiredHeight = _calculateDesiredHeight();
|
||||
final desiredHeight = _heightWhenNoLabels();
|
||||
final desiredSize = Size(desiredWidth, desiredHeight);
|
||||
return constraints.constrain(desiredSize);
|
||||
}
|
||||
|
||||
// When changing these remember to keep the gesture recognizer for the
|
||||
// thumb in sync.
|
||||
double _calculateDesiredHeight() {
|
||||
switch (_timeLabelLocation) {
|
||||
case TimeLabelLocation.below:
|
||||
case TimeLabelLocation.above:
|
||||
return _heightWhenLabelsAboveOrBelow();
|
||||
case TimeLabelLocation.sides:
|
||||
return _heightWhenLabelsOnSides();
|
||||
default:
|
||||
return _heightWhenNoLabels();
|
||||
}
|
||||
}
|
||||
|
||||
double _heightWhenLabelsAboveOrBelow() {
|
||||
return _heightWhenNoLabels() + _textHeight() + _timeLabelPadding;
|
||||
}
|
||||
|
||||
double _heightWhenLabelsOnSides() {
|
||||
return max(_heightWhenNoLabels(), _textHeight());
|
||||
}
|
||||
|
||||
double _heightWhenNoLabels() {
|
||||
return max(2 * _thumbRadius, _barHeight);
|
||||
}
|
||||
|
||||
double _textHeight() {
|
||||
return _leftLabelSize.height;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
|
||||
@@ -946,94 +693,18 @@ class _RenderProgressBar extends RenderBox {
|
||||
..save()
|
||||
..translate(offset.dx, offset.dy);
|
||||
|
||||
switch (_timeLabelLocation) {
|
||||
case TimeLabelLocation.above:
|
||||
case TimeLabelLocation.below:
|
||||
_drawProgressBarWithLabelsAboveOrBelow(canvas);
|
||||
break;
|
||||
case TimeLabelLocation.sides:
|
||||
_drawProgressBarWithLabelsOnSides(canvas);
|
||||
break;
|
||||
default:
|
||||
_drawProgressBarWithoutLabels(canvas);
|
||||
}
|
||||
_drawProgressBarWithoutLabels(canvas);
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// Draw the progress bar and labels vertically aligned:
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
/// | 01:23 05:00 |
|
||||
///
|
||||
/// Or like this:
|
||||
///
|
||||
/// | 01:23 05:00 |
|
||||
/// | -------O---------------- |
|
||||
void _drawProgressBarWithLabelsAboveOrBelow(Canvas canvas) {
|
||||
// calculate sizes
|
||||
final barWidth = size.width;
|
||||
final barHeight = _heightWhenNoLabels();
|
||||
|
||||
// whether to paint the labels below the progress bar or above it
|
||||
final isLabelBelow = _timeLabelLocation == TimeLabelLocation.below;
|
||||
|
||||
// current time label
|
||||
final labelDy = (isLabelBelow) ? barHeight + _timeLabelPadding : 0.0;
|
||||
final leftLabelOffset = Offset(0, labelDy);
|
||||
_leftTimeLabel().paint(canvas, leftLabelOffset);
|
||||
|
||||
// total or remaining time label
|
||||
final rightLabelDx = size.width - _rightLabelSize.width;
|
||||
final rightLabelOffset = Offset(rightLabelDx, labelDy);
|
||||
_rightTimeLabel().paint(canvas, rightLabelOffset);
|
||||
|
||||
// progress bar
|
||||
final barDy = (isLabelBelow)
|
||||
? 0.0
|
||||
: _leftLabelSize.height + _timeLabelPadding;
|
||||
_drawProgressBar(canvas, Offset(0, barDy), Size(barWidth, barHeight));
|
||||
}
|
||||
|
||||
/// Draw the progress bar and labels horizontally aligned:
|
||||
///
|
||||
/// | 01:23 -------O---------------- 05:00 |
|
||||
///
|
||||
void _drawProgressBarWithLabelsOnSides(Canvas canvas) {
|
||||
// left time label
|
||||
final leftLabelSize = _leftLabelSize;
|
||||
final verticalOffset = size.height / 2 - leftLabelSize.height / 2;
|
||||
final leftLabelOffset = Offset(0, verticalOffset);
|
||||
_leftTimeLabel().paint(canvas, leftLabelOffset);
|
||||
|
||||
// right time label
|
||||
final rightLabelSize = _rightLabelSize;
|
||||
final rightLabelWidth = rightLabelSize.width;
|
||||
final totalLabelDx = size.width - rightLabelWidth;
|
||||
final totalLabelOffset = Offset(totalLabelDx, verticalOffset);
|
||||
_rightTimeLabel().paint(canvas, totalLabelOffset);
|
||||
|
||||
// progress bar
|
||||
final leftLabelWidth = leftLabelSize.width;
|
||||
final barHeight = _heightWhenNoLabels();
|
||||
final barWidth =
|
||||
size.width -
|
||||
2 * _defaultSidePadding -
|
||||
2 * _timeLabelPadding -
|
||||
leftLabelWidth -
|
||||
rightLabelWidth;
|
||||
final barDy = size.height / 2 - barHeight / 2;
|
||||
final barDx = leftLabelWidth + _defaultSidePadding + _timeLabelPadding;
|
||||
_drawProgressBar(canvas, Offset(barDx, barDy), Size(barWidth, barHeight));
|
||||
}
|
||||
|
||||
/// Draw the progress bar without labels like this:
|
||||
///
|
||||
/// | -------O---------------- |
|
||||
///
|
||||
void _drawProgressBarWithoutLabels(Canvas canvas) {
|
||||
final barWidth = size.width;
|
||||
final barHeight = 2 * _thumbRadius;
|
||||
final barHeight = _heightWhenNoLabels();
|
||||
_drawProgressBar(canvas, Offset.zero, Size(barWidth, barHeight));
|
||||
}
|
||||
|
||||
@@ -1105,7 +776,7 @@ class _RenderProgressBar extends RenderBox {
|
||||
thumbDx = thumbDx.clamp(_thumbRadius, localSize.width - _thumbRadius);
|
||||
}
|
||||
final center = Offset(thumbDx, localSize.height / 2);
|
||||
if (_userIsDraggingThumb) {
|
||||
if (_userIsDraggingThumb && _paintThumbGlow) {
|
||||
final thumbGlowPaint = Paint()..color = thumbGlowColor;
|
||||
canvas.drawCircle(center, thumbGlowRadius, thumbGlowPaint);
|
||||
}
|
||||
@@ -1119,19 +790,6 @@ class _RenderProgressBar extends RenderBox {
|
||||
return (duration.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
String _getTimeString(Duration time) {
|
||||
final minutes = time.inMinutes
|
||||
.remainder(Duration.minutesPerHour)
|
||||
.toString();
|
||||
final seconds = time.inSeconds
|
||||
.remainder(Duration.secondsPerMinute)
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
return time.inHours > 0
|
||||
? "${time.inHours}:${minutes.padLeft(2, "0")}:$seconds"
|
||||
: "$minutes:$seconds";
|
||||
}
|
||||
|
||||
@override
|
||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||
super.describeSemanticsConfiguration(config);
|
||||
@@ -1160,16 +818,16 @@ class _RenderProgressBar extends RenderBox {
|
||||
void increaseAction() {
|
||||
final newValue = _thumbValue + _semanticActionUnit;
|
||||
_thumbValue = (newValue).clamp(0.0, 1.0);
|
||||
onSeek?.call(_currentThumbDuration());
|
||||
markNeedsPaint();
|
||||
markNeedsSemanticsUpdate();
|
||||
onSeek?.call(_currentThumbDuration());
|
||||
}
|
||||
|
||||
void decreaseAction() {
|
||||
final newValue = _thumbValue - _semanticActionUnit;
|
||||
_thumbValue = (newValue).clamp(0.0, 1.0);
|
||||
onSeek?.call(_currentThumbDuration());
|
||||
markNeedsPaint();
|
||||
markNeedsSemanticsUpdate();
|
||||
onSeek?.call(_currentThumbDuration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,26 @@ class Segment {
|
||||
this.from,
|
||||
this.to,
|
||||
]);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other is Segment) {
|
||||
return start == other.start &&
|
||||
end == other.end &&
|
||||
color == other.color &&
|
||||
title == other.title &&
|
||||
url == other.url &&
|
||||
from == other.from &&
|
||||
to == other.to;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(start, end, color, title, url, from, to);
|
||||
}
|
||||
|
||||
class SegmentProgressBar extends CustomPainter {
|
||||
@@ -126,7 +146,7 @@ class SegmentProgressBar extends CustomPainter {
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return false;
|
||||
bool shouldRepaint(SegmentProgressBar oldDelegate) {
|
||||
return segmentColors != oldDelegate.segmentColors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import 'package:flutter/material.dart';
|
||||
Widget videoProgressIndicator(double progress) => ClipRect(
|
||||
clipper: ProgressClipper(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: StyleString.imgRadius,
|
||||
bottomRight: StyleString.imgRadius,
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(bottom: StyleString.imgRadius),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 10,
|
||||
value: progress,
|
||||
// ignore: deprecated_member_use
|
||||
year2023: true,
|
||||
stopIndicatorColor: Colors.transparent,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,43 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RadioWidget<T> extends StatelessWidget {
|
||||
class RadioWidget<T> extends StatefulWidget {
|
||||
final T value;
|
||||
final T? groupValue;
|
||||
final ValueChanged<T?> onChanged;
|
||||
final String title;
|
||||
final bool tristate;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final MainAxisSize mainAxisSize;
|
||||
|
||||
const RadioWidget({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.groupValue,
|
||||
required this.onChanged,
|
||||
required this.title,
|
||||
this.tristate = false,
|
||||
this.padding,
|
||||
this.mainAxisSize = MainAxisSize.min,
|
||||
});
|
||||
|
||||
Widget _child() => Row(
|
||||
children: [
|
||||
Radio<T>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
Text(title),
|
||||
],
|
||||
);
|
||||
@override
|
||||
State<RadioWidget<T>> createState() => RadioWidgetState<T>();
|
||||
}
|
||||
|
||||
class RadioWidgetState<T> extends State<RadioWidget<T>> with RadioClient<T> {
|
||||
late final _RadioRegistry<T> _radioRegistry = _RadioRegistry<T>(this);
|
||||
|
||||
@override
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
T get radioValue => widget.value;
|
||||
|
||||
bool get checked => radioValue == registry!.groupValue;
|
||||
|
||||
@override
|
||||
bool get tristate => widget.tristate;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
registry = null;
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
registry = RadioGroup.maybeOf(context);
|
||||
assert(registry != null);
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
if (checked) {
|
||||
if (tristate) registry!.onChanged(null);
|
||||
return;
|
||||
}
|
||||
registry!.onChanged(radioValue);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Row(
|
||||
mainAxisSize: widget.mainAxisSize,
|
||||
children: [
|
||||
Focus(
|
||||
parentNode: focusNode,
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
includeSemantics: true,
|
||||
descendantsAreFocusable: false,
|
||||
descendantsAreTraversable: false,
|
||||
child: Radio<T>(
|
||||
value: radioValue,
|
||||
groupRegistry: _radioRegistry,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
Text(widget.title),
|
||||
],
|
||||
);
|
||||
return InkWell(
|
||||
onTap: () => onChanged(value),
|
||||
child: padding != null
|
||||
? Padding(
|
||||
padding: padding!,
|
||||
child: _child(),
|
||||
)
|
||||
: _child(),
|
||||
onTap: _handleTap,
|
||||
focusNode: focusNode,
|
||||
child: widget.padding == null
|
||||
? child
|
||||
: Padding(padding: widget.padding!, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,16 +90,12 @@ class RadioWidget<T> extends StatelessWidget {
|
||||
class WrapRadioOptionsGroup<T> extends StatelessWidget {
|
||||
final String groupTitle;
|
||||
final Map<T, String> options;
|
||||
final T? selectedValue;
|
||||
final ValueChanged<T?> onChanged;
|
||||
final EdgeInsetsGeometry? itemPadding;
|
||||
|
||||
const WrapRadioOptionsGroup({
|
||||
super.key,
|
||||
required this.groupTitle,
|
||||
required this.options,
|
||||
required this.selectedValue,
|
||||
required this.onChanged,
|
||||
this.itemPadding,
|
||||
});
|
||||
|
||||
@@ -75,14 +116,10 @@ class WrapRadioOptionsGroup<T> extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Wrap(
|
||||
children: options.entries.map((entry) {
|
||||
return IntrinsicWidth(
|
||||
child: RadioWidget<T>(
|
||||
value: entry.key,
|
||||
groupValue: selectedValue,
|
||||
onChanged: onChanged,
|
||||
title: entry.value,
|
||||
padding: itemPadding ?? const EdgeInsets.only(right: 10),
|
||||
),
|
||||
return RadioWidget<T>(
|
||||
value: entry.key,
|
||||
title: entry.value,
|
||||
padding: itemPadding ?? const EdgeInsets.only(right: 10),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -91,3 +128,27 @@ class WrapRadioOptionsGroup<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry to controls internal [Radio] and hides it from [RadioGroup]
|
||||
/// ancestor.
|
||||
///
|
||||
/// [RadioListTile] implements the [RadioClient] directly to register to
|
||||
/// [RadioGroup] ancestor. Therefore, it has to hide the internal [Radio] from
|
||||
/// participate in the [RadioGroup] ancestor.
|
||||
class _RadioRegistry<T> extends RadioGroupRegistry<T> {
|
||||
_RadioRegistry(this.state);
|
||||
|
||||
final RadioWidgetState<T> state;
|
||||
|
||||
@override
|
||||
T? get groupValue => state.registry!.groupValue;
|
||||
|
||||
@override
|
||||
ValueChanged<T?> get onChanged => state.registry!.onChanged;
|
||||
|
||||
@override
|
||||
void registerClient(RadioClient<T> radio) {}
|
||||
|
||||
@override
|
||||
void unregisterClient(RadioClient<T> radio) {}
|
||||
}
|
||||
|
||||