[ADD] add auth pages

This commit is contained in:
2026-05-23 00:21:32 +03:30
parent 68b2e3ca9d
commit 8ab6a5a959
21 changed files with 2361 additions and 256 deletions

View File

@@ -34,7 +34,8 @@
"imageWidth": 76 "imageWidth": 76
} }
} }
] ],
"expo-secure-store"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

266
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10", "@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33", "@react-navigation/native": "^7.1.33",
"axios": "^1.16.1",
"expo": "~55.0.24", "expo": "~55.0.24",
"expo-constants": "~55.0.16", "expo-constants": "~55.0.16",
"expo-device": "~55.0.17", "expo-device": "~55.0.17",
@@ -19,6 +20,7 @@
"expo-image": "~55.0.10", "expo-image": "~55.0.10",
"expo-linking": "~55.0.15", "expo-linking": "~55.0.15",
"expo-router": "~55.0.14", "expo-router": "~55.0.14",
"expo-secure-store": "~55.0.14",
"expo-splash-screen": "~55.0.21", "expo-splash-screen": "~55.0.21",
"expo-status-bar": "~55.0.6", "expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.8", "expo-symbols": "~55.0.8",
@@ -3098,6 +3100,45 @@
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3466,6 +3507,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -3631,6 +3684,17 @@
"simple-swizzle": "^0.2.2" "simple-swizzle": "^0.2.2"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -3823,6 +3887,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -3858,6 +3930,19 @@
"resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz",
"integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==" "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA=="
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3889,6 +3974,14 @@
"stackframe": "^1.3.4" "stackframe": "^1.3.4"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -3897,6 +3990,31 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4229,6 +4347,14 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/expo-secure-store": {
"version": "55.0.14",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.14.tgz",
"integrity": "sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ==",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-server": { "node_modules/expo-server": {
"version": "55.0.9", "version": "55.0.9",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.9.tgz",
@@ -4657,11 +4783,64 @@
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
"integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==" "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/fontfaceobserver": { "node_modules/fontfaceobserver": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz",
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==" "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="
}, },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -4712,6 +4891,29 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": { "node_modules/get-nonce": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4728,6 +4930,18 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/getenv": { "node_modules/getenv": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
@@ -4752,6 +4966,17 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -4765,6 +4990,31 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -5649,6 +5899,14 @@
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==" "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -6572,6 +6830,14 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/query-string": { "node_modules/query-string": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",

View File

@@ -14,6 +14,7 @@
"@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10", "@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33", "@react-navigation/native": "^7.1.33",
"axios": "^1.16.1",
"expo": "~55.0.24", "expo": "~55.0.24",
"expo-constants": "~55.0.16", "expo-constants": "~55.0.16",
"expo-device": "~55.0.17", "expo-device": "~55.0.17",
@@ -22,6 +23,7 @@
"expo-image": "~55.0.10", "expo-image": "~55.0.10",
"expo-linking": "~55.0.15", "expo-linking": "~55.0.15",
"expo-router": "~55.0.14", "expo-router": "~55.0.14",
"expo-secure-store": "~55.0.14",
"expo-splash-screen": "~55.0.21", "expo-splash-screen": "~55.0.21",
"expo-status-bar": "~55.0.6", "expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.8", "expo-symbols": "~55.0.8",
@@ -31,11 +33,11 @@
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-native": "0.83.6", "react-native": "0.83.6",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~2.30.0",
"react-native-worklets": "0.7.4",
"react-native-reanimated": "4.2.1", "react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0" "react-native-web": "~0.21.0",
"react-native-worklets": "0.7.4"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",

229
src/app/(auth)/OTPLogin.tsx Normal file
View File

@@ -0,0 +1,229 @@
import { useAuth } from "@/constants/AuthContext";
import { useTheme } from "@/hooks/use-theme";
import { authStyles } from "@/styles/auth.styles";
import { Feather } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import apiClient from "../../services/api";
export default function OTPLogin() {
const { phoneNumber } = useLocalSearchParams<{ phoneNumber: string }>();
const [otp, setOtp] = useState("");
const [focusedField, setFocusedField] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [timeLeft, setTimeLeft] = useState(120);
const [canResend, setCanResend] = useState(false);
const { signIn } = useAuth();
const theme = useTheme();
const scrollViewRef = useRef<ScrollView>(null);
useEffect(() => {
if (timeLeft <= 0) {
setCanResend(true);
return;
}
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
const handleBack = () => {
router.back();
};
const handleVerifyOTP = async () => {
if (!otp || otp.length < 4) {
Alert.alert("خطا", "لطفا کد تایید را کامل وارد کنید");
return;
}
setIsLoading(true);
try {
const response = await apiClient.post("/api/auth/verify_login/", {
phone_number: phoneNumber,
code: otp,
});
const { access } = response.data;
await signIn(access);
} catch (error: any) {
Alert.alert(
"خطا",
error.response?.data?.message || "کد تایید اشتباه است",
);
} finally {
setIsLoading(false);
}
};
const handleResendCode = async () => {
if (!phoneNumber) return;
if (!canResend) {
Alert.alert("صبر کنید", `لطفاً ${timeLeft} ثانیه دیگر تلاش کنید`);
return;
}
setIsLoading(true);
try {
await apiClient.post("/api/auth/login/", { phone_number: phoneNumber });
Alert.alert("کد مجدداً ارسال شد");
setTimeLeft(120);
setCanResend(false);
} catch (error: any) {
Alert.alert("خطا", "مشکل در ارسال مجدد کد");
} finally {
setIsLoading(false);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
};
const handleFocus = () => {
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 120, animated: true });
}, 100);
};
const styles = useMemo(
() => authStyles(theme, focusedField),
[theme, focusedField],
);
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginBottom: 12 }}>
<TouchableOpacity
onPress={handleBack}
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.border,
alignItems: "center",
justifyContent: "center",
}}
>
<Feather
name="arrow-left"
size={16}
color={theme.textSecondary}
/>
</TouchableOpacity>
</View>
<View style={styles.phoneCard}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Feather name="key" size={18} color={theme.accent} />
</View>
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>تایید کد</Text>
</View>
</View>
<Text style={styles.subtitle}>
کد تایید برای شماره {phoneNumber} ارسال شد
</Text>
<Text style={styles.fieldLabel}>کد تایید</Text>
<View style={styles.inputContainer}>
<Feather
name="key"
size={14}
color={
focusedField === "otp" ? theme.accent : theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="کد ۶ رقمی"
placeholderTextColor={theme.inputPlaceholder}
value={otp}
onChangeText={setOtp}
onFocus={() => {
setFocusedField("otp");
handleFocus();
}}
onBlur={() => setFocusedField(null)}
keyboardType="number-pad"
maxLength={6}
autoFocus
textAlign="center"
/>
</View>
<TouchableOpacity
disabled={isLoading}
style={styles.continueButton}
onPress={handleVerifyOTP}
>
{!isLoading && (
<Feather name="arrow-left" size={14} color="#fff" />
)}
<Text style={styles.continueText}>
{isLoading ? "در حال تایید..." : "تایید کد"}
</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={isLoading || !canResend}
style={[
styles.secondaryButton,
(!canResend || isLoading) && styles.secondaryButtonDisabled,
]}
onPress={handleResendCode}
>
<Feather
name="refresh-cw"
size={14}
color={
!canResend || isLoading ? theme.textSecondary : theme.accent
}
/>
<Text
style={[
styles.secondaryButtonText,
(!canResend || isLoading) &&
styles.secondaryButtonTextDisabled,
]}
>
{!canResend
? `ارسال مجدد کد (${formatTime(timeLeft)})`
: "ارسال مجدد کد"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,229 @@
import { useAuth } from "@/constants/AuthContext";
import { useTheme } from "@/hooks/use-theme";
import { authStyles } from "@/styles/auth.styles";
import { Feather } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import apiClient from "../../services/api";
export default function OTPRegister() {
const { phoneNumber } = useLocalSearchParams<{ phoneNumber: string }>();
const [otp, setOtp] = useState("");
const [focusedField, setFocusedField] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [timeLeft, setTimeLeft] = useState(120);
const [canResend, setCanResend] = useState(false);
const { signIn } = useAuth();
const theme = useTheme();
const scrollViewRef = useRef<ScrollView>(null);
useEffect(() => {
if (timeLeft <= 0) {
setCanResend(true);
return;
}
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
const handleBack = () => {
router.back();
};
const handleVerifyOTP = async () => {
if (!otp || otp.length < 4) {
Alert.alert("خطا", "لطفا کد تایید را کامل وارد کنید");
return;
}
setIsLoading(true);
try {
const response = await apiClient.post("/api/auth/verify_register/", {
phone_number: phoneNumber,
code: otp,
});
const { access } = response.data;
await signIn(access);
} catch (error: any) {
Alert.alert(
"خطا",
error.response?.data?.message || "کد تایید اشتباه است",
);
} finally {
setIsLoading(false);
}
};
const handleResendCode = async () => {
if (!phoneNumber) return;
if (!canResend) {
Alert.alert("صبر کنید", `لطفاً ${timeLeft} ثانیه دیگر تلاش کنید`);
return;
}
setIsLoading(true);
try {
await apiClient.post("/api/auth/login/", { phone_number: phoneNumber });
Alert.alert("کد مجدداً ارسال شد");
setTimeLeft(120);
setCanResend(false);
} catch (error: any) {
Alert.alert("خطا", "مشکل در ارسال مجدد کد");
} finally {
setIsLoading(false);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
};
const handleFocus = () => {
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 120, animated: true });
}, 100);
};
const styles = useMemo(
() => authStyles(theme, focusedField),
[theme, focusedField],
);
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginBottom: 12 }}>
<TouchableOpacity
onPress={handleBack}
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.border,
alignItems: "center",
justifyContent: "center",
}}
>
<Feather
name="arrow-left"
size={16}
color={theme.textSecondary}
/>
</TouchableOpacity>
</View>
<View style={styles.phoneCard}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Feather name="key" size={18} color={theme.accent} />
</View>
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>تایید کد</Text>
</View>
</View>
<Text style={styles.subtitle}>
کد تایید برای شماره {phoneNumber} ارسال شد
</Text>
<Text style={styles.fieldLabel}>کد تایید</Text>
<View style={styles.inputContainer}>
<Feather
name="key"
size={14}
color={
focusedField === "otp" ? theme.accent : theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="کد ۶ رقمی"
placeholderTextColor={theme.inputPlaceholder}
value={otp}
onChangeText={setOtp}
onFocus={() => {
setFocusedField("otp");
handleFocus();
}}
onBlur={() => setFocusedField(null)}
keyboardType="number-pad"
maxLength={6}
autoFocus
textAlign="center"
/>
</View>
<TouchableOpacity
disabled={isLoading}
style={styles.continueButton}
onPress={handleVerifyOTP}
>
{!isLoading && (
<Feather name="arrow-left" size={14} color="#fff" />
)}
<Text style={styles.continueText}>
{isLoading ? "در حال تایید..." : "تایید کد"}
</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={isLoading || !canResend}
style={[
styles.secondaryButton,
(!canResend || isLoading) && styles.secondaryButtonDisabled,
]}
onPress={handleResendCode}
>
<Feather
name="refresh-cw"
size={14}
color={
!canResend || isLoading ? theme.textSecondary : theme.accent
}
/>
<Text
style={[
styles.secondaryButtonText,
(!canResend || isLoading) &&
styles.secondaryButtonTextDisabled,
]}
>
{!canResend
? `ارسال مجدد کد (${formatTime(timeLeft)})`
: "ارسال مجدد کد"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

244
src/app/(auth)/SignUp.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { useTheme } from "@/hooks/use-theme";
import apiClient from "@/services/api";
import { authStyles } from "@/styles/auth.styles";
import { Feather } from "@expo/vector-icons";
import { router } from "expo-router";
import React, { useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const SignUpScreen = () => {
const theme = useTheme();
const [phoneNumber, setPhoneNumber] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [focusedField, setFocusedField] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const isPhoneValid = phoneNumber.trim().length >= 10;
const isUsernameValid = username.trim().length >= 3;
const isEmailValid = email.includes("@") && email.includes(".");
const handleBack = () => {
router.back();
};
const handleSignUp = async () => {
if (!isPhoneValid) {
Alert.alert("خطا", "شماره موبایل معتبر وارد کنید (حداقل ۱۰ رقم)");
return;
}
if (!isUsernameValid) {
Alert.alert("خطا", "نام کاربری باید حداقل ۳ کاراکتر باشد");
return;
}
if (!isEmailValid) {
Alert.alert("خطا", "ایمیل معتبر وارد کنید");
return;
}
setIsLoading(true);
try {
await apiClient.post("/api/auth/register/", {
phone_number: phoneNumber,
username: username,
email: email,
});
Alert.alert(
"ثبت‌نامه موفق",
"حساب کاربری شما ایجاد شد. لطفاً وارد شوید.",
);
router.push("/(auth)/login");
} catch (error: any) {
Alert.alert(
"خطا",
error.response?.data?.message || "مشکلی در ثبت‌نامه پیش آمد",
);
} finally {
setIsLoading(false);
}
};
const handleLogin = () => {
router.push("/(auth)/login");
};
const handleFocus = (fieldName: string) => {
setFocusedField(fieldName);
let yOffset = 0;
switch (fieldName) {
case "phone":
yOffset = 200;
break;
case "username":
yOffset = 280;
break;
case "email":
yOffset = 360;
break;
}
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: yOffset, animated: true });
}, 100);
};
const styles = useMemo(
() => authStyles(theme, focusedField),
[theme, focusedField],
);
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginBottom: 12 }}>
<TouchableOpacity
onPress={handleBack}
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.border,
alignItems: "center",
justifyContent: "center",
}}
>
<Feather
name="arrow-left"
size={16}
color={theme.textSecondary}
/>
</TouchableOpacity>
</View>
{/* کارت ثبت‌نام */}
<View style={styles.phoneCard}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Feather name="user-plus" size={18} color={theme.accent} />
</View>
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>ایجاد حساب کاربری</Text>
</View>
</View>
{/* شماره موبایل */}
<Text style={styles.fieldLabel}>شماره موبایل</Text>
<View style={styles.inputContainer}>
<Feather
name="phone"
size={14}
color={
focusedField === "phone" ? theme.accent : theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="09123456789"
placeholderTextColor={theme.inputPlaceholder}
value={phoneNumber}
onChangeText={setPhoneNumber}
onFocus={() => handleFocus("phone")}
onBlur={() => setFocusedField(null)}
keyboardType="phone-pad"
autoCapitalize="none"
textAlign="right"
/>
</View>
{/* نام کاربری */}
<Text style={styles.fieldLabel}>نام کاربری</Text>
<View style={styles.inputContainer}>
<Feather
name="user"
size={14}
color={
focusedField === "username"
? theme.accent
: theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="example_user"
placeholderTextColor={theme.inputPlaceholder}
value={username}
onChangeText={setUsername}
onFocus={() => handleFocus("username")}
onBlur={() => setFocusedField(null)}
autoCapitalize="none"
textAlign="right"
/>
</View>
{/* ایمیل */}
<Text style={styles.fieldLabel}>ایمیل سازمانی</Text>
<View style={styles.inputContainer}>
<Feather
name="mail"
size={14}
color={
focusedField === "email" ? theme.accent : theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="example@company.com"
placeholderTextColor={theme.inputPlaceholder}
value={email}
onChangeText={setEmail}
onFocus={() => handleFocus("email")}
onBlur={() => setFocusedField(null)}
keyboardType="email-address"
autoCapitalize="none"
textAlign="right"
/>
</View>
<TouchableOpacity
style={styles.continueButton}
onPress={handleSignUp}
disabled={isLoading}
>
<Text style={styles.continueText}>
{isLoading ? "در حال ثبت‌نام..." : "ثبت‌نام"}
</Text>
<Feather name="arrow-left" size={14} color="#fff" />
</TouchableOpacity>
<View style={styles.switchContainer}>
<Text style={styles.switchText}>حساب کاربری دارید؟ </Text>
<TouchableOpacity onPress={handleLogin}>
<Text style={styles.switchLink}>وارد شوید</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default SignUpScreen;

View File

@@ -0,0 +1,13 @@
import { Tabs, Stack } from "expo-router";
export default function AuthLayout() {
return (
<Tabs screenOptions={{ headerShown: false, tabBarStyle: { display: "none" } }}>
<Tabs.Screen name="homeLogin" options={{ href: null }} />
<Tabs.Screen name="login" options={{ href: null }} />
<Tabs.Screen name="OTPLogin" options={{ href: null }} />
<Tabs.Screen name="OTPRegister" options={{ href: null }} />
<Tabs.Screen name="SignUp" options={{ href: null }} />
</Tabs>
);
}

View File

@@ -0,0 +1,190 @@
import { router } from "expo-router";
import React, { useMemo } from "react";
import {
ScrollView,
StatusBar,
Text,
TouchableOpacity,
View,
DimensionValue,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTheme } from "@/hooks/use-theme";
import { createWelcomeStyles } from "@/styles/auth.styles";
interface CardItem {
id: string;
dotColor: string;
line1WidthPercent: number;
line2WidthPercent: number;
tagText: string;
tagBg: string;
tagColor: string;
isAccent: boolean;
}
const WelcomeScreen = () => {
const theme = useTheme();
const isDark = theme.background === '#0D0D14';
const getCardColors = (accentType: 'primary' | 'green' | 'orange' | 'pink') => {
if (isDark) {
switch (accentType) {
case 'primary':
return { tagBg: '#1a1635', tagColor: '#8B7FFF', dotColor: theme.accent };
case 'green':
return { tagBg: '#0a1f18', tagColor: '#5DCAA5', dotColor: '#1D9E75' };
case 'orange':
return { tagBg: '#1e1500', tagColor: '#EF9F27', dotColor: '#EF9F27' };
case 'pink':
return { tagBg: '#1a0d12', tagColor: '#ED93B1', dotColor: '#D4537E' };
}
} else {
switch (accentType) {
case 'primary':
return { tagBg: '#EEEEFF', tagColor: '#6C63FF', dotColor: theme.accent };
case 'green':
return { tagBg: '#E6F7F0', tagColor: '#1D9E75', dotColor: '#1D9E75' };
case 'orange':
return { tagBg: '#FFF3E6', tagColor: '#EF9F27', dotColor: '#EF9F27' };
case 'pink':
return { tagBg: '#FFEEF2', tagColor: '#D4537E', dotColor: '#D4537E' };
}
}
return { tagBg: theme.backgroundElement, tagColor: theme.textSecondary, dotColor: theme.textSecondary };
};
const cardsData: CardItem[] = [
{
id: "1",
dotColor: getCardColors('primary').dotColor,
line1WidthPercent: 85,
line2WidthPercent: 60,
tagText: "طراحی",
tagBg: getCardColors('primary').tagBg,
tagColor: getCardColors('primary').tagColor,
isAccent: true,
},
{
id: "2",
dotColor: getCardColors('green').dotColor,
line1WidthPercent: 75,
line2WidthPercent: 45,
tagText: "انجام شد",
tagBg: getCardColors('green').tagBg,
tagColor: getCardColors('green').tagColor,
isAccent: false,
},
{
id: "3",
dotColor: getCardColors('orange').dotColor,
line1WidthPercent: 65,
line2WidthPercent: 40,
tagText: "بررسی",
tagBg: getCardColors('orange').tagBg,
tagColor: getCardColors('orange').tagColor,
isAccent: false,
},
{
id: "4",
dotColor: getCardColors('pink').dotColor,
line1WidthPercent: 80,
line2WidthPercent: 50,
tagText: "راه‌اندازی",
tagBg: getCardColors('pink').tagBg,
tagColor: getCardColors('pink').tagColor,
isAccent: true,
},
];
const percent = (value: number): DimensionValue => `${value}%` as DimensionValue;
const dynamicStyles = useMemo(
() => createWelcomeStyles(theme, isDark),
[theme, isDark]
);
const renderMiniCard = (item: CardItem) => {
const cardStyle = item.isAccent
? [dynamicStyles.miniCard, dynamicStyles.miniCardAccent]
: dynamicStyles.miniCard;
return (
<View key={item.id} style={cardStyle}>
<View style={[dynamicStyles.mcDot, { backgroundColor: item.dotColor }]} />
<View style={dynamicStyles.mcLines}>
<View
style={[
dynamicStyles.mcLine,
{ width: percent(item.line1WidthPercent) },
]}
/>
<View
style={[
dynamicStyles.mcLine,
{ width: percent(item.line2WidthPercent) },
]}
/>
</View>
<View style={[dynamicStyles.mcTag, { backgroundColor: item.tagBg }]}>
<Text style={[dynamicStyles.mcTagText, { color: item.tagColor }]}>
{item.tagText}
</Text>
</View>
</View>
);
};
const statusBarStyle = isDark ? 'light-content' : 'dark-content';
return (
<SafeAreaView style={dynamicStyles.container}>
<StatusBar barStyle={statusBarStyle} backgroundColor={theme.background} />
<ScrollView
contentContainerStyle={dynamicStyles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={dynamicStyles.logoMark}>
<View style={dynamicStyles.logoBox}>
<Text style={dynamicStyles.logoIcon}></Text>
</View>
<Text style={dynamicStyles.logoWord}>Plania</Text>
</View>
<View style={dynamicStyles.heroGrid}>
{cardsData.map(renderMiniCard)}
</View>
<View style={dynamicStyles.heroCta}>
<View>
<Text style={dynamicStyles.tagline}>
کار را مدیریت کن،{"\n"}
<Text style={dynamicStyles.taglineAccent}>زیبا.</Text>
</Text>
<Text style={dynamicStyles.subText}>
با تیم خود در زمان واقعی برنامهریزی،{"\n"}پیگیری و همکاری کن.
</Text>
</View>
<TouchableOpacity
style={dynamicStyles.primaryButton}
activeOpacity={0.8}
onPress={() => router.push("/(auth)/SignUp")}
>
<Text style={dynamicStyles.primaryButtonText}>شروع رایگان</Text>
</TouchableOpacity>
<TouchableOpacity
style={dynamicStyles.outlineButton}
activeOpacity={0.7}
onPress={() => router.push("/(auth)/login")}
>
<Text style={dynamicStyles.outlineButtonText}>حساب کاربری دارم</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default WelcomeScreen;

168
src/app/(auth)/login.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { useTheme } from "@/hooks/use-theme";
import apiClient from "@/services/api";
import { authStyles } from "@/styles/auth.styles";
import { Feather } from "@expo/vector-icons";
import { router } from "expo-router";
import React, { useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const LoginScreen = () => {
const [phoneNumber, setPhoneNumber] = useState("");
const [focusedField, setFocusedField] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const theme = useTheme();
const scrollViewRef = useRef<ScrollView>(null);
const handleBack = () => {
router.back();
};
const handleSendOTP = async () => {
if (!phoneNumber) {
Alert.alert("خطا", "لطفا شماره موبایل خود را وارد کنید");
return;
}
setIsLoading(true);
try {
await apiClient.post("/api/auth/login/", { phone_number: phoneNumber });
Alert.alert("کد تایید ارسال شد", "لطفا کد ارسال شده را وارد کنید");
router.push({
pathname: "/OTPLogin",
params: { phoneNumber: phoneNumber },
});
} catch (error: any) {
console.log(error);
Alert.alert(
"خطا",
error.response?.data?.message || "مشکلی در ارسال کد پیش آمد",
);
} finally {
setIsLoading(false);
}
};
const handleSignUp = () => {
router.push("/(auth)/SignUp");
};
const handleFocus = () => {
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 120, animated: true });
}, 100);
};
const styles = useMemo(
() => authStyles(theme, focusedField),
[theme, focusedField],
);
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* دکمه بازگشت */}
<View style={{ marginBottom: 12 }}>
<TouchableOpacity
onPress={handleBack}
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.border,
alignItems: "center",
justifyContent: "center",
}}
>
<Feather
name="arrow-left"
size={16}
color={theme.textSecondary}
/>
</TouchableOpacity>
</View>
<View style={styles.phoneCard}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Feather name="log-in" size={18} color={theme.accent} />
</View>
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>ورود به حساب</Text>
</View>
</View>
<Text style={styles.fieldLabel}>شماره موبایل</Text>
<View style={styles.inputContainer}>
<Feather
name="phone"
size={14}
color={
focusedField === "phone" ? theme.accent : theme.textSecondary
}
/>
<TextInput
style={styles.textInput}
placeholder="09123456789"
placeholderTextColor={theme.inputPlaceholder}
value={phoneNumber}
onChangeText={setPhoneNumber}
onFocus={() => {
setFocusedField("phone");
handleFocus();
}}
onBlur={() => setFocusedField(null)}
keyboardType="phone-pad"
autoCapitalize="none"
textAlign="right"
/>
</View>
<TouchableOpacity
style={styles.continueButton}
onPress={handleSendOTP}
disabled={isLoading}
>
<Text style={styles.continueText}>
{isLoading ? "در حال ارسال..." : "ورود"}
</Text>
<Feather name="arrow-left" size={14} color="#fff" />
</TouchableOpacity>
<View style={styles.switchContainer}>
<Text style={styles.switchText}>حساب کاربری ندارید؟ </Text>
<TouchableOpacity onPress={handleSignUp}>
<Text style={styles.switchLink}>ایجاد حساب رایگان</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default LoginScreen;

View File

@@ -0,0 +1,5 @@
import AppTabs from "@/components/app-tabs";
export default function TabsLayout() {
return <AppTabs />;
}

View File

@@ -1,18 +1,31 @@
import { Image } from 'expo-image'; import { Image } from "expo-image";
import { SymbolView } from 'expo-symbols'; import { SymbolView } from "expo-symbols";
import React from 'react'; import React, { useLayoutEffect } from "react";
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ExternalLink } from '@/components/external-link'; import { ExternalLink } from "@/components/external-link";
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from "@/components/themed-view";
import { Collapsible } from '@/components/ui/collapsible'; import { Collapsible } from "@/components/ui/collapsible";
import { WebBadge } from '@/components/web-badge'; import { WebBadge } from "@/components/web-badge";
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme";
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from "@/hooks/use-theme";
import { useNavigation } from "expo-router";
export default function TabTwoScreen() { export default function TabTwoScreen() {
const navigation = useNavigation();
useLayoutEffect(() => {
navigation.setOptions({
title: "explore",
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Image
source={require('@/assets/images/tabIcons/explore.png')}
style={{ width: size, height: size, tintColor: color }}
/>
),
});
}, [navigation]);
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
const insets = { const insets = {
...safeAreaInsets, ...safeAreaInsets,
@@ -37,12 +50,13 @@ export default function TabTwoScreen() {
<ScrollView <ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]} style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets} contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}> contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}
>
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}> <ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText> <ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary"> <ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started. This starter app includes example{"\n"}code to help you get started.
</ThemedText> </ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild> <ExternalLink href="https://docs.expo.dev" asChild>
@@ -51,7 +65,11 @@ export default function TabTwoScreen() {
<ThemedText type="link">Expo documentation</ThemedText> <ThemedText type="link">Expo documentation</ThemedText>
<SymbolView <SymbolView
tintColor={theme.text} tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }} name={{
ios: "arrow.up.right.square",
android: "link",
web: "link",
}}
size={12} size={12}
/> />
</ThemedView> </ThemedView>
@@ -62,11 +80,13 @@ export default function TabTwoScreen() {
<ThemedView style={styles.sectionsWrapper}> <ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing"> <Collapsible title="File-based routing">
<ThemedText type="small"> <ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '} This app has two screens:{" "}
<ThemedText type="code">src/app/index.tsx</ThemedText> and{" "}
<ThemedText type="code">src/app/explore.tsx</ThemedText> <ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText> </ThemedText>
<ThemedText type="small"> <ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up The layout file in{" "}
<ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator. the tab navigator.
</ThemedText> </ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction"> <ExternalLink href="https://docs.expo.dev/router/introduction">
@@ -75,14 +95,18 @@ export default function TabTwoScreen() {
</Collapsible> </Collapsible>
<Collapsible title="Android, iOS, and web support"> <Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}> <ThemedView
type="backgroundElement"
style={styles.collapsibleContent}
>
<ThemedText type="small"> <ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version, You can open this project on Android, iOS, and the web. To open
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this the web version, press{" "}
project. <ThemedText type="smallBold">w</ThemedText> in the terminal
running this project.
</ThemedText> </ThemedText>
<Image <Image
source={require('@/assets/images/tutorial-web.png')} source={require("@/assets/images/tutorial-web.png")}
style={styles.imageTutorial} style={styles.imageTutorial}
/> />
</ThemedView> </ThemedView>
@@ -90,11 +114,15 @@ export default function TabTwoScreen() {
<Collapsible title="Images"> <Collapsible title="Images">
<ThemedText type="small"> <ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '} For static images, you can use the{" "}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different <ThemedText type="code">@2x</ThemedText> and{" "}
screen densities. <ThemedText type="code">@3x</ThemedText> suffixes to provide files
for different screen densities.
</ThemedText> </ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} /> <Image
source={require("@/assets/images/react-logo.png")}
style={styles.imageReact}
/>
<ExternalLink href="https://reactnative.dev/docs/images"> <ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText> <ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink> </ExternalLink>
@@ -102,9 +130,10 @@ export default function TabTwoScreen() {
<Collapsible title="Light and dark mode components"> <Collapsible title="Light and dark mode components">
<ThemedText type="small"> <ThemedText type="small">
This template has light and dark mode support. The{' '} This template has light and dark mode support. The{" "}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the <ThemedText type="code">useColorScheme()</ThemedText> hook lets
user&apos;s current color scheme is, and so you can adjust UI colors accordingly. you inspect what the user&apos;s current color scheme is, and so
you can adjust UI colors accordingly.
</ThemedText> </ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> <ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText> <ThemedText type="linkPrimary">Learn more</ThemedText>
@@ -113,14 +142,17 @@ export default function TabTwoScreen() {
<Collapsible title="Animations"> <Collapsible title="Animations">
<ThemedText type="small"> <ThemedText type="small">
This template includes an example of an animated component. The{' '} This template includes an example of an animated component. The{" "}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses <ThemedText type="code">
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to src/components/ui/collapsible.tsx
animate opening this hint. </ThemedText>{" "}
component uses the powerful{" "}
<ThemedText type="code">react-native-reanimated</ThemedText>{" "}
library to animate opening this hint.
</ThemedText> </ThemedText>
</Collapsible> </Collapsible>
</ThemedView> </ThemedView>
{Platform.OS === 'web' && <WebBadge />} {Platform.OS === "web" && <WebBadge />}
</ThemedView> </ThemedView>
</ScrollView> </ScrollView>
); );
@@ -131,8 +163,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
contentContainer: { contentContainer: {
flexDirection: 'row', flexDirection: "row",
justifyContent: 'center', justifyContent: "center",
}, },
container: { container: {
maxWidth: MaxContentWidth, maxWidth: MaxContentWidth,
@@ -140,24 +172,24 @@ const styles = StyleSheet.create({
}, },
titleContainer: { titleContainer: {
gap: Spacing.three, gap: Spacing.three,
alignItems: 'center', alignItems: "center",
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six, paddingVertical: Spacing.six,
}, },
centerText: { centerText: {
textAlign: 'center', textAlign: "center",
}, },
pressed: { pressed: {
opacity: 0.7, opacity: 0.7,
}, },
linkButton: { linkButton: {
flexDirection: 'row', flexDirection: "row",
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two, paddingVertical: Spacing.two,
borderRadius: Spacing.five, borderRadius: Spacing.five,
justifyContent: 'center', justifyContent: "center",
gap: Spacing.one, gap: Spacing.one,
alignItems: 'center', alignItems: "center",
}, },
sectionsWrapper: { sectionsWrapper: {
gap: Spacing.five, gap: Spacing.five,
@@ -165,10 +197,10 @@ const styles = StyleSheet.create({
paddingTop: Spacing.three, paddingTop: Spacing.three,
}, },
collapsibleContent: { collapsibleContent: {
alignItems: 'center', alignItems: "center",
}, },
imageTutorial: { imageTutorial: {
width: '100%', width: "100%",
aspectRatio: 296 / 171, aspectRatio: 296 / 171,
borderRadius: Spacing.three, borderRadius: Spacing.three,
marginTop: Spacing.two, marginTop: Spacing.two,
@@ -176,6 +208,6 @@ const styles = StyleSheet.create({
imageReact: { imageReact: {
width: 100, width: 100,
height: 100, height: 100,
alignSelf: 'center', alignSelf: "center",
}, },
}); });

143
src/app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,143 @@
import * as Device from "expo-device";
import { Button, Platform, StyleSheet, ToastAndroid } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { AnimatedIcon } from "@/components/animated-icon";
import { HintRow } from "@/components/hint-row";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { WebBadge } from "@/components/web-badge";
import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme";
import { useEffect, useLayoutEffect, useState } from "react";
import apiClient from "@/services/api";
import { useAuth } from "@/constants/AuthContext";
import { Image } from "react-native";
import { useNavigation } from "expo-router";
interface Profile {
id: number;
name: string;
bio: string;
phone_number: string;
username: string;
email: string;
profile_picture: string;
color: string;
}
function getDevMenuHint() {
if (Platform.OS === "web") {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === "android" ? "cmd+m (or ctrl+m)" : "cmd+d";
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
);
}
export default function HomeScreen() {
const navigation = useNavigation();
useLayoutEffect(() => {
navigation.setOptions({
title: "خانه",
tabBarIcon: ({ color, size }: { color: string; size: number}) => (
<Image
source={require("@/assets/images/tabIcons/home.png")}
style={{ width: size, height: size, tintColor: color }}
/>
),
});
}, [navigation]);
const { signOut } = useAuth();
const [profile, setProfile] = useState<Profile>();
useEffect(() => {
apiClient
.get("api/auth/profile/")
.then((res) => {
if (res.data) {
setProfile(res.data);
}
})
.catch((err) => {});
}, []);
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon image={profile?.profile_picture || ""} />
<ThemedText type="title" style={styles.title}>
{profile?.name}
</ThemedText>
<ThemedText style={styles.title}>
phone : {profile?.phone_number}
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === "web" && <WebBadge />}
<Button title="خروج" onPress={signOut} />
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
flexDirection: "row",
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: "center",
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: "center",
justifyContent: "center",
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: "center",
},
code: {
textTransform: "uppercase",
},
stepContainer: {
gap: Spacing.three,
alignSelf: "stretch",
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
});

172
src/app/LoadingScreen.tsx Normal file
View File

@@ -0,0 +1,172 @@
// LoadingScreen.tsx
import React, { useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
Animated,
Easing,
Dimensions,
} from 'react-native';
const { width, height } = Dimensions.get('window');
interface LoadingScreenProps {
message?: string;
spinnerColor?: string;
backgroundColor?: string;
textColor?: string;
fullScreen?: boolean;
}
interface AnimatedDotProps {
delay: number;
color: string;
}
const LoadingScreen: React.FC<LoadingScreenProps> = ({
message = 'در حال بارگذاری ...',
spinnerColor = '#6366F1',
backgroundColor = '#FFFFFF',
textColor = '#4B5563',
fullScreen = true,
}) => {
const fadeAnim = useRef(new Animated.Value(0)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}),
Animated.spring(scaleAnim, {
toValue: 1,
friction: 8,
tension: 40,
useNativeDriver: true,
}),
]).start();
}, []);
return (
<View style={[styles.container, fullScreen && styles.fullScreen, { backgroundColor }]}>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}>
<View style={styles.spinnerWrapper}>
<ActivityIndicator size="large" color={spinnerColor} />
</View>
{message ? <Text style={[styles.message, { color: textColor }]}>{message}</Text> : null}
<View style={styles.dotContainer}>
<AnimatedDot delay={0} color={spinnerColor} />
<AnimatedDot delay={200} color={spinnerColor} />
<AnimatedDot delay={400} color={spinnerColor} />
</View>
</Animated.View>
</View>
);
};
const AnimatedDot: React.FC<AnimatedDotProps> = ({ delay, color }) => {
const anim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(anim, {
toValue: 1,
duration: 400,
delay,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: 0,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
])
).start();
}, [delay]);
const translateY = anim.interpolate({
inputRange: [0, 1],
outputRange: [0, -8],
});
return (
<Animated.View
style={[
styles.dot,
{
backgroundColor: color,
transform: [{ translateY }],
},
]}
/>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
fullScreen: {
width,
height,
position: 'absolute',
top: 0,
left: 0,
zIndex: 999,
},
content: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
},
spinnerWrapper: {
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
borderRadius: 40,
backgroundColor: 'transparent',
},
message: {
fontSize: 16,
fontWeight: '500',
letterSpacing: 0.5,
marginBottom: 16,
textAlign: 'center',
},
dotContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 5,
},
});
export default LoadingScreen;

View File

@@ -1,16 +1,81 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; // app/_layout.tsx
import React from 'react'; import { Redirect, Stack, useRouter, useSegments } from "expo-router";
import { useColorScheme } from 'react-native'; import { AuthProvider, useAuth } from "@/constants/AuthContext";
import { AnimatedSplashOverlay } from "@/components/animated-icon";
import { Appearance, Button, useColorScheme } from "react-native";
import { useTheme } from "@/hooks/use-theme";
import { useEffect, useState } from "react";
import * as SecureStore from "expo-secure-store";
import LoadingScreen from "./LoadingScreen";
import { AnimatedSplashOverlay } from '@/components/animated-icon'; function RootLayoutNav() {
import AppTabs from '@/components/app-tabs'; const { isLogin, isLoading } = useAuth();
const segments = useSegments();
const router = useRouter();
const inAuthGroup = segments[0] === "(auth)";
useEffect(() => {
if (isLoading) return;
if (!isLogin && !inAuthGroup) {
router.replace("/(auth)/homeLogin");
} else if (isLogin && inAuthGroup) {
router.replace("/(tabs)");
}
}, [isLogin, isLoading, inAuthGroup]);
if (isLoading) {
return <LoadingScreen message="در حال آماده‌سازی..." />;
}
const initialRoute = isLogin ? "(tabs)" : "(auth)";
export default function TabLayout() {
const colorScheme = useColorScheme();
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack
<AnimatedSplashOverlay /> initialRouteName={initialRoute}
<AppTabs /> screenOptions={{ headerShown: false }}
</ThemeProvider> />
);
}
export default function RootLayout() {
const systemColorScheme = useColorScheme();
const [userSelectedTheme, setUserSelectedTheme] = useState<
"light" | "dark" | null
>(null);
useEffect(() => {
const loadTheme = async () => {
try {
const saved = await SecureStore.getItemAsync("userTheme");
if (saved === "light" || saved === "dark") {
setUserSelectedTheme(saved);
Appearance.setColorScheme(saved);
} else {
setUserSelectedTheme(null);
Appearance.setColorScheme("unspecified");
}
} catch (error) {
console.warn(error);
}
};
loadTheme();
}, []);
const toggleTheme = async () => {
const currentEffective = userSelectedTheme ?? systemColorScheme ?? "light";
const newTheme = currentEffective === "light" ? "dark" : "light";
setUserSelectedTheme(newTheme);
await SecureStore.setItemAsync("userTheme", newTheme);
Appearance.setColorScheme(newTheme);
};
const activeColorScheme = userSelectedTheme ?? systemColorScheme ?? "light";
return (
<AuthProvider>
<RootLayoutNav />
{/* <Button title="تم" onPress={toggleTheme}/> */}
</AuthProvider>
); );
} }

View File

@@ -1,98 +0,0 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
);
}
export default function HomeScreen() {
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
});

View File

@@ -1,10 +1,10 @@
import { Image } from 'expo-image'; import { Image } from "expo-image";
import { useState } from 'react'; import { useState } from "react";
import { Dimensions, StyleSheet, View } from 'react-native'; import { Dimensions, StyleSheet, View } from "react-native";
import Animated, { Easing, Keyframe } from 'react-native-reanimated'; import Animated, { Easing, Keyframe } from "react-native-reanimated";
import { scheduleOnRN } from 'react-native-worklets'; import { scheduleOnRN } from "react-native-worklets";
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; const INITIAL_SCALE_FACTOR = Dimensions.get("screen").height / 90;
const DURATION = 600; const DURATION = 600;
export function AnimatedSplashOverlay() { export function AnimatedSplashOverlay() {
@@ -31,102 +31,39 @@ export function AnimatedSplashOverlay() {
}, },
}); });
return ( return <></>;
<Animated.View
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.backgroundSolidColor}
/>
);
} }
const keyframe = new Keyframe({ interface AnimatedIconProp {
0: { image: string;
transform: [{ scale: INITIAL_SCALE_FACTOR }], }
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const logoKeyframe = new Keyframe({ export function AnimatedIcon({ image }: AnimatedIconProp) {
0: {
transform: [{ scale: 1.3 }],
opacity: 0,
},
40: {
transform: [{ scale: 1.3 }],
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 1,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '0deg' }],
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return ( return (
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}> <Image style={styles.background} source={image} />
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: { iconContainer: {
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
width: 128, width: 128,
height: 128, height: 128,
zIndex: 100, zIndex: 100,
}, },
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: { background: {
borderRadius: 40, borderRadius: 40,
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`, experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
width: 128, width: 128,
height: 128, height: 128,
position: 'absolute', position: "absolute",
}, },
backgroundSolidColor: { backgroundSolidColor: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF', backgroundColor: "#208AEF",
zIndex: 1000, zIndex: 1000,
}, },
}); });

View File

@@ -0,0 +1,104 @@
// contexts/AuthContext.tsx
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import apiClient from "@/services/api"; // نمونه axios که بعداً می‌سازیم
import { useRouter } from "expo-router";
import axios from "axios";
interface AuthContextType {
isLogin: boolean;
isLoading: boolean;
signIn: (token: string) => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
isLogin: false,
isLoading: true,
signIn: async () => {},
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [isLogin, setIsLogin] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
const loadToken = () => {
axios
.get("https://api.plania.ir/api/auth/token/verify/")
.then((res) => {
setIsLogin(true);
})
.catch((err) => {
if (err.response?.status === 401) {
axios
.get(`https://api.plania.ir/api/auth/token/refresh/`)
.then(() => {
loadToken();
}).catch((err)=>{
if(err.response.status===401){
setIsLogin(false)
}
})
}
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(() => {
loadToken();
}, []);
useEffect(() => {}, [isLogin]);
const signIn = async (token: string) => {
try {
setIsLogin(true);
router.push({
pathname: "/(tabs)",
});
} catch (error) {
console.error("Failed to save token", error);
}
};
const signOut = async () => {
try {
apiClient.post("api/auth/logout/").then(async (res) => {
setIsLogin(false);
router.push({
pathname: "/(auth)/login",
});
});
} catch (error) {
console.error("Failed to remove token", error);
}
};
const value = useMemo(
() => ({
isLogin,
isLoading,
signIn,
signOut,
}),
[isLogin, isLoading, signIn, signOut],
);
return (
<AuthContext.Provider value={{ isLogin, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -1,6 +1,6 @@
/** /**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode. * Below are the colors that are used in the app. The colors are defined for light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. * This theme matches the OTP verification screen design.
*/ */
import '@/global.css'; import '@/global.css';
@@ -9,18 +9,26 @@ import { Platform } from 'react-native';
export const Colors = { export const Colors = {
light: { light: {
text: '#000000', text: '#1A1A1E', // تیره‌تر و خواناتر از مشکی خالص
background: '#ffffff', background: '#F8F9FC', // پس‌زمینهٔ بسیار روشن با مایل به آبی
backgroundElement: '#F0F0F3', backgroundElement: '#FFFFFF', // کارت‌ها و فیلدهای ورودی سفید
backgroundSelected: '#E0E1E6', backgroundSelected: '#E8ECF4', // آیتم انتخاب‌شده با رنگ ملایم
textSecondary: '#60646C', textSecondary: '#6E6E7A', // خاکستری با کنتراست خوب
accent: '#6C63FF', // همان رنگ بنفش برند
border: '#DCDEE3', // حاشیهٔ کمرنگ و شیک
inputBackground: '#FFFFFF', // ورودی‌ها سفید
inputPlaceholder: '#9A9AA6', // پلیسهولدر کمی تیره‌تر برای خوانایی
}, },
dark: { dark: {
text: '#ffffff', text: '#FFFFFF',
background: '#000000', background: '#0D0D14', // Main background
backgroundElement: '#212225', backgroundElement: '#161622', // Input, button backgrounds
backgroundSelected: '#2E3135', backgroundSelected: '#2E3135',
textSecondary: '#B0B4BA', textSecondary: '#888888', // Subtitle, labels
accent: '#6C63FF', // Primary button, focus border
border: '#2A2A3E', // Borders, dividers
inputBackground: '#161622', // Same as backgroundElement
inputPlaceholder: '#5A5A6E',
}, },
} as const; } as const;
@@ -28,16 +36,12 @@ export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({ export const Fonts = Platform.select({
ios: { ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui', sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif', serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded', rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace', mono: 'ui-monospace',
}, },
default: { android: {
sans: 'normal', sans: 'normal',
serif: 'serif', serif: 'serif',
rounded: 'normal', rounded: 'normal',
@@ -49,17 +53,59 @@ export const Fonts = Platform.select({
rounded: 'var(--font-rounded)', rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)', mono: 'var(--font-mono)',
}, },
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
}); });
export const Spacing = { export const Spacing = {
half: 2, half: 2,
one: 4, one: 4,
two: 8, two: 8,
three: 16, three: 12,
four: 24, four: 16,
five: 32, five: 20,
six: 64, six: 24,
seven: 32,
eight: 48,
nine: 64,
} as const;
export const Typography = {
title: {
fontSize: 20,
fontWeight: '500',
lineHeight: 28,
},
subtitle: {
fontSize: 12,
fontWeight: '400',
lineHeight: 16,
},
fieldLabel: {
fontSize: 11,
fontWeight: '500',
letterSpacing: 0.3,
},
body: {
fontSize: 13,
fontWeight: '400',
},
caption: {
fontSize: 11,
fontWeight: '400',
},
} as const;
export const BorderRadius = {
small: 8,
medium: 12,
large: 16,
xlarge: 24,
} as const; } as const;
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0; export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
export const MaxContentWidth = 800; export const MaxContentWidth = 800;

70
src/services/api.ts Normal file
View File

@@ -0,0 +1,70 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { router } from "expo-router";
interface RetryableRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface QueueItem {
resolve: (value?: any) => void;
reject: (reason?: any) => void;
}
const apiClient = axios.create({
baseURL: "https://api.plania.ir",
timeout: 15000,
withCredentials: true,
});
let isRefreshing = false;
let failedQueue: QueueItem[] = [];
const processQueue = (error: AxiosError | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryableRequestConfig;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
if (!isRefreshing) {
isRefreshing = true;
try {
await apiClient.get("/api/auth/token/refresh/");
processQueue(null);
return apiClient(originalRequest);
} catch (refreshError) {
console.log("asdf")
processQueue(refreshError as AxiosError);
if (router && typeof router.push === "function") {
router.push("/(auth)/login");
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => apiClient(originalRequest))
.catch((err) => Promise.reject(err));
}
}
);
export default apiClient;

276
src/styles/auth.styles.ts Normal file
View File

@@ -0,0 +1,276 @@
import { BorderRadius, Spacing, Typography } from "@/constants/theme";
import { Theme } from "@/types/theme";
import { DimensionValue, StyleSheet } from "react-native";
export const createWelcomeStyles = (theme: Theme, isDark: boolean) => {
const percent = (value: number): DimensionValue =>
`${value}%` as DimensionValue;
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.background,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.five,
paddingBottom: Spacing.seven,
},
logoMark: {
flexDirection: "row",
alignItems: "center",
marginBottom: Spacing.seven,
},
logoBox: {
width: 34,
height: 34,
backgroundColor: theme.accent,
borderRadius: BorderRadius.small,
justifyContent: "center",
alignItems: "center",
marginHorizontal: Spacing.two,
},
logoIcon: {
fontSize: 18,
color: "#FFFFFF",
fontWeight: "bold",
},
logoWord: {
fontSize: 15,
fontWeight: "500",
color: theme.text,
letterSpacing: 0.3,
},
heroGrid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
marginBottom: Spacing.five,
},
miniCard: {
width: "48%",
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.border,
borderRadius: BorderRadius.medium,
padding: Spacing.two,
marginBottom: Spacing.three,
},
miniCardAccent: {
backgroundColor: isDark ? "#1a1635" : "#F0EFFF",
borderColor: isDark ? "#534AB7" : theme.accent + "40",
},
mcDot: {
width: 7,
height: 7,
borderRadius: 3.5,
marginBottom: Spacing.two,
},
mcLines: {
marginBottom: Spacing.one,
},
mcLine: {
height: 3.5,
borderRadius: 2,
backgroundColor: isDark ? "#2e2e46" : "#E0E0E6",
marginVertical: Spacing.half,
},
mcTag: {
alignSelf: "flex-start",
borderRadius: BorderRadius.small / 2,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.one,
marginTop: Spacing.two,
},
mcTagText: {
fontSize: 8,
fontWeight: "500",
},
heroCta: {
marginTop: Spacing.one,
},
tagline: {
fontSize: 18,
fontWeight: "500",
color: theme.text,
lineHeight: 24,
marginBottom: Spacing.two,
textAlign: "right",
},
taglineAccent: {
color: isDark ? "#AFA9EC" : theme.accent,
},
subText: {
fontSize: 10,
color: theme.textSecondary,
lineHeight: 15,
marginBottom: Spacing.four,
textAlign: "right",
},
outlineButton: {
backgroundColor: "transparent",
borderWidth: 0.5,
borderColor: theme.border,
borderRadius: BorderRadius.large,
paddingVertical: Spacing.three,
alignItems: "center",
},
outlineButtonText: {
color: theme.textSecondary,
fontSize: Typography.body.fontSize,
fontWeight: "500",
},
primaryButton: {
backgroundColor: theme.accent,
borderRadius: BorderRadius.large,
paddingVertical: Spacing.three,
alignItems: "center",
marginBottom: Spacing.two,
},
primaryButtonText: {
color: "#FFFFFF",
fontSize: Typography.body.fontSize,
fontWeight: "500",
},
});
};
export const authStyles = (theme: Theme, focusedField: string | null = null) =>
StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: theme.background,
},
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
paddingVertical: Spacing.six,
paddingHorizontal: Spacing.four,
},
phoneCard: {
backgroundColor: theme.background,
borderRadius: 32,
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.five,
borderWidth: 0.5,
borderColor: theme.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: Spacing.three,
marginBottom: Spacing.five,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: BorderRadius.large,
backgroundColor: theme.backgroundElement,
borderWidth: 0.5,
borderColor: theme.accent + "80",
alignItems: "center",
justifyContent: "center",
},
headerTextContainer: {
flex: 1,
},
headerTitle: {
fontSize: Typography.title.fontSize,
fontWeight: Typography.title.fontWeight,
color: theme.text,
textAlign: "right",
},
fieldLabel: {
fontSize: Typography.fieldLabel.fontSize,
fontWeight: Typography.fieldLabel.fontWeight as any,
color: theme.textSecondary,
letterSpacing: Typography.fieldLabel.letterSpacing,
marginBottom: Spacing.two,
marginTop: Spacing.one,
textAlign: "right",
},
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.inputBackground,
borderWidth: 0.5,
borderColor: focusedField ? theme.accent : theme.border,
borderRadius: BorderRadius.medium,
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.three,
marginBottom: Spacing.three,
gap: Spacing.two,
},
textInput: {
flex: 1,
fontSize: 12,
color: theme.text,
padding: 0,
margin: 0,
textAlign: "right",
},
continueButton: {
backgroundColor: theme.accent,
borderRadius: BorderRadius.large,
paddingVertical: Spacing.three,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: Spacing.two,
marginTop: Spacing.two,
marginBottom: Spacing.four,
},
continueText: {
fontSize: Typography.body.fontSize,
fontWeight: "500",
color: "#fff",
},
switchContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: Spacing.two,
},
switchText: {
fontSize: 11,
color: theme.textSecondary,
},
switchLink: {
fontSize: 11,
fontWeight: "500",
color: theme.accent,
},
subtitle: {
fontSize: Typography.subtitle.fontSize,
fontWeight: Typography.subtitle.fontWeight,
color: theme.textSecondary,
lineHeight: Typography.subtitle.lineHeight,
marginBottom: Spacing.four,
textAlign: "right",
},
secondaryButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: Spacing.two,
backgroundColor: "transparent",
borderWidth: 0.5,
borderColor: theme.border,
borderRadius: BorderRadius.large,
paddingVertical: Spacing.three,
marginTop: Spacing.two,
},
secondaryButtonDisabled: {
opacity: 0.6,
},
secondaryButtonText: {
fontSize: Typography.body.fontSize,
fontWeight: "500",
color: theme.accent,
},
secondaryButtonTextDisabled: {
color: theme.textSecondary,
},
});

11
src/types/theme.ts Normal file
View File

@@ -0,0 +1,11 @@
export type Theme = {
background: string;
backgroundElement: string;
backgroundSelected: string;
text: string;
textSecondary: string;
accent: string;
border: string;
inputBackground: string;
inputPlaceholder: string;
};