feat: V2.0 #2
@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -57,6 +58,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -111,6 +113,7 @@
|
||||
name = wake;
|
||||
packageProductDependencies = (
|
||||
ABE8998D2E533A7100CD7BA6 /* Alamofire */,
|
||||
ABC150C02E5DB39A00A1F970 /* Lottie */,
|
||||
);
|
||||
productName = wake;
|
||||
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
|
||||
@ -142,6 +145,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
|
||||
@ -382,6 +386,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 4.5.2;
|
||||
};
|
||||
};
|
||||
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
|
||||
@ -393,6 +405,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
||||
productName = Lottie;
|
||||
};
|
||||
ABE8998D2E533A7100CD7BA6 /* Alamofire */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "e8f130fe30ac6cdc940ef06ee1e8535e9f46ffee6aeead1722b9525562f6ce08",
|
||||
"originHash" : "0e95cd18402f001189cea942918f7d0c4c8b04175c6c482029650c892d28d55a",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
@ -9,6 +9,15 @@
|
||||
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
|
||||
"version" : "5.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "lottie-spm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/airbnb/lottie-spm.git",
|
||||
"state" : {
|
||||
"revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9",
|
||||
"version" : "4.5.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
BIN
wake/Assets/Images/Gif/BlindLoading.gif
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
wake/Assets/Images/Gif/BlindOpen.gif
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
wake/Assets/Images/Gif/BlindReady.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
1
wake/Assets/Lottie/data.json
Normal file
@ -0,0 +1 @@
|
||||
{"v":"5.12.2","fr":25,"ip":0,"op":77,"w":1080,"h":1080,"nm":"合成 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"方","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":4,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":50,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":63,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":75,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[0]},{"t":63,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.713725507259,0.270588248968,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":50,"op":76,"st":50,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"圆","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":50,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":38,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":50,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[0]},{"t":38,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.905882358551,0.760784327984,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":25,"op":51,"st":25,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"三角","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":13,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":25,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":13,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.045751713216,0.045751713216,0.045751713216,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":26,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}}
|
||||
106
wake/Assets/Lottie/loading.json
Normal file
@ -0,0 +1,106 @@
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 60,
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"w": 100,
|
||||
"h": 100,
|
||||
"nm": "Loading",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Loading",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 0, "s": [0] },
|
||||
{ "t": 90, "s": [360] }
|
||||
],
|
||||
"ix": 10
|
||||
},
|
||||
"p": { "a": 0, "k": [50, 50, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.0706, 0.5608, 0.9843, 1], "ix": 3 },
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 8, "ix": 5 },
|
||||
"lc": 2,
|
||||
"lj": 2,
|
||||
"ml": 4,
|
||||
"d": [
|
||||
{ "n": "d", "nm": "d", "v": { "a": 0, "k": 1, "ix": 0 } },
|
||||
{ "n": "d", "nm": "d", "v": { "a": 0, "k": 2, "ix": 1 } }
|
||||
],
|
||||
"nm": "Stroke 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[0, -20],
|
||||
[0, 20]
|
||||
],
|
||||
"c": false
|
||||
},
|
||||
"ix": 1
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
9
wake/Assets/Svg/AboutIP.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
13
wake/Assets/Svg/AboutUs.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6215_3465)">
|
||||
<path d="M9.99984 18.3332C12.301 18.3332 14.3843 17.4004 15.8924 15.8924C17.4004 14.3843 18.3332 12.301 18.3332 9.99984C18.3332 7.69867 17.4004 5.61534 15.8924 4.10728C14.3843 2.59925 12.301 1.6665 9.99984 1.6665C7.69867 1.6665 5.61534 2.59925 4.10728 4.10728C2.59925 5.61534 1.6665 7.69867 1.6665 9.99984C1.6665 12.301 2.59925 14.3843 4.10728 15.8924C5.61534 17.4004 7.69867 18.3332 9.99984 18.3332Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0002 4.5835C10.5755 4.5835 11.0418 5.04987 11.0418 5.62516C11.0418 6.20045 10.5755 6.66683 10.0002 6.66683C9.42487 6.66683 8.9585 6.20045 8.9585 5.62516C8.9585 5.04987 9.42487 4.5835 10.0002 4.5835Z" fill="black"/>
|
||||
<path d="M10.2083 14.1668V8.3335H9.79167H9.375" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.75 14.1665H11.6667" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6215_3465">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
wake/Assets/Svg/Account.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6215_3436)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99984 18.3332C14.6022 18.3332 18.3332 14.6022 18.3332 9.99984C18.3332 5.39746 14.6022 1.6665 9.99984 1.6665C5.39746 1.6665 1.6665 5.39746 1.6665 9.99984C1.6665 14.6022 5.39746 18.3332 9.99984 18.3332Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.99984 9.58317C11.1504 9.58317 12.0832 8.65042 12.0832 7.49984C12.0832 6.34925 11.1504 5.4165 9.99984 5.4165C8.84925 5.4165 7.9165 6.34925 7.9165 7.49984C7.9165 8.65042 8.84925 9.58317 9.99984 9.58317Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M4.17578 15.9718C4.31899 13.8004 6.12561 12.0835 8.33328 12.0835H11.6666C13.8714 12.0835 15.6762 13.7959 15.8236 15.9632" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6215_3436">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
48
wake/Assets/Svg/BlindBg.svg
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
10
wake/Assets/Svg/BlindBoxBg.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="358" height="549" viewBox="0 0 358 549" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M358 531V18C358 8.05887 349.941 0 340 0H252.517C246.504 0 240.981 3.31723 238.157 8.62569C235.332 13.9342 229.809 17.2514 223.796 17.2514H124.079C118.428 17.2514 113.335 13.8467 111.174 8.62569C109.013 3.40468 103.919 0 98.2688 0H18C8.05887 0 0 8.05889 0 18V531C0 540.941 8.05887 549 18 549H340C349.941 549 358 540.941 358 531Z" fill="#FFB645"/>
|
||||
<path d="M339 461.5V72C339 62.0589 330.941 54 321 54H96.5552C87.5728 54 79.9652 60.6222 78.7271 69.5189L76.5056 85.4811C75.2675 94.3778 67.6599 101 58.6775 101H37C27.0589 101 19 109.059 19 119V500C19 509.941 27.0589 518 37 518H248.463C254.425 518 260 515.048 263.351 510.117L278.8 487.383C282.151 482.452 287.726 479.5 293.688 479.5H321C330.941 479.5 339 471.441 339 461.5Z" fill="white"/>
|
||||
<path d="M46.2666 88.0056C47.2481 88.0087 48.0853 87.2989 48.2426 86.3304L51.6466 65.3641C51.8439 64.1487 50.9059 63.0431 49.6742 63.0393L44.3504 63.0228C43.3875 63.0198 42.5607 63.7033 42.383 64.6493L38.4445 85.614C38.2135 86.8437 39.1565 87.9835 40.4083 87.9874L46.2666 88.0056Z" fill="black"/>
|
||||
<path d="M32.9979 88.0048C33.971 88.0078 34.8036 87.31 34.9702 86.3516L38.616 65.3861C38.8285 64.1641 37.8882 63.0431 36.6474 63.0392L30.6621 63.0206C29.7089 63.0177 28.8875 63.6878 28.6996 64.6219L24.4812 85.5857C24.2323 86.8226 25.1778 87.9805 26.4401 87.9844L32.9979 88.0048Z" fill="black"/>
|
||||
<path d="M59.9979 88.0048C60.971 88.0078 61.8036 87.31 61.9702 86.3516L65.616 65.3861C65.8285 64.1641 64.8882 63.0431 63.6474 63.0392L57.6621 63.0206C56.7089 63.0177 55.8875 63.6878 55.6996 64.6219L51.4812 85.5857C51.2323 86.8226 52.1778 87.9805 53.4401 87.9844L59.9979 88.0048Z" fill="black"/>
|
||||
<path d="M308.289 518.006C309.271 518.009 310.108 517.299 310.265 516.33L313.669 495.364C313.866 494.149 312.928 493.043 311.697 493.039L306.373 493.023C305.41 493.02 304.583 493.703 304.405 494.649L300.467 515.614C300.236 516.844 301.179 517.984 302.431 517.987L308.289 518.006Z" fill="black"/>
|
||||
<path d="M294.998 518.005C295.971 518.008 296.804 517.31 296.97 516.352L300.616 495.386C300.828 494.164 299.888 493.043 298.647 493.039L292.662 493.021C291.709 493.018 290.888 493.688 290.7 494.622L286.481 515.586C286.232 516.823 287.178 517.98 288.44 517.984L294.998 518.005Z" fill="black"/>
|
||||
<path d="M322.044 518.005C323.017 518.008 323.849 517.31 324.016 516.352L327.662 495.386C327.874 494.164 326.934 493.043 325.693 493.039L319.708 493.021C318.755 493.018 317.933 493.688 317.745 494.622L313.527 515.586C313.278 516.823 314.224 517.98 315.486 517.984L322.044 518.005Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
4
wake/Assets/Svg/BlindCount.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="106" height="66" viewBox="0 0 106 66" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.666667 60C0.666667 62.9455 3.05448 65.3333 6 65.3333C8.94552 65.3333 11.3333 62.9455 11.3333 60C11.3333 57.0545 8.94552 54.6667 6 54.6667C3.05448 54.6667 0.666667 57.0545 0.666667 60ZM22.2802 31.0204L23.152 31.5102L22.2802 31.0204ZM97.5663 30V29H24.0239V30V31H97.5663V30ZM22.2802 31.0204L21.4083 30.5306L5.12816 59.5102L6 60L6.87184 60.4898L23.152 31.5102L22.2802 31.0204ZM24.0239 30V29C22.9395 29 21.9395 29.5852 21.4083 30.5306L22.2802 31.0204L23.152 31.5102C23.3291 31.1951 23.6624 31 24.0239 31V30Z" fill="black"/>
|
||||
<rect x="16.8433" width="89.1566" height="30" rx="15" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
9
wake/Assets/Svg/BlindNone.svg
Normal file
|
After Width: | Height: | Size: 134 KiB |
5
wake/Assets/Svg/Box.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6667 5H3.33333C2.8731 5 2.5 5.3731 2.5 5.83333V16.6667C2.5 17.1269 2.8731 17.5 3.33333 17.5H16.6667C17.1269 17.5 17.5 17.1269 17.5 16.6667V5.83333C17.5 5.3731 17.1269 5 16.6667 5Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M7.479 10.0034H12.479" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.5 5.41683L5.41667 2.0835H14.5833L17.5 5.41683" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
9
wake/Assets/Svg/IP1.svg
Normal file
|
After Width: | Height: | Size: 183 KiB |
11
wake/Assets/Svg/JoinList.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6596_2873)">
|
||||
<rect x="0.0969238" y="0.567871" width="30.9623" height="30.9623" rx="15.4812" transform="rotate(0.179418 0.0969238 0.567871)" fill="black"/>
|
||||
<path d="M15.578 0.597365L28.9286 23.8893L2.08191 23.8052L15.578 0.597365Z" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6596_2873">
|
||||
<rect x="0.0969238" y="0.567871" width="30.9623" height="30.9623" rx="15.4812" transform="rotate(0.179418 0.0969238 0.567871)" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
45
wake/Assets/Svg/Light1.svg
Normal file
@ -0,0 +1,45 @@
|
||||
<svg width="372" height="504" viewBox="0 0 372 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2904)">
|
||||
<path d="M340 427.5V38C340 28.0589 331.941 20 322 20H97.5552C88.5728 20 80.9652 26.6222 79.7271 35.5189L77.5056 51.4811C76.2675 60.3778 68.6599 67 59.6775 67H38C28.0589 67 20 75.0589 20 85V466C20 475.941 28.0589 484 38 484H249.463C255.425 484 261 481.048 264.351 476.117L279.8 453.383C283.151 448.452 288.726 445.5 294.688 445.5H322C331.941 445.5 340 437.441 340 427.5Z" fill="url(#paint0_linear_6966_2904)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddddd_6966_2904" x="-11.1789" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.371177"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2904"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.742355"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_6966_2904" result="effect2_dropShadow_6966_2904"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2.59824"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_6966_2904" result="effect3_dropShadow_6966_2904"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.19648"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect3_dropShadow_6966_2904" result="effect4_dropShadow_6966_2904"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="8.90826"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_dropShadow_6966_2904" result="effect5_dropShadow_6966_2904"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="15.5894"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_dropShadow_6966_2904" result="effect6_dropShadow_6966_2904"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2904" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_6966_2904" x1="240.5" y1="150" x2="16.0608" y2="502.337" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#FFE688"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
48
wake/Assets/Svg/Light2.svg
Normal file
@ -0,0 +1,48 @@
|
||||
<svg width="384" height="504" viewBox="0 0 384 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2902)">
|
||||
<path d="M352 427.5V38C352 28.0589 343.941 20 334 20H109.555C100.573 20 92.9652 26.6222 91.7271 35.5189L89.5056 51.4811C88.2675 60.3778 80.6599 67 71.6775 67H50C40.0589 67 32 75.0589 32 85V466C32 475.941 40.0589 484 50 484H261.463C267.425 484 273 481.048 276.351 476.117L291.8 453.383C295.151 448.452 300.726 445.5 306.688 445.5H334C343.941 445.5 352 437.441 352 427.5Z" fill="url(#paint0_linear_6966_2902)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddddd_6966_2902" x="0.821104" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.371177"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2902"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.742355"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_6966_2902" result="effect2_dropShadow_6966_2902"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2.59824"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_6966_2902" result="effect3_dropShadow_6966_2902"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.19648"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect3_dropShadow_6966_2902" result="effect4_dropShadow_6966_2902"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="8.90826"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_dropShadow_6966_2902" result="effect5_dropShadow_6966_2902"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="15.5894"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_dropShadow_6966_2902" result="effect6_dropShadow_6966_2902"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2902" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_6966_2902" x1="386.214" y1="6.49997" x2="28.9202" y2="503.128" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEDAA"/>
|
||||
<stop offset="0.293269" stop-color="#FFFBEA"/>
|
||||
<stop offset="0.331731" stop-color="#FFFBED"/>
|
||||
<stop offset="0.600962" stop-color="white"/>
|
||||
<stop offset="1" stop-color="#FDEBA6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
45
wake/Assets/Svg/Light3.svg
Normal file
@ -0,0 +1,45 @@
|
||||
<svg width="372" height="504" viewBox="0 0 372 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2903)">
|
||||
<path d="M352 427.5V38C352 28.0589 343.941 20 334 20H109.555C100.573 20 92.9652 26.6222 91.7271 35.5189L89.5056 51.4811C88.2675 60.3778 80.6599 67 71.6775 67H50C40.0589 67 32 75.0589 32 85V466C32 475.941 40.0589 484 50 484H261.463C267.425 484 273 481.048 276.351 476.117L291.8 453.383C295.151 448.452 300.726 445.5 306.688 445.5H334C343.941 445.5 352 437.441 352 427.5Z" fill="url(#paint0_linear_6966_2903)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddddd_6966_2903" x="0.821104" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.371177"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2903"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.742355"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_6966_2903" result="effect2_dropShadow_6966_2903"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2.59824"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_6966_2903" result="effect3_dropShadow_6966_2903"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.19648"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect3_dropShadow_6966_2903" result="effect4_dropShadow_6966_2903"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="8.90826"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_dropShadow_6966_2903" result="effect5_dropShadow_6966_2903"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="15.5894"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_dropShadow_6966_2903" result="effect6_dropShadow_6966_2903"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2903" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_6966_2903" x1="119" y1="382" x2="372.15" y2="-12.5078" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#FFE78E"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
19
wake/Assets/Svg/LoadingNpng.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- 定义动画 -->
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 圆形加载动画 -->
|
||||
<g class="spinner">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#3498db" stroke-width="8" stroke-dasharray="251.2" stroke-dashoffset="0"/>
|
||||
<circle cx="50" cy="50" r="20" fill="#3498db"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
5
wake/Assets/Svg/Memory.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.25 2.5H3.75C3.05965 2.5 2.5 3.05965 2.5 3.75V16.25C2.5 16.9404 3.05965 17.5 3.75 17.5H16.25C16.9404 17.5 17.5 16.9404 17.5 16.25V3.75C17.5 3.05965 16.9404 2.5 16.25 2.5Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.49996 9.58317C8.65054 9.58317 9.58329 8.65042 9.58329 7.49984C9.58329 6.34925 8.65054 5.4165 7.49996 5.4165C6.34938 5.4165 5.41663 6.34925 5.41663 7.49984C5.41663 8.65042 6.34938 9.58317 7.49996 9.58317Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 15.0002L12.9167 10.8335L8.75 14.5835L5.83333 12.0835L2.5 14.5835" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
5
wake/Assets/Svg/Permission.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6667 9.1665H3.33333C2.8731 9.1665 2.5 9.5396 2.5 9.99984V17.4998C2.5 17.9601 2.8731 18.3332 3.33333 18.3332H16.6667C17.1269 18.3332 17.5 17.9601 17.5 17.4998V9.99984C17.5 9.5396 17.1269 9.1665 16.6667 9.1665Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M5.8335 9.1665V5.83317C5.8335 3.53198 7.699 1.6665 10.0002 1.6665C12.3013 1.6665 14.1668 3.53198 14.1668 5.83317V9.1665" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 12.5V15" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
14
wake/Assets/Svg/Pioneer.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg width="274" height="120" viewBox="0 0 274 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 120H256C265.941 120 274 111.941 274 102V60.6067C274 50.6656 265.941 42.6067 256 42.6067H226.773C221.12 42.6067 215.795 39.9507 212.394 35.4346L197.136 15.1722C193.735 10.656 188.41 8 182.757 8H18C8.05887 8 0 16.0589 0 26V102C0 111.941 8.05888 120 18 120Z" fill="#FFB645"/>
|
||||
<rect x="203.396" width="54.9113" height="54.9113" rx="27.4556" transform="rotate(13.0475 203.396 0)" fill="black"/>
|
||||
<path d="M227.444 17.8455L235.272 43.5257L209.119 37.4648L227.444 17.8455Z" fill="#D9D9D9"/>
|
||||
<path d="M13.4714 27.032C13.9079 27.032 14.2938 26.7489 14.4247 26.3326L17.2577 17.3222C17.4603 16.6778 16.9786 16.0216 16.303 16.0216L12.1813 16.0216C11.7444 16.0216 11.3583 16.3052 11.2278 16.7221L8.40683 25.7324C8.20516 26.3766 8.68677 27.0319 9.36191 27.0319L13.4714 27.032Z" fill="black"/>
|
||||
<path d="M52.1379 27.032C52.5744 27.032 52.9603 26.7489 53.0912 26.3326L55.9242 17.3222C56.1269 16.6778 55.6452 16.0216 54.9695 16.0216L50.8478 16.0216C50.4109 16.0216 50.0248 16.3052 49.8943 16.7221L47.0733 25.7324C46.8717 26.3766 47.3533 27.0319 48.0284 27.0319L52.1379 27.032Z" fill="black"/>
|
||||
<path d="M23.1382 27.031C23.5747 27.0311 23.9605 26.748 24.0914 26.3317L26.9245 17.3212C27.1271 16.6768 26.6454 16.0206 25.9697 16.0206L21.8481 16.0206C21.4112 16.0206 21.025 16.3043 20.8945 16.7211L18.0736 25.7315C17.8719 26.3756 18.3535 27.0309 19.0287 27.0309L23.1382 27.031Z" fill="black"/>
|
||||
<path d="M42.4714 27.031C42.9079 27.0311 43.2938 26.748 43.4247 26.3317L46.2577 17.3212C46.4603 16.6768 45.9786 16.0206 45.303 16.0206L41.1813 16.0206C40.7444 16.0206 40.3583 16.3043 40.2278 16.7211L37.4068 25.7315C37.2052 26.3756 37.6868 27.0309 38.3619 27.0309L42.4714 27.031Z" fill="black"/>
|
||||
<path d="M32.8049 27.031C33.2414 27.0311 33.6273 26.748 33.7582 26.3317L36.5912 17.3212C36.7938 16.6768 36.3121 16.0206 35.6365 16.0206L31.5148 16.0206C31.0779 16.0206 30.6918 16.3043 30.5613 16.7211L27.7403 25.7315C27.5387 26.3756 28.0203 27.0309 28.6954 27.0309L32.8049 27.031Z" fill="black"/>
|
||||
<path d="M61.8049 27.031C62.2414 27.0311 62.6273 26.748 62.7582 26.3317L65.5912 17.3212C65.7938 16.6768 65.3121 16.0206 64.6365 16.0206L60.5148 16.0206C60.0779 16.0206 59.6918 16.3043 59.5613 16.7211L56.7403 25.7315C56.5387 26.3756 57.0203 27.0309 57.6954 27.0309L61.8049 27.031Z" fill="black"/>
|
||||
<path d="M252.201 111.98C252.637 111.98 253.023 111.697 253.154 111.281L255.97 102.323C256.173 101.679 255.691 101.023 255.016 101.023L250.924 101.023C250.487 101.023 250.101 101.306 249.97 101.723L247.166 110.681C246.964 111.325 247.446 111.98 248.121 111.98L252.201 111.98Z" fill="black"/>
|
||||
<path d="M242.581 111.98C243.017 111.98 243.403 111.697 243.534 111.281L246.35 102.323C246.553 101.679 246.071 101.023 245.396 101.023L241.304 101.023C240.867 101.023 240.481 101.306 240.35 101.723L237.546 110.681C237.344 111.325 237.826 111.98 238.501 111.98L242.581 111.98Z" fill="black"/>
|
||||
<path d="M261.821 111.98C262.258 111.98 262.644 111.697 262.775 111.281L265.591 102.323C265.794 101.679 265.312 101.023 264.636 101.023L260.545 101.023C260.108 101.023 259.721 101.306 259.591 101.723L256.787 110.681C256.585 111.325 257.066 111.98 257.742 111.98L261.821 111.98Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
4
wake/Assets/Svg/Set.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.61821 17.9878C6.22192 17.5721 4.97904 16.8003 3.9949 15.7777C4.36204 15.3426 4.58329 14.7804 4.58329 14.1665C4.58329 12.7858 3.46401 11.6665 2.08329 11.6665C1.99977 11.6665 1.91721 11.6706 1.83579 11.6786C1.72487 11.1363 1.66663 10.5749 1.66663 9.99984C1.66663 9.12876 1.80028 8.28888 2.04821 7.49959C2.05988 7.49976 2.07158 7.49984 2.08329 7.49984C3.46401 7.49984 4.58329 6.38055 4.58329 4.99984C4.58329 4.60347 4.49104 4.22867 4.32688 3.89568C5.29058 2.99959 6.46683 2.32897 7.77167 1.96777C8.18513 2.77821 9.02775 3.33319 9.99996 3.33319C10.9722 3.33319 11.8148 2.77821 12.2283 1.96777C13.5331 2.32897 14.7093 2.99959 15.673 3.89568C15.5089 4.22867 15.4166 4.60347 15.4166 4.99984C15.4166 6.38055 16.5359 7.49984 17.9166 7.49984C17.9283 7.49984 17.94 7.49976 17.9517 7.49959C18.1996 8.28888 18.3333 9.12876 18.3333 9.99984C18.3333 10.5749 18.275 11.1363 18.1641 11.6786C18.0827 11.6706 18.0002 11.6665 17.9166 11.6665C16.5359 11.6665 15.4166 12.7858 15.4166 14.1665C15.4166 14.7804 15.6379 15.3426 16.005 15.7777C15.0209 16.8003 13.778 17.5721 12.3817 17.9878C12.0595 16.9798 11.115 16.2498 9.99996 16.2498C8.88496 16.2498 7.94046 16.9798 7.61821 17.9878Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M10 12.9168C11.6109 12.9168 12.9167 11.611 12.9167 10.0002C12.9167 8.38933 11.6109 7.0835 10 7.0835C8.38921 7.0835 7.08337 8.38933 7.08337 10.0002C7.08337 11.611 8.38921 12.9168 10 12.9168Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
12
wake/Assets/Svg/Suport.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6215_3454)">
|
||||
<path d="M15 13.3332C16.841 13.3332 18.3333 11.8408 18.3333 9.99984C18.3333 8.15888 16.841 6.6665 15 6.6665" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M4.99984 6.6665C3.15889 6.6665 1.6665 8.15888 1.6665 9.99984C1.6665 11.8408 3.15889 13.3332 4.99984 13.3332" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||
<path d="M5 13.3332V13.1248V12.0832V9.99984V6.6665C5 3.90508 7.23858 1.6665 10 1.6665C12.7614 1.6665 15 3.90508 15 6.6665V13.3332C15 16.0946 12.7614 18.3332 10 18.3332" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6215_3454">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 860 B |
11
wake/Assets/Svg/Tips.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_7357_2809)">
|
||||
<path d="M3.33325 12.6668V6.00016C3.33325 3.42283 5.42259 1.3335 7.99992 1.3335C10.5773 1.3335 12.6666 3.42283 12.6666 6.00016V12.6668M1.33325 12.6668H14.6666" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.99992 14.6665C8.92039 14.6665 9.66659 13.9203 9.66659 12.9998V12.6665H6.33325V12.9998C6.33325 13.9203 7.07945 14.6665 7.99992 14.6665Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7357_2809">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
10
wake/Assets/Svg/Upload.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_7079_2856" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="20" height="20">
|
||||
<path d="M20 0H0V20H20V0Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_7079_2856)">
|
||||
<path d="M2.5 10.0035V17.5H17.5V10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 6.25L10 2.5L6.25 6.25" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.99658 13.3333V2.5" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 672 B |
5
wake/Assets/Svg/User.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.97485 5.97461H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.97485 11.9746H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.97485 17.9746H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
46
wake/Components/Lottie/LottieView.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
struct LottieView: UIViewRepresentable {
|
||||
let name: String
|
||||
let loopMode: LottieLoopMode
|
||||
let animationSpeed: CGFloat
|
||||
|
||||
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0) {
|
||||
self.name = name
|
||||
self.loopMode = loopMode
|
||||
self.animationSpeed = animationSpeed
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> LottieAnimationView {
|
||||
let animationView = LottieAnimationView()
|
||||
|
||||
// 方法1: 直接使用文件名加载
|
||||
if let animation = LottieAnimation.named(name) {
|
||||
animationView.animation = animation
|
||||
}
|
||||
// 方法2: 如果方法1失败,尝试使用文件路径加载
|
||||
else if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
||||
let animation = LottieAnimation.filepath(path)
|
||||
animationView.animation = animation
|
||||
}
|
||||
|
||||
// 配置动画
|
||||
animationView.loopMode = loopMode
|
||||
animationView.animationSpeed = animationSpeed
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
animationView.backgroundBehavior = .pauseAndRestore
|
||||
|
||||
// 播放动画
|
||||
animationView.play()
|
||||
|
||||
return animationView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||
// 确保动画持续播放
|
||||
if !uiView.isAnimationPlaying {
|
||||
uiView.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
46
wake/Models/MediaType.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
/// Represents different types of media that can be displayed or processed
|
||||
public enum MediaType: Equatable, Hashable {
|
||||
case image(UIImage)
|
||||
case video(URL, UIImage?) // URL is the video URL, UIImage is the thumbnail
|
||||
|
||||
public var thumbnail: UIImage? {
|
||||
switch self {
|
||||
case .image(let image):
|
||||
return image
|
||||
case .video(_, let thumbnail):
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
public var isVideo: Bool {
|
||||
if case .video = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.image(let lhsImage), .image(let rhsImage)):
|
||||
return lhsImage.pngData() == rhsImage.pngData()
|
||||
case (.video(let lhsURL, _), .video(let rhsURL, _)):
|
||||
return lhsURL == rhsURL
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .image(let image):
|
||||
hasher.combine("image")
|
||||
hasher.combine(image.pngData())
|
||||
case .video(let url, _):
|
||||
hasher.combine("video")
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ struct Theme {
|
||||
static let background = Color(hex: "F8F9FA") // 背景色
|
||||
static let surface = Color.white // 表面色
|
||||
static let surfaceSecondary = Color(hex: "F5F5F5") // 次级表面色
|
||||
static let surfaceTertiary = Color(hex: "F7F7F7") // 次级表面色
|
||||
|
||||
// MARK: - 文本色
|
||||
static let textPrimary = Color.black // 主文本色
|
||||
|
||||
@ -24,9 +24,12 @@ extension FontFamily {
|
||||
/// 定义应用中使用的文本样式类型
|
||||
enum TypographyStyle {
|
||||
case largeTitle // 大标题
|
||||
case smallLargeTitle // 小大标题
|
||||
case headline // 大标题
|
||||
case headline1 // 大标题1
|
||||
case title // 标题
|
||||
case title2 // 标题
|
||||
case title3 // 标题
|
||||
case body // 正文
|
||||
case subtitle // 副标题
|
||||
case caption // 说明文字
|
||||
@ -51,7 +54,10 @@ struct Typography {
|
||||
/// 文本样式配置表
|
||||
private static let styleConfig: [TypographyStyle: TypographyConfig] = [
|
||||
.largeTitle: TypographyConfig(size: 32, weight: .heavy, textStyle: .largeTitle),
|
||||
.smallLargeTitle: TypographyConfig(size: 30, weight: .heavy, textStyle: .largeTitle),
|
||||
.headline1: TypographyConfig(size: 26, weight: .bold, textStyle: .headline),
|
||||
.headline: TypographyConfig(size: 24, weight: .bold, textStyle: .headline),
|
||||
.title3: TypographyConfig(size: 22, weight: .semibold, textStyle: .title2),
|
||||
.title: TypographyConfig(size: 20, weight: .semibold, textStyle: .title2),
|
||||
.title2: TypographyConfig(size: 18, weight: .bold, textStyle: .title2),
|
||||
.body: TypographyConfig(size: 16, weight: .regular, textStyle: .body),
|
||||
|
||||
125
wake/Utils/GIFView.swift
Normal file
@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GIFView: UIViewRepresentable {
|
||||
let name: String
|
||||
var onTap: (() -> Void)? = nil
|
||||
|
||||
func makeUIView(context: Context) -> UIImageView {
|
||||
let imageView = UIImageView()
|
||||
|
||||
// 加载GIF
|
||||
guard let url = Bundle.main.url(forResource: name, withExtension: "gif"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let image = UIImage.gif(data: data) else {
|
||||
return imageView
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
|
||||
// 添加点击手势
|
||||
if onTap != nil {
|
||||
imageView.isUserInteractionEnabled = true
|
||||
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap))
|
||||
imageView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
return imageView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIImageView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
var parent: GIFView
|
||||
|
||||
init(_ parent: GIFView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@objc func handleTap() {
|
||||
parent.onTap?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UIImage的扩展,用于处理GIF
|
||||
extension UIImage {
|
||||
static func gif(data: Data) -> UIImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
print("无法创建CGImageSource")
|
||||
return nil
|
||||
}
|
||||
|
||||
let count = CGImageSourceGetCount(source)
|
||||
var images = [UIImage]()
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
for i in 0..<count {
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) else {
|
||||
continue
|
||||
}
|
||||
|
||||
duration += UIImage.gifDelayForImageAtIndex(source: source, index: i)
|
||||
images.append(UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up))
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
return images.first
|
||||
} else {
|
||||
return UIImage.animatedImage(with: images, duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
static func gifDelayForImageAtIndex(source: CGImageSource, index: Int) -> TimeInterval {
|
||||
var delay = 0.1
|
||||
|
||||
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
|
||||
let properties = cfProperties as? [String: Any] ?? [:]
|
||||
let gifProperties = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] ?? [:]
|
||||
|
||||
if let delayTime = gifProperties[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double {
|
||||
delay = delayTime
|
||||
} else if let delayTime = gifProperties[kCGImagePropertyGIFDelayTime as String] as? Double {
|
||||
delay = delayTime
|
||||
}
|
||||
|
||||
if delay < 0.011 {
|
||||
delay = 0.1
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例 - 带点击事件
|
||||
struct GIFWithTapExample: View {
|
||||
@State private var tapCount = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("点击GIF图片")
|
||||
.font(.title)
|
||||
|
||||
GIFView(name: "Blind") {
|
||||
// 点击事件处理
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .video))
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.border(Color.blue) // 可选:添加边框显示可点击区域
|
||||
|
||||
Text("点击次数: \(tapCount)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GIFWithTapExample_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GIFWithTapExample()
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import Security
|
||||
/// 用于安全存储和检索敏感信息(如令牌)的 Keychain 帮助类
|
||||
public class KeychainHelper {
|
||||
// Keychain 键名
|
||||
private enum KeychainKey: String {
|
||||
private enum KeychainKey: String, CaseIterable {
|
||||
case accessToken = "com.memorywake.accessToken"
|
||||
case refreshToken = "com.memorywake.refreshToken"
|
||||
}
|
||||
@ -35,6 +35,38 @@ public class KeychainHelper {
|
||||
delete(for: .refreshToken)
|
||||
}
|
||||
|
||||
/// 清除所有存储的 Keychain 数据
|
||||
public static func clearAll() {
|
||||
// 清除所有已知的 keychain 项
|
||||
KeychainKey.allCases.forEach { key in
|
||||
delete(for: key)
|
||||
}
|
||||
|
||||
// 额外清理:删除所有通用密码项(作为安全措施)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
if let account = item[kSecAttrAccount as String] as? String,
|
||||
let service = item[kSecAttrService as String] as? String {
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrService as String: service
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
|
||||
private static func save(_ string: String, for key: KeychainKey) -> Bool {
|
||||
|
||||
@ -18,7 +18,84 @@ private struct RequestIdentifier {
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error {
|
||||
public protocol NetworkServiceProtocol {
|
||||
func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any],
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
)
|
||||
|
||||
@discardableResult
|
||||
func upload(
|
||||
request: URLRequest,
|
||||
fileData: Data,
|
||||
onProgress: @escaping (Double) -> Void,
|
||||
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
|
||||
) -> URLSessionUploadTask?
|
||||
}
|
||||
|
||||
extension NetworkService: NetworkServiceProtocol {
|
||||
public func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any],
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = [String: String]()
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
|
||||
post(path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func upload(
|
||||
request: URLRequest,
|
||||
fileData: Data,
|
||||
onProgress: @escaping (Double) -> Void,
|
||||
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
|
||||
) -> URLSessionUploadTask? {
|
||||
var request = request
|
||||
|
||||
// Set content length header if not already set
|
||||
if request.value(forHTTPHeaderField: "Content-Length") == nil {
|
||||
request.setValue("\(fileData.count)", forHTTPHeaderField: "Content-Length")
|
||||
}
|
||||
|
||||
var progressObserver: NSKeyValueObservation?
|
||||
|
||||
let task = URLSession.shared.uploadTask(with: request, from: fileData) { [weak self] data, response, error in
|
||||
// Invalidate the progress observer when the task completes
|
||||
progressObserver?.invalidate()
|
||||
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let response = response else {
|
||||
completion(.failure(NetworkError.invalidURL))
|
||||
return
|
||||
}
|
||||
|
||||
completion(.success((data, response)))
|
||||
}
|
||||
|
||||
// Add progress tracking if available
|
||||
if #available(iOS 11.0, *) {
|
||||
progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
|
||||
DispatchQueue.main.async {
|
||||
onProgress(progressValue.fractionCompleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
public enum NetworkError: Error {
|
||||
case invalidURL
|
||||
case noData
|
||||
case decodingError(Error)
|
||||
@ -27,15 +104,16 @@ enum NetworkError: Error {
|
||||
case other(Error)
|
||||
case networkError(Error)
|
||||
case unknownError(Error)
|
||||
case invalidParameters
|
||||
|
||||
var localizedDescription: String {
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "无效的URL"
|
||||
case .noData:
|
||||
return "没有收到数据"
|
||||
return "没有接收到数据"
|
||||
case .decodingError(let error):
|
||||
return "数据解析错误: \(error.localizedDescription)"
|
||||
return "解码错误: \(error.localizedDescription)"
|
||||
case .serverError(let message):
|
||||
return "服务器错误: \(message)"
|
||||
case .unauthorized:
|
||||
@ -43,9 +121,11 @@ enum NetworkError: Error {
|
||||
case .other(let error):
|
||||
return error.localizedDescription
|
||||
case .networkError(let error):
|
||||
return "网络请求错误: \(error.localizedDescription)"
|
||||
return "网络错误: \(error.localizedDescription)"
|
||||
case .unknownError(let error):
|
||||
return "未知错误: \(error.localizedDescription)"
|
||||
case .invalidParameters:
|
||||
return "无效的参数"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,7 +148,7 @@ class NetworkService {
|
||||
private func request<T: Decodable>(
|
||||
_ method: String,
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
@ -92,9 +172,11 @@ class NetworkService {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// 添加认证头
|
||||
APIConfig.authHeaders.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
// 添加认证头 - 排除登录接口
|
||||
if !path.contains("/iam/login/") {
|
||||
APIConfig.authHeaders.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自定义头(如果提供)
|
||||
@ -105,7 +187,13 @@ class NetworkService {
|
||||
// 设置请求体(如果是POST/PUT请求)
|
||||
if let parameters = parameters, (method == "POST" || method == "PUT") {
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
||||
if JSONSerialization.isValidJSONObject(parameters) {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
||||
} else {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数不是有效的JSON对象")
|
||||
completion(.failure(.invalidParameters))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数序列化失败: \(error.localizedDescription)")
|
||||
completion(.failure(.other(error)))
|
||||
@ -363,17 +451,29 @@ class NetworkService {
|
||||
/// POST 请求
|
||||
func post<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("POST", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
var params: Any?
|
||||
if let parameters = parameters {
|
||||
if let dict = parameters as? [String: Any] {
|
||||
params = dict
|
||||
} else if let array = parameters as? [Any] {
|
||||
params = array
|
||||
} else {
|
||||
print("❌ [Network] POST 请求参数类型不支持")
|
||||
completion(.failure(.invalidParameters))
|
||||
return
|
||||
}
|
||||
}
|
||||
request("POST", path: path, parameters: params, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// POST 请求(带Token)
|
||||
func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
@ -391,6 +491,10 @@ class NetworkService {
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = headers ?? [:]
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
request("DELETE", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
|
||||
74
wake/Utils/Router.swift
Normal file
@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum AppRoute: Hashable {
|
||||
case login
|
||||
case avatarBox
|
||||
case feedbackView
|
||||
case feedbackDetail(type: FeedbackView.FeedbackType)
|
||||
case mediaUpload
|
||||
case blindBox(mediaType: BlindBoxView.BlindBoxMediaType)
|
||||
case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil)
|
||||
case memories
|
||||
case subscribe
|
||||
case userInfo
|
||||
case account
|
||||
case about
|
||||
case permissionManagement
|
||||
case feedback
|
||||
|
||||
@ViewBuilder
|
||||
var view: some View {
|
||||
switch self {
|
||||
case .login:
|
||||
LoginView()
|
||||
case .avatarBox:
|
||||
AvatarBoxView()
|
||||
case .feedbackView:
|
||||
FeedbackView()
|
||||
case .feedbackDetail(let type):
|
||||
FeedbackDetailView(feedbackType: type)
|
||||
case .mediaUpload:
|
||||
MediaUploadView()
|
||||
case .blindBox(let mediaType):
|
||||
BlindBoxView(mediaType: mediaType)
|
||||
case .blindOutcome(let media, let time, let description):
|
||||
BlindOutcomeView(media: media, time: time, description: description)
|
||||
case .memories:
|
||||
MemoriesView()
|
||||
case .subscribe:
|
||||
SubscribeView()
|
||||
case .userInfo:
|
||||
UserInfo()
|
||||
case .account:
|
||||
AccountView()
|
||||
case .about:
|
||||
AboutUsView()
|
||||
case .permissionManagement:
|
||||
PermissionManagementView()
|
||||
case .feedback:
|
||||
FeedbackView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Router: ObservableObject {
|
||||
static let shared = Router()
|
||||
|
||||
@Published var path = NavigationPath()
|
||||
|
||||
private init() {}
|
||||
|
||||
func navigate(to destination: AppRoute) {
|
||||
path.append(destination)
|
||||
}
|
||||
|
||||
func pop() {
|
||||
path.removeLast()
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path = NavigationPath()
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Token管理器
|
||||
/// 负责管理应用的认证令牌,包括验证、刷新和过期处理
|
||||
@ -6,8 +7,9 @@ class TokenManager {
|
||||
/// 单例实例
|
||||
static let shared = TokenManager()
|
||||
|
||||
private let logger = Logger(subsystem: "com.yourapp.tokenmanager", category: "TokenManager")
|
||||
|
||||
/// token有效期阈值(秒),在token即将过期前进行刷新
|
||||
/// 例如:设置为300表示在token过期前5分钟开始刷新
|
||||
private let tokenValidityThreshold: TimeInterval = 300
|
||||
|
||||
/// 私有化初始化方法,确保单例模式
|
||||
@ -17,142 +19,162 @@ class TokenManager {
|
||||
|
||||
/// 检查是否存在有效的访问令牌
|
||||
var hasToken: Bool {
|
||||
return KeychainHelper.getAccessToken()?.isEmpty == false
|
||||
let hasToken = KeychainHelper.getAccessToken()?.isEmpty == false
|
||||
logger.debug("检查token存在状态: \(hasToken ? "存在" : "不存在")")
|
||||
return hasToken
|
||||
}
|
||||
|
||||
// MARK: - Token 验证
|
||||
|
||||
/// 验证并刷新token(如果需要)
|
||||
/// - 检查token是否存在
|
||||
/// - 检查token是否有效
|
||||
/// - 在token即将过期时自动刷新
|
||||
/// - Parameter completion: 完成回调,返回验证/刷新结果
|
||||
/// - isValid: token是否有效
|
||||
/// - error: 错误信息(如果有)
|
||||
func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool, Error?) -> Void) {
|
||||
logger.debug("开始验证token状态...")
|
||||
|
||||
// 1. 检查token是否存在
|
||||
guard let token = KeychainHelper.getAccessToken(), !token.isEmpty else {
|
||||
// token不存在,返回未授权错误
|
||||
let error = NSError(
|
||||
domain: "TokenManager",
|
||||
code: 401,
|
||||
userInfo: [NSLocalizedDescriptionKey: "未找到访问令牌"]
|
||||
)
|
||||
logger.error("❌ Token验证失败: 未找到访问令牌")
|
||||
completion(false, error)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查token是否有效
|
||||
if isTokenValid(token) {
|
||||
// token有效,直接返回成功
|
||||
logger.debug("✅ Token验证通过,无需刷新")
|
||||
completion(true, nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("🔄 Token需要刷新,开始刷新流程...")
|
||||
|
||||
// 3. token无效或即将过期,尝试刷新
|
||||
refreshToken { [weak self] success, error in
|
||||
if success {
|
||||
// 刷新成功,返回成功
|
||||
self?.logger.debug("✅ Token刷新成功")
|
||||
completion(true, nil)
|
||||
} else {
|
||||
// 刷新失败,返回错误信息
|
||||
let finalError = error ?? NSError(
|
||||
domain: "TokenManager",
|
||||
code: 401,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token刷新失败"]
|
||||
)
|
||||
self?.logger.error("❌ Token刷新失败: \(finalError.localizedDescription)")
|
||||
completion(false, finalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查token是否有效
|
||||
/// - Parameter token: 要检查的token字符串
|
||||
/// - Returns: 如果token有效返回true,否则返回false
|
||||
///
|
||||
/// 该方法会检查token的有效性,包括检查token是否为空、是否过期以及通过网络请求验证token。
|
||||
///
|
||||
/// - Note: 该方法会打印一些调试信息,包括token验证开始、token过期时间等。
|
||||
public func isTokenValid(_ token: String) -> Bool {
|
||||
print("🔍 TokenManager: 开始验证token...")
|
||||
logger.debug("开始验证token有效性...")
|
||||
|
||||
// 1. 基础验证:检查token是否为空
|
||||
guard !token.isEmpty else {
|
||||
print("❌ TokenManager: Token为空")
|
||||
logger.error("❌ Token为空")
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. 检查token是否过期(如果可能)
|
||||
// 2. 检查token是否过期
|
||||
if let expiryDate = getTokenExpiryDate(token) {
|
||||
print("⏰ TokenManager: Token过期时间: \(expiryDate)")
|
||||
logger.debug("Token过期时间: \(expiryDate)")
|
||||
if Date() > expiryDate {
|
||||
print("❌ TokenManager: Token已过期")
|
||||
logger.error("❌ Token已过期")
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要刷新(在过期前5分钟)
|
||||
let timeRemaining = expiryDate.timeIntervalSinceNow
|
||||
logger.debug("Token剩余有效时间: \(Int(timeRemaining))秒")
|
||||
|
||||
if timeRemaining < tokenValidityThreshold {
|
||||
logger.debug("⚠️ Token即将过期,需要刷新")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建信号量用于同步网络请求
|
||||
// 3. 验证token有效性
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var isValid = false
|
||||
var requestCompleted = false
|
||||
|
||||
print("🌐 TokenManager: 发送验证请求到服务器...")
|
||||
let validationRequest = createValidationRequest(token: token)
|
||||
logger.debug("发送Token验证请求: \(validationRequest.url?.absoluteString ?? "未知URL")")
|
||||
logger.debug("请求头: \(validationRequest.allHTTPHeaderFields ?? [:])")
|
||||
|
||||
// 4. 发送验证请求
|
||||
let task = URLSession.shared.dataTask(with: createValidationRequest(token: token)) { data, response, error in
|
||||
let startTime = Date()
|
||||
|
||||
let task = URLSession.shared.dataTask(with: validationRequest) { data, response, error in
|
||||
defer {
|
||||
requestCompleted = true
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
let responseTime = String(format: "%.2f秒", Date().timeIntervalSince(startTime))
|
||||
|
||||
// 检查网络错误
|
||||
if let error = error {
|
||||
print("❌ TokenManager: 验证请求错误: \(error.localizedDescription)")
|
||||
self.logger.error("❌ Token验证请求错误: \(error.localizedDescription) (耗时: \(responseTime))")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态码
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
print("❌ TokenManager: 无效的服务器响应")
|
||||
self.logger.error("❌ 无效的服务器响应 (耗时: \(responseTime))")
|
||||
return
|
||||
}
|
||||
|
||||
print("📡 TokenManager: 服务器响应状态码: \(httpResponse.statusCode)")
|
||||
let statusCode = httpResponse.statusCode
|
||||
self.logger.debug("收到Token验证响应 - 状态码: \(statusCode) (耗时: \(responseTime))")
|
||||
|
||||
// 检查状态码
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
print("❌ TokenManager: 服务器返回错误状态码: \(httpResponse.statusCode)")
|
||||
guard (200...299).contains(statusCode) else {
|
||||
self.logger.error("❌ 服务器返回错误状态码: \(statusCode)")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有数据
|
||||
// 检查响应数据
|
||||
if let data = data, !data.isEmpty {
|
||||
do {
|
||||
// 尝试解析响应数据
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
self.logger.debug("Token验证响应数据: \(json)")
|
||||
}
|
||||
|
||||
let response = try JSONDecoder().decode(IdentityCheckResponse.self, from: data)
|
||||
isValid = response.isValid
|
||||
print("✅ TokenManager: Token验证\(isValid ? "成功" : "失败")")
|
||||
self.logger.debug("✅ Token验证\(isValid ? "成功" : "失败")")
|
||||
|
||||
if let userId = response.userId {
|
||||
self.logger.debug("用户ID: \(userId)")
|
||||
}
|
||||
|
||||
if let expiresAt = response.expiresAt {
|
||||
self.logger.debug("Token过期时间: \(expiresAt)")
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("❌ TokenManager: 解析响应数据失败: \(error.localizedDescription)")
|
||||
self.logger.error("❌ 解析响应数据失败: \(error.localizedDescription)")
|
||||
// 如果解析失败但状态码是200,我们假设token是有效的
|
||||
isValid = true
|
||||
print("ℹ️ TokenManager: 状态码200,假设token有效")
|
||||
self.logger.debug("ℹ️ 状态码200,假设token有效")
|
||||
}
|
||||
} else {
|
||||
// 如果没有返回数据但状态码是200,我们假设token是有效的
|
||||
print("ℹ️ TokenManager: 没有返回数据,但状态码为200,假设token有效")
|
||||
self.logger.debug("ℹ️ 没有返回数据,但状态码为200,假设token有效")
|
||||
isValid = true
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
|
||||
// 5. 设置超时时间(10秒)
|
||||
// 设置超时时间(15秒)
|
||||
let timeoutResult = semaphore.wait(timeout: .now() + 15)
|
||||
|
||||
// 检查是否超时
|
||||
if !requestCompleted && timeoutResult == .timedOut {
|
||||
print("⚠️ TokenManager: 验证请求超时")
|
||||
logger.error("⚠️ Token验证请求超时")
|
||||
task.cancel()
|
||||
return false
|
||||
}
|
||||
@ -170,22 +192,30 @@ class TokenManager {
|
||||
return request
|
||||
}
|
||||
|
||||
/// 从token中提取过期时间(示例实现)
|
||||
/// 从token中提取过期时间
|
||||
private func getTokenExpiryDate(_ token: String) -> Date? {
|
||||
// 这里需要根据实际的JWT或其他token格式来解析过期时间
|
||||
// 以下是JWT token的示例解析
|
||||
logger.debug("开始解析token过期时间...")
|
||||
|
||||
let parts = token.components(separatedBy: ".")
|
||||
guard parts.count > 1, let payloadData = base64UrlDecode(parts[1]) else {
|
||||
guard parts.count > 1 else {
|
||||
logger.error("❌ 无效的token格式")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let payloadData = base64UrlDecode(parts[1]) else {
|
||||
logger.error("❌ 无法解码token payload")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
if let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
||||
let exp = payload["exp"] as? TimeInterval {
|
||||
return Date(timeIntervalSince1970: exp)
|
||||
let expiryDate = Date(timeIntervalSince1970: exp)
|
||||
logger.debug("✅ 成功解析token过期时间: \(expiryDate)")
|
||||
return expiryDate
|
||||
}
|
||||
} catch {
|
||||
print("❌ TokenManager: 解析token过期时间失败: \(error.localizedDescription)")
|
||||
logger.error("❌ 解析token过期时间失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -209,49 +239,65 @@ class TokenManager {
|
||||
}
|
||||
|
||||
/// 刷新token
|
||||
/// - Parameter completion: 刷新完成回调
|
||||
/// - success: 是否刷新成功
|
||||
/// - error: 错误信息(如果有)
|
||||
func refreshToken(completion: @escaping (Bool, Error?) -> Void) {
|
||||
logger.debug("开始刷新token...")
|
||||
|
||||
// 获取刷新令牌
|
||||
guard let refreshToken = KeychainHelper.getRefreshToken(), !refreshToken.isEmpty else {
|
||||
// 没有可用的刷新令牌
|
||||
let error = NSError(
|
||||
domain: "TokenManager",
|
||||
code: 401,
|
||||
userInfo: [NSLocalizedDescriptionKey: "未找到刷新令牌"]
|
||||
)
|
||||
logger.error("❌ 刷新token失败: 未找到刷新令牌")
|
||||
completion(false, error)
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到刷新令牌,准备请求...")
|
||||
|
||||
// 准备刷新请求参数
|
||||
let parameters: [String: Any] = [
|
||||
"refresh_token": refreshToken,
|
||||
"grant_type": "refresh_token"
|
||||
]
|
||||
|
||||
let url = APIConfig.baseURL + "/v1/iam/access-token-refresh"
|
||||
logger.debug("发送刷新token请求到: \(url)")
|
||||
logger.debug("请求参数: \(parameters)")
|
||||
|
||||
let startTime = Date()
|
||||
|
||||
// 发送刷新请求
|
||||
NetworkService.shared.post(path: "/v1/iam/access-token-refresh", parameters: parameters) {
|
||||
(result: Result<TokenResponse, NetworkError>) in
|
||||
|
||||
let responseTime = String(format: "%.2f秒", Date().timeIntervalSince(startTime))
|
||||
|
||||
switch result {
|
||||
case .success(let tokenResponse):
|
||||
// 1. 保存新的访问令牌
|
||||
KeychainHelper.saveAccessToken(tokenResponse.accessToken)
|
||||
self.logger.debug("✅ Token刷新成功 (耗时: \(responseTime))")
|
||||
self.logger.debug("新的access_token: \(tokenResponse.accessToken.prefix(10))...")
|
||||
|
||||
// 2. 如果返回了新的刷新令牌,也保存起来
|
||||
if let newRefreshToken = tokenResponse.refreshToken {
|
||||
self.logger.debug("新的refresh_token: \(newRefreshToken.prefix(10))...")
|
||||
KeychainHelper.saveRefreshToken(newRefreshToken)
|
||||
}
|
||||
|
||||
print("✅ Token刷新成功")
|
||||
if let expiresIn = tokenResponse.expiresIn {
|
||||
self.logger.debug("Token有效期: \(expiresIn)秒")
|
||||
}
|
||||
|
||||
// 保存新的访问令牌
|
||||
KeychainHelper.saveAccessToken(tokenResponse.accessToken)
|
||||
|
||||
completion(true, nil)
|
||||
|
||||
case .failure(let error):
|
||||
print("❌ Token刷新失败: \(error.localizedDescription)")
|
||||
self.logger.error("❌ Token刷新失败 (耗时: \(responseTime)): \(error.localizedDescription)")
|
||||
|
||||
// 刷新失败,清除本地token,需要用户重新登录
|
||||
self.logger.debug("清除所有token...")
|
||||
KeychainHelper.clearTokens()
|
||||
|
||||
completion(false, error)
|
||||
@ -261,30 +307,30 @@ class TokenManager {
|
||||
|
||||
/// 清除所有存储的 token
|
||||
func clearTokens() {
|
||||
print("🗑️ TokenManager: 清除所有 token")
|
||||
logger.debug("开始清除所有token...")
|
||||
|
||||
// 清除Keychain中的token
|
||||
KeychainHelper.clearTokens()
|
||||
// 清除其他与 token 相关的存储
|
||||
|
||||
// 清除UserDefaults中的token相关信息
|
||||
UserDefaults.standard.removeObject(forKey: "tokenExpiryDate")
|
||||
UserDefaults.standard.synchronize()
|
||||
|
||||
logger.debug("✅ 所有token已清除")
|
||||
|
||||
// 发送登出通知
|
||||
NotificationCenter.default.post(name: .userDidLogout, object: nil)
|
||||
logger.debug("已发送登出通知")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token响应模型
|
||||
/// 用于解析token刷新接口的响应数据
|
||||
// MARK: - 响应模型
|
||||
private struct TokenResponse: Codable {
|
||||
/// 访问令牌
|
||||
let accessToken: String
|
||||
|
||||
/// 刷新令牌(可选)
|
||||
let refreshToken: String?
|
||||
|
||||
/// 过期时间(秒)
|
||||
let expiresIn: TimeInterval?
|
||||
|
||||
/// 令牌类型(如:Bearer)
|
||||
let tokenType: String?
|
||||
|
||||
// 使用CodingKeys自定义键名映射
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
@ -293,16 +339,9 @@ private struct TokenResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 身份验证响应模型
|
||||
/// 用于解析身份验证接口的响应数据
|
||||
private struct IdentityCheckResponse: Codable {
|
||||
/// 是否有效
|
||||
let isValid: Bool
|
||||
|
||||
/// 用户ID(可选)
|
||||
let userId: String?
|
||||
|
||||
/// 过期时间(可选)
|
||||
let expiresAt: Date?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@ -313,9 +352,6 @@ private struct IdentityCheckResponse: Codable {
|
||||
}
|
||||
|
||||
// MARK: - 通知名称
|
||||
/// 定义应用中使用的通知名称
|
||||
extension Notification.Name {
|
||||
/// 用户登出通知
|
||||
/// 当token失效或用户主动登出时发送
|
||||
static let userDidLogout = Notification.Name("UserDidLogoutNotification")
|
||||
}
|
||||
|
||||
105
wake/View/Blind/AvatarBox.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AvatarBoxView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var router: Router
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background color
|
||||
Color.white
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Navigation Bar
|
||||
HStack {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundColor(.black)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("动画页面")
|
||||
.font(.headline)
|
||||
.foregroundColor(.black)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Invisible spacer to center the title
|
||||
Color.clear
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.background(Color.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Animated Content
|
||||
ZStack {
|
||||
// Pulsing circle animation
|
||||
Circle()
|
||||
.fill(Color.blue.opacity(0.2))
|
||||
.frame(width: 200, height: 200)
|
||||
.scaleEffect(isAnimating ? 1.5 : 1.0)
|
||||
.opacity(isAnimating ? 0.5 : 1.0)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 1.5)
|
||||
.repeatForever(autoreverses: true),
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
// Center icon
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.blue)
|
||||
.rotationEffect(.degrees(isAnimating ? 360 : 0))
|
||||
.animation(
|
||||
Animation.linear(duration: 8)
|
||||
.repeatForever(autoreverses: false),
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom Button
|
||||
Button(action: {
|
||||
router.navigate(to: .feedbackView)
|
||||
}) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(25)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
struct AvatarBoxView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
AvatarBoxView()
|
||||
.environmentObject(Router.shared)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
}
|
||||
0
wake/View/Blind/BlindBox.swift
Normal file
400
wake/View/Blind/BlindOutCome.swift
Normal file
@ -0,0 +1,400 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import os.log
|
||||
|
||||
/// A view that displays either an image or a video with fullscreen support
|
||||
struct BlindOutcomeView: View {
|
||||
let media: MediaType
|
||||
let time: String?
|
||||
let description: String?
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State private var isFullscreen = false
|
||||
@State private var isPlaying = false
|
||||
@State private var showControls = true
|
||||
@State private var showIPListModal = false
|
||||
|
||||
init(media: MediaType, time: String? = nil, description: String? = nil) {
|
||||
self.media = media
|
||||
self.time = time
|
||||
self.description = description
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 自定义导航栏
|
||||
HStack {
|
||||
Button(action: {
|
||||
// 返回上一级
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.headline)
|
||||
}
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Blind Box")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 占位,保持标题居中
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.opacity(0)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.zIndex(1) // 确保导航栏在其他内容之上
|
||||
|
||||
Spacer()
|
||||
.frame(height: 30)
|
||||
|
||||
// Media content
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
// 添加白色背景
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
switch media {
|
||||
case .image(let uiImage):
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.cornerRadius(10)
|
||||
.padding(4)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
isFullscreen.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
case .video(let url, _):
|
||||
VideoPlayerView(url: url, isPlaying: $isPlaying)
|
||||
.frame(width: UIScreen.main.bounds.width - 40)
|
||||
.background(Color.clear)
|
||||
.cornerRadius(10)
|
||||
.clipped()
|
||||
.onAppear {
|
||||
// Auto-play the video when it appears
|
||||
isPlaying = true
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $isFullscreen) {
|
||||
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: nil)
|
||||
}
|
||||
.overlay(
|
||||
showControls ? VideoControls(
|
||||
isPlaying: $isPlaying,
|
||||
onClose: { isFullscreen = false }
|
||||
) : nil
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
if let description = description, !description.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Description")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
Spacer()
|
||||
// Button at bottom
|
||||
VStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
// 如果携带的类型是video显示弹窗
|
||||
if case .video = media {
|
||||
withAnimation {
|
||||
showIPListModal = true
|
||||
}
|
||||
} else {
|
||||
Router.shared.navigate(to: .feedbackView)
|
||||
}
|
||||
}) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(26)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.onDisappear {
|
||||
// Clean up video player when view disappears
|
||||
if case .video = media {
|
||||
isPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true) // 确保隐藏系统导航栏
|
||||
.navigationBarBackButtonHidden(true) // 确保隐藏系统返回按钮
|
||||
.statusBar(hidden: isFullscreen)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle()) // 确保在iPad上也能正确显示
|
||||
.navigationBarHidden(true) // 额外确保隐藏导航栏
|
||||
.overlay(
|
||||
JoinModal(isPresented: $showIPListModal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fullscreen Media View
|
||||
private struct FullscreenMediaView: View {
|
||||
let media: MediaType
|
||||
@Binding var isPresented: Bool
|
||||
@Binding var isPlaying: Bool
|
||||
@State private var showControls = true
|
||||
@State private var player: AVPlayer?
|
||||
|
||||
init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) {
|
||||
self.media = media
|
||||
self._isPresented = isPresented
|
||||
self._isPlaying = isPlaying
|
||||
if let player = player {
|
||||
self._player = State(initialValue: player)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
|
||||
// Media content
|
||||
ZStack {
|
||||
switch media {
|
||||
case .image(let uiImage):
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
case .video(let url, _):
|
||||
VideoPlayerView(url: url, isPlaying: $isPlaying)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
showControls ? VideoControls(
|
||||
isPlaying: $isPlaying,
|
||||
onClose: { isPresented = false }
|
||||
) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// Close button (always visible)
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.5))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if case .video = media {
|
||||
if isPlaying {
|
||||
// player?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if case .video = media {
|
||||
// player?.pause()
|
||||
// player?.replaceCurrentItem(with: nil)
|
||||
// player = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Controls
|
||||
private struct VideoControls: View {
|
||||
@Binding var isPlaying: Bool
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
// Empty view - no controls shown
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Player with Dynamic Aspect Ratio
|
||||
struct VideoPlayerView: UIViewRepresentable {
|
||||
let url: URL
|
||||
@Binding var isPlaying: Bool
|
||||
|
||||
func makeUIView(context: Context) -> PlayerView {
|
||||
let view = PlayerView()
|
||||
view.setupPlayer(url: url)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerView, context: Context) {
|
||||
if isPlaying {
|
||||
uiView.play()
|
||||
} else {
|
||||
uiView.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerView: UIView {
|
||||
private var player: AVPlayer?
|
||||
private var playerLayer: AVPlayerLayer?
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var playerItemObserver: NSKeyValueObservation?
|
||||
|
||||
func setupPlayer(url: URL) {
|
||||
// Clean up existing resources
|
||||
cleanup()
|
||||
|
||||
// Create new player
|
||||
let asset = AVAsset(url: url)
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
self.playerItem = playerItem
|
||||
|
||||
player = AVPlayer(playerItem: playerItem)
|
||||
|
||||
// Setup player layer
|
||||
let playerLayer = AVPlayerLayer(player: player)
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer.addSublayer(playerLayer)
|
||||
self.playerLayer = playerLayer
|
||||
|
||||
// Layout
|
||||
playerLayer.frame = bounds
|
||||
|
||||
// Add observer for video end
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(playerItemDidReachEnd),
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
// Remove observers
|
||||
if let playerItem = playerItem {
|
||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||
}
|
||||
|
||||
// Pause and clean up player
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
player = nil
|
||||
|
||||
// Remove player layer
|
||||
playerLayer?.removeFromSuperlayer()
|
||||
playerLayer = nil
|
||||
|
||||
// Release player item
|
||||
playerItem?.cancelPendingSeeks()
|
||||
playerItem?.asset.cancelLoading()
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
@objc private func playerItemDidReachEnd() {
|
||||
player?.seek(to: .zero)
|
||||
player?.play()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer?.frame = bounds
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
struct BlindOutcomeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
// Preview with image and details
|
||||
BlindOutcomeView(
|
||||
media: .image(UIImage(systemName: "photo")!),
|
||||
time: "2:30",
|
||||
description: "This is a sample description for the preview. It shows how the text will wrap and display below the media content."
|
||||
)
|
||||
|
||||
// Preview with video and details
|
||||
if let url = URL(string: "https://example.com/sample.mp4") {
|
||||
BlindOutcomeView(
|
||||
media: .video(url, nil),
|
||||
time: "1:45",
|
||||
description: "Video content with time and description"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
wake/View/Blind/Card.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CustomLightSequenceAnimation: View {
|
||||
// 核心循环序列:按"123321123321"规律定义基础单元
|
||||
private let baseSequence: [Int] = [1, 2, 3, 3, 2, 1, 1, 2, 3, 3, 2, 1]
|
||||
@State private var currentLight: Int = 1 // 当前显示的图片序号
|
||||
@State private var sequenceIndex: Int = 0 // 当前在序列中的索引
|
||||
|
||||
// 淡入淡出透明度控制(确保切换丝滑)
|
||||
@State private var currentOpacity: CGFloat = 1.0
|
||||
@State private var nextOpacity: CGFloat = 0.0
|
||||
|
||||
// 尺寸参数(适配正方形卡片)
|
||||
private let screenWidth = UIScreen.main.bounds.width
|
||||
private let squareSize: CGFloat
|
||||
private let imageSize: CGFloat
|
||||
|
||||
init() {
|
||||
self.squareSize = screenWidth * 1.8 // 正方形背景尺寸
|
||||
self.imageSize = squareSize / 3 // 光束卡片尺寸(1/3背景大小)
|
||||
}
|
||||
|
||||
// 卡片中心位置(固定,确保摆正居中)
|
||||
private var centerPosition: CGPoint {
|
||||
CGPoint(x: screenWidth / 2, y: squareSize * 0.325)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 底部背景(正方形)
|
||||
SVGImage(svgName: "BlindBg")
|
||||
.frame(width: squareSize, height: squareSize)
|
||||
.position(centerPosition)
|
||||
|
||||
// 当前显示的光束卡片(摆正状态)
|
||||
SVGImage(svgName: "Light\(currentLight)")
|
||||
.frame(width: imageSize, height: imageSize)
|
||||
.position(centerPosition)
|
||||
.opacity(currentOpacity)
|
||||
|
||||
// 下一张待显示的光束卡片(提前加载,摆正状态)
|
||||
SVGImage(svgName: "Light\(nextLight)")
|
||||
.frame(width: imageSize, height: imageSize)
|
||||
.position(centerPosition)
|
||||
.opacity(nextOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
startLoopAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 计算下一张卡片序号(基于当前索引循环)
|
||||
private var nextLight: Int {
|
||||
let nextIdx = (sequenceIndex + 1) % baseSequence.count
|
||||
return baseSequence[nextIdx]
|
||||
}
|
||||
|
||||
// 启动循环切换动画
|
||||
private func startLoopAnimation() {
|
||||
// 每1.2秒切换一次(可调整速度)
|
||||
Timer.scheduledTimer(withTimeInterval: 1.2, repeats: true) { _ in
|
||||
// 0.5秒淡入淡出过渡(丝滑无卡顿)
|
||||
withAnimation(Animation.easeInOut(duration: 0.5)) {
|
||||
currentOpacity = 0.0
|
||||
nextOpacity = 1.0
|
||||
}
|
||||
|
||||
// 动画完成后更新状态(确保顺序无偏差)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
currentLight = nextLight
|
||||
sequenceIndex = (sequenceIndex + 1) % baseSequence.count
|
||||
currentOpacity = 1.0
|
||||
nextOpacity = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
struct CustomLightSequenceAnimation_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CustomLightSequenceAnimation()
|
||||
}
|
||||
}
|
||||
229
wake/View/Blind/JoinModal.swift
Normal file
@ -0,0 +1,229 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JoinModal: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Semi-transparent background
|
||||
if isPresented {
|
||||
Color.black.opacity(0.4)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modal content
|
||||
if isPresented {
|
||||
VStack(spacing: 0) {
|
||||
// IP Image peeking from top
|
||||
HStack {
|
||||
// Make sure you have an image named "IP" in your assets
|
||||
SVGImage(svgName: "IP1")
|
||||
.frame(width: 116, height: 65)
|
||||
.offset(x: 30)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 65)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Close button on the right
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .all))
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(12)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
|
||||
// 文本
|
||||
VStack(spacing: 8) {
|
||||
Text("Join us!")
|
||||
.font(Typography.font(for: .headline1, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text("Join us to get more exclusive benefits.")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
// List content
|
||||
VStack (alignment: .leading) {
|
||||
HStack {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
HStack (alignment: .top){
|
||||
Text("Unlimited")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" blind box purchases.")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.leading,12)
|
||||
HStack (alignment: .center) {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
VStack (alignment: .leading,spacing: 4) {
|
||||
HStack {
|
||||
Text("Freely")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" upload image and video")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
Text(" materials.")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.leading,12)
|
||||
|
||||
HStack(alignment: .top) {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
VStack (alignment: .leading,spacing: 4) {
|
||||
HStack {
|
||||
Text("500")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" credits daily,")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
VStack (alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("5000")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" permanent credits on your first")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
Text(" purchase!")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.padding(.leading,12)
|
||||
HStack {
|
||||
Spacer() // This will push the button to the right
|
||||
Button(action: {
|
||||
// 点击跳转到会员页面
|
||||
Router.shared.navigate(to: .subscribe)
|
||||
}) {
|
||||
HStack {
|
||||
Text("See More")
|
||||
.font(.system(size: 16))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 24)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16) // Add some right padding to match the design
|
||||
Button(action: {
|
||||
// 点击跳转到会员页面
|
||||
Router.shared.navigate(to: .subscribe)
|
||||
}) {
|
||||
HStack {
|
||||
Text("Subscribe")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
Spacer()
|
||||
Text("$1.00/Mon")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
}
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 30)
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
// 协议条款
|
||||
HStack(alignment: .center) {
|
||||
Button(action: {
|
||||
// Action for Terms of Service
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Terms of Service")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.5))
|
||||
.frame(width: 1, height: 16)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: {
|
||||
// 打开网页
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Privacy Policy")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.5))
|
||||
.frame(width: 1, height: 16)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: {
|
||||
// Action for Restore Purchase
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("AI Usage Guidelines")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(20, corners: [.topLeft, .topRight])
|
||||
}
|
||||
.frame(height: nil)
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.animation(.easeInOut, value: isPresented)
|
||||
}
|
||||
}
|
||||
|
||||
struct JoinModal_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
JoinModal(isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
/// 自定义的 Apple 登录按钮组件
|
||||
struct AppleSignInButton: View {
|
||||
@ -15,6 +16,12 @@ struct AppleSignInButton: View {
|
||||
/// 按钮文字
|
||||
let buttonText: String
|
||||
|
||||
// 创建日志记录器
|
||||
private let logger = Logger(subsystem: "com.yourapp", category: "AppleSignInButton")
|
||||
|
||||
// 添加一个强引用到coordinator
|
||||
@State private var coordinator: Coordinator?
|
||||
|
||||
// MARK: - 初始化方法
|
||||
|
||||
init(buttonText: String = "Continue with Apple",
|
||||
@ -23,48 +30,117 @@ struct AppleSignInButton: View {
|
||||
self.buttonText = buttonText
|
||||
self.onRequest = onRequest
|
||||
self.onCompletion = onCompletion
|
||||
logger.debug("AppleSignInButton 初始化,按钮文字: \(buttonText)")
|
||||
}
|
||||
|
||||
// MARK: - 视图主体
|
||||
|
||||
var body: some View {
|
||||
Button(action: handleSignIn) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: "applelogo")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
Text(buttonText)
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 60)
|
||||
.background(Color.white)
|
||||
.foregroundColor(.black)
|
||||
.cornerRadius(30)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(Color.black, lineWidth: 1) // 使用黑色边框
|
||||
)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: "applelogo")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
Text(buttonText)
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 60)
|
||||
.background(Color.white)
|
||||
.foregroundColor(.black)
|
||||
.cornerRadius(30)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(Color.black, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
|
||||
private func handleSignIn() {
|
||||
logger.debug("🍎 用户点击了Apple登录按钮")
|
||||
|
||||
let provider = ASAuthorizationAppleIDProvider()
|
||||
let request = provider.createRequest()
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
|
||||
// 创建 nonce 用于安全验证
|
||||
let nonce = String.randomURLSafeString(length: 32)
|
||||
let nonce = randomNonceString(length: 32)
|
||||
logger.debug("🔑 生成Nonce: \(nonce)")
|
||||
|
||||
request.nonce = sha256(nonce)
|
||||
logger.debug("🔐 Nonce的SHA256: \(request.nonce ?? "nil")")
|
||||
|
||||
// 调用请求回调
|
||||
logger.debug("📞 调用onRequest回调")
|
||||
onRequest(request)
|
||||
|
||||
// 创建并显示授权控制器
|
||||
logger.debug("🔄 创建ASAuthorizationController")
|
||||
let controller = ASAuthorizationController(authorizationRequests: [request])
|
||||
controller.delegate = Coordinator(onCompletion: onCompletion)
|
||||
controller.performRequests()
|
||||
|
||||
// 创建presentation context provider
|
||||
let presentationContextProvider = PresentationContextProvider()
|
||||
|
||||
// 创建coordinator并保持强引用
|
||||
let newCoordinator = Coordinator(
|
||||
onCompletion: { [logger] result in
|
||||
// 处理完成后释放coordinator
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator = nil
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let auth):
|
||||
logger.debug("✅ 授权成功 - 开始处理授权响应")
|
||||
|
||||
if let appleIDCredential = auth.credential as? ASAuthorizationAppleIDCredential {
|
||||
let userIdentifier = appleIDCredential.user
|
||||
logger.debug("👤 用户标识符: \(userIdentifier)")
|
||||
|
||||
// 保存用户ID用于后续验证
|
||||
UserDefaults.standard.set(userIdentifier, forKey: "appleAuthorizedUserIdKey")
|
||||
}
|
||||
case .failure(let error):
|
||||
let nsError = error as NSError
|
||||
logger.error("❌ 授权失败: \(nsError.localizedDescription)")
|
||||
}
|
||||
|
||||
// 调用完成回调
|
||||
self.onCompletion(result)
|
||||
},
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// 保存coordinator的强引用
|
||||
self.coordinator = newCoordinator
|
||||
|
||||
// 设置代理和presentation context provider
|
||||
controller.delegate = newCoordinator
|
||||
controller.presentationContextProvider = presentationContextProvider
|
||||
|
||||
// 执行请求
|
||||
logger.debug("🚀 开始执行授权请求...")
|
||||
DispatchQueue.main.async {
|
||||
controller.performRequests()
|
||||
}
|
||||
}
|
||||
|
||||
private func logAppleIDError(_ error: ASAuthorizationError, logger: Logger) {
|
||||
switch error.code {
|
||||
case .canceled:
|
||||
logger.error("❌ 用户取消了授权")
|
||||
case .failed:
|
||||
logger.error("❌ 授权请求失败")
|
||||
case .invalidResponse:
|
||||
logger.error("❌ 无效的授权响应")
|
||||
case .notHandled:
|
||||
logger.error("❌ 授权请求未被处理")
|
||||
case .unknown:
|
||||
logger.error("❌ 未知的授权错误")
|
||||
@unknown default:
|
||||
logger.error("❌ 未处理的授权错误")
|
||||
}
|
||||
}
|
||||
|
||||
private func sha256(_ input: String) -> String {
|
||||
@ -73,23 +149,123 @@ struct AppleSignInButton: View {
|
||||
return hashedData.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - 协调器
|
||||
// 使用项目中的现有方法生成随机字符串
|
||||
private func randomNonceString(length: Int) -> String {
|
||||
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return String((0..<length).map{ _ in letters.randomElement()! })
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
private class Coordinator: NSObject, ASAuthorizationControllerDelegate {
|
||||
let onCompletion: (Result<ASAuthorization, Error>) -> Void
|
||||
private let logger: Logger
|
||||
|
||||
init(onCompletion: @escaping (Result<ASAuthorization, Error>) -> Void) {
|
||||
init(onCompletion: @escaping (Result<ASAuthorization, Error>) -> Void, logger: Logger) {
|
||||
self.onCompletion = onCompletion
|
||||
self.logger = logger
|
||||
super.init()
|
||||
logger.debug("Coordinator 初始化")
|
||||
}
|
||||
|
||||
// 授权成功回调
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||
func authorizationController(controller: ASAuthorizationController,
|
||||
didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||
logger.debug("✅ 授权成功 - 开始处理授权响应")
|
||||
|
||||
if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
|
||||
// 创建用户标识符
|
||||
let userIdentifier = appleIDCredential.user
|
||||
logger.debug("👤 用户标识符: \(userIdentifier)")
|
||||
|
||||
// 检查是否是新用户
|
||||
if let email = appleIDCredential.email, let fullName = appleIDCredential.fullName {
|
||||
logger.debug("👋 检测到新用户")
|
||||
logger.debug("📧 新用户邮箱: \(email)")
|
||||
logger.debug("👤 新用户全名: \(fullName.givenName ?? "") \(fullName.familyName ?? "")")
|
||||
} else {
|
||||
logger.debug("👋 现有用户登录")
|
||||
}
|
||||
|
||||
// 保存用户标识符到UserDefaults
|
||||
UserDefaults.standard.set(userIdentifier, forKey: "appleAuthorizedUserIdKey")
|
||||
|
||||
// 获取授权码
|
||||
if let authCodeData = appleIDCredential.authorizationCode,
|
||||
let _ = String(data: authCodeData, encoding: .utf8) {
|
||||
logger.debug("🔑 成功获取授权码")
|
||||
} else {
|
||||
logger.warning("⚠️ 未获取到授权码")
|
||||
}
|
||||
|
||||
// 获取身份令牌
|
||||
if let identityTokenData = appleIDCredential.identityToken,
|
||||
let _ = String(data: identityTokenData, encoding: .utf8) {
|
||||
logger.debug("🔐 成功获取身份令牌")
|
||||
} else {
|
||||
logger.error("❌ 获取身份令牌失败")
|
||||
}
|
||||
} else {
|
||||
logger.error("❌ 无法获取有效的 Apple ID 凭证")
|
||||
}
|
||||
|
||||
// 调用完成处理程序
|
||||
logger.debug("🔄 调用完成处理程序")
|
||||
onCompletion(.success(authorization))
|
||||
}
|
||||
|
||||
// 授权失败回调
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
||||
func authorizationController(controller: ASAuthorizationController,
|
||||
didCompleteWithError error: Error) {
|
||||
let nsError = error as NSError
|
||||
logger.error("❌ 授权失败: \(nsError.localizedDescription)")
|
||||
logger.error("错误域: \(nsError.domain), 错误码: \(nsError.code)")
|
||||
|
||||
if let appleIDError = error as? ASAuthorizationError {
|
||||
switch appleIDError.code {
|
||||
case .canceled:
|
||||
logger.error("❌ 用户取消了授权")
|
||||
case .failed:
|
||||
logger.error("❌ 授权请求失败")
|
||||
case .invalidResponse:
|
||||
logger.error("❌ 无效的授权响应")
|
||||
case .notHandled:
|
||||
logger.error("❌ 授权请求未被处理")
|
||||
case .unknown:
|
||||
logger.error("❌ 未知的授权错误")
|
||||
@unknown default:
|
||||
logger.error("❌ 未处理的授权错误")
|
||||
}
|
||||
|
||||
// 记录更多错误详情
|
||||
if let errorString = (appleIDError as NSError).userInfo[NSDebugDescriptionErrorKey] as? String {
|
||||
logger.error("🔍 错误详情: \(errorString)")
|
||||
}
|
||||
}
|
||||
|
||||
// 调用完成处理程序
|
||||
logger.debug("🔄 调用完成处理程序(错误)")
|
||||
onCompletion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presentation Context Provider
|
||||
|
||||
private class PresentationContextProvider: NSObject, ASAuthorizationControllerPresentationContextProviding {
|
||||
private let logger = Logger(subsystem: "com.yourapp.applesignin", category: "PresentationContextProvider")
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
logger.debug("PresentationContextProvider 初始化")
|
||||
}
|
||||
|
||||
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||
logger.debug("获取presentation anchor")
|
||||
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
||||
logger.error("无法获取key window,将使用第一个window")
|
||||
return UIApplication.shared.windows.first ?? UIWindow()
|
||||
}
|
||||
return window
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -199,32 +199,48 @@ public class ImageUploadService {
|
||||
|
||||
self.uploader.uploadImage(
|
||||
compressedThumbnail,
|
||||
progress: { _ in },
|
||||
progress: { uploadProgress in
|
||||
// 缩略图上传进度(占总进度的后10%)
|
||||
let progressInfo = UploadProgress(
|
||||
current: 90 + Int(uploadProgress * 10),
|
||||
total: 100,
|
||||
progress: 0.9 + (uploadProgress * 0.1),
|
||||
isOriginal: false
|
||||
)
|
||||
progress(progressInfo)
|
||||
},
|
||||
completion: { thumbnailResult in
|
||||
switch thumbnailResult {
|
||||
case .success(let thumbnailUploadResult):
|
||||
print("✅ 视频缩略图上传完成, fileId: \(thumbnailUploadResult.fileId)")
|
||||
let result = MediaUploadResult.video(
|
||||
video: videoResult,
|
||||
thumbnail: thumbnailUploadResult
|
||||
|
||||
// 确保返回的视频结果中,preview_file_id 是缩略图的 ID
|
||||
let finalVideoResult = ImageUploaderGetID.UploadResult(
|
||||
fileUrl: videoResult.fileUrl,
|
||||
fileName: videoResult.fileName,
|
||||
fileSize: videoResult.fileSize,
|
||||
fileId: videoResult.fileId,
|
||||
previewFileId: thumbnailUploadResult.fileId // 使用缩略图的ID作为preview_file_id
|
||||
)
|
||||
completion(.success(result))
|
||||
|
||||
completion(.success(.video(video: finalVideoResult, thumbnail: thumbnailUploadResult)))
|
||||
|
||||
case .failure(let error):
|
||||
print("❌ 视频缩略图上传失败: \(error.localizedDescription)")
|
||||
completion(.failure(error))
|
||||
// 即使缩略图上传失败,也返回视频上传成功
|
||||
completion(.success(.video(video: videoResult, thumbnail: nil)))
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
let error = NSError(domain: "ImageUploadService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to compress thumbnail"])
|
||||
print("❌ 视频缩略图压缩失败")
|
||||
completion(.failure(error))
|
||||
// 缩略图压缩失败,只返回视频
|
||||
completion(.success(.video(video: videoResult, thumbnail: nil)))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
print("❌ 视频缩略图提取失败: \(error.localizedDescription)")
|
||||
completion(.failure(error))
|
||||
// 缩略图提取失败,只返回视频
|
||||
completion(.success(.video(video: videoResult, thumbnail: nil)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -334,15 +350,25 @@ public class ImageUploadService {
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// 媒体类型
|
||||
public enum MediaType {
|
||||
public enum MediaType: Equatable {
|
||||
case image(UIImage)
|
||||
case video(URL, UIImage?)
|
||||
|
||||
// 确保 id 计算属性存在
|
||||
var id: String {
|
||||
switch self {
|
||||
case .image(let uiImage):
|
||||
return "image_\(uiImage.hashValue)"
|
||||
case .video(let url, _):
|
||||
return "video_\(url.absoluteString.hashValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体上传结果
|
||||
public enum MediaUploadResult {
|
||||
case file(ImageUploaderGetID.UploadResult)
|
||||
case video(video: ImageUploaderGetID.UploadResult, thumbnail: ImageUploaderGetID.UploadResult)
|
||||
case video(video: ImageUploaderGetID.UploadResult, thumbnail: ImageUploaderGetID.UploadResult?)
|
||||
|
||||
/// 获取文件ID(对于视频,返回视频文件的ID)
|
||||
public var fileId: String {
|
||||
@ -353,6 +379,36 @@ public class ImageUploadService {
|
||||
return videoResult.fileId
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取预览文件ID(对于视频,返回缩略图的ID)
|
||||
public var previewFileId: String? {
|
||||
switch self {
|
||||
case .file:
|
||||
return nil
|
||||
case .video(_, let thumbnailResult):
|
||||
return thumbnailResult?.fileId
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文件URL(对于视频,返回视频文件的URL)
|
||||
public var fileUrl: String {
|
||||
switch self {
|
||||
case .file(let result):
|
||||
return result.fileUrl
|
||||
case .video(let videoResult, _):
|
||||
return videoResult.fileUrl
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缩略图URL(如果有)
|
||||
public var thumbnailUrl: String? {
|
||||
switch self {
|
||||
case .file:
|
||||
return nil
|
||||
case .video(_, let thumbnailResult):
|
||||
return thumbnailResult?.fileUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传进度信息
|
||||
|
||||
@ -12,12 +12,14 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
public let fileName: String
|
||||
public let fileSize: Int
|
||||
public let fileId: String
|
||||
public let previewFileId: String?
|
||||
|
||||
public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String) {
|
||||
public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String, previewFileId: String? = nil) {
|
||||
self.fileUrl = fileUrl
|
||||
self.fileName = fileName
|
||||
self.fileSize = fileSize
|
||||
self.fileId = fileId
|
||||
self.previewFileId = previewFileId
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,7 +92,11 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
}
|
||||
|
||||
// 2. 获取上传URL
|
||||
getUploadURL(for: imageData) { [weak self] result in
|
||||
getUploadURL(
|
||||
for: imageData,
|
||||
mimeType: "image/jpeg",
|
||||
originalFilename: "image_\(UUID().uuidString).jpg"
|
||||
) { [weak self] result in
|
||||
switch result {
|
||||
case .success((let fileId, let uploadURL)):
|
||||
print("📤 获取到上传URL,开始上传文件...")
|
||||
@ -110,7 +116,7 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
// 4. 确认上传
|
||||
self?.confirmUpload(
|
||||
fileId: fileId,
|
||||
fileName: "avatar_\(UUID().uuidString).jpg",
|
||||
fileName: "image_\(UUID().uuidString).jpg",
|
||||
fileSize: imageData.count,
|
||||
completion: completion
|
||||
)
|
||||
@ -206,76 +212,6 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
|
||||
// MARK: - 私有方法
|
||||
|
||||
/// 获取上传URL
|
||||
private func getUploadURL(
|
||||
for imageData: Data,
|
||||
completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void
|
||||
) {
|
||||
let fileName = "avatar_\(UUID().uuidString).jpg"
|
||||
let parameters: [String: Any] = [
|
||||
"filename": fileName,
|
||||
"content_type": "image/jpeg",
|
||||
"file_size": imageData.count
|
||||
]
|
||||
|
||||
let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
|
||||
guard let url = URL(string: urlString) else {
|
||||
completion(.failure(UploadError.invalidURL))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = apiConfig.authHeaders
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
||||
print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB")
|
||||
} catch {
|
||||
print("❌ 序列化请求参数失败: \(error.localizedDescription)")
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(UploadError.uploadFailed(error)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
// 打印调试信息
|
||||
if let responseString = String(data: data, encoding: .utf8) {
|
||||
print("📥 获取上传URL响应: \(responseString)")
|
||||
}
|
||||
|
||||
do {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataDict = json["data"] as? [String: Any],
|
||||
let fileId = dataDict["file_id"] as? String,
|
||||
let uploadURLString = dataDict["upload_url"] as? String,
|
||||
let uploadURL = URL(string: uploadURLString) else {
|
||||
throw UploadError.invalidResponse
|
||||
}
|
||||
|
||||
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
||||
} catch {
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// 获取上传URL
|
||||
private func getUploadURL(
|
||||
for fileData: Data,
|
||||
@ -291,7 +227,14 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
]
|
||||
|
||||
let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
|
||||
print("🌐 准备请求上传URL...")
|
||||
print(" - 目标URL: \(urlString)")
|
||||
print(" - 文件名: \(fileName)")
|
||||
print(" - 文件大小: \(Double(fileData.count) / 1024.0) KB")
|
||||
print(" - MIME类型: \(mimeType)")
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("❌ 错误: 无效的URL: \(urlString)")
|
||||
completion(.failure(UploadError.invalidURL))
|
||||
return
|
||||
}
|
||||
@ -302,7 +245,9 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
||||
print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(fileData.count) / 1024.0) KB")
|
||||
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
|
||||
print("📤 请求体: \(bodyString)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ 序列化请求参数失败: \(error.localizedDescription)")
|
||||
completion(.failure(error))
|
||||
@ -311,37 +256,61 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
print("❌ 获取上传URL请求失败: \(error.localizedDescription)")
|
||||
completion(.failure(UploadError.uploadFailed(error)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
print("❌ 无效的服务器响应")
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
print("📥 收到上传URL响应")
|
||||
print(" - 状态码: \(httpResponse.statusCode)")
|
||||
|
||||
guard let data = data else {
|
||||
print("❌ 响应数据为空")
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
// 打印调试信息
|
||||
// 打印响应头
|
||||
print(" - 响应头: \(httpResponse.allHeaderFields)")
|
||||
|
||||
// 打印响应体
|
||||
if let responseString = String(data: data, encoding: .utf8) {
|
||||
print("📥 上传URL响应: \(responseString)")
|
||||
print(" - 响应体: \(responseString)")
|
||||
}
|
||||
|
||||
do {
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
guard let code = json?["code"] as? Int, code == 0,
|
||||
let dataDict = json?["data"] as? [String: Any],
|
||||
print(" - 解析的JSON: \(String(describing: json))")
|
||||
|
||||
guard let code = json?["code"] as? Int, code == 0 else {
|
||||
let errorMessage = json?["message"] as? String ?? "未知错误"
|
||||
print("❌ 服务器返回错误: \(errorMessage)")
|
||||
completion(.failure(UploadError.serverError(errorMessage)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let dataDict = json?["data"] as? [String: Any],
|
||||
let fileId = dataDict["file_id"] as? String,
|
||||
let uploadURLString = dataDict["upload_url"] as? String,
|
||||
let uploadURL = URL(string: uploadURLString) else {
|
||||
throw UploadError.invalidResponse
|
||||
print("❌ 响应数据格式错误")
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ 成功获取上传URL")
|
||||
print(" - 文件ID: \(fileId)")
|
||||
print(" - 上传URL: \(uploadURLString)")
|
||||
|
||||
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
||||
} catch {
|
||||
print("❌ 解析响应数据失败: \(error.localizedDescription)")
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
}
|
||||
}
|
||||
@ -421,75 +390,61 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
public func uploadFile(
|
||||
fileData: Data,
|
||||
to uploadURL: URL,
|
||||
mimeType: String = "application/octet-stream",
|
||||
mimeType: String,
|
||||
onProgress: @escaping (Double) -> Void,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) -> URLSessionUploadTask {
|
||||
print("📤 开始上传文件...")
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let task = session.uploadTask(with: request, from: fileData) { _, response, error in
|
||||
let task = session.uploadTask(
|
||||
with: request,
|
||||
from: fileData
|
||||
) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
print("❌ 文件上传失败: \(error.localizedDescription)")
|
||||
completion(.failure(UploadError.uploadFailed(error)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
print("❌ 无效的响应")
|
||||
completion(.failure(UploadError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
let statusCode = httpResponse.statusCode
|
||||
completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)")))
|
||||
print("❌ 服务器返回错误状态码: \(statusCode)")
|
||||
completion(.failure(UploadError.serverError("HTTP \(statusCode)")))
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ 文件上传成功")
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
// 添加进度观察
|
||||
if #available(iOS 11.0, *) {
|
||||
let progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
|
||||
DispatchQueue.main.async {
|
||||
onProgress(progressValue.fractionCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
task.addCompletionHandler { [weak task] in
|
||||
progressObserver.invalidate()
|
||||
task?.progress.cancel()
|
||||
}
|
||||
} else {
|
||||
var lastProgress: Double = 0
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
|
||||
let bytesSent = task.countOfBytesSent
|
||||
let totalBytes = task.countOfBytesExpectedToSend
|
||||
let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0
|
||||
|
||||
// 只有当进度有显著变化时才回调,避免频繁更新UI
|
||||
if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 {
|
||||
lastProgress = currentProgress
|
||||
DispatchQueue.main.async {
|
||||
onProgress(min(currentProgress, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
if currentProgress >= 1.0 {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
task.addCompletionHandler {
|
||||
timer.invalidate()
|
||||
}
|
||||
let progressObserver = task.progress.observe(\.fractionCompleted) { progress, _ in
|
||||
let percentComplete = progress.fractionCompleted
|
||||
print("📊 文件上传进度: \(Int(percentComplete * 100))%")
|
||||
onProgress(percentComplete)
|
||||
}
|
||||
|
||||
// 存储观察者以避免提前释放
|
||||
objc_setAssociatedObject(task, &AssociatedKeys.progressObserver, progressObserver, .OBJC_ASSOCIATION_RETAIN)
|
||||
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
|
||||
private struct AssociatedKeys {
|
||||
static var progressObserver = "progressObserver"
|
||||
}
|
||||
|
||||
// MARK: - 文件上传状态
|
||||
|
||||
/// 文件上传状态
|
||||
|
||||
@ -3,39 +3,6 @@ import PhotosUI
|
||||
import os.log
|
||||
import AVKit
|
||||
|
||||
/// 媒体类型
|
||||
public enum MediaType: Equatable {
|
||||
case image(UIImage)
|
||||
case video(URL, UIImage?) // URL 是视频地址,UIImage 是视频缩略图
|
||||
|
||||
public var thumbnail: UIImage? {
|
||||
switch self {
|
||||
case .image(let image):
|
||||
return image
|
||||
case .video(_, let thumbnail):
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
public var isVideo: Bool {
|
||||
if case .video = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.image(let lhsImage), .image(let rhsImage)):
|
||||
return lhsImage.pngData() == rhsImage.pngData()
|
||||
case (.video(let lhsURL, _), .video(let rhsURL, _)):
|
||||
return lhsURL == rhsURL
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaTypeFilter {
|
||||
case imagesOnly
|
||||
case videosOnly
|
||||
@ -57,6 +24,7 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
let onDismiss: (() -> Void)?
|
||||
let allowedMediaTypes: MediaTypeFilter
|
||||
let selectionMode: SelectionMode
|
||||
let onUploadProgress: ((Int, Double) -> Void)?
|
||||
|
||||
/// 选择模式
|
||||
enum SelectionMode {
|
||||
@ -72,18 +40,21 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
init(selectedMedia: Binding<[MediaType]>,
|
||||
imageSelectionLimit: Int = 10,
|
||||
videoSelectionLimit: Int = 10,
|
||||
allowedMediaTypes: MediaTypeFilter = .all,
|
||||
selectionMode: SelectionMode = .multiple,
|
||||
onDismiss: (() -> Void)? = nil) {
|
||||
imageSelectionLimit: Int = 10,
|
||||
videoSelectionLimit: Int = 10,
|
||||
allowedMediaTypes: MediaTypeFilter = .all,
|
||||
selectionMode: SelectionMode = .multiple,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
onUploadProgress: ((Int, Double) -> Void)? = nil) {
|
||||
self._selectedMedia = selectedMedia
|
||||
self.imageSelectionLimit = imageSelectionLimit
|
||||
self.videoSelectionLimit = videoSelectionLimit
|
||||
self.allowedMediaTypes = allowedMediaTypes
|
||||
self.selectionMode = selectionMode
|
||||
self.onDismiss = onDismiss
|
||||
self.onUploadProgress = onUploadProgress
|
||||
}
|
||||
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
|
||||
@ -182,17 +153,26 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理选择的媒体
|
||||
processSelectedMedia(results: results, picker: picker, processedMedia: &processedMedia)
|
||||
// 先关闭选择器
|
||||
picker.dismiss(animated: true) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// 处理选中的媒体
|
||||
var processedMedia = self.parent.selectionMode == .single ? [] : self.parent.selectedMedia
|
||||
self.processSelectedMedia(results: results, picker: picker, processedMedia: &processedMedia)
|
||||
|
||||
// 调用 onDismiss 通知外部选择器已关闭
|
||||
self.parent.onDismiss?()
|
||||
}
|
||||
}
|
||||
|
||||
private func processSelectedMedia(results: [PHPickerResult],
|
||||
picker: PHPickerViewController,
|
||||
processedMedia: inout [MediaType]) {
|
||||
picker: PHPickerViewController,
|
||||
processedMedia: inout [MediaType]) {
|
||||
let group = DispatchGroup()
|
||||
let mediaCollector = MediaCollector()
|
||||
|
||||
for result in results {
|
||||
for (index, result) in results.enumerated() {
|
||||
let itemProvider = result.itemProvider
|
||||
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
@ -202,6 +182,10 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
processImage(itemProvider: itemProvider) { media in
|
||||
if let media = media {
|
||||
mediaCollector.add(media: media)
|
||||
// 更新上传进度
|
||||
DispatchQueue.main.async {
|
||||
self.parent.onUploadProgress?(index, 1.0) // 图片直接完成
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
@ -212,21 +196,19 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
processVideo(itemProvider: itemProvider) { media in
|
||||
if let media = media {
|
||||
mediaCollector.add(media: media)
|
||||
// 更新上传进度
|
||||
DispatchQueue.main.async {
|
||||
self.parent.onUploadProgress?(index, 1.0) // 视频直接完成
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a local copy of the parent reference
|
||||
let parent = self.parent
|
||||
|
||||
|
||||
group.notify(queue: .main) {
|
||||
let finalMedia = mediaCollector.mediaItems
|
||||
parent.selectedMedia = finalMedia
|
||||
picker.dismiss(animated: true) {
|
||||
parent.onDismiss?()
|
||||
}
|
||||
self.parent.selectedMedia = finalMedia
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,18 +2,27 @@ import SwiftUI
|
||||
import os.log
|
||||
|
||||
/// 媒体上传状态
|
||||
public enum MediaUploadStatus: Equatable {
|
||||
public enum MediaUploadStatus: Equatable, Identifiable {
|
||||
case pending
|
||||
case uploading(progress: Double)
|
||||
case completed(fileId: String)
|
||||
case failed(Error)
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .pending: return "pending"
|
||||
case .uploading: return "uploading"
|
||||
case .completed(let fileId): return "completed_\(fileId)"
|
||||
case .failed: return "failed"
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: MediaUploadStatus, rhs: MediaUploadStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.pending, .pending):
|
||||
return true
|
||||
case (.uploading(let lhsProgress), .uploading(let rhsProgress)):
|
||||
return lhsProgress == rhsProgress
|
||||
return abs(lhsProgress - rhsProgress) < 0.01
|
||||
case (.completed(let lhsId), .completed(let rhsId)):
|
||||
return lhsId == rhsId
|
||||
case (.failed, .failed):
|
||||
@ -27,120 +36,212 @@ public enum MediaUploadStatus: Equatable {
|
||||
switch self {
|
||||
case .pending: return "等待上传"
|
||||
case .uploading(let progress): return "上传中 \(Int(progress * 100))%"
|
||||
case .completed(let fileId): return "上传完成 (ID: \(fileId.prefix(8))...)"
|
||||
case .completed(let fileId): return "上传完成 (ID: \(fileId.prefix(8)))..."
|
||||
case .failed(let error): return "上传失败: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
public var isCompleted: Bool {
|
||||
if case .completed = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var isUploading: Bool {
|
||||
if case .uploading = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体上传管理器
|
||||
@MainActor
|
||||
public class MediaUploadManager: ObservableObject {
|
||||
/// 已选媒体文件
|
||||
@Published public var selectedMedia: [MediaType] = []
|
||||
@Published public private(set) var selectedMedia: [MediaType] = []
|
||||
/// 上传状态
|
||||
@Published public var uploadStatus: [String: MediaUploadStatus] = [:]
|
||||
@Published public private(set) var uploadStatus: [String: MediaUploadStatus] = [:]
|
||||
/// 上传结果
|
||||
@Published public private(set) var uploadResults: [String: UploadResult] = [:]
|
||||
|
||||
private let uploader = ImageUploadService()
|
||||
private let uploader = ImageUploadService() // Use ImageUploadService
|
||||
private let logger = Logger(subsystem: "com.yourapp.media", category: "MediaUploadManager")
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 添加上传媒体
|
||||
public func addMedia(_ media: [MediaType]) {
|
||||
selectedMedia.append(contentsOf: media)
|
||||
let newMedia = media.filter { newItem in
|
||||
!self.selectedMedia.contains { $0.id == newItem.id }
|
||||
}
|
||||
|
||||
var updatedMedia = self.selectedMedia
|
||||
for item in newMedia {
|
||||
updatedMedia.append(item)
|
||||
self.uploadStatus[item.id] = .pending
|
||||
}
|
||||
self.selectedMedia = updatedMedia
|
||||
|
||||
// 如果是第一次添加媒体,发送通知
|
||||
if !newMedia.isEmpty, let firstMedia = newMedia.first, self.selectedMedia.count == newMedia.count {
|
||||
NotificationCenter.default.post(
|
||||
name: .didAddFirstMedia,
|
||||
object: nil,
|
||||
userInfo: ["media": firstMedia]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除指定索引的媒体
|
||||
public func removeMedia(at index: Int) {
|
||||
guard index < selectedMedia.count else { return }
|
||||
selectedMedia.remove(at: index)
|
||||
// 更新状态字典
|
||||
var newStatus: [String: MediaUploadStatus] = [:]
|
||||
uploadStatus.forEach { key, value in
|
||||
if let keyInt = Int(key), keyInt < index {
|
||||
newStatus[key] = value
|
||||
} else if let keyInt = Int(key), keyInt > index {
|
||||
newStatus["\(keyInt - 1)"] = value
|
||||
}
|
||||
/// 移除指定ID的媒体
|
||||
public func removeMedia(id: String) {
|
||||
Task { @MainActor in
|
||||
self.selectedMedia.removeAll { $0.id == id }
|
||||
self.uploadStatus.removeValue(forKey: id)
|
||||
self.uploadResults.removeValue(forKey: id)
|
||||
}
|
||||
uploadStatus = newStatus
|
||||
}
|
||||
|
||||
/// 清空所有媒体
|
||||
public func clearAllMedia() {
|
||||
selectedMedia.removeAll()
|
||||
uploadStatus.removeAll()
|
||||
uploadResults.removeAll()
|
||||
}
|
||||
|
||||
/// 开始上传所有选中的媒体
|
||||
public func startUpload() {
|
||||
print("🔄 开始批量上传 \(selectedMedia.count) 个文件")
|
||||
// 重置上传状态
|
||||
uploadStatus.removeAll()
|
||||
logger.info("🔄 开始批量上传 \(self.selectedMedia.count) 个文件")
|
||||
|
||||
for (index, media) in selectedMedia.enumerated() {
|
||||
let id = "\(index)"
|
||||
uploadStatus[id] = .pending
|
||||
|
||||
// Convert MediaType to ImageUploadService.MediaType
|
||||
let uploadMediaType: ImageUploadService.MediaType
|
||||
switch media {
|
||||
case .image(let image):
|
||||
uploadMediaType = .image(image)
|
||||
case .video(let url, let thumbnail):
|
||||
uploadMediaType = .video(url, thumbnail)
|
||||
}
|
||||
uploadMedia(uploadMediaType, id: id)
|
||||
// 只处理状态为pending或failed的媒体
|
||||
let mediaToUpload = self.selectedMedia.filter { media in
|
||||
guard let status = self.uploadStatus[media.id] else { return true }
|
||||
return !status.isCompleted && !status.isUploading
|
||||
}
|
||||
|
||||
// 清空之前的上传结果
|
||||
self.uploadResults.removeAll()
|
||||
|
||||
for media in mediaToUpload {
|
||||
self.uploadMedia(media)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取上传结果
|
||||
public func getUploadResults() -> [String: String] {
|
||||
var results: [String: String] = [:]
|
||||
for (id, status) in uploadStatus {
|
||||
if case .completed(let fileId) = status {
|
||||
results[id] = fileId
|
||||
}
|
||||
}
|
||||
return results
|
||||
public func getUploadResults() -> [String: UploadResult] {
|
||||
return uploadResults
|
||||
}
|
||||
|
||||
/// 检查是否所有上传都已完成
|
||||
public var isAllUploaded: Bool {
|
||||
guard !selectedMedia.isEmpty else { return false }
|
||||
return uploadStatus.allSatisfy { _, status in
|
||||
if case .completed = status { return true }
|
||||
guard !self.selectedMedia.isEmpty else { return false }
|
||||
return self.selectedMedia.allSatisfy { media in
|
||||
if case .completed = self.uploadStatus[media.id] { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func uploadMedia(_ media: ImageUploadService.MediaType, id: String) {
|
||||
print("🔄 开始处理媒体: \(id)")
|
||||
uploadStatus[id] = .uploading(progress: 0)
|
||||
/// 上传结果
|
||||
public struct UploadResult: Codable, Equatable {
|
||||
public let fileId: String
|
||||
public let thumbnailId: String?
|
||||
|
||||
uploader.uploadMedia(
|
||||
media,
|
||||
progress: { progress in
|
||||
print("📊 上传进度 (\(id)): \(progress.current)%")
|
||||
DispatchQueue.main.async {
|
||||
self.uploadStatus[id] = .uploading(progress: progress.progress)
|
||||
}
|
||||
},
|
||||
completion: { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let uploadResult):
|
||||
print("✅ 上传成功 (\(id)): \(uploadResult.fileId)")
|
||||
self.uploadStatus[id] = .completed(fileId: uploadResult.fileId)
|
||||
case .failure(let error):
|
||||
print("❌ 上传失败 (\(id)): \(error.localizedDescription)")
|
||||
self.uploadStatus[id] = .failed(error)
|
||||
}
|
||||
}
|
||||
public init(fileId: String, thumbnailId: String? = nil) {
|
||||
self.fileId = fileId
|
||||
self.thumbnailId = thumbnailId
|
||||
}
|
||||
|
||||
public static func == (lhs: UploadResult, rhs: UploadResult) -> Bool {
|
||||
return lhs.fileId == rhs.fileId && lhs.thumbnailId == rhs.thumbnailId
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadMedia(_ media: MediaType) {
|
||||
logger.info("🔄 开始处理媒体: \(media.id)")
|
||||
|
||||
// 更新状态为上传中
|
||||
updateStatus(for: media.id, status: .uploading(progress: 0))
|
||||
|
||||
// 转换媒体类型
|
||||
let uploadMedia: ImageUploadService.MediaType
|
||||
switch media {
|
||||
case .image(let uiImage):
|
||||
uploadMedia = .image(uiImage)
|
||||
case .video(let url, let thumbnail):
|
||||
uploadMedia = .video(url as URL, thumbnail)
|
||||
}
|
||||
|
||||
// 上传媒体文件
|
||||
uploader.uploadMedia(uploadMedia,
|
||||
progress: { progress in
|
||||
// 更新上传进度
|
||||
Task { @MainActor in
|
||||
self.updateStatus(for: media.id, status: .uploading(progress: progress.progress))
|
||||
}
|
||||
},
|
||||
completion: { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
switch result {
|
||||
case .success(let uploadResult):
|
||||
// 处理上传结果
|
||||
let fileId: String
|
||||
let thumbnailId: String?
|
||||
|
||||
switch uploadResult {
|
||||
case .file(let result):
|
||||
fileId = result.fileId
|
||||
thumbnailId = nil
|
||||
case .video(let video, let thumbnail):
|
||||
fileId = video.fileId
|
||||
thumbnailId = thumbnail?.fileId
|
||||
}
|
||||
|
||||
// 保存上传结果
|
||||
let result = UploadResult(fileId: fileId, thumbnailId: thumbnailId)
|
||||
self.uploadResults[media.id] = result
|
||||
self.logger.info("✅ 上传成功 (\(media.id)): \(fileId), 缩略图ID: \(thumbnailId ?? "无")")
|
||||
self.updateStatus(for: media.id, status: .completed(fileId: fileId))
|
||||
|
||||
// 打印上传结果
|
||||
if self.isAllUploaded {
|
||||
self.printUploadResults()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.logger.error("❌ 上传失败 (\(media.id)): \(error.localizedDescription)")
|
||||
self.updateStatus(for: media.id, status: .failed(error))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateStatus(for mediaId: String, status: MediaUploadStatus) {
|
||||
uploadStatus[mediaId] = status
|
||||
}
|
||||
|
||||
// MARK: - Upload Results
|
||||
|
||||
/// 打印上传结果
|
||||
private func printUploadResults() {
|
||||
let results = self.selectedMedia.compactMap { media -> [String: String]? in
|
||||
guard let result = self.uploadResults[media.id] else { return nil }
|
||||
return [
|
||||
"file_id": result.fileId,
|
||||
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||
]
|
||||
}
|
||||
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: results, options: [.prettyPrinted, .sortedKeys])
|
||||
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
print("📦 上传完成,文件ID列表:")
|
||||
print(jsonString)
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
print("❌ 无法序列化上传结果: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +252,6 @@ struct MediaUploadExample: View {
|
||||
@StateObject private var uploadManager = MediaUploadManager()
|
||||
@State private var showMediaPicker = false
|
||||
|
||||
// 添加图片和视频选择限制参数
|
||||
let imageSelectionLimit: Int
|
||||
let videoSelectionLimit: Int
|
||||
|
||||
@ -211,7 +311,15 @@ struct MediaUploadExample: View {
|
||||
.navigationTitle("媒体上传")
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
MediaPicker(
|
||||
selectedMedia: $uploadManager.selectedMedia,
|
||||
selectedMedia: Binding(
|
||||
get: { self.uploadManager.selectedMedia },
|
||||
set: { newMedia in
|
||||
Task { @MainActor in
|
||||
self.uploadManager.clearAllMedia()
|
||||
self.uploadManager.addMedia(newMedia)
|
||||
}
|
||||
}
|
||||
),
|
||||
imageSelectionLimit: imageSelectionLimit,
|
||||
videoSelectionLimit: videoSelectionLimit,
|
||||
onDismiss: { showMediaPicker = false }
|
||||
@ -233,15 +341,15 @@ struct MediaSelectionView: View {
|
||||
|
||||
// 显示媒体缩略图和上传状态
|
||||
List {
|
||||
ForEach(0..<uploadManager.selectedMedia.count, id: \.self) { index in
|
||||
let media = uploadManager.selectedMedia[index]
|
||||
let mediaId = "\(index)"
|
||||
let status = uploadManager.uploadStatus[mediaId] ?? .pending
|
||||
ForEach(uploadManager.selectedMedia, id: \.id) { media in
|
||||
let status = uploadManager.uploadStatus[media.id] ?? .pending
|
||||
|
||||
HStack {
|
||||
// 缩略图
|
||||
MediaThumbnailView(media: media, onDelete: nil)
|
||||
.frame(width: 60, height: 60)
|
||||
MediaThumbnailView(media: media, onDelete: {
|
||||
uploadManager.removeMedia(id: media.id)
|
||||
})
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(media.isVideo ? "视频" : "图片")
|
||||
@ -263,11 +371,6 @@ struct MediaSelectionView: View {
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.forEach { index in
|
||||
uploadManager.removeMedia(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
}
|
||||
@ -300,18 +403,35 @@ private struct MediaThumbnailView: View {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 80, height: 80)
|
||||
.frame(width: 60, height: 60)
|
||||
.cornerRadius(8)
|
||||
.clipped()
|
||||
|
||||
if media.isVideo {
|
||||
Image(systemName: "video.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 12))
|
||||
.padding(4)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
// 删除按钮
|
||||
if let onDelete = onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.background(Color.black.opacity(0.8))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
} else {
|
||||
Color.gray.opacity(0.3)
|
||||
.frame(width: 60, height: 60)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,132 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
// User profile model
|
||||
struct UserProfile: Codable {
|
||||
let userId: String
|
||||
let nickname: String
|
||||
let avatarUrl: String?
|
||||
let account: String
|
||||
let email: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case nickname
|
||||
case avatarUrl = "avatar_file_url"
|
||||
case account
|
||||
case email
|
||||
}
|
||||
}
|
||||
// API Response wrapper
|
||||
struct APIResponse<T: Codable>: Codable {
|
||||
let code: Int
|
||||
let data: T
|
||||
}
|
||||
|
||||
struct UserProfileModal: View {
|
||||
@Binding var showModal: Bool
|
||||
@Binding var showSettings: Bool
|
||||
@State private var userProfile: UserProfile?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var isCopied = false
|
||||
|
||||
var body: some View {
|
||||
// Modal content with transparent background
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
.frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
// 用户信息区域
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else if let error = errorMessage {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
// 头像
|
||||
Image(systemName: "person.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.foregroundColor(.blue)
|
||||
.clipShape(Circle())
|
||||
|
||||
// 姓名和ID
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("用户名")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("ID: 12345678")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if let avatarUrl = userProfile?.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(Circle())
|
||||
default:
|
||||
Image(systemName: "person.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(userProfile?.nickname ?? "Name")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
HStack(spacing: 4) {
|
||||
Text("ID: \(userProfile?.userId ?? "")")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: 120)
|
||||
|
||||
Button(action: {
|
||||
print("Copy ID button tapped")
|
||||
UIPasteboard.general.string = userProfile?.userId
|
||||
print("Copied to clipboard:", userProfile?.userId ?? "nil")
|
||||
withAnimation {
|
||||
isCopied = true
|
||||
// Reset after 2 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
isCopied = false
|
||||
print("Reset copy button state")
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if isCopied {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.themePrimary)
|
||||
} else {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.animation(.easeInOut, value: isCopied)
|
||||
.contentShape(Rectangle()) // Make the entire button area tappable
|
||||
.frame(width: 24, height: 24) // Ensure minimum touch target size
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("会员等级")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text("会员时间")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("会员中心")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button(action: {
|
||||
Router.shared.navigate(to: .subscribe)
|
||||
}) {
|
||||
ZStack(alignment: .center) {
|
||||
// SVG背景 - 确保铺满整个区域
|
||||
SVGImage(svgName: "Pioneer")
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// 内容区域
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Pioneer")
|
||||
.font(Typography.font(for: .title3))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.top, 6)
|
||||
|
||||
Text("Expires on :")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.top, 2)
|
||||
|
||||
Text("March 15, 2025")
|
||||
.font(.system(size: 12))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 32)
|
||||
// 可以添加内边距使内容不紧贴边缘
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(height: 112)
|
||||
.frame(maxWidth: .infinity)
|
||||
.cornerRadius(16)
|
||||
.clipped() // 确保内容不会超出边界
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color.clear)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// upload
|
||||
Button(action: {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
SVGImage(svgName: "Upload")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("Upload Resources")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.cornerRadius(10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// memories
|
||||
Button(action: {
|
||||
print("memories")
|
||||
Router.shared.navigate(to: .memories)
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "crown.fill")
|
||||
SVGImage(svgName: "Memory")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("My Memories")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.cornerRadius(10)
|
||||
.contentShape(Rectangle()) // 使整个区域可点击
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle()) // 移除按钮默认样式
|
||||
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Box
|
||||
Button(action: {
|
||||
print("Box")
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "clock.fill")
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 24, height: 24)
|
||||
Text("My Bind Box")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
SVGImage(svgName: "Box")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("My Blind Box")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.cornerRadius(10)
|
||||
.contentShape(Rectangle()) // 使整个区域可点击
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle()) // 移除按钮默认样式
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// setting
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
showSettings = true
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.foregroundColor(.purple)
|
||||
.frame(width: 24, height: 24)
|
||||
SVGImage(svgName: "Set")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("Setting")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle()) // 使整个区域可点击
|
||||
.cornerRadius(10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle()) // 移除按钮默认样式
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: UIScreen.main.bounds.width * 0.8)
|
||||
.background(Color(red: 0.87, green: 0.87, blue: 0.87))
|
||||
.cornerRadius(20, corners: [.topRight, .bottomRight])
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onAppear {
|
||||
fetchUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchUserInfo() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
NetworkService.shared.get(
|
||||
path: "/iam/user-info",
|
||||
parameters: nil
|
||||
) { (result: Result<APIResponse<UserProfile>, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let response):
|
||||
self.userProfile = response.data
|
||||
print("✅ Successfully fetched user info:", response.data)
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
print("❌ Failed to fetch user info:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ struct CreditsInfoCard: View {
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.background(Theme.Colors.primaryLight)
|
||||
.cornerRadius(Theme.CornerRadius.extraLarge)
|
||||
.shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
||||
// .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
||||
}
|
||||
|
||||
// MARK: - 主要积分显示区域
|
||||
|
||||
@ -24,17 +24,21 @@ struct MediaUploadDemo: View {
|
||||
.padding(.horizontal)
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
MediaPicker(
|
||||
selectedMedia: $uploadManager.selectedMedia,
|
||||
selectedMedia: Binding(
|
||||
get: { uploadManager.selectedMedia },
|
||||
set: { newMedia in
|
||||
uploadManager.clearAllMedia()
|
||||
uploadManager.addMedia(newMedia)
|
||||
}
|
||||
),
|
||||
imageSelectionLimit: 1,
|
||||
videoSelectionLimit: 0,
|
||||
allowedMediaTypes: .imagesOnly, // This needs to come before selectionMode
|
||||
selectionMode: .single, // This was moved after allowedMediaTypes
|
||||
allowedMediaTypes: .imagesOnly,
|
||||
selectionMode: .single,
|
||||
onDismiss: {
|
||||
showMediaPicker = false
|
||||
// 当媒体选择器关闭时,如果有选中的媒体,开始上传
|
||||
if !uploadManager.selectedMedia.isEmpty {
|
||||
isUploading = true
|
||||
uploadManager.startUpload()
|
||||
// Start upload logic here
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
272
wake/View/Feedback.swift
Normal file
@ -0,0 +1,272 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeedbackView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var router: Router
|
||||
|
||||
@State private var selectedFeedback: FeedbackType? = FeedbackType.allCases.first
|
||||
@State private var showNextScreen = false
|
||||
|
||||
enum FeedbackType: String, CaseIterable, Identifiable {
|
||||
case excellent = "Excellent"
|
||||
case good = "Good"
|
||||
case okay = "Okay"
|
||||
case bad = "Bad"
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .excellent: return "😘"
|
||||
case .good: return "😊"
|
||||
case .okay: return "😐"
|
||||
case .bad: return "😞"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Custom Navigation Bar
|
||||
HStack {
|
||||
// Back Button
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text("Feedback")
|
||||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Spacer to balance the HStack
|
||||
Spacer()
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
|
||||
// Main Content
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Top spacing for vertical centering
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 24) {
|
||||
Text("How are you feeling?")
|
||||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 50)
|
||||
|
||||
// Feedback Type Selection
|
||||
VStack(spacing: 12) {
|
||||
ForEach(FeedbackType.allCases) { type in
|
||||
Button(action: {
|
||||
selectedFeedback = type
|
||||
}) {
|
||||
let isSelected = selectedFeedback == type
|
||||
HStack {
|
||||
Text(type.icon)
|
||||
.font(.body)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
|
||||
Text(type.rawValue)
|
||||
.font(.body)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected ? Color.themePrimary : Color.themePrimaryLight)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? Color.themePrimary : Color.themePrimaryLight, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color.white)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minHeight: geometry.size.height - 120) // Subtract navigation bar and bottom button height
|
||||
|
||||
// Bottom spacing for vertical centering
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height - 44) // Subtract navigation bar height
|
||||
}
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary) // Add background color to the GeometryReader
|
||||
|
||||
// Continue Button
|
||||
|
||||
Button(action: {
|
||||
router.navigate(to: .mediaUpload) // or your custom navigation method
|
||||
}) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(selectedFeedback != nil ? .themeTextMessageMain : .gray)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 32)
|
||||
.fill(selectedFeedback != nil ?
|
||||
Color.themePrimary : Color.themeTextWhiteSecondary)
|
||||
)
|
||||
}
|
||||
.disabled(selectedFeedback == nil)
|
||||
.padding()
|
||||
.background(Color.themeTextWhiteSecondary) // Add background color to the button area
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary) // Set the background for the entire view
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback Detail View
|
||||
struct FeedbackDetailView: View {
|
||||
let feedbackType: FeedbackView.FeedbackType
|
||||
@State private var feedbackText = ""
|
||||
@State private var contactInfo = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Navigation Bar
|
||||
HStack {
|
||||
// Back Button
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(feedbackType.rawValue)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Spacer to balance the HStack
|
||||
Spacer()
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
|
||||
// Form
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Feedback Type
|
||||
HStack {
|
||||
Image(systemName: feedbackType.icon)
|
||||
.foregroundColor(.blue)
|
||||
Text(feedbackType.rawValue)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
// Feedback Text
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Describe your \(feedbackType.rawValue.lowercased())")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextEditor(text: $feedbackText)
|
||||
.frame(minHeight: 150)
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Contact Info
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Contact Information (Optional)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Email or phone number", text: $contactInfo)
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
Button(action: {
|
||||
submitFeedback()
|
||||
}) {
|
||||
Text("Submit Feedback")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 25)
|
||||
.fill(Color.themePrimary)
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
private func submitFeedback() {
|
||||
// TODO: Implement feedback submission logic
|
||||
print("Feedback submitted:")
|
||||
print("Type: \(feedbackType.rawValue)")
|
||||
print("Message: \(feedbackText)")
|
||||
if !contactInfo.isEmpty {
|
||||
print("Contact: \(contactInfo)")
|
||||
}
|
||||
|
||||
// Dismiss back to feedback type selection
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
struct FeedbackView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
FeedbackView()
|
||||
.environmentObject(Router.shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbackDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
FeedbackDetailView(feedbackType: .excellent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,14 +23,14 @@ struct LoginView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Hi, I'm MeMo!")
|
||||
.font(Typography.font(for: .largeTitle))
|
||||
.font(Typography.font(for: .largeTitle, family: .quicksandBold))
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 44)
|
||||
|
||||
Text("Welcome~")
|
||||
.font(Typography.font(for: .largeTitle))
|
||||
.font(Typography.font(for: .largeTitle, family: .quicksandBold))
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
@ -61,29 +61,35 @@ struct LoginView: View {
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarHidden(true)
|
||||
.fullScreenCover(isPresented: $isLoggedIn) {
|
||||
NavigationStack {
|
||||
UserInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private func signInButton() -> some View {
|
||||
AppleSignInButton { request in
|
||||
print(" [1] 用户点击了登录按钮")
|
||||
return AppleSignInButton { request in
|
||||
print(" 开始创建登录请求")
|
||||
let nonce = String.randomURLSafeString(length: 32)
|
||||
self.currentNonce = nonce
|
||||
request.nonce = self.sha256(nonce)
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
print(" 登录请求配置完成,nonce 已设置")
|
||||
} onCompletion: { result in
|
||||
print(" 收到 Apple 登录回调")
|
||||
switch result {
|
||||
case .success(let authResults):
|
||||
print("✅ [Apple Sign In] 登录授权成功")
|
||||
print(" Apple 登录授权成功,开始处理凭证")
|
||||
if let appleIDCredential = authResults.credential as? ASAuthorizationAppleIDCredential {
|
||||
print(" 成功获取 Apple ID 凭证")
|
||||
self.processAppleIDCredential(appleIDCredential)
|
||||
} else {
|
||||
print(" 凭证类型转换失败")
|
||||
print(" 凭证类型: \(type(of: authResults.credential))")
|
||||
self.showError(message: "无法处理登录凭证")
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ [Apple Sign In] 登录失败: \(error.localizedDescription)")
|
||||
print(" Apple 登录失败: \(error.localizedDescription)")
|
||||
print(" 错误详情: \(error as NSError)")
|
||||
self.handleSignInError(error)
|
||||
}
|
||||
}
|
||||
@ -107,7 +113,9 @@ struct LoginView: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Service") {
|
||||
openURL("https://yourwebsite.com/terms")
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
@ -117,7 +125,9 @@ struct LoginView: View {
|
||||
.font(.caption)
|
||||
|
||||
Button("Privacy Policy") {
|
||||
openURL("https://yourwebsite.com/privacy")
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
@ -144,21 +154,22 @@ struct LoginView: View {
|
||||
// MARK: - Authentication
|
||||
|
||||
private func handleAppleSignIn(result: Result<ASAuthorization, Error>) {
|
||||
print("🔵 [Apple Sign In] 开始处理登录结果...")
|
||||
print(" 开始处理登录结果...")
|
||||
switch result {
|
||||
case .success(let authResults):
|
||||
print("✅ [Apple Sign In] 登录授权成功")
|
||||
print(" 登录授权成功")
|
||||
processAppleIDCredential(authResults.credential)
|
||||
case .failure(let error):
|
||||
print("❌ [Apple Sign In] 登录失败: \(error.localizedDescription)")
|
||||
print(" 登录失败: \(error.localizedDescription)")
|
||||
handleSignInError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func processAppleIDCredential(_ credential: ASAuthorizationCredential) {
|
||||
print("🔵 [Apple ID] 开始处理凭证...")
|
||||
print(" 开始处理 Apple ID 凭证")
|
||||
guard let appleIDCredential = credential as? ASAuthorizationAppleIDCredential else {
|
||||
print("❌ [Apple ID] 凭证类型不匹配")
|
||||
print(" 凭证类型不匹配")
|
||||
print(" 实际凭证类型: \(type(of: credential))")
|
||||
showError(message: "无法处理Apple ID凭证")
|
||||
return
|
||||
}
|
||||
@ -172,24 +183,26 @@ struct LoginView: View {
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
|
||||
print("ℹ️ [Apple ID] 用户数据 - ID: \(userId), 邮箱: \(email.isEmpty ? "未提供" : email), 姓名: \(fullName.isEmpty ? "未提供" : fullName)")
|
||||
print(" 用户数据 - ID: \(userId), 邮箱: \(email.isEmpty ? "未提供" : email), 姓名: \(fullName.isEmpty ? "未提供" : fullName)")
|
||||
|
||||
guard let identityTokenData = appleIDCredential.identityToken,
|
||||
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
|
||||
print("❌ [Apple ID] 无法获取身份令牌")
|
||||
print(" 无法获取身份令牌")
|
||||
showError(message: "无法获取身份令牌")
|
||||
return
|
||||
}
|
||||
|
||||
print(" 成功获取 identityToken")
|
||||
|
||||
var authCode: String? = nil
|
||||
if let authCodeData = appleIDCredential.authorizationCode {
|
||||
authCode = String(data: authCodeData, encoding: .utf8)
|
||||
print("ℹ️ [Apple ID] 获取到授权码")
|
||||
print(" 获取到授权码")
|
||||
} else {
|
||||
print("ℹ️ [Apple ID] 未获取到授权码(可选)")
|
||||
print(" 未获取到授权码(可选)")
|
||||
}
|
||||
|
||||
print("🔵 [Apple ID] 准备调用后端认证...")
|
||||
print(" 准备调用后端认证接口...")
|
||||
authenticateWithBackend(
|
||||
identityToken: identityToken,
|
||||
authCode: authCode
|
||||
@ -202,8 +215,8 @@ struct LoginView: View {
|
||||
identityToken: String,
|
||||
authCode: String?
|
||||
) {
|
||||
print(" 开始后端认证流程...")
|
||||
isLoading = true
|
||||
print("🔵 [Backend] 开始后端认证...")
|
||||
|
||||
var parameters: [String: Any] = [
|
||||
"token": identityToken,
|
||||
@ -212,56 +225,120 @@ struct LoginView: View {
|
||||
|
||||
if let authCode = authCode {
|
||||
parameters["authorization_code"] = authCode
|
||||
print(" 添加授权码到请求参数")
|
||||
}
|
||||
|
||||
print(" 发送认证请求到服务器...")
|
||||
print(" 接口: /iam/login/oauth")
|
||||
print(" 请求参数: \(parameters.keys)")
|
||||
|
||||
NetworkService.shared.post(
|
||||
path: "/iam/login/oauth",
|
||||
path: "/iam/login/oauth",
|
||||
parameters: parameters
|
||||
) { (result: Result<AuthResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
) { (result: Result<AuthResponse, NetworkError>) -> Void in
|
||||
let handleResult: () -> Void = {
|
||||
self.isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let authResponse):
|
||||
print("✅ [Backend] 认证成功")
|
||||
print("✅ [15] 后端认证成功")
|
||||
|
||||
// 保存token等认证信息
|
||||
if let loginInfo = authResponse.data?.userLoginInfo {
|
||||
print("🔑 [16] 保存认证信息")
|
||||
print(" - 用户ID: \(loginInfo.userId)")
|
||||
print(" - 昵称: \(loginInfo.nickname)")
|
||||
|
||||
KeychainHelper.saveAccessToken(loginInfo.accessToken)
|
||||
KeychainHelper.saveRefreshToken(loginInfo.refreshToken)
|
||||
// 可以在这里保存其他用户信息,如userId, nickname等
|
||||
print("👤 用户ID: \(loginInfo.userId)")
|
||||
print("👤 昵称: \(loginInfo.nickname)")
|
||||
|
||||
print("🔄 [17] 准备跳转到用户信息页面...")
|
||||
print("🔍 isLoggedIn 当前值: \(self.isLoggedIn)")
|
||||
|
||||
self.isLoggedIn = true
|
||||
|
||||
print("✅ [18] isLoggedIn 已设置为 true")
|
||||
print("🎉 登录流程完成,即将跳转")
|
||||
} else {
|
||||
print("⚠️ [16] 认证成功但返回的用户信息不完整")
|
||||
self.errorMessage = "登录信息不完整,请重试"
|
||||
self.showError = true
|
||||
}
|
||||
|
||||
self.isLoggedIn = true
|
||||
// 跳转到userinfo
|
||||
Router.shared.navigate(to: .userInfo)
|
||||
|
||||
case .failure(let error):
|
||||
print("❌ [Backend] 认证失败: \(error.localizedDescription)")
|
||||
self.errorMessage = error.localizedDescription
|
||||
print("❌ [15] 后端认证失败")
|
||||
print("⚠️ 错误类型: \(type(of: error))")
|
||||
print("📝 错误信息: \(error.localizedDescription)")
|
||||
|
||||
var errorMessage = "登录失败,请重试"
|
||||
|
||||
switch error {
|
||||
case .invalidURL:
|
||||
print(" → 无效的URL")
|
||||
errorMessage = "服务器地址无效"
|
||||
case .noData:
|
||||
print(" → 服务器未返回数据")
|
||||
errorMessage = "服务器未响应,请检查网络"
|
||||
case .decodingError(let error):
|
||||
print(" → 数据解析失败: \(error.localizedDescription)")
|
||||
errorMessage = "服务器响应格式错误"
|
||||
case .serverError(let message):
|
||||
print(" → 服务器错误: \(message)")
|
||||
errorMessage = "服务器错误: \(message)"
|
||||
case .unauthorized:
|
||||
print(" → 认证失败: 未授权")
|
||||
errorMessage = "登录信息已过期,请重新登录"
|
||||
case .networkError(let error):
|
||||
print(" → 网络错误: \(error.localizedDescription)")
|
||||
errorMessage = "网络连接失败,请检查网络"
|
||||
case .other(let error):
|
||||
print(" → 其他错误: \(error.localizedDescription)")
|
||||
errorMessage = "发生未知错误"
|
||||
case .unknownError(let error):
|
||||
print(" → 未知错误: \(error.localizedDescription)")
|
||||
errorMessage = "发生未知错误"
|
||||
case .invalidParameters:
|
||||
print(" → 无效的参数")
|
||||
errorMessage = "请求参数错误,请重试"
|
||||
}
|
||||
|
||||
self.errorMessage = errorMessage
|
||||
self.showError = true
|
||||
self.isLoading = false
|
||||
print("❌ 登录失败: \(errorMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
// Execute on main thread
|
||||
if Thread.isMainThread {
|
||||
handleResult()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: handleResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func handleSuccessfulAuthentication() {
|
||||
print("✅ [Auth] 登录成功,准备跳转到用户信息页面...")
|
||||
print(" 登录成功,准备跳转到用户信息页面...")
|
||||
print(" isLoggedIn before update: \(isLoggedIn)")
|
||||
DispatchQueue.main.async {
|
||||
print(" Setting isLoggedIn to true")
|
||||
self.isLoggedIn = true
|
||||
print(" isLoggedIn after update: \(self.isLoggedIn)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSignInError(_ error: Error) {
|
||||
let errorMessage = (error as NSError).localizedDescription
|
||||
print("❌ [Auth] 登录错误: \(errorMessage)")
|
||||
print(" 登录错误: \(errorMessage)")
|
||||
showError(message: "登录失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
private func handleAuthenticationError(_ error: Error) {
|
||||
let errorMessage = error.localizedDescription
|
||||
print("❌ [Auth] 认证错误: \(errorMessage)")
|
||||
print(" 认证错误: \(errorMessage)")
|
||||
DispatchQueue.main.async {
|
||||
self.isLoggedIn = false
|
||||
self.showError(message: "登录失败: \(errorMessage)")
|
||||
|
||||
449
wake/View/Memories/MemoriesView.swift
Normal file
@ -0,0 +1,449 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
// MARK: - API Response Models
|
||||
struct MaterialResponse: Decodable {
|
||||
let code: Int
|
||||
let data: MaterialData
|
||||
|
||||
struct MaterialData: Decodable {
|
||||
let items: [MemoryItem]
|
||||
let hasMore: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case hasMore = "has_more"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryItem: Identifiable, Decodable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let description: String?
|
||||
let fileInfo: FileInfo
|
||||
let previewFileInfo: FileInfo
|
||||
|
||||
var title: String { name ?? "Untitled" }
|
||||
var subtitle: String { description ?? "" }
|
||||
var mediaType: MemoryMediaType {
|
||||
let url = fileInfo.url.lowercased()
|
||||
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
|
||||
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
|
||||
} else {
|
||||
return .image(fileInfo.url)
|
||||
}
|
||||
}
|
||||
var aspectRatio: CGFloat { 1.0 }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, description
|
||||
case fileInfo = "file_info"
|
||||
case previewFileInfo = "preview_file_info"
|
||||
}
|
||||
}
|
||||
|
||||
struct FileInfo: Decodable {
|
||||
let id: String
|
||||
let fileName: String
|
||||
let url: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case url
|
||||
}
|
||||
}
|
||||
|
||||
enum MemoryMediaType: Equatable {
|
||||
case image(String)
|
||||
case video(url: String, previewUrl: String)
|
||||
}
|
||||
|
||||
struct MemoriesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var memories: [MemoryItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedMemory: MemoryItem? = nil
|
||||
|
||||
let columns = [
|
||||
GridItem(.flexible(), spacing: 1),
|
||||
GridItem(.flexible(), spacing: 1)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
// Top navigation bar
|
||||
HStack {
|
||||
Button(action: {
|
||||
self.dismiss()
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
Spacer()
|
||||
Text("My Memories")
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
|
||||
// Content area
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
} else if let error = errorMessage {
|
||||
Text("Error: \(error)")
|
||||
.foregroundColor(.red)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 4) {
|
||||
ForEach(memories) { memory in
|
||||
MemoryCard(memory: memory)
|
||||
.padding(.horizontal, 2)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring()) {
|
||||
selectedMemory = memory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full Screen Modal
|
||||
if let memory = selectedMemory {
|
||||
FullScreenMediaView(memory: memory, isPresented: $selectedMemory)
|
||||
.transition(.opacity)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
fetchMemories()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMemories() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let parameters: [String: Any] = ["page": 0]
|
||||
|
||||
NetworkService.shared.get(path: "/material/list", parameters: parameters) { [self] (result: Result<MaterialResponse, NetworkError>) in
|
||||
DispatchQueue.main.async { [self] in
|
||||
self.isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let response):
|
||||
print("✅ Successfully fetched \(response.data.items) memory items")
|
||||
response.data.items.forEach { item in
|
||||
print("📝 Item ID: \(item.id), Title: \(item.name ?? "Untitled"), URL: \(item)")
|
||||
}
|
||||
self.memories = response.data.items
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
print("❌ Failed to fetch memories: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FullScreenMediaView: View {
|
||||
let memory: MemoryItem
|
||||
@Binding var isPresented: MemoryItem?
|
||||
@State private var isVideoPlaying = false
|
||||
@State private var showControls = true
|
||||
@State private var controlsTimer: Timer? = nil
|
||||
@State private var player: AVPlayer? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Media content with back button overlay
|
||||
ZStack {
|
||||
// Media content
|
||||
switch memory.mediaType {
|
||||
case .image(let url):
|
||||
if let imageURL = URL(string: url) {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: UIScreen.main.bounds.width,
|
||||
height: UIScreen.main.bounds.height)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
case .failure(_):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
case .empty:
|
||||
ProgressView()
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .video(let url, let previewUrl):
|
||||
if let videoURL = URL(string: url) {
|
||||
VideoPlayer(player: player)
|
||||
.onAppear {
|
||||
self.player = AVPlayer(url: videoURL)
|
||||
self.player?.play()
|
||||
self.isVideoPlaying = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.player?.pause()
|
||||
self.player = nil
|
||||
}
|
||||
.frame(width: UIScreen.main.bounds.width,
|
||||
height: UIScreen.main.bounds.height)
|
||||
.onTapGesture {
|
||||
togglePlayPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back button - Always visible at the top-left of the device screen
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
withAnimation(.spring()) {
|
||||
isPresented = nil
|
||||
pauseVideo()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(12)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.zIndex(2) // Higher z-index to keep it above media content
|
||||
|
||||
// Video controls overlay (only for video)
|
||||
if case .video = memory.mediaType, showControls {
|
||||
VStack {
|
||||
Spacer()
|
||||
// Play/pause button
|
||||
Button(action: {
|
||||
togglePlayPause()
|
||||
}) {
|
||||
Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 70))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.shadow(radius: 3)
|
||||
}
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear {
|
||||
resetControlsTimer()
|
||||
}
|
||||
.zIndex(3) // Highest z-index for controls
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.statusBar(hidden: true)
|
||||
}
|
||||
.onTapGesture {
|
||||
if case .video = memory.mediaType {
|
||||
withAnimation(.easeInOut) {
|
||||
showControls.toggle()
|
||||
}
|
||||
if showControls {
|
||||
resetControlsTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.statusBar(hidden: true)
|
||||
.onAppear {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
if case .video = memory.mediaType {
|
||||
setupVideoPlayer()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
controlsTimer?.invalidate()
|
||||
pauseVideo()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVideoPlayer() {
|
||||
if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
|
||||
self.player = AVPlayer(url: videoURL)
|
||||
self.player?.play()
|
||||
self.isVideoPlaying = true
|
||||
|
||||
// Add observer for playback end
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: self.player?.currentItem,
|
||||
queue: .main
|
||||
) { _ in
|
||||
self.player?.seek(to: .zero) { _ in
|
||||
self.player?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlayPause() {
|
||||
if isVideoPlaying {
|
||||
pauseVideo()
|
||||
} else {
|
||||
playVideo()
|
||||
}
|
||||
withAnimation {
|
||||
showControls = true
|
||||
}
|
||||
resetControlsTimer()
|
||||
}
|
||||
|
||||
private func playVideo() {
|
||||
player?.play()
|
||||
isVideoPlaying = true
|
||||
}
|
||||
|
||||
private func pauseVideo() {
|
||||
player?.pause()
|
||||
isVideoPlaying = false
|
||||
}
|
||||
|
||||
private func resetControlsTimer() {
|
||||
controlsTimer?.invalidate()
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
|
||||
withAnimation(.easeInOut) {
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoPlayer: UIViewRepresentable {
|
||||
let player: AVPlayer?
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
if let player = player {
|
||||
let playerLayer = AVPlayerLayer(player: player)
|
||||
playerLayer.frame = UIScreen.main.bounds
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(playerLayer)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer {
|
||||
playerLayer.player = player
|
||||
playerLayer.frame = UIScreen.main.bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryCard: View {
|
||||
let memory: MemoryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ZStack {
|
||||
// Media content
|
||||
Group {
|
||||
switch memory.mediaType {
|
||||
case .image(let url):
|
||||
if let url = URL(string: url) {
|
||||
AsyncImage(url: url) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else if phase.error != nil {
|
||||
Color.gray.opacity(0.3)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .video(_, let previewUrl):
|
||||
if let previewUrl = URL(string: previewUrl) {
|
||||
AsyncImage(url: previewUrl) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else if phase.error != nil {
|
||||
Color.gray.opacity(0.3)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.gray.opacity(0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: (UIScreen.main.bounds.width / 2) - 24,
|
||||
height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio))
|
||||
.clipped()
|
||||
.cornerRadius(12)
|
||||
|
||||
// Show play button for videos
|
||||
if case .video = memory.mediaType {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.shadow(radius: 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Title and Subtitle
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(memory.title)
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(memory.subtitle)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MemoriesView()
|
||||
}
|
||||
157
wake/View/Owner/AboutUsView.swift
Normal file
@ -0,0 +1,157 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AboutUsView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background color
|
||||
Color.themeTextWhiteSecondary
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Navigation Header
|
||||
SimpleNaviHeader(title: "About Us") {
|
||||
Router.shared.pop()
|
||||
}
|
||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
|
||||
// Main Content
|
||||
VStack(spacing: 0) {
|
||||
// IP Address Section
|
||||
VStack(spacing: 12) {
|
||||
SVGImage(svgName: "AboutIP")
|
||||
.frame(width: 102, height: 102)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
|
||||
// Version & ICP Info
|
||||
VStack(spacing: 12) {
|
||||
Text("Version : 1.1.1")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Text("ICP 备案号: 京ICP备XXXXXXXX号")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
// Legal Links
|
||||
VStack(spacing: 0) {
|
||||
settingRow(
|
||||
title: "Terms of Service",
|
||||
action: {
|
||||
withAnimation {
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding()
|
||||
settingRow(
|
||||
title: "Privacy Policy",
|
||||
action: {
|
||||
withAnimation {
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding()
|
||||
settingRow(
|
||||
title: "AI Usage Guidelines",
|
||||
action: {
|
||||
withAnimation {
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.white)
|
||||
.cornerRadius(16)
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
// 标题
|
||||
Text(title)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧箭头
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 12) // 增加垂直内边距
|
||||
.background(Color.white)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowBackground(Color.white)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func getIPAddress() -> String? {
|
||||
var address: String?
|
||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
||||
|
||||
if getifaddrs(&ifaddr) == 0 {
|
||||
var ptr = ifaddr
|
||||
while ptr != nil {
|
||||
defer { ptr = ptr?.pointee.ifa_next }
|
||||
|
||||
let interface = ptr?.pointee
|
||||
let addrFamily = interface?.ifa_addr.pointee.sa_family
|
||||
|
||||
if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6),
|
||||
let name = interface?.ifa_name,
|
||||
String(cString: name) == "en0",
|
||||
let addr = interface?.ifa_addr {
|
||||
|
||||
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
getnameinfo(addr, socklen_t(addr.pointee.sa_len),
|
||||
&hostname, socklen_t(hostname.count),
|
||||
nil, socklen_t(0),
|
||||
NI_NUMERICHOST)
|
||||
address = String(cString: hostname)
|
||||
}
|
||||
}
|
||||
freeifaddrs(ifaddr)
|
||||
}
|
||||
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
AboutUsView()
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
import Foundation
|
||||
import SystemConfiguration
|
||||
import Network
|
||||
import Darwin
|
||||
196
wake/View/Owner/AccountView.swift
Normal file
@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 设置页面视图
|
||||
struct AccountView: View {
|
||||
// MARK: - 属性
|
||||
|
||||
/// 环境变量 - 用于dismiss视图
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// 控制删除确认弹窗显示
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
// MARK: - 动画配置
|
||||
|
||||
/// 动画配置
|
||||
private let animation = Animation.spring(
|
||||
response: 0.6, // 响应时间
|
||||
dampingFraction: 0.9, // 阻尼系数
|
||||
blendDuration: 0.8 // 混合时间
|
||||
)
|
||||
|
||||
// MARK: - 主体视图
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Theme background color
|
||||
Color.themeTextWhiteSecondary
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 自定义导航头
|
||||
SimpleNaviHeader(title: "Account & Security") {
|
||||
withAnimation(animation) {
|
||||
Router.shared.pop()
|
||||
}
|
||||
}
|
||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top)
|
||||
|
||||
// 内容区域
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 删除账号
|
||||
settingRow(
|
||||
title: "Delete Account",
|
||||
action: {
|
||||
withAnimation {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.overlay(
|
||||
// 删除确认弹窗
|
||||
Group {
|
||||
if showDeleteConfirmation {
|
||||
ZStack {
|
||||
// 半透明黑色遮罩
|
||||
Color.black.opacity(0.6)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认弹窗内容
|
||||
VStack(spacing: 20) {
|
||||
Text("Are you sure you want to delete this account?")
|
||||
.font(.headline)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
|
||||
Text("Once deleted, you can’t get it back.")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
}) {
|
||||
Text("Cancel")
|
||||
.font(Typography.font(for: .subtitle, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(32)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
// 处理删除账号逻辑
|
||||
NetworkService.shared.delete(path: "/iam/delete-user", parameters: nil) { (result: Result<String, NetworkError>) in
|
||||
switch result {
|
||||
case .success(let data):
|
||||
print("删除账号成功: \(data)")
|
||||
case .failure(let error):
|
||||
print("删除账号失败: \(error)")
|
||||
}
|
||||
}
|
||||
// 关闭弹窗
|
||||
withAnimation {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
|
||||
}) {
|
||||
Text("Confirm")
|
||||
.foregroundColor(.themeTextWhite)
|
||||
.font(.system(size: 12))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.cornerRadius(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 300)
|
||||
.background(Color.white)
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 10)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
)
|
||||
.navigationBarBackButtonHidden(true) // 隐藏默认返回按钮
|
||||
.navigationBarHidden(true) // 隐藏导航栏
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
|
||||
/// 配置TableView外观
|
||||
private func configureTableView() {
|
||||
// 移除列表底部分隔线
|
||||
UITableView.appearance().tableFooterView = UIView()
|
||||
// 移除列表顶部分隔线
|
||||
UITableView.appearance().tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNonzeroMagnitude))
|
||||
// 移除分隔线缩进
|
||||
UITableView.appearance().separatorInset = .zero
|
||||
// 移除列表顶部额外间距
|
||||
UITableView.appearance().contentInset = .zero
|
||||
}
|
||||
|
||||
/// 创建设置项行视图
|
||||
/// - Parameters:
|
||||
/// - icon: 图标名称
|
||||
/// - title: 标题
|
||||
/// - action: 点击动作
|
||||
/// - Returns: 返回设置项行视图
|
||||
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
|
||||
// 标题
|
||||
Text(title)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧箭头
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 12) // 增加垂直内边距
|
||||
.background(Color.white)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowBackground(Color.white)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview {
|
||||
NavigationView {
|
||||
AccountView()
|
||||
}
|
||||
}
|
||||
175
wake/View/Owner/PermissionManagementView.swift
Normal file
@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Photos
|
||||
|
||||
/// 权限管理视图,用于管理应用的各种权限设置
|
||||
struct PermissionManagementView: View {
|
||||
// MARK: - 状态变量
|
||||
@State private var photoLibraryStatus: PHAuthorizationStatus = .notDetermined // 相册权限状态
|
||||
@State private var notificationStatus: UNAuthorizationStatus = .notDetermined // 通知权限状态
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 设置整体背景色
|
||||
Color.themeTextWhiteSecondary
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 自定义导航栏
|
||||
SimpleNaviHeader(title: "Permission Management") {
|
||||
Router.shared.pop() // 返回上一页
|
||||
}
|
||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
|
||||
// 权限设置卡片
|
||||
VStack(spacing: 0) {
|
||||
// 1. 相册权限
|
||||
PermissionRow(
|
||||
title: "Gallery Permissions",
|
||||
isEnabled: photoLibraryStatus == .authorized
|
||||
) {
|
||||
requestPhotoLibraryPermission() // 请求相册权限
|
||||
}
|
||||
|
||||
// 2. 通知权限
|
||||
PermissionRow(
|
||||
title: "Notification Permissions",
|
||||
isEnabled: notificationStatus == .authorized
|
||||
) {
|
||||
requestNotificationPermission() // 请求通知权限
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(16)
|
||||
.padding()
|
||||
|
||||
Spacer() // 将内容推到顶部
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true) // 隐藏系统导航栏
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onAppear {
|
||||
// 视图出现时检查权限状态
|
||||
checkPhotoLibraryPermission()
|
||||
checkNotificationPermission()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 相册权限相关方法
|
||||
|
||||
/// 检查相册权限状态
|
||||
private func checkPhotoLibraryPermission() {
|
||||
photoLibraryStatus = PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
|
||||
/// 请求相册权限
|
||||
private func requestPhotoLibraryPermission() {
|
||||
// 如果未授权,则请求权限
|
||||
if photoLibraryStatus == .notDetermined {
|
||||
PHPhotoLibrary.requestAuthorization { status in
|
||||
DispatchQueue.main.async {
|
||||
self.photoLibraryStatus = status
|
||||
// 如果用户拒绝,则打开应用设置
|
||||
if status != .authorized {
|
||||
self.openAppSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果已决定过权限,则直接打开设置
|
||||
openAppSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 通知权限相关方法
|
||||
|
||||
/// 检查通知权限状态
|
||||
private func checkNotificationPermission() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
self.notificationStatus = settings.authorizationStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求通知权限
|
||||
private func requestNotificationPermission() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
// 如果从未请求过权限
|
||||
if settings.authorizationStatus == .notDetermined {
|
||||
// 请求通知权限
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.notificationStatus = granted ? .authorized : .denied
|
||||
// 如果用户拒绝,则打开应用设置
|
||||
if !granted {
|
||||
self.openAppSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果已经请求过权限,则直接打开设置
|
||||
DispatchQueue.main.async {
|
||||
self.openAppSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 辅助方法
|
||||
|
||||
/// 打开应用设置页面
|
||||
private func openAppSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限设置行视图
|
||||
struct PermissionRow: View {
|
||||
let title: String // 权限名称
|
||||
let isEnabled: Bool // 是否已授权
|
||||
let action: () -> Void // 点击事件
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
// 权限名称
|
||||
Text(title)
|
||||
.foregroundColor(.primary)
|
||||
.font(.system(size: 16))
|
||||
|
||||
Spacer()
|
||||
|
||||
// 权限开关
|
||||
Toggle("", isOn: .constant(isEnabled))
|
||||
.labelsHidden()
|
||||
.tint(Color.themePrimary)
|
||||
.disabled(true)
|
||||
.onAppear {
|
||||
// 使用主题色并确保完全不透明
|
||||
let themeColor = UIColor(Color.themePrimary)
|
||||
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).onTintColor = themeColor
|
||||
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).thumbTintColor = .white
|
||||
// 确保使用不透明的背景
|
||||
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).backgroundColor = UIColor.clear
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
// 添加底部边框
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(Color(.systemGray6)),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
#Preview {
|
||||
PermissionManagementView()
|
||||
}
|
||||
@ -9,6 +9,7 @@ struct SettingsView: View {
|
||||
|
||||
/// 状态 - 控制视图显示/隐藏
|
||||
@Binding var isPresented: Bool
|
||||
@State private var isShowingAccountView = false
|
||||
|
||||
// MARK: - 动画配置
|
||||
|
||||
@ -22,57 +23,68 @@ struct SettingsView: View {
|
||||
// MARK: - 主体视图
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 简洁导航头
|
||||
SimpleNaviHeader(title: "Setting") {
|
||||
withAnimation(animation) {
|
||||
isPresented = false
|
||||
NavigationView {
|
||||
ZStack {
|
||||
// Theme background color
|
||||
Color.themeTextWhiteSecondary.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 简洁导航头
|
||||
SimpleNaviHeader(title: "Setting") {
|
||||
withAnimation(animation) {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置项列表
|
||||
List {
|
||||
// 账号与安全
|
||||
settingRow(
|
||||
icon: "Account",
|
||||
title: "Account & Security",
|
||||
action: {
|
||||
Router.shared.navigate(to: .account)
|
||||
}
|
||||
)
|
||||
|
||||
// 权限管理
|
||||
settingRow(
|
||||
icon: "Permission",
|
||||
title: "Permission Management",
|
||||
action: {
|
||||
Router.shared.navigate(to: .permissionManagement)
|
||||
}
|
||||
)
|
||||
|
||||
// 支持与服务
|
||||
settingRow(
|
||||
icon: "Suport",
|
||||
title: "Support & Service",
|
||||
action: {}
|
||||
)
|
||||
|
||||
// 关于我们
|
||||
settingRow(
|
||||
icon: "AboutUs",
|
||||
title: "About Us",
|
||||
action: {
|
||||
Router.shared.navigate(to: .about)
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(12)
|
||||
.padding()
|
||||
.listStyle(PlainListStyle())
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.frame(height: CGFloat(5 * 60)) // 4 rows × 60 points each
|
||||
.frame(maxWidth: .infinity)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置项列表
|
||||
List {
|
||||
|
||||
// 账号与安全
|
||||
settingRow(
|
||||
icon: "person.crop.circle",
|
||||
title: "Account & Security",
|
||||
action: {}
|
||||
)
|
||||
|
||||
// 权限管理
|
||||
settingRow(
|
||||
icon: "lock.shield",
|
||||
title: "Permission Management",
|
||||
action: {}
|
||||
)
|
||||
|
||||
// 支持与服务
|
||||
settingRow(
|
||||
icon: "questionmark.circle",
|
||||
title: "Support & Service",
|
||||
action: {}
|
||||
)
|
||||
|
||||
// 关于我们
|
||||
settingRow(
|
||||
icon: "info.circle",
|
||||
title: "About Us",
|
||||
action: {}
|
||||
)
|
||||
}
|
||||
// 设置列表样式为普通样式(无分组效果)
|
||||
.listStyle(PlainListStyle())
|
||||
// 隐藏所有行的分割线
|
||||
.listRowSeparator(.hidden)
|
||||
// 移除列表行的内边距
|
||||
.listRowInsets(EdgeInsets())
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
// 设置水平尺寸类别为常规宽度(regular),用于支持分屏和不同设备尺寸的布局
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
@ -99,10 +111,8 @@ struct SettingsView: View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
// 左侧图标
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 40)
|
||||
SVGImage(svgName: icon)
|
||||
.frame(width: 22, height: 22)
|
||||
|
||||
// 标题
|
||||
Text(title)
|
||||
@ -115,19 +125,16 @@ struct SettingsView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 6) // 减少垂直内边距
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.padding(.vertical, 12) // 增加垂直内边距
|
||||
.background(Color.white)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowBackground(Color(.systemGroupedBackground))
|
||||
.listRowBackground(Color.white)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview {
|
||||
NavigationView {
|
||||
SettingsView(isPresented: .constant(true))
|
||||
}
|
||||
SettingsView(isPresented: .constant(true))
|
||||
}
|
||||
|
||||
@ -23,20 +23,26 @@ public struct AvatarPicker: View {
|
||||
self._uploadedFileId = uploadedFileId
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat {
|
||||
isKeyboardVisible ? 125 : 225
|
||||
// 添加缩放比例
|
||||
private var scaleFactor: CGFloat {
|
||||
isKeyboardVisible ? 0.55 : 1.0
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
isKeyboardVisible ? 3 : 4
|
||||
}
|
||||
|
||||
// 添加动画配置
|
||||
private var animation: Animation {
|
||||
.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.3)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack() {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 20) {
|
||||
// Avatar Image Button
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
withAnimation(animation) {
|
||||
showMediaPicker = true
|
||||
}
|
||||
}) {
|
||||
@ -45,17 +51,21 @@ public struct AvatarPicker: View {
|
||||
Image(uiImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(width: 225, height: 225)
|
||||
.scaleEffect(scaleFactor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color.themePrimary, lineWidth: borderWidth)
|
||||
.scaleEffect(scaleFactor)
|
||||
)
|
||||
} else {
|
||||
// Default SVG avatar with animated dashed border
|
||||
SVGImage(svgName: "IP")
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.frame(width: 225, height: 225)
|
||||
.scaleEffect(scaleFactor)
|
||||
.contentShape(Rectangle())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(style: StrokeStyle(
|
||||
@ -65,6 +75,7 @@ public struct AvatarPicker: View {
|
||||
dashPhase: isAnimating ? 40 : 0
|
||||
))
|
||||
.foregroundColor(Color.themePrimary)
|
||||
.scaleEffect(scaleFactor)
|
||||
)
|
||||
.onAppear {
|
||||
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
@ -77,22 +88,23 @@ public struct AvatarPicker: View {
|
||||
if isUploading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .themePrimary))
|
||||
.scaleEffect(1.5)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.scaleEffect(1.5 * scaleFactor)
|
||||
.frame(width: 225, height: 225)
|
||||
.scaleEffect(scaleFactor)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor))
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isKeyboardVisible)
|
||||
.frame(width: 225 * scaleFactor, height: 225 * scaleFactor)
|
||||
.animation(animation, value: isKeyboardVisible)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
|
||||
// Upload Button (only shown when username is not shown)
|
||||
if !showUsername {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
withAnimation(animation) {
|
||||
showMediaPicker = true
|
||||
}
|
||||
}) {
|
||||
@ -106,47 +118,101 @@ public struct AvatarPicker: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.themePrimaryLight)
|
||||
)
|
||||
.scaleEffect(scaleFactor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.animation(animation, value: isKeyboardVisible)
|
||||
}
|
||||
}
|
||||
.animation(animation, value: isKeyboardVisible)
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
MediaPicker(
|
||||
selectedMedia: $uploadManager.selectedMedia,
|
||||
selectedMedia: Binding(
|
||||
get: { uploadManager.selectedMedia },
|
||||
set: { newMedia in
|
||||
// Only process if we have new media
|
||||
if !newMedia.isEmpty {
|
||||
uploadManager.clearAllMedia()
|
||||
uploadManager.addMedia(newMedia)
|
||||
|
||||
// Start upload process
|
||||
withAnimation(animation) {
|
||||
isUploading = true
|
||||
}
|
||||
uploadManager.startUpload()
|
||||
print("🔄 Upload started")
|
||||
}
|
||||
|
||||
// Dismiss the picker after processing
|
||||
showMediaPicker = false
|
||||
}
|
||||
),
|
||||
imageSelectionLimit: 1,
|
||||
videoSelectionLimit: 0,
|
||||
allowedMediaTypes: .imagesOnly,
|
||||
selectionMode: .single,
|
||||
onDismiss: {
|
||||
showMediaPicker = false
|
||||
if !uploadManager.selectedMedia.isEmpty {
|
||||
withAnimation {
|
||||
isUploading = true
|
||||
}
|
||||
uploadManager.startUpload()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: uploadManager.uploadStatus) { _ in
|
||||
if let firstMedia = uploadManager.selectedMedia.first,
|
||||
case .image(let image) = firstMedia,
|
||||
uploadManager.isAllUploaded {
|
||||
withAnimation(.spring()) {
|
||||
selectedImage = image
|
||||
isUploading = false
|
||||
if let status = uploadManager.uploadStatus["0"],
|
||||
case .completed(let fileId) = status {
|
||||
uploadedFileId = fileId
|
||||
.onChange(of: uploadManager.uploadStatus) { status in
|
||||
print("🔄 Upload status changed: ", status)
|
||||
|
||||
// 检查是否有待处理的上传
|
||||
let pendingUploads = uploadManager.selectedMedia.filter { media in
|
||||
guard let status = uploadManager.uploadStatus[media.id] else { return true }
|
||||
return !status.isCompleted && !status.isUploading
|
||||
}
|
||||
|
||||
if !pendingUploads.isEmpty {
|
||||
print("🔄 Found \(pendingUploads.count) pending uploads, starting upload...")
|
||||
uploadManager.startUpload()
|
||||
}
|
||||
|
||||
// 检查是否有已完成的上传
|
||||
for (mediaId, status) in status {
|
||||
if case .completed(let fileId) = status {
|
||||
print("✅ Found completed upload with fileId: ", fileId)
|
||||
|
||||
// 查找对应的媒体项
|
||||
if let media = uploadManager.selectedMedia.first(where: { $0.id == mediaId }),
|
||||
case .image(let image) = media {
|
||||
|
||||
print("🖼️ Updating selected image")
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(animation) {
|
||||
self.selectedImage = image
|
||||
self.uploadedFileId = fileId
|
||||
self.isUploading = false
|
||||
}
|
||||
// 成功更新后清除上传状态
|
||||
self.uploadManager.clearAllMedia()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有失败的上传
|
||||
let hasFailures = status.values.contains {
|
||||
if case .failed = $0 { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
if hasFailures {
|
||||
print("❌ Some uploads failed")
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(animation) {
|
||||
self.isUploading = false
|
||||
}
|
||||
uploadManager.clearAllMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !showUsername {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
withAnimation(animation) {
|
||||
showImageCapture = true
|
||||
}
|
||||
}) {
|
||||
@ -160,13 +226,16 @@ public struct AvatarPicker: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.themePrimaryLight)
|
||||
)
|
||||
.scaleEffect(scaleFactor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.animation(animation, value: isKeyboardVisible)
|
||||
.sheet(isPresented: $showImageCapture) {
|
||||
CustomCameraView(isPresented: $showImageCapture) { image in
|
||||
selectedImage = image
|
||||
uploadManager.selectedMedia = [.image(image)]
|
||||
withAnimation {
|
||||
uploadManager.clearAllMedia()
|
||||
uploadManager.addMedia([.image(image)])
|
||||
withAnimation(animation) {
|
||||
isUploading = true
|
||||
}
|
||||
uploadManager.startUpload()
|
||||
|
||||
@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
struct UserInfo: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var router = Router.shared
|
||||
|
||||
// Sample user data - replace with your actual data model
|
||||
@State private var userName = ""
|
||||
@ -19,12 +20,35 @@ struct UserInfo: View {
|
||||
@State private var isKeyboardVisible = false
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
// 使用静态属性来确保只初始化一次
|
||||
private static let keyboardPreloader: Void = {
|
||||
let textField = UITextField()
|
||||
textField.autocorrectionType = .no
|
||||
textField.autocapitalizationType = .none
|
||||
textField.spellCheckingType = .no
|
||||
textField.isHidden = true
|
||||
|
||||
if let window = UIApplication.shared.windows.first {
|
||||
window.addSubview(textField)
|
||||
textField.becomeFirstResponder()
|
||||
textField.resignFirstResponder()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
textField.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
private let keyboardPublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
|
||||
.map { _ in true }
|
||||
.merge(with: NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
|
||||
.map { _ in false })
|
||||
.receive(on: RunLoop.main)
|
||||
|
||||
init() {
|
||||
// 在初始化时预加载键盘
|
||||
_ = UserInfo.keyboardPreloader
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 背景色
|
||||
@ -60,85 +84,84 @@ struct UserInfo: View {
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.zIndex(1) // 确保导航栏在最上层
|
||||
// Dynamic text that changes based on keyboard state
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 6) {
|
||||
SVGImage(svgName: "Tips")
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.leading,6)
|
||||
Text("Choose a photo as your avatar, and we'll generate a video mystery box for you.")
|
||||
.font(Typography.font(for: .caption))
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(isKeyboardVisible ? 1 : 2)
|
||||
.padding(6)
|
||||
.background(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(red: 1.0, green: 0.97, blue: 0.87),
|
||||
.white,
|
||||
Color(red: 1.0, green: 0.97, blue: 0.84)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.padding(3)
|
||||
}
|
||||
.padding(10)
|
||||
.animation(.easeInOut(duration: 0.3), value: isKeyboardVisible)
|
||||
.transition(.opacity)
|
||||
.background(
|
||||
Color.themeTextWhite
|
||||
.cornerRadius(12)
|
||||
)
|
||||
.padding(10)
|
||||
|
||||
// 可滚动的内容区域
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部Spacer - 只在非键盘状态下生效
|
||||
if !isKeyboardVisible {
|
||||
Spacer(minLength: 0)
|
||||
.frame(height: geometry.size.height * 0.1) // 10% of available height
|
||||
}
|
||||
|
||||
// Content VStack
|
||||
VStack(spacing: 20) {
|
||||
// Title
|
||||
Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
// Avatar
|
||||
AvatarPicker(
|
||||
selectedImage: $avatarImage,
|
||||
showUsername: $showUsername,
|
||||
isKeyboardVisible: $isKeyboardVisible,
|
||||
uploadedFileId: $uploadedFileId
|
||||
)
|
||||
.padding(.top, isKeyboardVisible ? 0 : 20)
|
||||
|
||||
if showUsername {
|
||||
TextField("Username", text: $userName)
|
||||
.font(Typography.font(for: .subtitle, family: .inter))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.foregroundColor(.black)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.themePrimaryLight)
|
||||
)
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onSubmit {
|
||||
isTextFieldFocused = false
|
||||
}
|
||||
ZStack(alignment: .bottom) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部Spacer - 只在非键盘状态下生效
|
||||
if !isKeyboardVisible {
|
||||
Spacer(minLength: 0)
|
||||
.frame(height: geometry.size.height * 0.1) // 10% of available height
|
||||
}
|
||||
|
||||
// Content VStack
|
||||
VStack(spacing: 20) {
|
||||
// Title
|
||||
Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
// Avatar
|
||||
AvatarPicker(
|
||||
selectedImage: $avatarImage,
|
||||
showUsername: $showUsername,
|
||||
isKeyboardVisible: $isKeyboardVisible,
|
||||
uploadedFileId: $uploadedFileId
|
||||
)
|
||||
.padding(.top, isKeyboardVisible ? 0 : 20)
|
||||
|
||||
if showUsername {
|
||||
TextField("Username", text: $userName)
|
||||
.font(Typography.font(for: .subtitle, family: .inter))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.foregroundColor(.black)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.themePrimaryLight)
|
||||
)
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onSubmit {
|
||||
isTextFieldFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.white))
|
||||
.cornerRadius(20)
|
||||
.padding(.horizontal)
|
||||
|
||||
// 添加底部间距,为固定按钮留出空间
|
||||
Spacer(minLength: 40) // 按钮高度 + 底部间距
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.white))
|
||||
.cornerRadius(20)
|
||||
.padding(.horizontal)
|
||||
|
||||
// 底部Spacer - 只在非键盘状态下生效
|
||||
if !isKeyboardVisible {
|
||||
Spacer(minLength: 0)
|
||||
.frame(height: geometry.size.height * 0.1) // 10% of available height
|
||||
}
|
||||
|
||||
// Continue Button
|
||||
.frame(minHeight: geometry.size.height) // 确保内容至少填满可用高度
|
||||
.padding(.bottom, isKeyboardVisible ? 300 : 0) // 为键盘留出空间
|
||||
}
|
||||
|
||||
// Fixed Button at bottom
|
||||
VStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
if showUsername {
|
||||
let parameters: [String: Any] = [
|
||||
@ -154,18 +177,13 @@ struct UserInfo: View {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
print("✅ 用户信息更新成功")
|
||||
// Update local state with the new user info
|
||||
if let userData = response.data {
|
||||
self.userName = userData.username
|
||||
// You can update other user data here if needed
|
||||
}
|
||||
// Show success message or navigate back
|
||||
self.dismiss()
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .image))
|
||||
|
||||
case .failure(let error):
|
||||
print("❌ 用户信息更新失败: \(error.localizedDescription)")
|
||||
// Show error message to user
|
||||
// You can use an @State variable to show an alert or toast
|
||||
self.errorMessage = "更新失败: \(error.localizedDescription)"
|
||||
self.showError = true
|
||||
}
|
||||
@ -188,20 +206,10 @@ struct UserInfo: View {
|
||||
.fill(Color.themePrimary)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 32) // 添加上下边距,与上方按钮保持一致
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, isKeyboardVisible ? 20 : 40)
|
||||
.disabled(showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.opacity((showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty) ? 0.6 : 1.0)
|
||||
.animation(.easeInOut, value: showUsername)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// 底部安全区域占位
|
||||
if isKeyboardVisible {
|
||||
Spacer(minLength: 0)
|
||||
.frame(height: 20) // 添加一些底部间距当键盘显示时
|
||||
}
|
||||
}
|
||||
.frame(minHeight: geometry.size.height) // 确保内容至少填满可用高度
|
||||
}
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
@ -244,6 +252,7 @@ struct UserInfo: View {
|
||||
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isKeyboardVisible = false
|
||||
// 当键盘隐藏时,确保预加载的TextField失去焦点
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,12 +60,12 @@ struct PlanCompare: View {
|
||||
}
|
||||
.background(Theme.Colors.cardBackground)
|
||||
.cornerRadius(Theme.CornerRadius.medium)
|
||||
.shadow(
|
||||
color: Theme.Shadows.small,
|
||||
radius: Theme.Shadows.cardShadow.radius,
|
||||
x: Theme.Shadows.cardShadow.x,
|
||||
y: Theme.Shadows.cardShadow.y
|
||||
)
|
||||
// .shadow(
|
||||
// color: Theme.Shadows.small,
|
||||
// radius: Theme.Shadows.cardShadow.radius,
|
||||
// x: Theme.Shadows.cardShadow.x,
|
||||
// y: Theme.Shadows.cardShadow.y
|
||||
// )
|
||||
}
|
||||
|
||||
// MARK: - 功能名称列
|
||||
|
||||
@ -100,7 +100,7 @@ struct PlanCard: View {
|
||||
.background(
|
||||
plan == .pioneer ?
|
||||
Theme.Colors.primary :
|
||||
Theme.Colors.surface
|
||||
Theme.Colors.surfaceTertiary
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
|
||||
@ -57,12 +57,12 @@ struct SubscribeButton: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Theme.Colors.primary) // primary color background
|
||||
.clipShape(Capsule())
|
||||
.shadow(
|
||||
color: Theme.Shadows.buttonShadow.color,
|
||||
radius: Theme.Shadows.buttonShadow.radius,
|
||||
x: Theme.Shadows.buttonShadow.x,
|
||||
y: Theme.Shadows.buttonShadow.y
|
||||
)
|
||||
// .shadow(
|
||||
// color: Theme.Shadows.buttonShadow.color,
|
||||
// radius: Theme.Shadows.buttonShadow.radius,
|
||||
// x: Theme.Shadows.buttonShadow.x,
|
||||
// y: Theme.Shadows.buttonShadow.y
|
||||
// )
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading || subscribed)
|
||||
|
||||
@ -109,7 +109,7 @@ struct SubscriptionStatusBar: View {
|
||||
.padding(20)
|
||||
.background(status.backgroundColor)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
|
||||
// .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - 日期格式化
|
||||
|
||||
@ -60,6 +60,8 @@ struct SubscribeView: View {
|
||||
SimpleNaviHeader(title: "Subscription") {
|
||||
dismiss()
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.padding(.bottom, Theme.Spacing.lg)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
@ -96,6 +98,7 @@ struct SubscribeView: View {
|
||||
}
|
||||
.background(Theme.Colors.background)
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.navigationBarHidden(true)
|
||||
.task {
|
||||
// Load products and refresh current entitlements on appear
|
||||
|
||||
719
wake/View/Upload/MediaUploadView.swift
Normal file
@ -0,0 +1,719 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import AVKit
|
||||
import CoreTransferable
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
extension Notification.Name {
|
||||
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
|
||||
}
|
||||
/// 主上传视图
|
||||
/// 提供媒体选择、预览和上传功能
|
||||
@MainActor
|
||||
struct MediaUploadView: View {
|
||||
// MARK: - 属性
|
||||
|
||||
/// 上传管理器,负责处理上传逻辑
|
||||
@StateObject private var uploadManager = MediaUploadManager()
|
||||
/// 控制媒体选择器的显示/隐藏
|
||||
@State private var showMediaPicker = false
|
||||
/// 当前选中的媒体项
|
||||
@State private var selectedMedia: MediaType? = nil
|
||||
/// 当前选中的媒体索引集合
|
||||
@State private var selectedIndices: Set<Int> = []
|
||||
@State private var mediaPickerSelection: [MediaType] = [] // 添加这个状态变量
|
||||
/// 上传完成状态
|
||||
@State private var uploadComplete = false
|
||||
/// 上传完成的文件ID列表
|
||||
@State private var uploadedFileIds: [[String: String]] = []
|
||||
|
||||
// MARK: - 视图主体
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部导航栏
|
||||
topNavigationBar
|
||||
|
||||
// 上传提示信息
|
||||
uploadHintView
|
||||
Spacer()
|
||||
.frame(height: uploadManager.selectedMedia.isEmpty ? 80 : 40)
|
||||
// 主上传区域
|
||||
MainUploadArea(
|
||||
uploadManager: uploadManager,
|
||||
showMediaPicker: $showMediaPicker,
|
||||
selectedMedia: $selectedMedia
|
||||
)
|
||||
.id("mainUploadArea\(uploadManager.selectedMedia.count)")
|
||||
|
||||
Spacer()
|
||||
|
||||
// // 上传结果展示
|
||||
// if uploadComplete && !uploadedFileIds.isEmpty {
|
||||
// VStack(alignment: .leading) {
|
||||
// Text("上传完成!")
|
||||
// .font(.headline)
|
||||
|
||||
// ScrollView {
|
||||
// ForEach(Array(uploadedFileIds.enumerated()), id: \.offset) { index, fileInfo in
|
||||
// VStack(alignment: .leading) {
|
||||
// Text("文件 \(index + 1):")
|
||||
// .font(.subheadline)
|
||||
// Text("ID: \(fileInfo["file_id"] ?? "")")
|
||||
// .font(.caption)
|
||||
// .foregroundColor(.gray)
|
||||
// }
|
||||
// .padding()
|
||||
// .frame(maxWidth: .infinity, alignment: .leading)
|
||||
// .background(Color.gray.opacity(0.1))
|
||||
// .cornerRadius(8)
|
||||
// }
|
||||
// }
|
||||
// .frame(height: 200)
|
||||
// }
|
||||
// .padding()
|
||||
// }
|
||||
|
||||
// 继续按钮
|
||||
continueButton
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
// 媒体选择器
|
||||
mediaPickerView
|
||||
}
|
||||
.onChange(of: uploadManager.uploadResults) { newResults in
|
||||
handleUploadCompletion(results: newResults)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 子视图
|
||||
|
||||
/// 顶部导航栏
|
||||
private var topNavigationBar: some View {
|
||||
HStack {
|
||||
// 返回按钮
|
||||
Button(action: { Router.shared.pop() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 标题
|
||||
Text("Complete Your Profile")
|
||||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧占位视图(保持布局平衡)
|
||||
Color.clear
|
||||
.frame(width: 24, height: 24)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
// .padding(.horizontal)
|
||||
.zIndex(1) // 确保导航栏显示在最上层
|
||||
}
|
||||
|
||||
/// 上传提示视图
|
||||
private var uploadHintView: some View {
|
||||
HStack (spacing: 6) {
|
||||
SVGImage(svgName: "Tips")
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.leading,6)
|
||||
Text("The upload process will take approximately 2 minutes. Thank you for your patience.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(3)
|
||||
}
|
||||
.background(
|
||||
Color.themeTextWhite
|
||||
.cornerRadius(6)
|
||||
)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
/// 继续按钮
|
||||
private var continueButton: some View {
|
||||
Button(action: handleContinue) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(uploadManager.selectedMedia.isEmpty ? Color.themeTextMessage : Color.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(uploadManager.selectedMedia.isEmpty ? Color.white : Color.themePrimary)
|
||||
.cornerRadius(28)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(uploadManager.selectedMedia.isEmpty)
|
||||
}
|
||||
|
||||
/// 媒体选择器视图
|
||||
private var mediaPickerView: some View {
|
||||
MediaPicker(
|
||||
selectedMedia: Binding(
|
||||
get: { mediaPickerSelection },
|
||||
set: { newSelections in
|
||||
print("🔄 开始处理用户选择的媒体文件")
|
||||
print("📌 新选择的媒体数量: \(newSelections.count)")
|
||||
|
||||
// 1. 去重处理:过滤掉已经存在的媒体项
|
||||
var uniqueNewMedia: [MediaType] = []
|
||||
|
||||
for newItem in newSelections {
|
||||
let isDuplicate = uploadManager.selectedMedia.contains { existingItem in
|
||||
switch (existingItem, newItem) {
|
||||
case (.image(let existingImage), .image(let newImage)):
|
||||
return existingImage.pngData() == newImage.pngData()
|
||||
case (.video(let existingURL, _), .video(let newURL, _)):
|
||||
return existingURL == newURL
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !isDuplicate {
|
||||
uniqueNewMedia.append(newItem)
|
||||
} else {
|
||||
print("⚠️ 检测到重复文件,已跳过: \(newItem)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 添加新文件
|
||||
if !uniqueNewMedia.isEmpty {
|
||||
print("✅ 添加 \(uniqueNewMedia.count) 个新文件")
|
||||
uploadManager.addMedia(uniqueNewMedia)
|
||||
|
||||
// 如果没有当前选中的媒体,则选择第一个新添加的
|
||||
if selectedMedia == nil, let firstNewItem = uniqueNewMedia.first {
|
||||
selectedMedia = firstNewItem
|
||||
}
|
||||
|
||||
// 开始上传
|
||||
uploadManager.startUpload()
|
||||
} else {
|
||||
print("ℹ️ 没有新文件需要添加,所有选择的文件都已存在")
|
||||
}
|
||||
}
|
||||
),
|
||||
imageSelectionLimit: max(0, 20 - uploadManager.selectedMedia.filter {
|
||||
if case .image = $0 { return true }
|
||||
return false
|
||||
}.count),
|
||||
videoSelectionLimit: max(0, 5 - uploadManager.selectedMedia.filter {
|
||||
if case .video = $0 { return true }
|
||||
return false
|
||||
}.count),
|
||||
selectionMode: .multiple,
|
||||
onDismiss: handleMediaPickerDismiss,
|
||||
onUploadProgress: { index, progress in
|
||||
print("文件 \(index) 上传进度: \(progress * 100)%")
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
// 重置选择状态当选择器出现时
|
||||
mediaPickerSelection = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
|
||||
/// 处理媒体选择器关闭事件
|
||||
private func handleMediaPickerDismiss() {
|
||||
showMediaPicker = false
|
||||
print("媒体选择器关闭 - 开始处理")
|
||||
|
||||
// 如果有选中的媒体,开始上传
|
||||
if !uploadManager.selectedMedia.isEmpty {
|
||||
// 不需要在这里开始上传,因为handleMediaChange会处理
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理媒体变化
|
||||
/// - Parameters:
|
||||
/// - newMedia: 新的媒体数组
|
||||
/// - oldMedia: 旧的媒体数组
|
||||
private func handleMediaChange(_ newMedia: [MediaType], oldMedia: [MediaType]) {
|
||||
print("开始处理媒体变化,新数量: \(newMedia.count), 原数量: \(oldMedia.count)")
|
||||
|
||||
// 如果没有变化,直接返回
|
||||
guard newMedia != oldMedia else {
|
||||
print("媒体未发生变化,跳过处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 在后台线程处理媒体变化
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
// 找出新增的媒体(在newMedia中但不在oldMedia中的项)
|
||||
let newItems = newMedia.filter { newItem in
|
||||
!oldMedia.contains { $0.id == newItem.id }
|
||||
}
|
||||
|
||||
print("检测到\(newItems.count)个新增媒体项")
|
||||
|
||||
// 如果有新增媒体
|
||||
if !newItems.isEmpty {
|
||||
print("准备添加\(newItems.count)个新项...")
|
||||
|
||||
// 在主线程更新UI
|
||||
DispatchQueue.main.async { [self] in
|
||||
// 创建新的数组,包含原有媒体和新媒体
|
||||
var updatedMedia = uploadManager.selectedMedia
|
||||
updatedMedia.append(contentsOf: newItems)
|
||||
|
||||
// 更新选中的媒体
|
||||
uploadManager.clearAllMedia()
|
||||
uploadManager.addMedia(updatedMedia)
|
||||
|
||||
// 如果当前没有选中的媒体,则选中第一个新增的媒体
|
||||
if selectedIndices.isEmpty && !newItems.isEmpty {
|
||||
selectedIndices = [oldMedia.count] // 选择第一个新增项的索引
|
||||
selectedMedia = newItems.first
|
||||
}
|
||||
|
||||
// 开始上传新添加的媒体
|
||||
uploadManager.startUpload()
|
||||
print("媒体添加完成,总数量: \(uploadManager.selectedMedia.count)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否有正在上传的文件
|
||||
/// - Returns: 是否正在上传
|
||||
private func isUploading() -> Bool {
|
||||
return uploadManager.uploadStatus.values.contains { status in
|
||||
if case .uploading = status { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理上传完成
|
||||
private func handleUploadCompletion(results: [String: MediaUploadManager.UploadResult]) {
|
||||
// 转换为需要的格式
|
||||
let formattedResults = results.map { (_, result) -> [String: String] in
|
||||
return [
|
||||
"file_id": result.fileId,
|
||||
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||
]
|
||||
}
|
||||
|
||||
uploadedFileIds = formattedResults
|
||||
uploadComplete = !uploadedFileIds.isEmpty
|
||||
}
|
||||
|
||||
/// 处理继续按钮点击
|
||||
private func handleContinue() {
|
||||
// 获取所有已上传文件的结果
|
||||
let uploadResults = uploadManager.uploadResults
|
||||
guard !uploadResults.isEmpty else {
|
||||
print("⚠️ 没有可用的文件ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 准备请求参数
|
||||
let files = uploadResults.map { (_, result) -> [String: String] in
|
||||
return [
|
||||
"file_id": result.fileId,
|
||||
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||
]
|
||||
}
|
||||
|
||||
// 发送POST请求到/material接口
|
||||
NetworkService.shared.postWithToken(
|
||||
path: "/material",
|
||||
parameters: files
|
||||
) { (result: Result<EmptyResponse, NetworkError>) in
|
||||
switch result {
|
||||
case .success:
|
||||
print("✅ 素材提交成功")
|
||||
// 跳转到盲盒页面
|
||||
DispatchQueue.main.async {
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .video))
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ 素材提交失败: \(error.localizedDescription)")
|
||||
// 这里可以添加错误处理逻辑,比如显示错误提示
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主上传区域
|
||||
|
||||
/// 主上传区域视图
|
||||
/// 显示上传提示、媒体预览和添加更多按钮
|
||||
struct MainUploadArea: View {
|
||||
// MARK: - 属性
|
||||
|
||||
/// 上传管理器
|
||||
@ObservedObject var uploadManager: MediaUploadManager
|
||||
/// 控制媒体选择器的显示/隐藏
|
||||
@Binding var showMediaPicker: Bool
|
||||
/// 当前选中的媒体
|
||||
@Binding var selectedMedia: MediaType?
|
||||
|
||||
// MARK: - 视图主体
|
||||
|
||||
var body: some View {
|
||||
VStack() {
|
||||
Spacer()
|
||||
.frame(height: 30)
|
||||
// 标题
|
||||
Text("Click to upload 20 images and 5 videos to generate your next blind box.")
|
||||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.black)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
Spacer()
|
||||
.frame(height: 50)
|
||||
// 主显示区域
|
||||
if let mediaToDisplay = selectedMedia ?? uploadManager.selectedMedia.first {
|
||||
Button(action: { showMediaPicker = true }) {
|
||||
MediaPreview(media: mediaToDisplay)
|
||||
.id(mediaToDisplay.id)
|
||||
.frame(width: 225, height: 225)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.themePrimary, lineWidth: 5)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal)
|
||||
.transition(.opacity)
|
||||
}
|
||||
} else {
|
||||
UploadPromptView(showMediaPicker: $showMediaPicker)
|
||||
}
|
||||
// 媒体预览区域
|
||||
mediaPreviewSection
|
||||
Spacer()
|
||||
.frame(height: 10)
|
||||
}
|
||||
.onAppear {
|
||||
print("MainUploadArea appeared")
|
||||
print("Selected media count: \(uploadManager.selectedMedia.count)")
|
||||
|
||||
if selectedMedia == nil, let firstMedia = uploadManager.selectedMedia.first {
|
||||
print("Selecting first media: \(firstMedia.id)")
|
||||
selectedMedia = firstMedia
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .didAddFirstMedia)) { notification in
|
||||
if let media = notification.userInfo?["media"] as? MediaType, selectedMedia == nil {
|
||||
selectedMedia = media
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(18)
|
||||
.animation(.default, value: selectedMedia?.id)
|
||||
}
|
||||
|
||||
// MARK: - 子视图
|
||||
|
||||
/// 媒体预览区域
|
||||
private var mediaPreviewSection: some View {
|
||||
Group {
|
||||
if !uploadManager.selectedMedia.isEmpty {
|
||||
VStack(spacing: 4) {
|
||||
// 横向滚动的缩略图列表
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 10) {
|
||||
ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in
|
||||
mediaItemView(for: media, at: index)
|
||||
}
|
||||
// 当没有选择媒体时显示添加更多按钮
|
||||
if !uploadManager.selectedMedia.isEmpty {
|
||||
addMoreButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(height: 70)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个媒体项视图
|
||||
/// - Parameters:
|
||||
/// - media: 媒体项
|
||||
/// - index: 索引
|
||||
/// - Returns: 媒体项视图
|
||||
private func mediaItemView(for media: MediaType, at index: Int) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
// 媒体预览 - 始终使用本地资源
|
||||
MediaPreview(media: media)
|
||||
.frame(width: 58, height: 58)
|
||||
.cornerRadius(8)
|
||||
.shadow(radius: 1)
|
||||
.overlay(
|
||||
// 左上角序号
|
||||
ZStack(alignment: .topLeading) {
|
||||
Path { path in
|
||||
let radius: CGFloat = 4
|
||||
let width: CGFloat = 14
|
||||
let height: CGFloat = 10
|
||||
|
||||
// 从左上角开始(带圆角)
|
||||
path.move(to: CGPoint(x: 0, y: radius))
|
||||
path.addQuadCurve(to: CGPoint(x: radius, y: 0),
|
||||
control: CGPoint(x: 0, y: 0))
|
||||
|
||||
// 上边缘(右上角保持直角)
|
||||
path.addLine(to: CGPoint(x: width, y: 0))
|
||||
|
||||
// 右边缘(右下角保持直角)
|
||||
path.addLine(to: CGPoint(x: width, y: height - radius))
|
||||
|
||||
// 右下角圆角
|
||||
path.addQuadCurve(to: CGPoint(x: width - radius, y: height),
|
||||
control: CGPoint(x: width, y: height))
|
||||
|
||||
// 下边缘(左下角保持直角)
|
||||
path.addLine(to: CGPoint(x: 0, y: height))
|
||||
|
||||
// 闭合路径
|
||||
path.closeSubpath()
|
||||
}
|
||||
.fill(Color(hex: "BEBEBE").opacity(0.6))
|
||||
.frame(width: 14, height: 10)
|
||||
.overlay(
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.frame(width: 14, height: 10)
|
||||
.offset(y: -1),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.padding([.top, .leading], 2)
|
||||
|
||||
// 右下角视频时长
|
||||
if case .video(let url, _) = media, let videoURL = url as? URL {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(getVideoDuration(url: videoURL))
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(height: 10)
|
||||
.background(Color(hex: "BEBEBE").opacity(0.6))
|
||||
.cornerRadius(2)
|
||||
}
|
||||
.padding([.trailing, .bottom], 0)
|
||||
}
|
||||
}else{
|
||||
// 占位
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("占位")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(height: 10)
|
||||
.background(Color(hex: "BEBEBE").opacity(0.6))
|
||||
.cornerRadius(2)
|
||||
}
|
||||
.padding([.trailing, .bottom], 0)
|
||||
}
|
||||
.opacity(0)
|
||||
}
|
||||
},
|
||||
alignment: .topLeading
|
||||
)
|
||||
.onTapGesture {
|
||||
print("点击了媒体项,索引: \(index)")
|
||||
withAnimation {
|
||||
selectedMedia = media
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
// 右上角关闭按钮
|
||||
Button(action: {
|
||||
uploadManager.removeMedia(id: media.id)
|
||||
if selectedMedia == media {
|
||||
selectedMedia = nil
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.frame(width: 12, height: 12)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color(hex: "BEBEBE").opacity(0.6))
|
||||
.frame(width: 12, height: 12)
|
||||
)
|
||||
}
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
/// 添加更多按钮
|
||||
private var addMoreButton: some View {
|
||||
Button(action: { showMediaPicker = true }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.frame(width: 58, height: 58)
|
||||
.background(Color.white)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(style: StrokeStyle(
|
||||
lineWidth: 2,
|
||||
dash: [4, 4]
|
||||
))
|
||||
.foregroundColor(Color.themePrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 上传提示视图
|
||||
|
||||
/// 上传提示视图
|
||||
/// 显示上传区域的占位图和提示
|
||||
struct UploadPromptView: View {
|
||||
/// 控制媒体选择器的显示/隐藏
|
||||
@Binding var showMediaPicker: Bool
|
||||
|
||||
var body: some View {
|
||||
Button(action: { showMediaPicker = true }) {
|
||||
// 上传图标
|
||||
SVGImage(svgName: "IP")
|
||||
.frame(width: 225, height: 225)
|
||||
.contentShape(Rectangle())
|
||||
.overlay(
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(style: StrokeStyle(
|
||||
lineWidth: 5,
|
||||
lineCap: .round,
|
||||
dash: [12, 8]
|
||||
))
|
||||
.foregroundColor(Color.themePrimary)
|
||||
|
||||
// Add plus icon in the center
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 媒体预览视图
|
||||
|
||||
/// 媒体预览视图
|
||||
/// 显示图片或视频的预览图,始终使用本地资源
|
||||
struct MediaPreview: View {
|
||||
// MARK: - 属性
|
||||
|
||||
/// 媒体类型
|
||||
let media: MediaType
|
||||
|
||||
// MARK: - 计算属性
|
||||
|
||||
/// 获取要显示的图片
|
||||
private var displayImage: UIImage? {
|
||||
switch media {
|
||||
case .image(let uiImage):
|
||||
return uiImage
|
||||
case .video(_, let thumbnail):
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 视图主体
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 1. 显示图片或视频缩略图
|
||||
if let image = displayImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
|
||||
} else {
|
||||
// 2. 加载中的占位图
|
||||
Color.gray.opacity(0.1)
|
||||
}
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.themePrimary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func getVideoDuration(url: URL) -> String {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let durationInSeconds = CMTimeGetSeconds(asset.duration)
|
||||
guard durationInSeconds.isFinite else { return "0:00" }
|
||||
|
||||
let minutes = Int(durationInSeconds) / 60
|
||||
let seconds = Int(durationInSeconds) % 60
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
// MARK: - Response Types
|
||||
|
||||
private struct EmptyResponse: Decodable {
|
||||
// Empty response type for endpoints that don't return data
|
||||
}
|
||||
|
||||
// MARK: - 扩展
|
||||
|
||||
/// 扩展 MediaType 以支持 Identifiable 协议
|
||||
extension MediaType: Identifiable {
|
||||
/// 唯一标识符
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .image(let uiImage):
|
||||
return "image_\(uiImage.hashValue)"
|
||||
case .video(let url, _):
|
||||
return "video_\(url.absoluteString.hashValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeInterval {
|
||||
var formattedDuration: String {
|
||||
let minutes = Int(self) / 60
|
||||
let seconds = Int(self) % 60
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
|
||||
struct MediaUploadView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
MediaUploadView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ struct SplashView: View {
|
||||
)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
VStack(spacing: 50) {
|
||||
FilmAnimation()
|
||||
// FilmAnimation()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import SwiftData
|
||||
|
||||
@main
|
||||
struct WakeApp: App {
|
||||
@StateObject private var router = Router.shared
|
||||
@StateObject private var authState = AuthState.shared
|
||||
@State private var showSplash = true
|
||||
|
||||
@ -36,35 +37,43 @@ struct WakeApp: App {
|
||||
// 显示启动页
|
||||
SplashView()
|
||||
.environmentObject(authState)
|
||||
// .onAppear {
|
||||
// // 启动页显示时检查token有效性
|
||||
// checkTokenValidity()
|
||||
// }
|
||||
.onAppear {
|
||||
// 启动页显示时检查token有效性
|
||||
checkTokenValidity()
|
||||
}
|
||||
} else {
|
||||
// 根据登录状态显示不同视图
|
||||
if authState.isAuthenticated {
|
||||
// 已登录:显示userInfo页面
|
||||
UserInfo()
|
||||
.environmentObject(authState)
|
||||
// 已登录:显示主页面
|
||||
NavigationStack(path: $router.path) {
|
||||
BlindBoxView(mediaType: .all)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.view
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录:显示登录界面
|
||||
// ContentView()
|
||||
// .environmentObject(authState)
|
||||
UserInfo()
|
||||
.environmentObject(authState)
|
||||
NavigationStack(path: $router.path) {
|
||||
LoginView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.view
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// .onAppear {
|
||||
// //3秒后自动隐藏启动页
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
// withAnimation {
|
||||
// showSplash = false
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
.environmentObject(router)
|
||||
.environmentObject(authState)
|
||||
.onAppear {
|
||||
//2秒后自动隐藏启动页
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
showSplash = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
|
||||
// MARK: - 私有方法
|
||||
@ -92,11 +101,11 @@ struct WakeApp: App {
|
||||
authState.isAuthenticated = true
|
||||
}
|
||||
|
||||
// 3秒后自动隐藏启动页
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
// withAnimation {
|
||||
// showSplash = false
|
||||
// }
|
||||
// }
|
||||
// 2秒后自动隐藏启动页
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
showSplash = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||