feat: V2.0

This commit is contained in:
jinyaqiu 2025-08-24 15:36:50 +08:00
parent 0aa1271c93
commit d7911d0828
70 changed files with 6071 additions and 873 deletions

View File

@ -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" */;

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View 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":{}}

View 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": []
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

5
wake/Assets/Svg/Box.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 183 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

@ -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
View 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?()
}
}
}
// UIImageGIF
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()
}
}

View File

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

View File

@ -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
View 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()
}
}

View File

@ -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")
/// tokentoken
/// 300token5
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: tokentruefalse
///
/// tokentokentoken
///
/// - Note: tokentoken
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)")
// 200token
isValid = true
print(" TokenManager: 状态码200假设token有效")
self.logger.debug(" 状态码200假设token有效")
}
} else {
// 200token
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? {
// JWTtoken
// 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...")
// Keychaintoken
KeychainHelper.clearTokens()
// token
// UserDefaultstoken
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")
}

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

View File

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

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

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

View File

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

View File

@ -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 // 使IDpreview_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?)
/// IDID
public var fileId: String {
@ -353,6 +379,36 @@ public class ImageUploadService {
return videoResult.fileId
}
}
/// IDID
public var previewFileId: String? {
switch self {
case .file:
return nil
case .video(_, let thumbnailResult):
return thumbnailResult?.fileId
}
}
/// URLURL
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
}
}
}
///

View File

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

View File

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

View File

@ -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)
// pendingfailed
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)
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View 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

View 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 cant 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()
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
// newMediaoldMedia
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()
}
}
}

View File

@ -19,7 +19,7 @@ struct SplashView: View {
)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 50) {
FilmAnimation()
// FilmAnimation()
}
.padding()
}

View File

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