├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── 01-bug_report.yml │ ├── 02-feature_request.yml │ ├── 03-other_issue.yml │ └── config.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .watchmanconfig ├── LICENSE ├── README.md ├── android ├── app │ ├── build.gradle │ ├── debug.keystore │ ├── google-services.json │ ├── proguard-rules.pro │ └── src │ │ ├── debug │ │ ├── AndroidManifest.xml │ │ └── res │ │ │ └── xml │ │ │ └── network_security_config.xml │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── ic_launcher-web.png │ │ ├── java │ │ └── io │ │ │ └── robertying │ │ │ └── learnx │ │ │ ├── MainActivity.kt │ │ │ └── MainApplication.kt │ │ └── res │ │ ├── drawable-hdpi │ │ └── app_icon.png │ │ ├── drawable-mdpi │ │ └── app_icon.png │ │ ├── drawable-night-hdpi │ │ └── app_icon.png │ │ ├── drawable-night-mdpi │ │ └── app_icon.png │ │ ├── drawable-night-xhdpi │ │ └── app_icon.png │ │ ├── drawable-night-xxhdpi │ │ └── app_icon.png │ │ ├── drawable-night-xxxhdpi │ │ └── app_icon.png │ │ ├── drawable-night │ │ └── splash_screen_icon.png │ │ ├── drawable-xhdpi │ │ └── app_icon.png │ │ ├── drawable-xxhdpi │ │ └── app_icon.png │ │ ├── drawable-xxxhdpi │ │ └── app_icon.png │ │ ├── drawable │ │ └── splash_screen_icon.png │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── colors.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ ├── data_extraction_rules.xml │ │ └── network_security_config.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle ├── app.json ├── babel.config.js ├── docs ├── PRIVACY_POLICY_CN.md ├── PRIVACY_POLICY_EN.md ├── assets │ ├── Download_on_the_App_Store_Badge_CNSC_RGB_blk_092917.svg │ ├── Download_on_the_Mac_App_Store_Badge_CNSC_RGB_blk_092917.svg │ ├── google-play-badge.png │ └── logo.png └── screenshots │ ├── iphone.png │ └── mac.png ├── index.js ├── ios ├── .xcode.env ├── GoogleService-Info.plist ├── Podfile ├── Podfile.lock ├── ShareExtension │ ├── Base.lproj │ │ └── MainInterface.storyboard │ ├── Info.plist │ └── ShareExtension.entitlements ├── learnX.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── learnX.xcscheme ├── learnX.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── learnX │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-MacOS-128x128@1x.png │ │ ├── Icon-MacOS-128x128@2x.png │ │ ├── Icon-MacOS-16x16@1x.png │ │ ├── Icon-MacOS-16x16@2x.png │ │ ├── Icon-MacOS-256x256@1x.png │ │ ├── Icon-MacOS-256x256@2x.png │ │ ├── Icon-MacOS-32x32@1x.png │ │ ├── Icon-MacOS-32x32@2x.png │ │ ├── Icon-MacOS-512x512@1x.png │ │ ├── Icon-MacOS-512x512@2x.png │ │ ├── Icon_1024x1024.png │ │ ├── Icon_Dark_1024x1024.png │ │ └── Icon_Grayscale_1024x1024.png │ ├── Contents.json │ ├── MaskedAppIcon.imageset │ │ ├── Black.png │ │ ├── Black@2x.png │ │ ├── Black@3x.png │ │ ├── Contents.json │ │ ├── MaskedAppIcon.png │ │ ├── MaskedAppIcon@2x.png │ │ └── MaskedAppIcon@3x.png │ └── ThemeColor.colorset │ │ └── Contents.json │ ├── Info.plist │ ├── LaunchScreen.storyboard │ ├── PrivacyInfo.xcprivacy │ ├── en.lproj │ └── InfoPlist.strings │ ├── learnX.entitlements │ └── zh-Hans.lproj │ └── InfoPlist.strings ├── metro.config.js ├── package.json ├── patches ├── @types+react-native-share-menu+5.0.5.patch ├── expo-calendar+14.1.4.patch ├── expo-document-picker+13.1.5.patch ├── expo-file-system+18.1.8.patch ├── react-native+0.79.2.patch ├── react-native-fs+2.20.0.patch ├── react-native-pager-view+6.7.1.patch ├── react-native-paper+5.14.0.patch ├── react-native-reorderable-list+0.14.0.patch ├── react-native-screens+4.11.0-beta.2.patch ├── react-native-share-menu+6.0.0.patch ├── react-native-vector-icons+10.2.0.patch └── redux-persist+6.0.0.patch ├── pnpm-lock.yaml ├── polyfills.js ├── scripts ├── rename_apks_for_release.sh └── test_android_bundle.sh ├── src ├── App.tsx ├── assets │ └── translations │ │ ├── en.ts │ │ └── zh.ts ├── components │ ├── AssignmentCard.tsx │ ├── AutoHeightWebView.tsx │ ├── CardWrapper.tsx │ ├── CourseCard.tsx │ ├── Empty.tsx │ ├── FileCard.tsx │ ├── Filter.tsx │ ├── FilterList.tsx │ ├── HeaderTitle.tsx │ ├── NoticeCard.tsx │ ├── SafeArea.tsx │ ├── Skeleton.tsx │ ├── Splash.tsx │ ├── SplitView.tsx │ ├── TableCell.tsx │ ├── TextButton.tsx │ ├── Toast.tsx │ └── Touchable.tsx ├── constants │ ├── Colors.ts │ ├── DeviceInfo.ts │ ├── Numbers.ts │ ├── Styles.ts │ └── Urls.ts ├── data │ ├── actions │ │ ├── assignments.ts │ │ ├── auth.ts │ │ ├── courses.ts │ │ ├── files.ts │ │ ├── notices.ts │ │ ├── root.ts │ │ ├── semesters.ts │ │ ├── settings.ts │ │ └── user.ts │ ├── mock.ts │ ├── reducers │ │ ├── assignments.ts │ │ ├── auth.ts │ │ ├── courses.ts │ │ ├── files.ts │ │ ├── notices.ts │ │ ├── root.ts │ │ ├── semesters.ts │ │ ├── settings.ts │ │ └── user.ts │ ├── source.ts │ ├── store.ts │ └── types │ │ ├── actions.ts │ │ ├── constants.ts │ │ └── state.ts ├── helpers │ ├── background.ts │ ├── coursex.ts │ ├── env.ts │ ├── event.ts │ ├── fs.ts │ ├── html.ts │ ├── i18n.ts │ ├── parse.ts │ ├── preval │ │ ├── darkreader.preval.js │ │ ├── katex.preval.js │ │ ├── katexMathtexScript.preval.js │ │ ├── katexStyles.preval.js │ │ └── readFile.js │ ├── reorder.ts │ ├── retry.ts │ └── update.ts ├── hooks │ ├── useDetailNavigator.ts │ ├── useFilteredData.ts │ ├── useNavigationAnimation.ts │ ├── useSearch.ts │ └── useToast.ts └── screens │ ├── About.tsx │ ├── AssignmentDetail.tsx │ ├── AssignmentSubmission.tsx │ ├── Assignments.tsx │ ├── CalendarEvent.tsx │ ├── Changelog.tsx │ ├── CourseDetail.tsx │ ├── CourseInformationSharing.tsx │ ├── CourseX.tsx │ ├── Courses.tsx │ ├── FileDetail.tsx │ ├── FileSettings.tsx │ ├── Files.tsx │ ├── Help.tsx │ ├── Login.tsx │ ├── Maintainer.tsx │ ├── NoticeDetail.tsx │ ├── Notices.tsx │ ├── Search.tsx │ ├── SemesterSelection.tsx │ ├── Settings.tsx │ └── types.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native', 4 | overrides: [ 5 | { 6 | files: ['*.ts', '*.tsx'], 7 | rules: { 8 | 'no-spaced-func': 'off', 9 | 'no-shadow': 'off', 10 | 'no-undef': 'off', 11 | '@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '_'}], 12 | '@typescript-eslint/no-shadow': 'off', 13 | 'react/jsx-uses-react': 'off', 14 | 'react/react-in-jsx-scope': 'off', 15 | 'react/no-unstable-nested-components': ['warn', {allowAsProps: true}], 16 | 'react-native/no-inline-styles': 'off', 17 | }, 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: 问题反馈与帮助 3 | title: "[Bug] 在此填写简短的问题描述作为标题……" 4 | 5 | body: 6 | - type: textarea 7 | id: problem-description 8 | attributes: 9 | label: 问题描述 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: reproduction-steps 15 | attributes: 16 | label: 复现过程 17 | value: | 18 | 1. 19 | 2. 20 | 3. 21 | ... 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | id: attempted-solutions 27 | attributes: 28 | label: 已尝试的解决方案 29 | value: | 30 | 1. 31 | 2. 32 | 3. 33 | ... 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: screenshots 39 | attributes: 40 | label: 截图 41 | 42 | - type: input 43 | id: app-version 44 | attributes: 45 | label: App 版本 46 | description: "设置 - 关于" 47 | placeholder: "vX.Y.Z" 48 | validations: 49 | required: true 50 | 51 | - type: input 52 | id: device-model 53 | attributes: 54 | label: 设备型号 55 | placeholder: "例如:iPhone 15 Pro,小米手机 12" 56 | validations: 57 | required: true 58 | 59 | - type: input 60 | id: system-version 61 | attributes: 62 | label: 系统版本 63 | placeholder: "例如:iOS 17.4,macOS 14.4,Android 12" 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: additional-information 69 | attributes: 70 | label: 补充内容 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: 功能建议与改进 3 | title: "[Feature Request] 在此填写简短的功能描述作为标题……" 4 | 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | attributes: 9 | label: 功能描述 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: screenshots 15 | attributes: 16 | label: 截图 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03-other_issue.yml: -------------------------------------------------------------------------------- 1 | name: Other Issue 2 | description: 其他问题 3 | title: "在此填写简短的问题描述作为标题……" 4 | 5 | body: 6 | - type: textarea 7 | id: issue-description 8 | attributes: 9 | label: 问题描述 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | **/.xcode.env.local 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | *.hprof 33 | .cxx/ 34 | *.keystore 35 | !debug.keystore 36 | .kotlin/ 37 | 38 | # node.js 39 | # 40 | node_modules/ 41 | npm-debug.log 42 | yarn-error.log 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://docs.fastlane.tools/best-practices/source-control/ 50 | 51 | **/fastlane/report.xml 52 | **/fastlane/Preview.html 53 | **/fastlane/screenshots 54 | **/fastlane/test_output 55 | 56 | # Bundle artifact 57 | *.jsbundle 58 | 59 | # Ruby / CocoaPods 60 | **/Pods/ 61 | /vendor/bundle/ 62 | 63 | # Temporary files created by Metro to check the health of the file watcher 64 | .metro-health-check* 65 | 66 | # testing 67 | /coverage 68 | 69 | # Yarn 70 | .yarn/* 71 | !.yarn/patches 72 | !.yarn/plugins 73 | !.yarn/releases 74 | !.yarn/sdks 75 | !.yarn/versions 76 | 77 | # vscode 78 | .vscode/ 79 | 80 | # secrets 81 | .env 82 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: true, 4 | bracketSpacing: false, 5 | singleQuote: true, 6 | trailingComma: 'all', 7 | }; 8 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rui Ying 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | For those who are working or have worked for Computer and Information Managing 13 | Center, Tsinghua University or those whose project is financially supported 14 | by any institute in relation to Tsinghua University: any usage of code, without 15 | explicit authorizations from the author, from this project will be considered 16 | as infringement of copyright. The word "usage" may refer to making copies of, 17 | modifying, redistributing of the source code or any derivative of this project, 18 | for either commercial or non-commercial use. 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "org.jetbrains.kotlin.android" 4 | id "com.facebook.react" 5 | id "com.google.gms.google-services" 6 | id "com.google.firebase.crashlytics" 7 | } 8 | 9 | import com.android.build.OutputFile 10 | 11 | react { 12 | autolinkLibrariesWithApp() 13 | } 14 | 15 | project.ext.vectoricons = [ 16 | iconFontNames: ["MaterialIcons.ttf", "MaterialCommunityIcons.ttf"] 17 | ] 18 | 19 | android { 20 | ndkVersion rootProject.ext.ndkVersion 21 | externalNativeBuild { 22 | cmake { 23 | version rootProject.ext.cmakeVersion 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility rootProject.ext.javaVersion 28 | targetCompatibility rootProject.ext.javaVersion 29 | } 30 | compileSdk rootProject.ext.compileSdkVersion 31 | 32 | namespace "io.robertying.learnx" 33 | 34 | defaultConfig { 35 | applicationId "io.robertying.learnx" 36 | minSdkVersion rootProject.ext.minSdkVersion 37 | targetSdkVersion rootProject.ext.targetSdkVersion 38 | compileSdk rootProject.ext.compileSdkVersion 39 | versionCode 9203310 40 | versionName "15.3.1" 41 | } 42 | 43 | splits { 44 | abi { 45 | reset() 46 | enable true 47 | universalApk true 48 | include "armeabi-v7a", "x86", "x86_64", "arm64-v8a" 49 | } 50 | } 51 | 52 | signingConfigs { 53 | debug { 54 | storeFile file("debug.keystore") 55 | storePassword "android" 56 | keyAlias "androiddebugkey" 57 | keyPassword "android" 58 | } 59 | 60 | Properties properties = new Properties() 61 | properties.load(project.rootProject.file("local.properties").newDataInputStream()) 62 | 63 | release { 64 | storeFile file("release.keystore") 65 | storePassword properties.getProperty("LEARNX_RELEASE_STORE_PASSWORD") 66 | keyAlias properties.getProperty("LEARNX_RELEASE_KEY_ALIAS") 67 | keyPassword properties.getProperty("LEARNX_RELEASE_KEY_PASSWORD") 68 | } 69 | } 70 | 71 | buildTypes { 72 | debug { 73 | signingConfig signingConfigs.debug 74 | } 75 | release { 76 | signingConfig signingConfigs.release 77 | minifyEnabled true 78 | proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" 79 | firebaseCrashlytics { 80 | nativeSymbolUploadEnabled true 81 | } 82 | } 83 | } 84 | 85 | applicationVariants.all { variant -> 86 | variant.outputs.each { output -> 87 | def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] 88 | def abi = output.getFilter(OutputFile.ABI) 89 | if (abi != null) { 90 | output.versionCodeOverride = 91 | defaultConfig.versionCode * 1000 + versionCodes.get(abi) 92 | } 93 | } 94 | } 95 | } 96 | 97 | dependencies { 98 | implementation("com.facebook.react:react-android") 99 | implementation("com.facebook.react:hermes-android") 100 | 101 | implementation("androidx.core:core-splashscreen:1.0.1") 102 | implementation("com.google.android.material:material:1.12.0") 103 | 104 | implementation platform("com.google.firebase:firebase-bom:33.13.0") 105 | implementation("com.google.firebase:firebase-crashlytics") 106 | implementation("com.google.firebase:firebase-crashlytics-ndk") 107 | implementation("com.google.firebase:firebase-analytics") 108 | } 109 | 110 | apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle") 111 | -------------------------------------------------------------------------------- /android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/debug.keystore -------------------------------------------------------------------------------- /android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "858579386216", 4 | "project_id": "learnx-5e172", 5 | "storage_bucket": "learnx-5e172.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:858579386216:android:16394808c4c699e57f60dd", 11 | "android_client_info": { 12 | "package_name": "io.robertying.learnx" 13 | } 14 | }, 15 | "oauth_client": [], 16 | "api_key": [ 17 | { 18 | "current_key": "AIzaSyAeMhvGJQTUdq2SAp13amoRESkN3E7kfFo" 19 | } 20 | ], 21 | "services": { 22 | "appinvite_service": { 23 | "other_platform_oauth_client": [] 24 | } 25 | } 26 | } 27 | ], 28 | "configuration_version": "1" 29 | } 30 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # react native 2 | 3 | -keep,allowobfuscation @interface com.facebook.proguard.annotations.DoNotStrip 4 | -keep,allowobfuscation @interface com.facebook.proguard.annotations.KeepGettersAndSetters 5 | 6 | -keep @com.facebook.proguard.annotations.DoNotStrip class * 7 | -keepclassmembers class * { 8 | @com.facebook.proguard.annotations.DoNotStrip *; 9 | } 10 | 11 | -keep @com.facebook.proguard.annotations.DoNotStripAny class * { 12 | *; 13 | } 14 | 15 | -keepclassmembers @com.facebook.proguard.annotations.KeepGettersAndSetters class * { 16 | void set*(***); 17 | *** get*(); 18 | } 19 | 20 | -keep class * implements com.facebook.react.bridge.JavaScriptModule { *; } 21 | -keep class * implements com.facebook.react.bridge.NativeModule { *; } 22 | -keepclassmembers,includedescriptorclasses class * { native ; } 23 | -keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactProp ; } 24 | -keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactPropGroup ; } 25 | 26 | -dontwarn com.facebook.react.** 27 | -keep,includedescriptorclasses class com.facebook.react.bridge.** { *; } 28 | -keep,includedescriptorclasses class com.facebook.react.turbomodule.core.** { *; } 29 | 30 | 31 | # hermes 32 | 33 | -keep class com.facebook.jni.** { *; } 34 | 35 | 36 | # okio 37 | 38 | -keep class sun.misc.Unsafe { *; } 39 | -dontwarn java.nio.file.* 40 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 41 | -dontwarn okio.** 42 | 43 | 44 | # expo 45 | 46 | -keep class expo.modules.** { *; } 47 | -keep class org.unimodules.** { *; } 48 | 49 | -keepclassmembers public class com.facebook.react.ReactActivityDelegate { 50 | protected *; 51 | private ReactDelegate mReactDelegate; 52 | } 53 | -keepclassmembers public class com.facebook.react.ReactActivity { 54 | private final ReactActivityDelegate mDelegate; 55 | } 56 | 57 | -keepclassmembers public class com.facebook.react.ReactNativeHost { 58 | protected *; 59 | } 60 | 61 | -keepclassmembers public class expo.modules.ExpoModulesPackageList { 62 | public *; 63 | } 64 | 65 | -keepnames class * extends expo.modules.core.BasePackage 66 | -keepnames class * implements expo.modules.core.interfaces.Package 67 | 68 | -keep @expo.modules.core.interfaces.DoNotStrip class * 69 | -keepclassmembers class * { 70 | @expo.modules.core.interfaces.DoNotStrip *; 71 | } 72 | 73 | -keep class * implements expo.modules.kotlin.records.Record { 74 | *; 75 | } 76 | -keep enum * implements expo.modules.kotlin.types.Enumerable { 77 | *; 78 | } 79 | -keepnames class kotlin.Pair 80 | 81 | -keep,allowoptimization,allowobfuscation class * extends expo.modules.kotlin.modules.Module { 82 | public (); 83 | public expo.modules.kotlin.modules.ModuleDefinitionData definition(); 84 | } 85 | 86 | -keepclassmembers class * implements expo.modules.kotlin.views.ExpoView { 87 | public (android.content.Context); 88 | public (android.content.Context, expo.modules.kotlin.AppContext); 89 | } 90 | 91 | -keepclassmembers class * { 92 | expo.modules.kotlin.viewevent.ViewEventCallback *; 93 | } 94 | 95 | -keepclassmembers class * { 96 | expo.modules.kotlin.viewevent.ViewEventDelegate *; 97 | } 98 | 99 | 100 | # crash reports 101 | 102 | -keepattributes SourceFile,LineNumberTable 103 | -keep public class * extends java.lang.Exception 104 | 105 | 106 | # reanimated 107 | 108 | -keep class com.swmansion.reanimated.** { *; } 109 | -keep class com.facebook.react.turbomodule.** { *; } 110 | 111 | 112 | # others 113 | 114 | -dontwarn javax.lang.model.element.Element 115 | -dontwarn javax.lang.model.type.TypeMirror 116 | -dontwarn javax.lang.model.type.TypeVisitor 117 | -dontwarn javax.lang.model.util.SimpleTypeVisitor7 118 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/debug/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | localhost 5 | 10.0.2.2 6 | learn.tsinghua.edu.cn 7 | zhjw.cic.tsinghua.edu.cn 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 21 | 22 | 34 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /android/app/src/main/java/io/robertying/learnx/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.robertying.learnx 2 | 3 | import android.os.Bundle 4 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 5 | import com.facebook.react.ReactActivity 6 | import com.facebook.react.ReactActivityDelegate 7 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled 8 | import com.facebook.react.defaults.DefaultReactActivityDelegate 9 | import expo.modules.ReactActivityDelegateWrapper 10 | 11 | class MainActivity : ReactActivity() { 12 | 13 | override fun getMainComponentName(): String = "learnX" 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | installSplashScreen() 17 | super.onCreate(null) 18 | } 19 | 20 | override fun createReactActivityDelegate(): ReactActivityDelegate = 21 | ReactActivityDelegateWrapper( 22 | this, 23 | BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, 24 | DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /android/app/src/main/java/io/robertying/learnx/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package io.robertying.learnx 2 | 3 | import android.app.Application 4 | import android.content.res.Configuration 5 | import com.facebook.react.PackageList 6 | import com.facebook.react.ReactApplication 7 | import com.facebook.react.ReactHost 8 | import com.facebook.react.ReactNativeHost 9 | import com.facebook.react.ReactPackage 10 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load 11 | import com.facebook.react.defaults.DefaultReactNativeHost 12 | import com.facebook.react.soloader.OpenSourceMergedSoMapping 13 | import com.facebook.soloader.SoLoader 14 | import expo.modules.ApplicationLifecycleDispatcher 15 | import expo.modules.ReactNativeHostWrapper 16 | 17 | class MainApplication : Application(), ReactApplication { 18 | 19 | override val reactNativeHost: ReactNativeHost = 20 | ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { 21 | override fun getPackages(): List = 22 | PackageList(this).packages.apply { 23 | } 24 | 25 | override fun getJSMainModuleName(): String = "index" 26 | 27 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG 28 | 29 | override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED 30 | override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED 31 | }) 32 | 33 | override val reactHost: ReactHost 34 | get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) 35 | 36 | override fun onCreate() { 37 | super.onCreate() 38 | SoLoader.init(this, OpenSourceMergedSoMapping) 39 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { 40 | load() 41 | } 42 | ApplicationLifecycleDispatcher.onApplicationCreate(this) 43 | } 44 | 45 | override fun onConfigurationChanged(newConfig: Configuration) { 46 | super.onConfigurationChanged(newConfig) 47 | ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-hdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-mdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-hdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night-hdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-mdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night-mdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night-xhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xxhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night-xxhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xxxhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night-xxxhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/splash_screen_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-night/splash_screen_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-xhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-xxhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable-xxxhdpi/app_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/splash_screen_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/drawable/splash_screen_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #F9ABFF 4 | #570066 5 | #7B008F 6 | #FFD6FE 7 | #F6ADFD 8 | #50155B 9 | #6A2E74 10 | #FFD5FF 11 | #FFB4A7 12 | #5F150C 13 | #7D2B20 14 | #FFDAD4 15 | #FFB4AB 16 | #93000A 17 | #690005 18 | #FFDAD6 19 | #1E1A1D 20 | #E9E0E4 21 | #1E1A1D 22 | #E9E0E4 23 | #4D444C 24 | #D0C3CC 25 | #998D96 26 | #1E1A1D 27 | #E9E0E4 28 | #9A25AE 29 | #000000 30 | #F9ABFF 31 | #4D444C 32 | #000000 33 | 34 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #9C27B0 4 | #9A25AE 5 | #FFFFFF 6 | #FFD6FE 7 | #35003F 8 | #84468E 9 | #FFFFFF 10 | #FFD5FF 11 | #350040 12 | #9C4235 13 | #FFFFFF 14 | #FFDAD4 15 | #400100 16 | #BA1A1A 17 | #FFDAD6 18 | #FFFFFF 19 | #410002 20 | #FFFBFF 21 | #1E1A1D 22 | #FFFBFF 23 | #1E1A1D 24 | #ECDFE8 25 | #4D444C 26 | #7F747D 27 | #F7EEF3 28 | #332F32 29 | #F9ABFF 30 | #000000 31 | #9A25AE 32 | #D0C3CC 33 | #000000 34 | 35 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | learnX 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | learn.tsinghua.edu.cn 5 | zhjw.cic.tsinghua.edu.cn 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | buildToolsVersion = "36.0.0" 4 | minSdkVersion = 28 5 | compileSdkVersion = 36 6 | targetSdkVersion = 36 7 | ndkVersion = "28.1.13356709" 8 | cmakeVersion = "3.31.6" 9 | javaVersion = JavaVersion.VERSION_17 10 | kotlinVersion = "2.1.20" 11 | } 12 | } 13 | 14 | plugins { 15 | id "com.facebook.react.rootproject" apply false 16 | id "expo-root-project" apply false 17 | id "com.google.gms.google-services" version "4.4.2" apply false 18 | id "com.google.firebase.crashlytics" version "3.0.3" apply false 19 | } 20 | 21 | subprojects { subproject -> 22 | afterEvaluate { 23 | if (subproject.hasProperty("android")) { 24 | android { 25 | buildToolsVersion rootProject.ext.buildToolsVersion 26 | compileSdkVersion rootProject.ext.compileSdkVersion 27 | ndkVersion rootProject.ext.ndkVersion 28 | externalNativeBuild { 29 | cmake { 30 | version rootProject.ext.cmakeVersion 31 | } 32 | } 33 | defaultConfig { 34 | minSdkVersion rootProject.ext.minSdkVersion 35 | targetSdkVersion rootProject.ext.targetSdkVersion 36 | } 37 | compileOptions { 38 | sourceCompatibility = rootProject.ext.javaVersion 39 | targetCompatibility = rootProject.ext.javaVersion 40 | } 41 | if (subproject.plugins.hasPlugin("org.jetbrains.kotlin.android")) { 42 | kotlinOptions { 43 | jvmTarget = rootProject.ext.javaVersion.majorVersion 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=4096m 2 | org.gradle.parallel=true 3 | 4 | android.useAndroidX=true 5 | 6 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 7 | 8 | newArchEnabled=false 9 | hermesEnabled=true 10 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | 8 | def reactNativeGradlePlugin = new File( 9 | providers.exec { 10 | workingDir(rootDir) 11 | commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") 12 | }.standardOutput.asText.get().trim() 13 | ).getParentFile().absolutePath 14 | includeBuild(reactNativeGradlePlugin) 15 | 16 | def expoPluginsPath = new File( 17 | providers.exec { 18 | workingDir(rootDir) 19 | commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") 20 | }.standardOutput.asText.get().trim(), 21 | "../android/expo-gradle-plugin" 22 | ).absolutePath 23 | includeBuild(expoPluginsPath) 24 | } 25 | 26 | plugins { 27 | id("com.facebook.react.settings") 28 | id("expo-autolinking-settings") 29 | } 30 | 31 | extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> 32 | if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { 33 | ex.autolinkLibrariesFromCommand() 34 | } else { 35 | ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) 36 | } 37 | } 38 | expoAutolinking.useExpoModules() 39 | 40 | rootProject.name = "learnX" 41 | 42 | expoAutolinking.useExpoVersionCatalog() 43 | 44 | include ":app" 45 | includeBuild(expoAutolinking.reactNativeGradlePlugin) 46 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learnX", 3 | "displayName": "learnX", 4 | "owner": "robertying", 5 | "slug": "learnx" 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | plugins: [ 4 | [ 5 | 'module-resolver', 6 | { 7 | root: ['./src'], 8 | extensions: ['.ts', '.tsx'], 9 | }, 10 | ], 11 | 'preval', 12 | '@babel/plugin-transform-export-namespace-from', 13 | 'react-native-paper/babel', 14 | 'react-native-reanimated/plugin', 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /docs/PRIVACY_POLICY_CN.md: -------------------------------------------------------------------------------- 1 | # learnX 隐私政策 2 | 3 | Rui Ying 将 learnX 应用程序构建为免费应用程序。本服务由 Rui Ying 免费提供,旨在按原样使用。 4 | 5 | 此页面用于告知访客有关该应用收集、使用和披露个人信息的政策。 6 | 7 | 如果您选择使用我的服务,则表示您同意与此政策相关的信息。我收集的个人信息仅用于提供和改进服务。除非本隐私政策中所述,否则我不会向任何人使用或分享您的信息。 8 | 9 | 本隐私政策中使用的术语与我们的条款和条件具有相同的含义,除非本隐私政策另有规定。 10 | 11 | ## 信息收集和使用 12 | 13 | 为了获得更好的体验,在使用我们的服务时,我可能会要求您向我们提供某些个人身份信息,包括但不限于用户凭据。我请求的信息将保留在您的设备上,不会以任何方式收集。 14 | 15 | 该应用程序使用可能收集用于识别您身份信息的第三方服务。 16 | 17 | 可能使用的第三方服务提供商的隐私政策: 18 | 19 | - [Google Play 服务](https://policies.google.com/privacy) 20 | - [Visual Studio App Center](https://learn.microsoft.com/zh-cn/appcenter/gdpr/data-from-your-end-users) 21 | - [Firebase](https://firebase.google.com/support/privacy) 22 | 23 | ## 日志数据 24 | 25 | 每当您使用我的服务时,如果应用程序出错,我会在您的手机上通过第三方服务收集日志数据。此日志数据可能包括诸如设备 IP 地址、设备名称、操作系统版本、使用我的服务时应用程序的配置、使用服务的时间和日期以及其他统计信息等。 26 | 27 | ## Cookies 28 | 29 | Cookies 是包含少量数据的文件,通常用作匿名唯一标识符。这些是从应用程序访问的 API 发送的,并存储在设备的内存中。 30 | 31 | 本服务不明确使用这些 cookies。但是,该应用程序可能会使用第三方代码和使用 cookies 的库来收集信息并改进其服务。您可以选择拒绝这些 cookies,但将可能无法继续使用部分服务。 32 | 33 | ## 安全 34 | 35 | 我非常重视您的信任,因此我们正在努力使用商业上可接受的方式来保护您的个人信息。但请记住,通过互联网传输或电子存储的方法永远不是 100% 安全可靠的,我无法保证其绝对的安全性。 36 | 37 | ## 其他网站的链接 38 | 39 | 本服务可能包含指向其他站点的链接。如果您点击第三方链接,系统会将您定向到该网站。请注意,这些外部网站不由我负责。因此,我强烈建议您查看这些网站的隐私政策。我无法控制任何第三方网站或服务的内容、隐私政策或行为,也不承担任何责任。 40 | 41 | ## 儿童隐私 42 | 43 | 这些服务不适用于 13 岁以下的任何人。我不会有意收集 13 岁以下儿童的个人身份信息。在我发现 13 岁以下的儿童向我提供个人信息的情况下,我会立即从我们的服务器上删除此信息。如果您是父母或监护人,并且您知道您的孩子向我们提供了个人信息,请与我联系,以便我能够采取必要的行动。 44 | 45 | ## 本隐私政策的变更 46 | 47 | 我可能会不时更新我们的隐私政策。因此,建议您定期查看此页面以了解任何更改。我将通过在此页面上发布新的隐私政策来进行任何更改。这些更改在此页面上发布后立即生效。 48 | 49 | ## 联系我们 50 | 51 | 如果您对本隐私政策有任何疑问或建议,请随时[与我联系](mailto:learnX@ruiying.io)。 52 | -------------------------------------------------------------------------------- /docs/assets/google-play-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/docs/assets/google-play-badge.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/screenshots/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/docs/screenshots/iphone.png -------------------------------------------------------------------------------- /docs/screenshots/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/docs/screenshots/mac.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import './polyfills'; 2 | 3 | import {AppRegistry, LogBox, Platform, UIManager} from 'react-native'; 4 | import {name as appName} from './app.json'; 5 | import App from './src/App'; 6 | 7 | LogBox.ignoreAllLogs(true); 8 | 9 | if ( 10 | Platform.OS === 'android' && 11 | UIManager.setLayoutAnimationEnabledExperimental 12 | ) { 13 | UIManager.setLayoutAnimationEnabledExperimental(true); 14 | } 15 | 16 | AppRegistry.registerComponent(appName, () => App); 17 | -------------------------------------------------------------------------------- /ios/.xcode.env: -------------------------------------------------------------------------------- 1 | export NODE_BINARY=$(command -v node) 2 | -------------------------------------------------------------------------------- /ios/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_KEY 6 | AIzaSyD2ofNsrVlaCJqHK8IsJX7y_lXis4zwkU4 7 | GCM_SENDER_ID 8 | 858579386216 9 | PLIST_VERSION 10 | 1 11 | BUNDLE_ID 12 | io.robertying.learnX 13 | PROJECT_ID 14 | learnx-5e172 15 | STORAGE_BUCKET 16 | learnx-5e172.appspot.com 17 | IS_ADS_ENABLED 18 | 19 | IS_ANALYTICS_ENABLED 20 | 21 | IS_APPINVITE_ENABLED 22 | 23 | IS_GCM_ENABLED 24 | 25 | IS_SIGNIN_ENABLED 26 | 27 | GOOGLE_APP_ID 28 | 1:858579386216:ios:17593760bca1eca47f60dd 29 | 30 | 31 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") 2 | require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods") 3 | 4 | platform :ios, '15.5' 5 | prepare_react_native_project! 6 | 7 | use_frameworks! :linkage => :static 8 | 9 | if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' 10 | config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; 11 | else 12 | config_command = [ 13 | 'npx', 14 | 'expo-modules-autolinking', 15 | 'react-native-config', 16 | '--json', 17 | '--platform', 18 | 'ios' 19 | ] 20 | end 21 | 22 | target 'learnX' do 23 | use_expo_modules! 24 | 25 | config = use_native_modules!(config_command) 26 | 27 | use_react_native!( 28 | :path => config[:reactNativePath], 29 | :app_path => "#{Pod::Config.instance.installation_root}/..", 30 | hermes_enabled: true 31 | ) 32 | 33 | pod 'FirebaseAnalytics/WithoutAdIdSupport' 34 | pod 'FirebaseCrashlytics' 35 | 36 | post_install do |installer| 37 | react_native_post_install( 38 | installer, 39 | config[:reactNativePath], 40 | :mac_catalyst_enabled => true 41 | ) 42 | 43 | installer.pods_project.targets.each do |target| 44 | target.build_configurations.each do |config| 45 | config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'NO' 46 | config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' 47 | config.build_settings.delete 'EMBEDDED_CONTENT_CONTAINS_SWIFT' 48 | config.build_settings.delete 'ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES' 49 | end 50 | end 51 | 52 | installer.pods_project.build_configurations.each do |config| 53 | config.build_settings['ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS'] = 'YES' 54 | config.build_settings['DEAD_CODE_STRIPPING'] = 'YES' 55 | config.build_settings['CODE_SIGN_IDENTITY'] = '' 56 | config.build_settings.delete 'STRIP_INSTALLED_PRODUCT' 57 | config.build_settings.delete 'STRIP_STYLE' 58 | config.build_settings.delete 'STRIP_SWIFT_SYMBOLS' 59 | end 60 | end 61 | end 62 | 63 | target 'ShareExtension' do 64 | config = use_native_modules!(config_command) 65 | 66 | use_react_native!( 67 | :path => config[:reactNativePath], 68 | :app_path => "#{Pod::Config.instance.installation_root}/..", 69 | hermes_enabled: true 70 | ) 71 | 72 | pod 'RNShareMenu', :path => '../node_modules/react-native-share-menu' 73 | end 74 | -------------------------------------------------------------------------------- /ios/ShareExtension/Base.lproj/MainInterface.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /ios/ShareExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HostAppBundleIdentifier 6 | io.robertying.learnX 7 | HostAppURLScheme 8 | learnx:// 9 | NSExtension 10 | 11 | NSExtensionAttributes 12 | 13 | NSExtensionActivationRule 14 | 15 | NSExtensionActivationSupportsAttachmentsWithMaxCount 16 | 1 17 | NSExtensionActivationSupportsFileWithMaxCount 18 | 1 19 | NSExtensionActivationSupportsImageWithMaxCount 20 | 1 21 | NSExtensionActivationSupportsMovieWithMaxCount 22 | 1 23 | NSExtensionActivationSupportsText 24 | 25 | 26 | 27 | NSExtensionMainStoryboard 28 | MainInterface 29 | NSExtensionPointIdentifier 30 | com.apple.share-services 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /ios/ShareExtension/ShareExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.io.robertying.learnX 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | com.apple.security.network.client 14 | 15 | com.apple.security.personal-information.photos-library 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ios/learnX.xcodeproj/xcshareddata/xcschemes/learnX.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ios/learnX.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/learnX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/learnX/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Expo 2 | import FirebaseCore 3 | import RNShareMenu 4 | import React 5 | import ReactAppDependencyProvider 6 | 7 | @UIApplicationMain 8 | public class AppDelegate: ExpoAppDelegate { 9 | var window: UIWindow? 10 | 11 | var reactNativeDelegate: ExpoReactNativeFactoryDelegate? 12 | var reactNativeFactory: RCTReactNativeFactory? 13 | 14 | public override func application( 15 | _ application: UIApplication, 16 | didFinishLaunchingWithOptions launchOptions: [UIApplication 17 | .LaunchOptionsKey: Any]? = nil 18 | ) -> Bool { 19 | FirebaseApp.configure() 20 | 21 | let delegate = ReactNativeDelegate() 22 | let factory = ExpoReactNativeFactory(delegate: delegate) 23 | delegate.dependencyProvider = RCTAppDependencyProvider() 24 | 25 | reactNativeDelegate = delegate 26 | reactNativeFactory = factory 27 | bindReactNativeFactory(factory) 28 | 29 | UIView.appearance().tintColor = .theme 30 | window = UIWindow(frame: UIScreen.main.bounds) 31 | factory.startReactNative( 32 | withModuleName: "learnX", 33 | in: window, 34 | launchOptions: launchOptions 35 | ) 36 | 37 | return super.application( 38 | application, 39 | didFinishLaunchingWithOptions: launchOptions 40 | ) 41 | } 42 | 43 | public override func application( 44 | _ app: UIApplication, 45 | open url: URL, 46 | options: [UIApplication.OpenURLOptionsKey: Any] = [:] 47 | ) -> Bool { 48 | return ShareMenuManager.application(app, open: url, options: options) 49 | } 50 | } 51 | 52 | class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { 53 | override func sourceURL(for bridge: RCTBridge) -> URL? { 54 | bridge.bundleURL ?? bundleURL() 55 | } 56 | 57 | override func bundleURL() -> URL? { 58 | #if DEBUG 59 | return RCTBundleURLProvider.sharedSettings().jsBundleURL( 60 | forBundleRoot: "index" 61 | ) 62 | #else 63 | return Bundle.main.url(forResource: "main", withExtension: "jsbundle") 64 | #endif 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon_1024x1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "Icon_Dark_1024x1024.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "Icon_Grayscale_1024x1024.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | }, 33 | { 34 | "filename" : "Icon-MacOS-16x16@1x.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "16x16" 38 | }, 39 | { 40 | "filename" : "Icon-MacOS-16x16@2x.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "16x16" 44 | }, 45 | { 46 | "filename" : "Icon-MacOS-32x32@1x.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "32x32" 50 | }, 51 | { 52 | "filename" : "Icon-MacOS-32x32@2x.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "32x32" 56 | }, 57 | { 58 | "filename" : "Icon-MacOS-128x128@1x.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "128x128" 62 | }, 63 | { 64 | "filename" : "Icon-MacOS-128x128@2x.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "128x128" 68 | }, 69 | { 70 | "filename" : "Icon-MacOS-256x256@1x.png", 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "256x256" 74 | }, 75 | { 76 | "filename" : "Icon-MacOS-256x256@2x.png", 77 | "idiom" : "mac", 78 | "scale" : "2x", 79 | "size" : "256x256" 80 | }, 81 | { 82 | "filename" : "Icon-MacOS-512x512@1x.png", 83 | "idiom" : "mac", 84 | "scale" : "1x", 85 | "size" : "512x512" 86 | }, 87 | { 88 | "filename" : "Icon-MacOS-512x512@2x.png", 89 | "idiom" : "mac", 90 | "scale" : "2x", 91 | "size" : "512x512" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@1x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@1x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@1x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@1x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_1024x1024.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_Dark_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_Dark_1024x1024.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_Grayscale_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/AppIcon.appiconset/Icon_Grayscale_1024x1024.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Black@3x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MaskedAppIcon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "filename" : "Black.png", 16 | "idiom" : "universal", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "filename" : "MaskedAppIcon@2x.png", 21 | "idiom" : "universal", 22 | "scale" : "2x" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "filename" : "Black@2x.png", 32 | "idiom" : "universal", 33 | "scale" : "2x" 34 | }, 35 | { 36 | "filename" : "MaskedAppIcon@3x.png", 37 | "idiom" : "universal", 38 | "scale" : "3x" 39 | }, 40 | { 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "filename" : "Black@3x.png", 48 | "idiom" : "universal", 49 | "scale" : "3x" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | }, 56 | "properties" : { 57 | "compression-type" : "automatic" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon@2x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertying/learnX/21802368768dea106a2793cbaa9ec1b723dd4ff0/ios/learnX/Assets.xcassets/MaskedAppIcon.imageset/MaskedAppIcon@3x.png -------------------------------------------------------------------------------- /ios/learnX/Assets.xcassets/ThemeColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors": [ 3 | { 4 | "color": { 5 | "color-space": "srgb", 6 | "components": { 7 | "alpha": "1.000", 8 | "blue": "0xAE", 9 | "green": "0x25", 10 | "red": "0x9A" 11 | } 12 | }, 13 | "idiom": "universal" 14 | }, 15 | { 16 | "appearances": [ 17 | { 18 | "appearance": "luminosity", 19 | "value": "dark" 20 | } 21 | ], 22 | "color": { 23 | "color-space": "srgb", 24 | "components": { 25 | "alpha": "1.000", 26 | "blue": "0xFF", 27 | "green": "0xAB", 28 | "red": "0xF9" 29 | } 30 | }, 31 | "idiom": "universal" 32 | } 33 | ], 34 | "info": { 35 | "author": "xcode", 36 | "version": 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios/learnX/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ios/learnX/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | CA92.1 13 | 1C8F.1 14 | C56D.1 15 | 16 | 17 | 18 | NSPrivacyAccessedAPIType 19 | NSPrivacyAccessedAPICategoryFileTimestamp 20 | NSPrivacyAccessedAPITypeReasons 21 | 22 | 0A2A.1 23 | 3B52.1 24 | C617.1 25 | 26 | 27 | 28 | NSPrivacyAccessedAPIType 29 | NSPrivacyAccessedAPICategoryDiskSpace 30 | NSPrivacyAccessedAPITypeReasons 31 | 32 | E174.1 33 | 85F4.1 34 | 35 | 36 | 37 | NSPrivacyAccessedAPIType 38 | NSPrivacyAccessedAPICategorySystemBootTime 39 | NSPrivacyAccessedAPITypeReasons 40 | 41 | 35F9.1 42 | 43 | 44 | 45 | NSPrivacyCollectedDataTypes 46 | 47 | NSPrivacyTracking 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /ios/learnX/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | NSCalendarsUsageDescription = "Allow learnX to sync courses and assignments to your calendars. This also allows learnX to delete its own calendar."; 2 | NSCalendarsWriteOnlyAccessUsageDescription = "Allow learnX to sync courses and assignments to your calendars. This also allows learnX to delete its own calendar."; 3 | NSCalendarsFullAccessUsageDescription = "Allow learnX to sync courses and assignments to your calendars. learnX may need to read the list of calendars on your device to decide which one to write into. This also allows learnX to delete its own calendar."; 4 | NSRemindersUsageDescription = "Allow learnX to sync assignments to your reminders. This also allows learnX to delete its own reminder list."; 5 | NSRemindersFullAccessUsageDescription = "Allow learnX to sync assignments to your reminders. This also allows learnX to delete its own reminder list."; 6 | NSContactsUsageDescription = "Allow learnX to access your contacts."; 7 | NSCameraUsageDescription = "Allow learnX to access your camera and use the photo for assignment submission."; 8 | NSMicrophoneUsageDescription = "Allow learnX to access your microphone."; 9 | NSPhotoLibraryUsageDescription = "Allow learnX to access your photos and use them for your assignment submission."; 10 | NSPhotoLibraryAddUsageDescription = "Allow learnX to access your photos."; 11 | NSDesktopFolderUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 12 | NSDocumentsFolderUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 13 | NSDownloadsFolderUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 14 | NSRemovableVolumesUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 15 | NSNetworkVolumesUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 16 | NSFileProviderDomainUsageDescription = "Allow learnX to access your files and use them for your assignment submission."; 17 | -------------------------------------------------------------------------------- /ios/learnX/learnX.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.io.robertying.learnX 10 | 11 | com.apple.security.files.downloads.read-write 12 | 13 | com.apple.security.files.user-selected.read-only 14 | 15 | com.apple.security.network.client 16 | 17 | com.apple.security.personal-information.calendars 18 | 19 | com.apple.security.personal-information.photos-library 20 | 21 | keychain-access-groups 22 | 23 | $(AppIdentifierPrefix)io.robertying.learnX 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/learnX/zh-Hans.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | NSCalendarsUsageDescription = "允许 learnX 将课程和作业同步到您的日历。这还能允许 learnX 删除自身写入的日历。"; 2 | NSCalendarsWriteOnlyAccessUsageDescription = "允许 learnX 将课程和作业同步到您的日历。这还能允许 learnX 删除自身写入的日历。"; 3 | NSCalendarsFullAccessUsageDescription = "允许 learnX 将课程和作业同步到您的日历。learnX 可能需要获取设备的日历列表来决定写入到哪个日历。这还能允许 learnX 删除自身写入的日历。"; 4 | NSRemindersUsageDescription = "允许 learnX 将作业同步到您的提醒事项。这还能允许 learnX 删除自身写入的提醒事项列表。"; 5 | NSRemindersFullAccessUsageDescription = "允许 learnX 将作业同步到您的提醒事项。这还能允许 learnX 删除自身写入的提醒事项列表。"; 6 | NSContactsUsageDescription = "允许 learnX 访问您的联系人。"; 7 | NSCameraUsageDescription = "允许 learnX 访问您的相机,并使用照片作为附件进行作业提交。"; 8 | NSMicrophoneUsageDescription = "允许 learnX 访问您的麦克风。"; 9 | NSPhotoLibraryUsageDescription = "允许 learnX 访问您的照片,并将其作为附件进行作业提交。"; 10 | NSPhotoLibraryAddUsageDescription = "允许 learnX 访问您的照片。"; 11 | NSDesktopFolderUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 12 | NSDocumentsFolderUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 13 | NSDownloadsFolderUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 14 | NSRemovableVolumesUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 15 | NSNetworkVolumesUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 16 | NSFileProviderDomainUsageDescription = "允许 learnX 访问您的文件,并将其作为附件进行作业提交。"; 17 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); 2 | const { 3 | wrapWithReanimatedMetroConfig, 4 | } = require('react-native-reanimated/metro-config'); 5 | 6 | /** 7 | * @type {import('metro-config').MetroConfig} 8 | */ 9 | const config = {}; 10 | 11 | module.exports = wrapWithReanimatedMetroConfig( 12 | mergeConfig(getDefaultConfig(__dirname), config), 13 | ); 14 | -------------------------------------------------------------------------------- /patches/@types+react-native-share-menu+5.0.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@types/react-native-share-menu/index.d.ts b/node_modules/@types/react-native-share-menu/index.d.ts 2 | index 3541f52..964ac9e 100644 3 | --- a/node_modules/@types/react-native-share-menu/index.d.ts 4 | +++ b/node_modules/@types/react-native-share-menu/index.d.ts 5 | @@ -14,6 +14,7 @@ interface ShareMenu { 6 | getSharedText(callback: ShareCallback): void; 7 | getInitialShare(callback: ShareCallback): void; 8 | addNewShareListener(callback: ShareCallback): ShareListener; 9 | + getSharedCacheDirectory(callback: (dir?: string | null) => void): void; 10 | clearSharedText(): void; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /patches/expo-calendar+14.1.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/expo-calendar/android/src/main/java/expo/modules/calendar/CalendarModule.kt b/node_modules/expo-calendar/android/src/main/java/expo/modules/calendar/CalendarModule.kt 2 | index b5c29f8..2dc7cd1 100644 3 | --- a/node_modules/expo-calendar/android/src/main/java/expo/modules/calendar/CalendarModule.kt 4 | +++ b/node_modules/expo-calendar/android/src/main/java/expo/modules/calendar/CalendarModule.kt 5 | @@ -5,6 +5,7 @@ import android.content.ContentUris 6 | import android.content.ContentValues 7 | import android.content.Intent 8 | import android.database.Cursor 9 | +import android.database.sqlite.SQLiteException 10 | import android.os.Bundle 11 | import android.provider.CalendarContract 12 | import android.util.Log 13 | @@ -436,7 +437,11 @@ class CalendarModule : Module() { 14 | try { 15 | when (startDate) { 16 | is String -> { 17 | - val parsedDate = sdf.parse(startDate) 18 | + val parsedDate = try { 19 | + sdf.parse(startDate) 20 | + } catch (e: Exception) { 21 | + null 22 | + } 23 | if (parsedDate != null) { 24 | startCal.time = parsedDate 25 | calendarEventBuilder.put(CalendarContract.Events.DTSTART, startCal.timeInMillis) 26 | @@ -626,7 +631,12 @@ class CalendarModule : Module() { 27 | reminderValues.put(CalendarContract.Reminders.EVENT_ID, eventID) 28 | reminderValues.put(CalendarContract.Reminders.MINUTES, minutes) 29 | reminderValues.put(CalendarContract.Reminders.METHOD, method) 30 | - contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) 31 | + try { 32 | + contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) 33 | + } 34 | + catch (e: SQLiteException) { 35 | + 36 | + } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /patches/expo-document-picker+13.1.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/expo-document-picker/android/src/main/java/expo/modules/documentpicker/DocumentPickerModule.kt b/node_modules/expo-document-picker/android/src/main/java/expo/modules/documentpicker/DocumentPickerModule.kt 2 | index 08cd420..ebb0b77 100644 3 | --- a/node_modules/expo-document-picker/android/src/main/java/expo/modules/documentpicker/DocumentPickerModule.kt 4 | +++ b/node_modules/expo-document-picker/android/src/main/java/expo/modules/documentpicker/DocumentPickerModule.kt 5 | @@ -35,7 +35,7 @@ class DocumentPickerModule : Module() { 6 | } 7 | pendingPromise = promise 8 | copyToCacheDirectory = options.copyToCacheDirectory 9 | - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { 10 | + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { 11 | addCategory(Intent.CATEGORY_OPENABLE) 12 | putExtra(Intent.EXTRA_ALLOW_MULTIPLE, options.multiple) 13 | type = if (options.type.size > 1) { 14 | -------------------------------------------------------------------------------- /patches/expo-file-system+18.1.8.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/expo-file-system/ios/FileSystemHelpers.swift b/node_modules/expo-file-system/ios/FileSystemHelpers.swift 2 | index 11f1c7d..850afb4 100644 3 | --- a/node_modules/expo-file-system/ios/FileSystemHelpers.swift 4 | +++ b/node_modules/expo-file-system/ios/FileSystemHelpers.swift 5 | @@ -63,6 +63,13 @@ internal func ensurePathPermission(_ appContext: AppContext?, path: String, flag 6 | guard let permissionsManager: EXFilePermissionModuleInterface = appContext?.legacyModule(implementing: EXFilePermissionModuleInterface.self) else { 7 | throw Exceptions.PermissionsModuleNotFound() 8 | } 9 | + 10 | + if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { 11 | + if path.hasPrefix(downloads.path) { 12 | + return 13 | + } 14 | + } 15 | + 16 | guard permissionsManager.getPathPermissions(path).contains(flag) else { 17 | throw flag == .read ? FileNotReadableException(path) : FileNotWritableException(path) 18 | } 19 | -------------------------------------------------------------------------------- /patches/react-native+0.79.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native/Libraries/Network/FormData.js b/node_modules/react-native/Libraries/Network/FormData.js 2 | index b237b45..fcc4f4b 100644 3 | --- a/node_modules/react-native/Libraries/Network/FormData.js 4 | +++ b/node_modules/react-native/Libraries/Network/FormData.js 5 | @@ -91,8 +91,9 @@ class FormData { 6 | // content type (cf. web Blob interface.) 7 | if (typeof value === 'object' && !Array.isArray(value) && value) { 8 | if (typeof value.name === 'string') { 9 | - headers['content-disposition'] += 10 | - `; filename="${encodeFilename(value.name)}"`; 11 | + headers['content-disposition'] += `; filename="${ 12 | + value.name 13 | + }"; filename*=utf-8''${encodeURI(value.name)}`; 14 | } 15 | if (typeof value.type === 'string') { 16 | headers['content-type'] = value.type; 17 | diff --git a/node_modules/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm b/node_modules/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm 18 | index 74f8178..44fdfca 100644 19 | --- a/node_modules/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm 20 | +++ b/node_modules/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm 21 | @@ -548,7 +548,7 @@ void RCTInstanceSetRuntimeDiagnosticFlags(NSString *flags) 22 | name:errorData[@"name"] 23 | componentStack:errorData[@"componentStack"] 24 | exceptionId:error.id 25 | - isFatal:errorData[@"isFatal"] 26 | + isFatal:[errorData[@"isFatal"] boolValue] 27 | extraData:errorData[@"extraData"]]) { 28 | JS::NativeExceptionsManager::ExceptionData jsErrorData{errorData}; 29 | id exceptionsManager = [_turboModuleManager moduleForName:"ExceptionsManager"]; 30 | -------------------------------------------------------------------------------- /patches/react-native-fs+2.20.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-fs/Downloader.m b/node_modules/react-native-fs/Downloader.m 2 | index cb2ca23..7f6dda3 100644 3 | --- a/node_modules/react-native-fs/Downloader.m 4 | +++ b/node_modules/react-native-fs/Downloader.m 5 | @@ -76,6 +76,9 @@ 6 | { 7 | NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)downloadTask.response; 8 | if (_params.beginCallback && !_statusCode) { 9 | + if (!([httpResponse respondsToSelector:@selector(statusCode)]) || httpResponse == nil) { 10 | + return; 11 | + } 12 | _statusCode = [NSNumber numberWithLong:httpResponse.statusCode]; 13 | _contentLength = [NSNumber numberWithLong:httpResponse.expectedContentLength]; 14 | return _params.beginCallback(_statusCode, _contentLength, httpResponse.allHeaderFields); 15 | @@ -112,6 +115,9 @@ 16 | { 17 | NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)downloadTask.response; 18 | if (!_statusCode) { 19 | + if (!([httpResponse respondsToSelector:@selector(statusCode)]) || httpResponse == nil) { 20 | + return; 21 | + } 22 | _statusCode = [NSNumber numberWithLong:httpResponse.statusCode]; 23 | } 24 | NSURL *destURL = [NSURL fileURLWithPath:_params.toFile]; 25 | -------------------------------------------------------------------------------- /patches/react-native-pager-view+6.7.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-pager-view/android/src/fabric/java/com/reactnativepagerview/PagerViewViewManager.kt b/node_modules/react-native-pager-view/android/src/fabric/java/com/reactnativepagerview/PagerViewViewManager.kt 2 | index aad38b2..ee5d22b 100644 3 | --- a/node_modules/react-native-pager-view/android/src/fabric/java/com/reactnativepagerview/PagerViewViewManager.kt 4 | +++ b/node_modules/react-native-pager-view/android/src/fabric/java/com/reactnativepagerview/PagerViewViewManager.kt 5 | @@ -37,7 +37,7 @@ class PagerViewViewManager : ViewGroupManager(), RNCViewPa 6 | return PagerViewViewManagerImpl.NAME 7 | } 8 | 9 | - override fun receiveCommand(root: NestedScrollableHost, commandId: String?, args: ReadableArray?) { 10 | + override fun receiveCommand(root: NestedScrollableHost, commandId: String, args: ReadableArray?) { 11 | mDelegate.receiveCommand(root, commandId, args) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /patches/react-native-reorderable-list+0.14.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-reorderable-list/src/hooks/useContext.ts b/node_modules/react-native-reorderable-list/src/hooks/useContext.ts 2 | index 2658655..c26ac80 100644 3 | --- a/node_modules/react-native-reorderable-list/src/hooks/useContext.ts 4 | +++ b/node_modules/react-native-reorderable-list/src/hooks/useContext.ts 5 | @@ -7,5 +7,5 @@ export const useContext = (context: React.Context) => { 6 | return value; 7 | } 8 | 9 | - throw 'Please consume ReorderableList context within its provider.'; 10 | + return {} as T; 11 | }; 12 | -------------------------------------------------------------------------------- /patches/react-native-screens+4.11.0-beta.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-screens/ios/RNSScreen.mm b/node_modules/react-native-screens/ios/RNSScreen.mm 2 | index 8481d21..87b3e1a 100644 3 | --- a/node_modules/react-native-screens/ios/RNSScreen.mm 4 | +++ b/node_modules/react-native-screens/ios/RNSScreen.mm 5 | @@ -593,7 +593,7 @@ RNS_IGNORE_SUPER_CALL_END 6 | int index = newDetentIndex; 7 | std::dynamic_pointer_cast(_eventEmitter) 8 | ->onSheetDetentChanged( 9 | - react::RNSScreenEventEmitter::OnSheetDetentChanged{.index = index, .isStable = isStable}); 10 | + react::RNSScreenEventEmitter::OnSheetDetentChanged{.index = index, .isStable = static_cast(isStable)}); 11 | } 12 | #else 13 | if (self.onSheetDetentChanged) { 14 | -------------------------------------------------------------------------------- /patches/react-native-vector-icons+10.2.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-vector-icons/RNVectorIcons.podspec b/node_modules/react-native-vector-icons/RNVectorIcons.podspec 2 | index 5877f53..ec19a9c 100644 3 | --- a/node_modules/react-native-vector-icons/RNVectorIcons.podspec 4 | +++ b/node_modules/react-native-vector-icons/RNVectorIcons.podspec 5 | @@ -13,7 +13,7 @@ Pod::Spec.new do |s| 6 | s.source = { :git => package["repository"]["url"], :tag => "v#{s.version}" } 7 | 8 | s.source_files = 'RNVectorIconsManager/**/*.{h,m,mm,swift}' 9 | - s.resources = "Fonts/*.ttf" 10 | + s.resources = "Fonts/MaterialCommunityIcons.ttf", "Fonts/MaterialIcons.ttf" 11 | s.preserve_paths = "**/*.js" 12 | # React Native Core dependency 13 | if defined? install_modules_dependencies 14 | -------------------------------------------------------------------------------- /patches/redux-persist+6.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/redux-persist/lib/persistReducer.js b/node_modules/redux-persist/lib/persistReducer.js 2 | index 1116881..1c5eb59 100644 3 | --- a/node_modules/redux-persist/lib/persistReducer.js 4 | +++ b/node_modules/redux-persist/lib/persistReducer.js 5 | @@ -128,14 +128,6 @@ function persistReducer(config, baseReducer) { 6 | } else if (action.type === _constants.PAUSE) { 7 | _paused = true; 8 | } else if (action.type === _constants.REHYDRATE) { 9 | - // noop on restState if purging 10 | - if (_purge) return _objectSpread({}, restState, { 11 | - _persist: _objectSpread({}, _persist, { 12 | - rehydrated: true 13 | - }) // @NOTE if key does not match, will continue to default else below 14 | - 15 | - }); 16 | - 17 | if (action.key === config.key) { 18 | var reducedState = baseReducer(restState, action); 19 | var inboundState = action.payload; // only reconcile state if stateReconciler and inboundState are both defined 20 | -------------------------------------------------------------------------------- /polyfills.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'buffer'; 2 | global.Buffer = Buffer; 3 | 4 | import 'react-native-url-polyfill/auto'; 5 | import 'react-native-gesture-handler'; 6 | -------------------------------------------------------------------------------- /scripts/rename_apks_for_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$(dirname "$0")" 4 | APK_DIR="${DIR}/../android/app/build/outputs/apk/release" 5 | cp $APK_DIR/app-x86_64-release.apk $APK_DIR/learnX-x86_64-$VERSION_TAG.apk 6 | cp $APK_DIR/app-arm64-v8a-release.apk $APK_DIR/learnX-arm64-v8a-$VERSION_TAG.apk 7 | cp $APK_DIR/app-armeabi-v7a-release.apk $APK_DIR/learnX-armeabi-v7a-$VERSION_TAG.apk 8 | cp $APK_DIR/app-x86-release.apk $APK_DIR/learnX-x86-$VERSION_TAG.apk 9 | cp $APK_DIR/app-universal-release.apk $APK_DIR/learnX-universal-$VERSION_TAG.apk 10 | -------------------------------------------------------------------------------- /scripts/test_android_bundle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APK_DIR="android/app/build/outputs/bundle/release" 4 | KS_DIR="android/app/release.keystore" 5 | 6 | bundletool build-apks --bundle=$APK_DIR/app-release.aab --output=$APK_DIR/app-release.apks --ks=$KS_DIR --ks-key-alias=io.robertying.learnx.key --overwrite 7 | bundletool install-apks --apks=$APK_DIR/app-release.apks 8 | -------------------------------------------------------------------------------- /src/components/AutoHeightWebView.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef, useState} from 'react'; 2 | import {Linking, Platform} from 'react-native'; 3 | import WebView, {WebViewProps} from 'react-native-webview'; 4 | import { 5 | WebViewMessageEvent, 6 | WebViewNavigation, 7 | WebViewSource, 8 | WebViewSourceHtml, 9 | } from 'react-native-webview/lib/WebViewTypes'; 10 | import CookieManager from '@react-native-cookies/cookies'; 11 | import Urls from 'constants/Urls'; 12 | 13 | const AutoHeightWebView: React.FC< 14 | React.PropsWithChildren 15 | > = props => { 16 | const [height, setHeight] = useState(0); 17 | const [cookieString, setCookieString] = useState(''); 18 | 19 | const webViewRef = useRef(null); 20 | 21 | const onMessage = (e: WebViewMessageEvent) => { 22 | try { 23 | const measuredHeight = parseInt(e.nativeEvent.data, 10); 24 | if (isNaN(measuredHeight) || measuredHeight < 1) { 25 | return; 26 | } 27 | setHeight(measuredHeight); 28 | } catch {} 29 | }; 30 | 31 | const onNavigationStateChange = (e: WebViewNavigation) => { 32 | if (e.navigationType === 'click') { 33 | if (webViewRef.current) { 34 | webViewRef.current.stopLoading(); 35 | } 36 | Linking.openURL(e.url); 37 | } 38 | }; 39 | 40 | const injectedScript = ` 41 | function waitForBridge() { 42 | if (!window.ReactNativeWebView.postMessage) { 43 | setTimeout(waitForBridge, 200); 44 | } else { 45 | window.ReactNativeWebView.postMessage( 46 | Math.max( 47 | document.documentElement?.clientHeight ?? 0, 48 | document.documentElement?.scrollHeight ?? 0, 49 | document.body?.clientHeight ?? 0, 50 | document.body?.scrollHeight ?? 0 51 | ).toString() 52 | ); 53 | } 54 | } 55 | 56 | // Wait for images and such to load and update the height again 57 | window.addEventListener("load", waitForBridge); 58 | 59 | // Update height as long as there's something 60 | waitForBridge(); 61 | 62 | true; 63 | `; 64 | 65 | useEffect(() => { 66 | (async () => { 67 | const cookies = await CookieManager.get(Urls.learn); 68 | await Promise.all( 69 | Object.entries(cookies).map(([, value]) => 70 | CookieManager.set(Urls.learn, value, true), 71 | ), 72 | ); 73 | 74 | setCookieString( 75 | Object.entries(cookies) 76 | .map(([key, value]) => `${key}=${value.value}`) 77 | .join('; '), 78 | ); 79 | })(); 80 | }, []); 81 | 82 | return ( 83 | 116 | ); 117 | }; 118 | 119 | export default AutoHeightWebView; 120 | -------------------------------------------------------------------------------- /src/components/CourseCard.tsx: -------------------------------------------------------------------------------- 1 | import {memo} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import {Caption, Subheading, Title, useTheme} from 'react-native-paper'; 4 | import Icon from 'react-native-vector-icons/MaterialIcons'; 5 | import {Course} from 'data/types/state'; 6 | import CardWrapper, {CardWrapperProps} from './CardWrapper'; 7 | 8 | export interface CourseCardProps extends CardWrapperProps { 9 | data: Course & { 10 | unreadNoticeCount: number; 11 | unfinishedAssignmentCount: number; 12 | unreadFileCount: number; 13 | }; 14 | } 15 | 16 | const CourseCard: React.FC> = ({ 17 | data: { 18 | name, 19 | teacherName, 20 | unreadNoticeCount, 21 | unfinishedAssignmentCount, 22 | unreadFileCount, 23 | }, 24 | ...restProps 25 | }) => { 26 | const theme = useTheme(); 27 | 28 | return ( 29 | 30 | 31 | 32 | {teacherName} 33 | {name} 34 | 35 | 36 | 37 | 38 | {unreadNoticeCount} 39 | 40 | 41 | 42 | 43 | {unfinishedAssignmentCount} 44 | 45 | 46 | 47 | 48 | {unreadFileCount} 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | const styles = StyleSheet.create({ 57 | root: { 58 | flex: 1, 59 | flexDirection: 'row', 60 | }, 61 | contentSections: { 62 | flex: 2, 63 | }, 64 | countSections: { 65 | flex: 1, 66 | flexDirection: 'row', 67 | alignItems: 'flex-end', 68 | marginBottom: 4, 69 | }, 70 | section: { 71 | flex: 1, 72 | marginLeft: 8, 73 | flexDirection: 'row', 74 | alignItems: 'center', 75 | }, 76 | sectionText: { 77 | marginLeft: 2, 78 | }, 79 | }); 80 | 81 | export default memo(CourseCard); 82 | -------------------------------------------------------------------------------- /src/components/Empty.tsx: -------------------------------------------------------------------------------- 1 | import {StyleSheet, View} from 'react-native'; 2 | import {Text, useTheme} from 'react-native-paper'; 3 | import Icon from 'react-native-vector-icons/MaterialIcons'; 4 | import Styles from 'constants/Styles'; 5 | import {t} from 'helpers/i18n'; 6 | 7 | const Empty: React.FC> = () => { 8 | const theme = useTheme(); 9 | 10 | return ( 11 | 12 | 18 | 19 | {t('empty')} 20 | 21 | 22 | ); 23 | }; 24 | 25 | const styles = StyleSheet.create({ 26 | root: { 27 | flex: 1, 28 | alignItems: 'center', 29 | justifyContent: 'center', 30 | }, 31 | }); 32 | 33 | export default Empty; 34 | -------------------------------------------------------------------------------- /src/components/FileCard.tsx: -------------------------------------------------------------------------------- 1 | import {memo} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import {Caption, Paragraph, Title, Subheading} from 'react-native-paper'; 4 | import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; 5 | import dayjs from 'dayjs'; 6 | import Styles from 'constants/Styles'; 7 | import Colors from 'constants/Colors'; 8 | import {File} from 'data/types/state'; 9 | import {removeTags} from 'helpers/html'; 10 | import CardWrapper, {CardWrapperProps} from './CardWrapper'; 11 | 12 | export interface FileCardProps extends CardWrapperProps { 13 | data: File; 14 | hideCourseName?: boolean; 15 | } 16 | 17 | const FileCard: React.FC> = ({ 18 | data: { 19 | title, 20 | description, 21 | courseName, 22 | size, 23 | fileType, 24 | markedImportant, 25 | isNew, 26 | uploadTime, 27 | category, 28 | }, 29 | hideCourseName, 30 | ...restProps 31 | }) => { 32 | return ( 33 | 34 | 35 | 36 | 37 | {hideCourseName ? null : ( 38 | {courseName} 39 | )} 40 | {title} 41 | 42 | 43 | {markedImportant ? ( 44 | 50 | ) : null} 51 | {isNew ? ( 52 | 57 | ) : null} 58 | 59 | 60 | {removeTags(description) ? ( 61 | {removeTags(description)} 62 | ) : null} 63 | 64 | 65 | {`${category?.title ?? ''} ${fileType?.toUpperCase() ?? ''} ${size}`.trim()} 66 | 67 | {dayjs(uploadTime).fromNow()} 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | const styles = StyleSheet.create({ 75 | title: { 76 | flex: 10, 77 | }, 78 | icons: { 79 | flex: 2, 80 | justifyContent: 'flex-end', 81 | }, 82 | icon: { 83 | marginLeft: 6, 84 | }, 85 | }); 86 | 87 | export default memo(FileCard); 88 | -------------------------------------------------------------------------------- /src/components/HeaderTitle.tsx: -------------------------------------------------------------------------------- 1 | import {Platform, StyleSheet, View} from 'react-native'; 2 | import {Text} from 'react-native-paper'; 3 | import DeviceInfo from 'constants/DeviceInfo'; 4 | 5 | export interface HeaderTitleProps { 6 | title: string; 7 | subtitle?: string; 8 | } 9 | 10 | const HeaderTitle: React.FC> = ({ 11 | title, 12 | subtitle, 13 | }) => { 14 | return ( 15 | 24 | 25 | {title} 26 | 27 | {subtitle ? ( 28 | 29 | {subtitle} 30 | 31 | ) : null} 32 | 33 | ); 34 | }; 35 | 36 | const styles = StyleSheet.create({ 37 | title: { 38 | fontSize: Platform.OS === 'ios' ? 17 : 20, 39 | fontWeight: Platform.OS === 'ios' ? '600' : 'bold', 40 | }, 41 | subtitle: { 42 | fontSize: 11, 43 | marginTop: 2, 44 | width: '100%', 45 | textAlign: Platform.OS === 'android' ? 'left' : 'center', 46 | }, 47 | }); 48 | 49 | export default HeaderTitle; 50 | -------------------------------------------------------------------------------- /src/components/NoticeCard.tsx: -------------------------------------------------------------------------------- 1 | import {memo} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import {Caption, Paragraph, Title, Subheading} from 'react-native-paper'; 4 | import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; 5 | import dayjs from 'dayjs'; 6 | import Styles from 'constants/Styles'; 7 | import Colors from 'constants/Colors'; 8 | import {Notice} from 'data/types/state'; 9 | import {removeTags} from 'helpers/html'; 10 | import CardWrapper, {CardWrapperProps} from './CardWrapper'; 11 | 12 | export interface NoticeCardProps extends CardWrapperProps { 13 | data: Notice; 14 | hideCourseName?: boolean; 15 | } 16 | 17 | const NoticeCard: React.FC> = ({ 18 | data: { 19 | title, 20 | content, 21 | courseName, 22 | publisher, 23 | publishTime, 24 | markedImportant, 25 | hasRead, 26 | attachment, 27 | }, 28 | hideCourseName, 29 | ...restProps 30 | }) => { 31 | return ( 32 | 33 | 34 | 35 | 36 | {hideCourseName ? null : ( 37 | {courseName} 38 | )} 39 | {title} 40 | 41 | 42 | {attachment && ( 43 | 49 | )} 50 | {markedImportant && ( 51 | 57 | )} 58 | {!hasRead && ( 59 | 64 | )} 65 | 66 | 67 | {removeTags(content) ? ( 68 | {removeTags(content)} 69 | ) : null} 70 | 71 | {publisher} 72 | {dayjs(publishTime).fromNow()} 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | const styles = StyleSheet.create({ 80 | title: { 81 | flex: 10, 82 | }, 83 | icons: { 84 | flex: 2, 85 | justifyContent: 'flex-end', 86 | }, 87 | icon: { 88 | marginLeft: 6, 89 | }, 90 | }); 91 | 92 | export default memo(NoticeCard); 93 | -------------------------------------------------------------------------------- /src/components/SafeArea.tsx: -------------------------------------------------------------------------------- 1 | import {View} from 'react-native'; 2 | import { 3 | SafeAreaViewProps, 4 | useSafeAreaInsets, 5 | } from 'react-native-safe-area-context'; 6 | import Styles from 'constants/Styles'; 7 | 8 | const SafeArea: React.FC< 9 | React.PropsWithChildren 10 | > = props => { 11 | const insets = useSafeAreaInsets(); 12 | return ( 13 | 23 | ); 24 | }; 25 | 26 | export default SafeArea; 27 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useCallback, useRef} from 'react'; 2 | import {StyleSheet, View, Animated} from 'react-native'; 3 | import {useTheme} from 'react-native-paper'; 4 | 5 | const Skeleton: React.FC> = () => { 6 | const theme = useTheme(); 7 | 8 | const opacity = useRef(new Animated.Value(1)); 9 | 10 | const fadeAnimation = useCallback(() => { 11 | const newValue = 0.5; 12 | const oldValue = 1; 13 | const duration = 500; 14 | 15 | Animated.sequence([ 16 | Animated.timing(opacity.current, { 17 | toValue: newValue, 18 | duration, 19 | useNativeDriver: true, 20 | }), 21 | Animated.timing(opacity.current, { 22 | toValue: oldValue, 23 | duration, 24 | useNativeDriver: true, 25 | }), 26 | ]).start(() => { 27 | fadeAnimation(); 28 | }); 29 | }, []); 30 | 31 | useEffect(() => { 32 | fadeAnimation(); 33 | }, [fadeAnimation]); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | const styles = StyleSheet.create({ 46 | root: { 47 | paddingVertical: 12, 48 | paddingHorizontal: 16, 49 | zIndex: 0, 50 | }, 51 | line1: { 52 | backgroundColor: 'lightgrey', 53 | height: 16, 54 | width: '30%', 55 | borderRadius: 4, 56 | marginVertical: 8, 57 | }, 58 | line2: { 59 | backgroundColor: 'lightgrey', 60 | height: 16, 61 | width: '100%', 62 | borderRadius: 4, 63 | marginVertical: 8, 64 | }, 65 | line3: { 66 | backgroundColor: 'lightgrey', 67 | height: 16, 68 | width: '80%', 69 | borderRadius: 4, 70 | marginVertical: 8, 71 | }, 72 | line4: { 73 | backgroundColor: 'lightgrey', 74 | height: 16, 75 | width: '20%', 76 | borderRadius: 4, 77 | marginVertical: 8, 78 | }, 79 | }); 80 | 81 | export default Skeleton; 82 | -------------------------------------------------------------------------------- /src/components/Splash.tsx: -------------------------------------------------------------------------------- 1 | import {Image, Platform, StyleSheet, useColorScheme, View} from 'react-native'; 2 | import {useTheme} from 'react-native-paper'; 3 | 4 | const Splash: React.FC> = () => { 5 | const colorScheme = useColorScheme(); 6 | const theme = useTheme(); 7 | 8 | return ( 9 | 21 | 29 | 30 | ); 31 | }; 32 | 33 | const styles = StyleSheet.create({ 34 | center: { 35 | flex: 1, 36 | justifyContent: 'center', 37 | alignItems: 'center', 38 | }, 39 | logo: { 40 | width: 150, 41 | height: 150, 42 | }, 43 | logoAndroid: { 44 | width: 192, 45 | height: 192, 46 | }, 47 | }); 48 | 49 | export default Splash; 50 | -------------------------------------------------------------------------------- /src/components/SplitView.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useState, Children, useEffect, useRef} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import {Divider} from 'react-native-paper'; 4 | import {NavigationContainerRef} from '@react-navigation/native'; 5 | import {BlurView} from 'expo-blur'; 6 | import Numbers from 'constants/Numbers'; 7 | 8 | const SplitViewContext = createContext<{ 9 | detailNavigationContainerRef: React.RefObject | null> | null; 10 | showDetail: boolean; 11 | showMaster: boolean; 12 | toggleMaster: (show: boolean) => void; 13 | }>({ 14 | detailNavigationContainerRef: null, 15 | showDetail: false, 16 | showMaster: true, 17 | toggleMaster: () => {}, 18 | }); 19 | 20 | export interface SplitViewProps { 21 | splitEnabled: boolean; 22 | detailNavigationContainerRef: React.RefObject | null> | null; 23 | showDetail: boolean; 24 | } 25 | 26 | const SplitViewProvider: React.FC> = ({ 27 | splitEnabled, 28 | detailNavigationContainerRef, 29 | showDetail, 30 | children, 31 | }) => { 32 | const rendered = useRef(false); 33 | 34 | const [showMaster, setShowMaster] = useState(true); 35 | const [blur, setBlur] = useState(false); 36 | 37 | useEffect(() => { 38 | if (rendered.current) { 39 | setBlur(true); 40 | setTimeout(() => { 41 | setBlur(false); 42 | }, 500); 43 | } else { 44 | rendered.current = true; 45 | } 46 | }, [showDetail, splitEnabled]); 47 | 48 | return ( 49 | 56 | {splitEnabled ? ( 57 | 58 | 66 | {Children.toArray(children)[0]} 67 | 68 | {showDetail && } 69 | 71 | {Children.toArray(children)[1]} 72 | 73 | 74 | ) : ( 75 | <>{Children.toArray(children)[0]} 76 | )} 77 | {blur && ( 78 | 83 | )} 84 | 85 | ); 86 | }; 87 | 88 | const styles = StyleSheet.create({ 89 | root: { 90 | flexDirection: 'row', 91 | flex: 1, 92 | }, 93 | master: { 94 | width: Numbers.splitViewMasterWidth, 95 | zIndex: 2, 96 | }, 97 | detail: { 98 | flex: 1, 99 | zIndex: 1, 100 | }, 101 | divider: { 102 | height: '100%', 103 | width: 1, 104 | }, 105 | blurView: { 106 | position: 'absolute', 107 | top: 0, 108 | left: 0, 109 | right: 0, 110 | bottom: 0, 111 | zIndex: 99999, 112 | }, 113 | }); 114 | 115 | export {SplitViewContext, SplitViewProvider}; 116 | -------------------------------------------------------------------------------- /src/components/TextButton.tsx: -------------------------------------------------------------------------------- 1 | import {TouchableOpacity, TouchableOpacityProps} from 'react-native'; 2 | import {Text, TextProps, useTheme} from 'react-native-paper'; 3 | import Styles from 'constants/Styles'; 4 | 5 | export interface TextButtonProps extends TextProps<'bodyMedium'> { 6 | disabled?: boolean; 7 | containerStyle?: TouchableOpacityProps['style']; 8 | onPress?: TouchableOpacityProps['onPress']; 9 | } 10 | 11 | const TextButton: React.FC> = ({ 12 | containerStyle, 13 | onPress, 14 | style, 15 | ellipsizeMode, 16 | disabled, 17 | ...restProps 18 | }) => { 19 | const theme = useTheme(); 20 | 21 | return ( 22 | 27 | 40 | 41 | ); 42 | }; 43 | 44 | export default TextButton; 45 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useCallback, useState} from 'react'; 2 | import {StyleSheet} from 'react-native'; 3 | import {Snackbar} from 'react-native-paper'; 4 | import * as Haptics from 'expo-haptics'; 5 | 6 | type ToastType = 'success' | 'warning' | 'error' | 'none'; 7 | 8 | const ToastContext = createContext<{ 9 | text: string; 10 | duration: number; 11 | toggleToast: (text: string, type: ToastType, duration?: number) => void; 12 | }>({ 13 | text: '', 14 | duration: 3000, 15 | toggleToast: () => {}, 16 | }); 17 | 18 | const ToastProvider: React.FC> = ({ 19 | children, 20 | }) => { 21 | const [toastText, setToastText] = useState(''); 22 | const [toastDuration, setToastDuration] = useState(3000); 23 | 24 | const handleToast = useCallback( 25 | (text: string, type: ToastType, duration?: number) => { 26 | setToastDuration( 27 | duration ?? 28 | (type === 'success' ? 3000 : type === 'warning' ? 4000 : 5000), 29 | ); 30 | setToastText(text); 31 | if (text && type !== 'none') { 32 | Haptics.notificationAsync( 33 | type === 'success' 34 | ? Haptics.NotificationFeedbackType.Success 35 | : type === 'warning' 36 | ? Haptics.NotificationFeedbackType.Warning 37 | : Haptics.NotificationFeedbackType.Error, 38 | ); 39 | } 40 | }, 41 | [], 42 | ); 43 | 44 | const handleDismiss = () => { 45 | setToastText(''); 46 | }; 47 | 48 | return ( 49 | 55 | {children} 56 | 62 | {toastText} 63 | 64 | 65 | ); 66 | }; 67 | 68 | const styles = StyleSheet.create({ 69 | snackbar: { 70 | marginBottom: 48, 71 | }, 72 | }); 73 | 74 | export {ToastContext, ToastProvider}; 75 | -------------------------------------------------------------------------------- /src/components/Touchable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Platform, 3 | TouchableHighlight, 4 | TouchableHighlightProps, 5 | TouchableOpacity, 6 | } from 'react-native'; 7 | import {TouchableRipple, useTheme} from 'react-native-paper'; 8 | 9 | const Touchable: React.FC< 10 | TouchableHighlightProps & { 11 | type?: 'opacity' | 'highlight'; 12 | highlightColorOpacity?: number; 13 | } 14 | > = ({type, highlightColorOpacity, ...props}) => { 15 | const theme = useTheme(); 16 | 17 | return Platform.OS === 'android' ? ( 18 | 19 | ) : type === 'opacity' ? ( 20 | 21 | ) : ( 22 | 31 | ); 32 | }; 33 | 34 | export default Touchable; 35 | -------------------------------------------------------------------------------- /src/constants/Colors.ts: -------------------------------------------------------------------------------- 1 | import {DynamicColorIOS, Platform} from 'react-native'; 2 | 3 | const plainTheme = '#9A25AE'; 4 | 5 | const theme = 6 | Platform.OS === 'ios' 7 | ? (DynamicColorIOS({ 8 | light: '#9A25AE', 9 | dark: '#F9ABFF', 10 | }) as unknown as string) 11 | : '#9A25AE'; 12 | 13 | export default { 14 | plainTheme, 15 | theme, 16 | red500: '#f44336', 17 | blue500: '#2196f3', 18 | green500: '#4caf50', 19 | orange500: '#ff9800', 20 | yellow500: '#ffeb3b', 21 | grey500: '#9e9e9e', 22 | }; 23 | -------------------------------------------------------------------------------- /src/constants/DeviceInfo.ts: -------------------------------------------------------------------------------- 1 | import Info from 'react-native-device-info'; 2 | 3 | const cached: { 4 | buildNo: string; 5 | isTablet: boolean; 6 | isMac: boolean; 7 | abi: string | null; 8 | systemVersion: string; 9 | isEmulator: boolean | null; 10 | isWSA: boolean; 11 | } = { 12 | buildNo: Info.getBuildNumber(), 13 | isTablet: Info.isTablet(), 14 | isMac: 15 | (Info.getSystemName() === 'iPhone OS' || 16 | Info.getSystemName() === 'iOS' || 17 | Info.getSystemName() === 'iPadOS' || 18 | Info.getSystemName() === 'Mac OS X') && 19 | Info.getDeviceType() === 'Desktop', 20 | abi: null, 21 | systemVersion: Info.getSystemVersion(), 22 | isEmulator: null, 23 | isWSA: Info.getModel() === 'Subsystem for Android(TM)', 24 | }; 25 | 26 | export default { 27 | buildNo: () => cached.buildNo, 28 | isTablet: () => cached.isTablet, 29 | isMac: () => cached.isMac, 30 | abi: async () => { 31 | if (cached.abi !== null) { 32 | return cached.abi; 33 | } 34 | cached.abi = (await Info.supportedAbis())?.[0] || 'universal'; 35 | return cached.abi; 36 | }, 37 | systemVersion: () => cached.systemVersion, 38 | isEmulator: async () => { 39 | if (cached.isEmulator !== null) { 40 | return cached.isEmulator; 41 | } 42 | cached.isEmulator = await Info.isEmulator(); 43 | return cached.isEmulator; 44 | }, 45 | isWSA: () => cached.isWSA, 46 | }; 47 | -------------------------------------------------------------------------------- /src/constants/Numbers.ts: -------------------------------------------------------------------------------- 1 | const Numbers = { 2 | splitViewMasterWidth: 393, 3 | }; 4 | 5 | export default Numbers; 6 | -------------------------------------------------------------------------------- /src/constants/Styles.ts: -------------------------------------------------------------------------------- 1 | import {StyleSheet} from 'react-native'; 2 | 3 | const Styles = StyleSheet.create({ 4 | flexRow: { 5 | flexDirection: 'row', 6 | alignItems: 'center', 7 | }, 8 | flexRowCenter: { 9 | flexDirection: 'row', 10 | justifyContent: 'space-between', 11 | alignItems: 'center', 12 | }, 13 | flex1: { 14 | flex: 1, 15 | }, 16 | ml0: { 17 | marginLeft: 0, 18 | }, 19 | mr0: { 20 | marginRight: 0, 21 | }, 22 | spacex1: { 23 | marginHorizontal: 4, 24 | }, 25 | spacey1: { 26 | marginVertical: 4, 27 | }, 28 | }); 29 | 30 | export default Styles; 31 | -------------------------------------------------------------------------------- /src/constants/Urls.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | learn: 'https://learn.tsinghua.edu.cn', 3 | }; 4 | -------------------------------------------------------------------------------- /src/data/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncAction} from 'typesafe-actions'; 2 | import {ApiError} from 'thu-learn-lib'; 3 | import {dataSource, resetDataSource} from 'data/source'; 4 | import {ThunkResult} from 'data/types/actions'; 5 | import { 6 | LOGIN_FAILURE, 7 | LOGIN_REQUEST, 8 | LOGIN_SUCCESS, 9 | } from 'data/types/constants'; 10 | import {Auth} from 'data/types/state'; 11 | import {getUserInfo} from './user'; 12 | import {serializeError} from 'helpers/parse'; 13 | import {retry} from 'helpers/retry'; 14 | 15 | export const loginAction = createAsyncAction( 16 | LOGIN_REQUEST, 17 | LOGIN_SUCCESS, 18 | LOGIN_FAILURE, 19 | )<{clearCredential?: boolean}, Auth | undefined, ApiError>(); 20 | 21 | export function login( 22 | username?: string, 23 | password?: string, 24 | reset?: boolean, 25 | ): ThunkResult { 26 | return async (dispatch, getState) => { 27 | if (!username || !password) { 28 | const {auth} = getState(); 29 | if (auth.loggingIn) { 30 | return; 31 | } 32 | } 33 | 34 | dispatch(loginAction.request({clearCredential: !!username && !!password})); 35 | 36 | try { 37 | if (reset) { 38 | resetDataSource(); 39 | } 40 | 41 | await retry(async () => { 42 | await dataSource.login(username, password); 43 | }); 44 | 45 | if (username && password) { 46 | dispatch( 47 | loginAction.success({ 48 | username, 49 | password, 50 | }), 51 | ); 52 | } else { 53 | dispatch(loginAction.success(undefined)); 54 | } 55 | dispatch(getUserInfo()); 56 | } catch (err) { 57 | dispatch(loginAction.failure(serializeError(err))); 58 | } 59 | }; 60 | } 61 | 62 | export function loginWithOfflineMode(): ThunkResult { 63 | return dispatch => { 64 | dispatch(loginAction.success(undefined)); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/data/actions/courses.ts: -------------------------------------------------------------------------------- 1 | import {ApiError, CourseType, Language} from 'thu-learn-lib'; 2 | import {createAction, createAsyncAction} from 'typesafe-actions'; 3 | import {dataSource} from 'data/source'; 4 | import {ThunkResult} from 'data/types/actions'; 5 | import { 6 | GET_COURSES_FOR_SEMESTER_FAILURE, 7 | GET_COURSES_FOR_SEMESTER_REQUEST, 8 | GET_COURSES_FOR_SEMESTER_SUCCESS, 9 | SET_HIDE_COURSE, 10 | SET_COURSE_ORDER, 11 | } from 'data/types/constants'; 12 | import {Course} from 'data/types/state'; 13 | import {isLocaleChinese} from 'helpers/i18n'; 14 | import {serializeError} from 'helpers/parse'; 15 | 16 | export const getCoursesForSemesterAction = createAsyncAction( 17 | GET_COURSES_FOR_SEMESTER_REQUEST, 18 | GET_COURSES_FOR_SEMESTER_SUCCESS, 19 | GET_COURSES_FOR_SEMESTER_FAILURE, 20 | )(); 21 | 22 | export function getCoursesForSemester(semesterId: string): ThunkResult { 23 | return async dispatch => { 24 | dispatch(getCoursesForSemesterAction.request()); 25 | 26 | const lang = isLocaleChinese() ? Language.ZH : Language.EN; 27 | 28 | try { 29 | await dataSource.setLanguage(lang); 30 | } catch (err) {} 31 | 32 | try { 33 | const results = await dataSource.getCourseList( 34 | semesterId, 35 | CourseType.STUDENT, 36 | lang, 37 | ); 38 | const courses = results 39 | .map(course => ({...course, semesterId})) 40 | .sort((a, b) => a.id.localeCompare(b.id)); 41 | dispatch(getCoursesForSemesterAction.success(courses)); 42 | } catch (err) { 43 | dispatch(getCoursesForSemesterAction.failure(serializeError(err))); 44 | } 45 | }; 46 | } 47 | 48 | export const setHideCourse = createAction( 49 | SET_HIDE_COURSE, 50 | (courseId: string, flag: boolean) => ({courseId, flag}), 51 | )(); 52 | 53 | export const setCourseOrder = createAction( 54 | SET_COURSE_ORDER, 55 | (courseIds: string[]) => courseIds, 56 | )(); 57 | -------------------------------------------------------------------------------- /src/data/actions/files.ts: -------------------------------------------------------------------------------- 1 | import {ContentType, CourseType, ApiError} from 'thu-learn-lib'; 2 | import {createAction, createAsyncAction} from 'typesafe-actions'; 3 | import dayjs from 'dayjs'; 4 | import {dataSource} from 'data/source'; 5 | import {ThunkResult} from 'data/types/actions'; 6 | import { 7 | GET_ALL_FILES_FOR_COURSES_FAILURE, 8 | GET_ALL_FILES_FOR_COURSES_REQUEST, 9 | GET_ALL_FILES_FOR_COURSES_SUCCESS, 10 | GET_FILES_FOR_COURSE_FAILURE, 11 | GET_FILES_FOR_COURSE_REQUEST, 12 | GET_FILES_FOR_COURSE_SUCCESS, 13 | SET_FAV_FILE, 14 | SET_ARCHIVE_FILES, 15 | } from 'data/types/constants'; 16 | import {File} from 'data/types/state'; 17 | import {serializeError} from 'helpers/parse'; 18 | 19 | export const getFilesForCourseAction = createAsyncAction( 20 | GET_FILES_FOR_COURSE_REQUEST, 21 | GET_FILES_FOR_COURSE_SUCCESS, 22 | GET_FILES_FOR_COURSE_FAILURE, 23 | )(); 24 | 25 | export function getFilesForCourse(courseId: string): ThunkResult { 26 | return async (dispatch, getState) => { 27 | dispatch(getFilesForCourseAction.request()); 28 | 29 | try { 30 | const results = await dataSource.getFileList( 31 | courseId, 32 | CourseType.STUDENT, 33 | ); 34 | const courseName = getState().courses.names[courseId]; 35 | const files = results 36 | .map(result => ({ 37 | ...result, 38 | courseId, 39 | courseName: courseName.name, 40 | courseTeacherName: courseName.teacherName, 41 | })) 42 | .sort( 43 | (a, b) => 44 | dayjs(b.uploadTime).unix() - dayjs(a.uploadTime).unix() || 45 | b.id.localeCompare(a.id), 46 | ); 47 | dispatch(getFilesForCourseAction.success({files, courseId})); 48 | } catch (err) { 49 | dispatch(getFilesForCourseAction.failure(serializeError(err))); 50 | } 51 | }; 52 | } 53 | 54 | export const getAllFilesForCoursesAction = createAsyncAction( 55 | GET_ALL_FILES_FOR_COURSES_REQUEST, 56 | GET_ALL_FILES_FOR_COURSES_SUCCESS, 57 | GET_ALL_FILES_FOR_COURSES_FAILURE, 58 | )(); 59 | 60 | export function getAllFilesForCourses(courseIds: string[]): ThunkResult { 61 | return async (dispatch, getState) => { 62 | dispatch(getAllFilesForCoursesAction.request()); 63 | 64 | try { 65 | const results = await dataSource.getAllContents( 66 | courseIds, 67 | ContentType.FILE, 68 | ); 69 | const courseNames = getState().courses.names; 70 | const files = Object.keys(results) 71 | .map(courseId => { 72 | const filesForCourse = results[courseId]; 73 | const courseName = courseNames[courseId]; 74 | return filesForCourse.map(file => ({ 75 | ...file, 76 | courseId, 77 | courseName: courseName.name, 78 | courseTeacherName: courseName.teacherName, 79 | })); 80 | }) 81 | .reduce((a, b) => a.concat(b), []) 82 | .sort( 83 | (a, b) => 84 | dayjs(b.uploadTime).unix() - dayjs(a.uploadTime).unix() || 85 | b.id.localeCompare(a.id), 86 | ); 87 | dispatch(getAllFilesForCoursesAction.success(files)); 88 | } catch (err) { 89 | dispatch(getAllFilesForCoursesAction.failure(serializeError(err))); 90 | } 91 | }; 92 | } 93 | 94 | export const setFavFile = createAction( 95 | SET_FAV_FILE, 96 | (fileId: string, flag: boolean) => ({ 97 | fileId, 98 | flag, 99 | }), 100 | )(); 101 | 102 | export const setArchiveFiles = createAction( 103 | SET_ARCHIVE_FILES, 104 | (fileIds: string[], flag: boolean) => ({ 105 | fileIds, 106 | flag, 107 | }), 108 | )(); 109 | -------------------------------------------------------------------------------- /src/data/actions/notices.ts: -------------------------------------------------------------------------------- 1 | import {ApiError, ContentType} from 'thu-learn-lib'; 2 | import {createAction, createAsyncAction} from 'typesafe-actions'; 3 | import dayjs from 'dayjs'; 4 | import {dataSource} from 'data/source'; 5 | import {ThunkResult} from 'data/types/actions'; 6 | import { 7 | GET_ALL_NOTICES_FOR_COURSES_FAILURE, 8 | GET_ALL_NOTICES_FOR_COURSES_REQUEST, 9 | GET_ALL_NOTICES_FOR_COURSES_SUCCESS, 10 | GET_NOTICES_FOR_COURSE_FAILURE, 11 | GET_NOTICES_FOR_COURSE_REQUEST, 12 | GET_NOTICES_FOR_COURSE_SUCCESS, 13 | SET_FAV_NOTICE, 14 | SET_ARCHIVE_NOTICES, 15 | } from 'data/types/constants'; 16 | import {Notice} from 'data/types/state'; 17 | import {serializeError} from 'helpers/parse'; 18 | 19 | export const getNoticesForCourseAction = createAsyncAction( 20 | GET_NOTICES_FOR_COURSE_REQUEST, 21 | GET_NOTICES_FOR_COURSE_SUCCESS, 22 | GET_NOTICES_FOR_COURSE_FAILURE, 23 | )(); 24 | 25 | export function getNoticesForCourse(courseId: string): ThunkResult { 26 | return async (dispatch, getState) => { 27 | dispatch(getNoticesForCourseAction.request()); 28 | 29 | try { 30 | const results = await dataSource.getNotificationList(courseId); 31 | const courseName = getState().courses.names[courseId]; 32 | const notices = results 33 | .map(result => ({ 34 | ...result, 35 | courseId, 36 | courseName: courseName.name, 37 | courseTeacherName: courseName.teacherName, 38 | })) 39 | .sort( 40 | (a, b) => 41 | dayjs(b.publishTime).unix() - dayjs(a.publishTime).unix() || 42 | b.id.localeCompare(a.id), 43 | ); 44 | dispatch(getNoticesForCourseAction.success({notices, courseId})); 45 | } catch (err) { 46 | dispatch(getNoticesForCourseAction.failure(serializeError(err))); 47 | } 48 | }; 49 | } 50 | 51 | export const getAllNoticesForCoursesAction = createAsyncAction( 52 | GET_ALL_NOTICES_FOR_COURSES_REQUEST, 53 | GET_ALL_NOTICES_FOR_COURSES_SUCCESS, 54 | GET_ALL_NOTICES_FOR_COURSES_FAILURE, 55 | )(); 56 | 57 | export function getAllNoticesForCourses(courseIds: string[]): ThunkResult { 58 | return async (dispatch, getState) => { 59 | dispatch(getAllNoticesForCoursesAction.request()); 60 | 61 | try { 62 | const results = await dataSource.getAllContents( 63 | courseIds, 64 | ContentType.NOTIFICATION, 65 | ); 66 | const courseNames = getState().courses.names; 67 | const notices = Object.keys(results) 68 | .map(courseId => { 69 | const noticesForCourse = results[courseId]; 70 | const courseName = courseNames[courseId]; 71 | return noticesForCourse.map(notice => ({ 72 | ...notice, 73 | courseId, 74 | courseName: courseName.name, 75 | courseTeacherName: courseName.teacherName, 76 | })); 77 | }) 78 | .reduce((a, b) => a.concat(b), []) 79 | .sort( 80 | (a, b) => 81 | dayjs(b.publishTime).unix() - dayjs(a.publishTime).unix() || 82 | b.id.localeCompare(a.id), 83 | ); 84 | dispatch(getAllNoticesForCoursesAction.success(notices)); 85 | } catch (err) { 86 | dispatch(getAllNoticesForCoursesAction.failure(serializeError(err))); 87 | } 88 | }; 89 | } 90 | 91 | export const setFavNotice = createAction( 92 | SET_FAV_NOTICE, 93 | (noticeId: string, flag: boolean) => ({ 94 | noticeId, 95 | flag, 96 | }), 97 | )(); 98 | 99 | export const setArchiveNotices = createAction( 100 | SET_ARCHIVE_NOTICES, 101 | (noticeIds: string[], flag: boolean) => ({ 102 | noticeIds, 103 | flag, 104 | }), 105 | )(); 106 | -------------------------------------------------------------------------------- /src/data/actions/root.ts: -------------------------------------------------------------------------------- 1 | import {createAction} from 'typesafe-actions'; 2 | import {persistor} from 'data/store'; 3 | import {ThunkResult} from 'data/types/actions'; 4 | import {CLEAR_STORE, SET_MOCK_STORE, RESET_LOADING} from 'data/types/constants'; 5 | 6 | export const resetLoading = createAction(RESET_LOADING)(); 7 | 8 | export const clearStoreAction = createAction(CLEAR_STORE)(); 9 | 10 | export function clearStore(): ThunkResult { 11 | return async dispatch => { 12 | dispatch(clearStoreAction()); 13 | await persistor.purge(); 14 | persistor.persist(); 15 | }; 16 | } 17 | 18 | export const setMockStore = createAction(SET_MOCK_STORE)(); 19 | -------------------------------------------------------------------------------- /src/data/actions/semesters.ts: -------------------------------------------------------------------------------- 1 | import {createAction, createAsyncAction} from 'typesafe-actions'; 2 | import {ApiError} from 'thu-learn-lib'; 3 | import {dataSource} from 'data/source'; 4 | import {ThunkResult} from 'data/types/actions'; 5 | import { 6 | GET_ALL_SEMESTERS_FAILURE, 7 | GET_ALL_SEMESTERS_REQUEST, 8 | GET_ALL_SEMESTERS_SUCCESS, 9 | GET_CURRENT_SEMESTER_FAILURE, 10 | GET_CURRENT_SEMESTER_REQUEST, 11 | GET_CURRENT_SEMESTER_SUCCESS, 12 | SET_CURRENT_SEMESTER, 13 | } from 'data/types/constants'; 14 | import {serializeError} from 'helpers/parse'; 15 | 16 | export const getAllSemestersAction = createAsyncAction( 17 | GET_ALL_SEMESTERS_REQUEST, 18 | GET_ALL_SEMESTERS_SUCCESS, 19 | GET_ALL_SEMESTERS_FAILURE, 20 | )(); 21 | 22 | export function getAllSemesters(): ThunkResult { 23 | return async dispatch => { 24 | dispatch(getAllSemestersAction.request()); 25 | 26 | try { 27 | const semesters = await dataSource.getSemesterIdList(); 28 | dispatch(getAllSemestersAction.success(semesters.sort().reverse())); 29 | } catch (err) { 30 | dispatch(getAllSemestersAction.failure(serializeError(err))); 31 | } 32 | }; 33 | } 34 | 35 | export const getCurrentSemesterAction = createAsyncAction( 36 | GET_CURRENT_SEMESTER_REQUEST, 37 | GET_CURRENT_SEMESTER_SUCCESS, 38 | GET_CURRENT_SEMESTER_FAILURE, 39 | )(); 40 | 41 | export function getCurrentSemester(): ThunkResult { 42 | return async (dispatch, getState) => { 43 | dispatch(getCurrentSemesterAction.request()); 44 | 45 | try { 46 | const semester = await dataSource.getCurrentSemester(); 47 | dispatch(getCurrentSemesterAction.success()); 48 | 49 | if (!getState().semesters.current) { 50 | dispatch(setCurrentSemester(semester.id)); 51 | } 52 | } catch (err) { 53 | dispatch(getCurrentSemesterAction.failure(serializeError(err))); 54 | } 55 | }; 56 | } 57 | 58 | export const setCurrentSemester = createAction( 59 | SET_CURRENT_SEMESTER, 60 | (semesterId: string) => semesterId, 61 | )(); 62 | -------------------------------------------------------------------------------- /src/data/actions/settings.ts: -------------------------------------------------------------------------------- 1 | import {createAction, PayloadAction} from 'typesafe-actions'; 2 | import { 3 | SET_SETTING, 4 | SET_EVENT_ID_FOR_ASSIGNMENT, 5 | CLEAR_EVENT_IDS, 6 | REMOVE_EVENT_ID_FOR_ASSIGNMENT, 7 | } from 'data/types/constants'; 8 | import {SettingsState} from 'data/types/state'; 9 | 10 | export const setSetting: ( 11 | key: T, 12 | value: Partial, 13 | ) => PayloadAction< 14 | typeof SET_SETTING, 15 | { 16 | key: T; 17 | value: SettingsState[T]; 18 | } 19 | > = createAction(SET_SETTING, (key, value) => ({ 20 | key, 21 | value, 22 | }))(); 23 | 24 | export const setEventIdForAssignment = createAction( 25 | SET_EVENT_ID_FOR_ASSIGNMENT, 26 | (type: 'calendar' | 'reminder', assignmentId: string, eventId: string) => ({ 27 | type, 28 | assignmentId, 29 | eventId, 30 | }), 31 | )(); 32 | 33 | export const removeEventIdForAssignment = createAction( 34 | REMOVE_EVENT_ID_FOR_ASSIGNMENT, 35 | (type: 'calendar' | 'reminder', assignmentId: string) => ({ 36 | type, 37 | assignmentId, 38 | }), 39 | )(); 40 | 41 | export const clearEventIds = createAction( 42 | CLEAR_EVENT_IDS, 43 | (type: 'calendar' | 'reminder') => ({type}), 44 | )(); 45 | -------------------------------------------------------------------------------- /src/data/actions/user.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncAction} from 'typesafe-actions'; 2 | import {CourseType, Language} from 'thu-learn-lib'; 3 | import { 4 | GET_USER_INFO_FAILURE, 5 | GET_USER_INFO_REQUEST, 6 | GET_USER_INFO_SUCCESS, 7 | } from 'data/types/constants'; 8 | import {User} from 'data/types/state'; 9 | import {ThunkResult} from 'data/types/actions'; 10 | import {dataSource} from 'data/source'; 11 | import {isLocaleChinese} from 'helpers/i18n'; 12 | 13 | export const getUserInfoAction = createAsyncAction( 14 | GET_USER_INFO_REQUEST, 15 | GET_USER_INFO_SUCCESS, 16 | GET_USER_INFO_FAILURE, 17 | )(); 18 | 19 | export function getUserInfo(): ThunkResult { 20 | return async dispatch => { 21 | dispatch(getUserInfoAction.request()); 22 | 23 | const lang = isLocaleChinese() ? Language.ZH : Language.EN; 24 | 25 | try { 26 | await dataSource.setLanguage(lang); 27 | } catch (err) {} 28 | 29 | try { 30 | const userInfo = await dataSource.getUserInfo(CourseType.STUDENT); 31 | if (!userInfo) { 32 | return; 33 | } 34 | if (userInfo.department?.includes('(未译)')) { 35 | userInfo.department = userInfo.department.replace('(未译)', ''); 36 | } 37 | dispatch(getUserInfoAction.success(userInfo)); 38 | } catch (err) { 39 | dispatch(getUserInfoAction.failure(err as Error)); 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/data/reducers/assignments.ts: -------------------------------------------------------------------------------- 1 | import {AssignmentsAction} from 'data/types/actions'; 2 | import { 3 | GET_ALL_ASSIGNMENTS_FOR_COURSES_FAILURE, 4 | GET_ALL_ASSIGNMENTS_FOR_COURSES_REQUEST, 5 | GET_ALL_ASSIGNMENTS_FOR_COURSES_SUCCESS, 6 | GET_ASSIGNMENTS_FOR_COURSE_FAILURE, 7 | GET_ASSIGNMENTS_FOR_COURSE_REQUEST, 8 | GET_ASSIGNMENTS_FOR_COURSE_SUCCESS, 9 | SET_FAV_ASSIGNMENT, 10 | SET_ARCHIVE_ASSIGNMENTS, 11 | SET_PENDING_ASSIGNMENT_DATA, 12 | } from 'data/types/constants'; 13 | import {AssignmentsState} from 'data/types/state'; 14 | 15 | export default function assignments( 16 | state: AssignmentsState = { 17 | fetching: false, 18 | favorites: [], 19 | archived: [], 20 | items: [], 21 | pendingAssignmentData: null, 22 | }, 23 | action: AssignmentsAction, 24 | ): AssignmentsState { 25 | switch (action.type) { 26 | case GET_ALL_ASSIGNMENTS_FOR_COURSES_REQUEST: 27 | return { 28 | ...state, 29 | fetching: true, 30 | error: null, 31 | }; 32 | case GET_ALL_ASSIGNMENTS_FOR_COURSES_SUCCESS: 33 | return { 34 | ...state, 35 | fetching: false, 36 | items: action.payload, 37 | error: null, 38 | }; 39 | case GET_ALL_ASSIGNMENTS_FOR_COURSES_FAILURE: 40 | return { 41 | ...state, 42 | fetching: false, 43 | error: action.payload.reason, 44 | }; 45 | case GET_ASSIGNMENTS_FOR_COURSE_REQUEST: 46 | return { 47 | ...state, 48 | fetching: true, 49 | error: null, 50 | }; 51 | case GET_ASSIGNMENTS_FOR_COURSE_SUCCESS: 52 | return { 53 | ...state, 54 | fetching: false, 55 | items: [ 56 | ...state.items.filter( 57 | item => item.courseId !== action.payload.courseId, 58 | ), 59 | ...action.payload.assignments, 60 | ], 61 | error: null, 62 | }; 63 | case GET_ASSIGNMENTS_FOR_COURSE_FAILURE: 64 | return { 65 | ...state, 66 | fetching: false, 67 | error: action.payload.reason, 68 | }; 69 | case SET_FAV_ASSIGNMENT: 70 | if (action.payload.flag) { 71 | return { 72 | ...state, 73 | favorites: [...state.favorites, action.payload.assignmentId], 74 | }; 75 | } else { 76 | return { 77 | ...state, 78 | favorites: state.favorites.filter( 79 | item => item !== action.payload.assignmentId, 80 | ), 81 | }; 82 | } 83 | case SET_ARCHIVE_ASSIGNMENTS: 84 | if (action.payload.flag) { 85 | return { 86 | ...state, 87 | archived: [...state.archived, ...action.payload.assignmentIds], 88 | }; 89 | } else { 90 | return { 91 | ...state, 92 | archived: state.archived.filter( 93 | i => !action.payload.assignmentIds.includes(i), 94 | ), 95 | }; 96 | } 97 | case SET_PENDING_ASSIGNMENT_DATA: 98 | return { 99 | ...state, 100 | pendingAssignmentData: action.payload, 101 | }; 102 | default: 103 | return state; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/data/reducers/auth.ts: -------------------------------------------------------------------------------- 1 | import {AuthAction} from 'data/types/actions'; 2 | import { 3 | LOGIN_FAILURE, 4 | LOGIN_REQUEST, 5 | LOGIN_SUCCESS, 6 | } from 'data/types/constants'; 7 | import {AuthState} from 'data/types/state'; 8 | 9 | export default function auth( 10 | state: AuthState = { 11 | loggingIn: false, 12 | loggedIn: false, 13 | username: null, 14 | password: null, 15 | }, 16 | action: AuthAction, 17 | ): AuthState { 18 | switch (action.type) { 19 | case LOGIN_REQUEST: 20 | if (action.payload.clearCredential) { 21 | return { 22 | ...state, 23 | loggingIn: true, 24 | error: null, 25 | username: null, 26 | password: null, 27 | }; 28 | } 29 | return { 30 | ...state, 31 | loggingIn: true, 32 | error: null, 33 | }; 34 | case LOGIN_SUCCESS: 35 | const payload = action.payload; 36 | if (payload) { 37 | return { 38 | ...state, 39 | loggingIn: false, 40 | loggedIn: true, 41 | username: payload.username, 42 | password: payload.password, 43 | error: null, 44 | }; 45 | } else { 46 | return { 47 | ...state, 48 | loggingIn: false, 49 | loggedIn: true, 50 | error: null, 51 | }; 52 | } 53 | case LOGIN_FAILURE: 54 | return { 55 | ...state, 56 | loggingIn: false, 57 | loggedIn: false, 58 | error: action.payload.reason, 59 | }; 60 | default: 61 | return state; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/data/reducers/courses.ts: -------------------------------------------------------------------------------- 1 | import {CoursesAction} from 'data/types/actions'; 2 | import { 3 | GET_COURSES_FOR_SEMESTER_FAILURE, 4 | GET_COURSES_FOR_SEMESTER_REQUEST, 5 | GET_COURSES_FOR_SEMESTER_SUCCESS, 6 | SET_COURSE_ORDER, 7 | SET_HIDE_COURSE, 8 | } from 'data/types/constants'; 9 | import {CoursesState} from 'data/types/state'; 10 | import {sortByOrder} from 'helpers/reorder'; 11 | 12 | export default function courses( 13 | state: CoursesState = { 14 | fetching: false, 15 | hidden: [], 16 | items: [], 17 | names: {}, 18 | order: [], 19 | }, 20 | action: CoursesAction, 21 | ): CoursesState { 22 | switch (action.type) { 23 | case GET_COURSES_FOR_SEMESTER_REQUEST: 24 | return { 25 | ...state, 26 | fetching: true, 27 | error: null, 28 | }; 29 | case GET_COURSES_FOR_SEMESTER_SUCCESS: { 30 | const courses = action.payload; 31 | const courseOrder = state.order; 32 | const orderedCourses = sortByOrder(courses, courseOrder); 33 | return { 34 | ...state, 35 | fetching: false, 36 | items: orderedCourses, 37 | names: orderedCourses.reduce( 38 | (prev, curr) => ({ 39 | ...prev, 40 | [curr.id]: { 41 | name: curr.name, 42 | teacherName: curr.teacherName, 43 | }, 44 | }), 45 | {}, 46 | ), 47 | error: null, 48 | }; 49 | } 50 | case GET_COURSES_FOR_SEMESTER_FAILURE: 51 | return { 52 | ...state, 53 | fetching: false, 54 | error: action.payload.reason, 55 | }; 56 | case SET_HIDE_COURSE: 57 | if (action.payload.flag) { 58 | return { 59 | ...state, 60 | hidden: [...state.hidden, action.payload.courseId], 61 | }; 62 | } else { 63 | return { 64 | ...state, 65 | hidden: state.hidden.filter(item => item !== action.payload.courseId), 66 | }; 67 | } 68 | case SET_COURSE_ORDER: { 69 | const courses = state.items; 70 | const newOrder = action.payload; 71 | const orderedCourses = sortByOrder(courses, newOrder); 72 | return { 73 | ...state, 74 | order: newOrder, 75 | items: orderedCourses, 76 | }; 77 | } 78 | default: 79 | return state; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/data/reducers/files.ts: -------------------------------------------------------------------------------- 1 | import {FilesAction} from 'data/types/actions'; 2 | import { 3 | GET_ALL_FILES_FOR_COURSES_FAILURE, 4 | GET_ALL_FILES_FOR_COURSES_REQUEST, 5 | GET_ALL_FILES_FOR_COURSES_SUCCESS, 6 | GET_FILES_FOR_COURSE_FAILURE, 7 | GET_FILES_FOR_COURSE_REQUEST, 8 | GET_FILES_FOR_COURSE_SUCCESS, 9 | SET_FAV_FILE, 10 | SET_ARCHIVE_FILES, 11 | } from 'data/types/constants'; 12 | import {FilesState} from 'data/types/state'; 13 | 14 | export default function files( 15 | state: FilesState = { 16 | fetching: false, 17 | favorites: [], 18 | archived: [], 19 | items: [], 20 | }, 21 | action: FilesAction, 22 | ): FilesState { 23 | switch (action.type) { 24 | case GET_ALL_FILES_FOR_COURSES_REQUEST: 25 | return { 26 | ...state, 27 | fetching: true, 28 | error: null, 29 | }; 30 | case GET_ALL_FILES_FOR_COURSES_SUCCESS: 31 | return { 32 | ...state, 33 | fetching: false, 34 | items: action.payload, 35 | error: null, 36 | }; 37 | case GET_ALL_FILES_FOR_COURSES_FAILURE: 38 | return { 39 | ...state, 40 | fetching: false, 41 | error: action.payload.reason, 42 | }; 43 | case GET_FILES_FOR_COURSE_REQUEST: 44 | return { 45 | ...state, 46 | fetching: true, 47 | error: null, 48 | }; 49 | case GET_FILES_FOR_COURSE_SUCCESS: 50 | return { 51 | ...state, 52 | fetching: false, 53 | items: [ 54 | ...state.items.filter( 55 | item => item.courseId !== action.payload.courseId, 56 | ), 57 | ...action.payload.files, 58 | ], 59 | error: null, 60 | }; 61 | case GET_FILES_FOR_COURSE_FAILURE: 62 | return { 63 | ...state, 64 | fetching: false, 65 | error: action.payload.reason, 66 | }; 67 | case SET_FAV_FILE: 68 | if (action.payload.flag) { 69 | return { 70 | ...state, 71 | favorites: [...state.favorites, action.payload.fileId], 72 | }; 73 | } else { 74 | return { 75 | ...state, 76 | favorites: state.favorites.filter( 77 | item => item !== action.payload.fileId, 78 | ), 79 | }; 80 | } 81 | case SET_ARCHIVE_FILES: 82 | if (action.payload.flag) { 83 | return { 84 | ...state, 85 | archived: [...state.archived, ...action.payload.fileIds], 86 | }; 87 | } else { 88 | return { 89 | ...state, 90 | archived: state.archived.filter( 91 | i => !action.payload.fileIds.includes(i), 92 | ), 93 | }; 94 | } 95 | default: 96 | return state; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/data/reducers/notices.ts: -------------------------------------------------------------------------------- 1 | import {NoticesAction} from 'data/types/actions'; 2 | import { 3 | GET_ALL_NOTICES_FOR_COURSES_FAILURE, 4 | GET_ALL_NOTICES_FOR_COURSES_REQUEST, 5 | GET_ALL_NOTICES_FOR_COURSES_SUCCESS, 6 | GET_NOTICES_FOR_COURSE_FAILURE, 7 | GET_NOTICES_FOR_COURSE_REQUEST, 8 | GET_NOTICES_FOR_COURSE_SUCCESS, 9 | SET_FAV_NOTICE, 10 | SET_ARCHIVE_NOTICES, 11 | } from 'data/types/constants'; 12 | import {NoticeState} from 'data/types/state'; 13 | 14 | export default function notices( 15 | state: NoticeState = { 16 | fetching: false, 17 | favorites: [], 18 | archived: [], 19 | items: [], 20 | }, 21 | action: NoticesAction, 22 | ): NoticeState { 23 | switch (action.type) { 24 | case GET_ALL_NOTICES_FOR_COURSES_REQUEST: 25 | return { 26 | ...state, 27 | fetching: true, 28 | error: null, 29 | }; 30 | case GET_ALL_NOTICES_FOR_COURSES_SUCCESS: 31 | return { 32 | ...state, 33 | fetching: false, 34 | items: action.payload, 35 | error: null, 36 | }; 37 | case GET_ALL_NOTICES_FOR_COURSES_FAILURE: 38 | return { 39 | ...state, 40 | fetching: false, 41 | error: action.payload.reason, 42 | }; 43 | case GET_NOTICES_FOR_COURSE_REQUEST: 44 | return { 45 | ...state, 46 | fetching: true, 47 | error: null, 48 | }; 49 | case GET_NOTICES_FOR_COURSE_SUCCESS: 50 | return { 51 | ...state, 52 | fetching: false, 53 | items: [ 54 | ...state.items.filter( 55 | item => item.courseId !== action.payload.courseId, 56 | ), 57 | ...action.payload.notices, 58 | ], 59 | error: null, 60 | }; 61 | case GET_NOTICES_FOR_COURSE_FAILURE: 62 | return { 63 | ...state, 64 | fetching: false, 65 | error: action.payload.reason, 66 | }; 67 | case SET_FAV_NOTICE: 68 | if (action.payload.flag) { 69 | return { 70 | ...state, 71 | favorites: [...state.favorites, action.payload.noticeId], 72 | }; 73 | } else { 74 | return { 75 | ...state, 76 | favorites: state.favorites.filter( 77 | item => item !== action.payload.noticeId, 78 | ), 79 | }; 80 | } 81 | case SET_ARCHIVE_NOTICES: 82 | if (action.payload.flag) { 83 | return { 84 | ...state, 85 | archived: [...state.archived, ...action.payload.noticeIds], 86 | }; 87 | } else { 88 | return { 89 | ...state, 90 | archived: state.archived.filter( 91 | i => !action.payload.noticeIds.includes(i), 92 | ), 93 | }; 94 | } 95 | default: 96 | return state; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/data/reducers/root.ts: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import {PersistConfig, persistReducer} from 'redux-persist'; 3 | import AsyncStorage from '@react-native-async-storage/async-storage'; 4 | import createSecureStore from 'redux-persist-expo-securestore'; 5 | import env from 'helpers/env'; 6 | import mockStore from 'data/mock'; 7 | import {AppActions, StoreAction} from 'data/types/actions'; 8 | import { 9 | CLEAR_STORE, 10 | SET_MOCK_STORE, 11 | RESET_LOADING, 12 | SET_FAV_ASSIGNMENT, 13 | SET_HIDE_COURSE, 14 | SET_COURSE_ORDER, 15 | SET_FAV_NOTICE, 16 | SET_ARCHIVE_NOTICES, 17 | SET_FAV_FILE, 18 | SET_ARCHIVE_FILES, 19 | SET_ARCHIVE_ASSIGNMENTS, 20 | SET_PENDING_ASSIGNMENT_DATA, 21 | SET_SETTING, 22 | SET_CURRENT_SEMESTER, 23 | } from 'data/types/constants'; 24 | import {AppState, AuthState, SettingsState} from 'data/types/state'; 25 | import assignments from './assignments'; 26 | import courses from './courses'; 27 | import files from './files'; 28 | import notices from './notices'; 29 | import semesters from './semesters'; 30 | import auth from './auth'; 31 | import user from './user'; 32 | import settings from './settings'; 33 | 34 | export const mainReducers = { 35 | user, 36 | semesters, 37 | courses, 38 | notices, 39 | assignments, 40 | files, 41 | }; 42 | 43 | const authPersistConfig: PersistConfig = { 44 | key: 'auth', 45 | storage: createSecureStore(), 46 | whitelist: ['username', 'password'], 47 | }; 48 | const settingsPersistConfig: PersistConfig = { 49 | key: 'settings', 50 | storage: AsyncStorage, 51 | blacklist: ['newUpdate'], 52 | }; 53 | 54 | const appReducer = combineReducers({ 55 | auth: persistReducer(authPersistConfig, auth), 56 | settings: persistReducer(settingsPersistConfig, settings), 57 | ...mainReducers, 58 | }); 59 | 60 | export function rootReducer( 61 | state: AppState | undefined, 62 | action: AppActions, 63 | ): AppState { 64 | if (state && action.type === RESET_LOADING) { 65 | return { 66 | ...state, 67 | auth: { 68 | ...state.auth, 69 | loggingIn: false, 70 | }, 71 | semesters: { 72 | ...state.semesters, 73 | fetching: false, 74 | error: null, 75 | }, 76 | courses: { 77 | ...state.courses, 78 | fetching: false, 79 | error: null, 80 | }, 81 | notices: { 82 | ...state.notices, 83 | fetching: false, 84 | error: null, 85 | }, 86 | files: { 87 | ...state.files, 88 | fetching: false, 89 | error: null, 90 | }, 91 | assignments: { 92 | ...state.assignments, 93 | fetching: false, 94 | error: null, 95 | }, 96 | }; 97 | } 98 | 99 | if (action.type === SET_MOCK_STORE) { 100 | return mockStore; 101 | } else if (action.type === CLEAR_STORE) { 102 | state = undefined; 103 | } else if (state && state.auth.username === env.DUMMY_USERNAME) { 104 | if ( 105 | ![ 106 | SET_FAV_NOTICE, 107 | SET_ARCHIVE_NOTICES, 108 | SET_FAV_FILE, 109 | SET_ARCHIVE_FILES, 110 | SET_FAV_ASSIGNMENT, 111 | SET_ARCHIVE_ASSIGNMENTS, 112 | SET_PENDING_ASSIGNMENT_DATA, 113 | SET_HIDE_COURSE, 114 | SET_COURSE_ORDER, 115 | SET_CURRENT_SEMESTER, 116 | SET_SETTING, 117 | ].includes(action.type) 118 | ) { 119 | return state; 120 | } 121 | } 122 | return appReducer(state, action as Exclude); 123 | } 124 | -------------------------------------------------------------------------------- /src/data/reducers/semesters.ts: -------------------------------------------------------------------------------- 1 | import {SemestersAction} from 'data/types/actions'; 2 | import { 3 | GET_ALL_SEMESTERS_FAILURE, 4 | GET_ALL_SEMESTERS_REQUEST, 5 | GET_ALL_SEMESTERS_SUCCESS, 6 | GET_CURRENT_SEMESTER_FAILURE, 7 | GET_CURRENT_SEMESTER_REQUEST, 8 | GET_CURRENT_SEMESTER_SUCCESS, 9 | SET_CURRENT_SEMESTER, 10 | } from 'data/types/constants'; 11 | import {SemestersState} from 'data/types/state'; 12 | 13 | export default function semesters( 14 | state: SemestersState = { 15 | fetching: false, 16 | items: [], 17 | current: null, 18 | }, 19 | action: SemestersAction, 20 | ): SemestersState { 21 | switch (action.type) { 22 | case GET_ALL_SEMESTERS_REQUEST: 23 | return { 24 | ...state, 25 | fetching: true, 26 | error: null, 27 | }; 28 | case GET_ALL_SEMESTERS_SUCCESS: 29 | return { 30 | ...state, 31 | fetching: false, 32 | items: action.payload, 33 | error: null, 34 | }; 35 | case GET_ALL_SEMESTERS_FAILURE: 36 | return { 37 | ...state, 38 | fetching: false, 39 | error: action.payload.reason, 40 | }; 41 | case GET_CURRENT_SEMESTER_REQUEST: 42 | return { 43 | ...state, 44 | fetching: true, 45 | error: null, 46 | }; 47 | case GET_CURRENT_SEMESTER_SUCCESS: 48 | return { 49 | ...state, 50 | fetching: false, 51 | error: null, 52 | }; 53 | case GET_CURRENT_SEMESTER_FAILURE: 54 | return { 55 | ...state, 56 | fetching: false, 57 | error: action.payload.reason, 58 | }; 59 | case SET_CURRENT_SEMESTER: 60 | return { 61 | ...state, 62 | current: action.payload, 63 | }; 64 | default: 65 | return state; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/data/reducers/settings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsAction} from 'data/types/actions'; 2 | import { 3 | CLEAR_EVENT_IDS, 4 | SET_EVENT_ID_FOR_ASSIGNMENT, 5 | REMOVE_EVENT_ID_FOR_ASSIGNMENT, 6 | SET_SETTING, 7 | } from 'data/types/constants'; 8 | import {SettingsState} from 'data/types/state'; 9 | 10 | export default function settings( 11 | state: SettingsState = { 12 | assignmentCalendarSync: false, 13 | assignmentReminderSync: false, 14 | syncedCalendarAssignments: {}, 15 | syncedReminderAssignments: {}, 16 | newUpdate: false, 17 | graduate: false, 18 | fileUseDocumentDir: false, 19 | fileOmitCourseName: false, 20 | tabFilterSelections: { 21 | notice: 'all', 22 | assignment: 'unfinished', 23 | file: 'all', 24 | course: 'all', 25 | }, 26 | alarms: { 27 | assignmentCalendarSecondAlarm: false, 28 | assignmentCalendarAlarm: false, 29 | assignmentCalendarNoAlarmIfComplete: false, 30 | assignmentReminderAlarm: false, 31 | courseAlarm: false, 32 | }, 33 | courseInformationSharing: false, 34 | courseInformationSharingBadgeShown: false, 35 | lastShowChangelogVersion: null, 36 | openFileAfterDownload: false, 37 | }, 38 | action: SettingsAction, 39 | ): SettingsState { 40 | switch (action.type) { 41 | case SET_SETTING: 42 | const oldValue = state[action.payload.key]; 43 | const newValue = action.payload.value; 44 | if (typeof oldValue === 'object' && typeof newValue === 'object') { 45 | return { 46 | ...state, 47 | [action.payload.key]: { 48 | ...oldValue, 49 | ...newValue, 50 | }, 51 | }; 52 | } else { 53 | return { 54 | ...state, 55 | [action.payload.key]: newValue, 56 | }; 57 | } 58 | case SET_EVENT_ID_FOR_ASSIGNMENT: 59 | if (action.payload.type === 'calendar') { 60 | return { 61 | ...state, 62 | syncedCalendarAssignments: { 63 | ...state.syncedCalendarAssignments, 64 | [action.payload.assignmentId]: action.payload.eventId, 65 | }, 66 | }; 67 | } else { 68 | return { 69 | ...state, 70 | syncedReminderAssignments: { 71 | ...state.syncedReminderAssignments, 72 | [action.payload.assignmentId]: action.payload.eventId, 73 | }, 74 | }; 75 | } 76 | case REMOVE_EVENT_ID_FOR_ASSIGNMENT: 77 | if (action.payload.type === 'calendar') { 78 | const syncedCalendarAssignments = {...state.syncedCalendarAssignments}; 79 | delete syncedCalendarAssignments[action.payload.assignmentId]; 80 | return { 81 | ...state, 82 | syncedCalendarAssignments, 83 | }; 84 | } else { 85 | const syncedReminderAssignments = {...state.syncedReminderAssignments}; 86 | delete syncedReminderAssignments[action.payload.assignmentId]; 87 | return { 88 | ...state, 89 | syncedReminderAssignments, 90 | }; 91 | } 92 | case CLEAR_EVENT_IDS: 93 | if (action.payload.type === 'calendar') { 94 | return { 95 | ...state, 96 | syncedCalendarAssignments: {}, 97 | }; 98 | } else { 99 | return { 100 | ...state, 101 | syncedReminderAssignments: {}, 102 | }; 103 | } 104 | default: 105 | return state; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/data/reducers/user.ts: -------------------------------------------------------------------------------- 1 | import {UserAction} from 'data/types/actions'; 2 | import {GET_USER_INFO_SUCCESS} from 'data/types/constants'; 3 | import {UserState} from 'data/types/state'; 4 | 5 | export default function user( 6 | state: UserState = { 7 | name: null, 8 | department: null, 9 | }, 10 | action: UserAction, 11 | ): UserState { 12 | switch (action.type) { 13 | case GET_USER_INFO_SUCCESS: 14 | return { 15 | ...state, 16 | ...action.payload, 17 | }; 18 | default: 19 | return state; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/data/source.ts: -------------------------------------------------------------------------------- 1 | import {Learn2018Helper, addCSRFTokenToUrl} from 'thu-learn-lib'; 2 | import mime from 'mime-types'; 3 | import axios from 'axios'; 4 | import {store} from 'data/store'; 5 | import Urls from 'constants/Urls'; 6 | 7 | export let dataSource: Learn2018Helper; 8 | 9 | export const resetDataSource = () => { 10 | dataSource = new Learn2018Helper({ 11 | provider: () => { 12 | const state = store.getState(); 13 | return { 14 | username: state.auth.username || undefined, 15 | password: state.auth.password || undefined, 16 | }; 17 | }, 18 | }); 19 | }; 20 | resetDataSource(); 21 | 22 | export const addCSRF = (url: string) => { 23 | if (new URL(url).hostname?.endsWith('tsinghua.edu.cn')) { 24 | return addCSRFTokenToUrl(url, dataSource.getCSRFToken()); 25 | } 26 | return url; 27 | }; 28 | 29 | const submitAssignmentUrl = `${Urls.learn}/b/wlxt/kczy/zy/student/tjzy`; 30 | 31 | export const submitAssignment = async ( 32 | studentHomeworkId: string, 33 | content?: string, 34 | attachment?: { 35 | uri: string; 36 | name: string; 37 | }, 38 | onProgress?: (progress: number) => void, 39 | remove: boolean = false, 40 | ) => { 41 | if (!content && !attachment && !remove) { 42 | return; 43 | } 44 | 45 | const body = new FormData(); 46 | body.append('xszyid', studentHomeworkId); 47 | body.append('zynr', content || ''); 48 | body.append('isDeleted', remove ? '1' : '0'); 49 | if (attachment) { 50 | body.append('fileupload', { 51 | uri: attachment.uri, 52 | name: attachment.name, 53 | type: mime.lookup(attachment.uri) || 'application/octet-stream', 54 | } as any); 55 | } 56 | 57 | try { 58 | await dataSource.login(); 59 | } catch { 60 | throw new Error('Failed to submit the assignment: login failed'); 61 | } 62 | 63 | const url = addCSRF(submitAssignmentUrl); 64 | const res = await axios.post(url, body, { 65 | headers: { 66 | 'Content-Type': 'multipart/form-data', 67 | }, 68 | onUploadProgress: e => { 69 | // New architecture doesn't like native floats 70 | onProgress?.(parseFloat((e.total ? e.loaded / e.total : 0).toFixed(2))); 71 | }, 72 | }); 73 | 74 | if (res.status !== 200) { 75 | throw new Error('Failed to submit the assignment: invalid login session'); 76 | } 77 | if (res.data?.result === 'error') { 78 | throw new Error('Failed to submit the assignment: ' + res.data?.msg); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/data/store.ts: -------------------------------------------------------------------------------- 1 | import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'; 2 | import { 3 | persistStore, 4 | persistReducer, 5 | FLUSH, 6 | REHYDRATE, 7 | PAUSE, 8 | PERSIST, 9 | PURGE, 10 | REGISTER, 11 | PersistConfig, 12 | } from 'redux-persist'; 13 | import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'; 14 | import AsyncStorage from '@react-native-async-storage/async-storage'; 15 | import {configureStore} from '@reduxjs/toolkit'; 16 | import {rootReducer} from 'data/reducers/root'; 17 | import {AppState, PersistAppState} from 'data/types/state'; 18 | import {AppActions} from 'data/types/actions'; 19 | 20 | const rootPersistConfig: PersistConfig = { 21 | key: 'root', 22 | storage: AsyncStorage, 23 | stateReconciler: autoMergeLevel2, 24 | blacklist: ['auth', 'settings'], 25 | }; 26 | 27 | export const store = configureStore({ 28 | reducer: persistReducer(rootPersistConfig, rootReducer), 29 | middleware: getDefaultMiddleware => 30 | getDefaultMiddleware({ 31 | serializableCheck: { 32 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 33 | }, 34 | }), 35 | }); 36 | export const persistor = persistStore(store); 37 | 38 | export type AppDispatch = typeof store.dispatch; 39 | export const useAppDispatch = () => useDispatch(); 40 | export const useAppSelector: TypedUseSelectorHook< 41 | ReturnType 42 | > = useSelector; 43 | -------------------------------------------------------------------------------- /src/data/types/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN_REQUEST = 'LOGIN_REQUEST'; 2 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 3 | export const LOGIN_FAILURE = 'LOGIN_FAILURE'; 4 | 5 | export const GET_USER_INFO_REQUEST = 'GET_USER_INFO_REQUEST'; 6 | export const GET_USER_INFO_SUCCESS = 'GET_USER_INFO_SUCCESS'; 7 | export const GET_USER_INFO_FAILURE = 'GET_USER_INFO_FAILURE'; 8 | 9 | export const GET_ALL_SEMESTERS_REQUEST = 'GET_ALL_SEMESTERS_REQUEST'; 10 | export const GET_ALL_SEMESTERS_SUCCESS = 'GET_ALL_SEMESTERS_SUCCESS'; 11 | export const GET_ALL_SEMESTERS_FAILURE = 'GET_ALL_SEMESTERS_FAILURE'; 12 | export const GET_CURRENT_SEMESTER_REQUEST = 'GET_CURRENT_SEMESTER_REQUEST'; 13 | export const GET_CURRENT_SEMESTER_SUCCESS = 'GET_CURRENT_SEMESTER_SUCCESS'; 14 | export const GET_CURRENT_SEMESTER_FAILURE = 'GET_CURRENT_SEMESTER_FAILURE'; 15 | export const SET_CURRENT_SEMESTER = 'SET_CURRENT_SEMESTER'; 16 | 17 | export const GET_COURSES_FOR_SEMESTER_REQUEST = 18 | 'GET_COURSES_FOR_SEMESTER_REQUEST'; 19 | export const GET_COURSES_FOR_SEMESTER_SUCCESS = 20 | 'GET_COURSES_FOR_SEMESTER_SUCCESS'; 21 | export const GET_COURSES_FOR_SEMESTER_FAILURE = 22 | 'GET_COURSES_FOR_SEMESTER_FAILURE'; 23 | export const SET_HIDE_COURSE = 'SET_HIDE_COURSE'; 24 | export const SET_COURSE_ORDER = 'SET_COURSE_ORDER'; 25 | 26 | export const GET_NOTICES_FOR_COURSE_REQUEST = 'GET_NOTICES_FOR_COURSE_REQUEST'; 27 | export const GET_NOTICES_FOR_COURSE_SUCCESS = 'GET_NOTICES_FOR_COURSE_SUCCESS'; 28 | export const GET_NOTICES_FOR_COURSE_FAILURE = 'GET_NOTICES_FOR_COURSE_FAILURE'; 29 | export const GET_ALL_NOTICES_FOR_COURSES_REQUEST = 30 | 'GET_ALL_NOTICES_FOR_COURSES_REQUEST'; 31 | export const GET_ALL_NOTICES_FOR_COURSES_SUCCESS = 32 | 'GET_ALL_NOTICES_FOR_COURSES_SUCCESS'; 33 | export const GET_ALL_NOTICES_FOR_COURSES_FAILURE = 34 | 'GET_ALL_NOTICES_FOR_COURSES_FAILURE'; 35 | export const SET_FAV_NOTICE = 'SET_FAV_NOTICE'; 36 | export const SET_ARCHIVE_NOTICES = 'SET_ARCHIVE_NOTICES'; 37 | 38 | export const GET_ASSIGNMENTS_FOR_COURSE_REQUEST = 39 | 'GET_ASSIGNMENTS_FOR_COURSE_REQUEST'; 40 | export const GET_ASSIGNMENTS_FOR_COURSE_SUCCESS = 41 | 'GET_ASSIGNMENTS_FOR_COURSE_SUCCESS'; 42 | export const GET_ASSIGNMENTS_FOR_COURSE_FAILURE = 43 | 'GET_ASSIGNMENTS_FOR_COURSE_FAILURE'; 44 | export const GET_ALL_ASSIGNMENTS_FOR_COURSES_REQUEST = 45 | 'GET_ALL_ASSIGNMENTS_FOR_COURSES_REQUEST'; 46 | export const GET_ALL_ASSIGNMENTS_FOR_COURSES_SUCCESS = 47 | 'GET_ALL_ASSIGNMENTS_FOR_COURSES_SUCCESS'; 48 | export const GET_ALL_ASSIGNMENTS_FOR_COURSES_FAILURE = 49 | 'GET_ALL_ASSIGNMENTS_FOR_COURSES_FAILURE'; 50 | export const SET_FAV_ASSIGNMENT = 'SET_FAV_ASSIGNMENT'; 51 | export const SET_ARCHIVE_ASSIGNMENTS = 'SET_ARCHIVE_ASSIGNMENTS'; 52 | export const SET_PENDING_ASSIGNMENT_DATA = 'SET_PENDING_ASSIGNMENT_DATA'; 53 | 54 | export const GET_FILES_FOR_COURSE_REQUEST = 'GET_FILES_FOR_COURSE_REQUEST'; 55 | export const GET_FILES_FOR_COURSE_SUCCESS = 'GET_FILES_FOR_COURSE_SUCCESS'; 56 | export const GET_FILES_FOR_COURSE_FAILURE = 'GET_FILES_FOR_COURSE_FAILURE'; 57 | export const GET_ALL_FILES_FOR_COURSES_REQUEST = 58 | 'GET_ALL_FILES_FOR_COURSES_REQUEST'; 59 | export const GET_ALL_FILES_FOR_COURSES_SUCCESS = 60 | 'GET_ALL_FILES_FOR_COURSES_SUCCESS'; 61 | export const GET_ALL_FILES_FOR_COURSES_FAILURE = 62 | 'GET_ALL_FILES_FOR_COURSES_FAILURE'; 63 | export const SET_FAV_FILE = 'SET_FAV_FILE'; 64 | export const SET_ARCHIVE_FILES = 'SET_ARCHIVE_FILES'; 65 | 66 | export const SET_SETTING = 'SET_SETTING'; 67 | export const SET_EVENT_ID_FOR_ASSIGNMENT = 'SET_EVENT_ID_FOR_ASSIGNMENT'; 68 | export const REMOVE_EVENT_ID_FOR_ASSIGNMENT = 'REMOVE_EVENT_ID_FOR_ASSIGNMENT'; 69 | export const CLEAR_EVENT_IDS = 'CLEAR_EVENT_IDS'; 70 | 71 | export const RESET_LOADING = 'RESET_LOADING'; 72 | export const CLEAR_STORE = 'CLEAR_STORE'; 73 | export const SET_MOCK_STORE = 'SET_MOCK_STORE'; 74 | -------------------------------------------------------------------------------- /src/helpers/coursex.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {gql, GraphQLClient} from 'graphql-request'; 3 | import {Course} from 'data/types/state'; 4 | import env from './env'; 5 | 6 | const graphQLClient = new GraphQLClient('https://api.tsinghua.app/v1/graphql'); 7 | 8 | export const uploadCourses = async (courses: Course[]) => { 9 | const response = await axios.post('https://tsinghua.app/api/auth/session', { 10 | refreshToken: env.COURSEX_REFRESH_TOKEN, 11 | }); 12 | 13 | const accessToken = response.data.accessToken; 14 | 15 | if (!accessToken) { 16 | return; 17 | } 18 | 19 | const coursesToUpload = courses.map(c => ({ 20 | id: c.id, 21 | name: c.chineseName, 22 | teacher: { 23 | data: {id: c.teacherNumber, name: c.teacherName}, 24 | on_conflict: {constraint: 'teacher_pkey', update_columns: ['name']}, 25 | }, 26 | time_location: JSON.stringify(c.timeAndLocation), 27 | semester_id: c.semesterId, 28 | number: c.courseNumber, 29 | index: c.courseIndex, 30 | englishName: c.englishName, 31 | })); 32 | 33 | await graphQLClient.request( 34 | gql` 35 | mutation AddCourses($objects: [course_insert_input!]!) { 36 | insert_course( 37 | objects: $objects 38 | on_conflict: { 39 | constraint: course_pkey 40 | update_columns: [time_location, name, englishName] 41 | } 42 | ) { 43 | affected_rows 44 | } 45 | } 46 | `, 47 | { 48 | objects: coursesToUpload, 49 | }, 50 | { 51 | Authorization: 'Bearer ' + accessToken, 52 | }, 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/helpers/env.ts: -------------------------------------------------------------------------------- 1 | declare const preval: any; 2 | 3 | const env = preval` 4 | const path = require("path"); 5 | const p = path.resolve(process.cwd(), process.cwd().includes("ios") ? '../.env': './.env'); 6 | const env = require('dotenv').config({ path: p }) 7 | module.exports = env.parsed 8 | `; 9 | 10 | export default env; 11 | -------------------------------------------------------------------------------- /src/helpers/i18n.ts: -------------------------------------------------------------------------------- 1 | import {getLocales} from 'react-native-localize'; 2 | import {HomeworkGradeLevel} from 'thu-learn-lib'; 3 | import en from 'assets/translations/en'; 4 | import zh from 'assets/translations/zh'; 5 | 6 | export const getLocale = () => { 7 | const preferredLocales = getLocales(); 8 | return preferredLocales[0].languageTag; 9 | }; 10 | 11 | export const isLocaleChinese = () => getLocale().startsWith('zh'); 12 | 13 | type TranslationKey = typeof en | typeof zh; 14 | 15 | const translations = (isLocaleChinese() ? zh : en) as TranslationKey; 16 | 17 | export function t(key: K): string { 18 | return translations[key]; 19 | } 20 | 21 | const assignmentGradeLevelDescriptionMap: Partial<{ 22 | [key in HomeworkGradeLevel]: keyof TranslationKey; 23 | }> = { 24 | [HomeworkGradeLevel.CHECKED]: 'reviewed', 25 | [HomeworkGradeLevel.DISTINCTION]: 'good', 26 | [HomeworkGradeLevel.EXEMPTED_COURSE]: 'exemptedCourse', 27 | [HomeworkGradeLevel.EXEMPTION]: 'exempted', 28 | [HomeworkGradeLevel.PASS]: 'pass', 29 | [HomeworkGradeLevel.FAILURE]: 'fail', 30 | [HomeworkGradeLevel.INCOMPLETE]: 'incomplete', 31 | }; 32 | 33 | export const getAssignmentGradeLevelDescription = ( 34 | gradeLevel: HomeworkGradeLevel, 35 | ) => { 36 | const translationKey = assignmentGradeLevelDescriptionMap[gradeLevel]; 37 | if (!translationKey) { 38 | return gradeLevel; 39 | } 40 | return t(translationKey); 41 | }; 42 | -------------------------------------------------------------------------------- /src/helpers/parse.ts: -------------------------------------------------------------------------------- 1 | import {ApiError, FailReason} from 'thu-learn-lib'; 2 | import {isLocaleChinese} from './i18n'; 3 | 4 | export const getSemesterTextFromId = (semesterId: string) => { 5 | const texts = semesterId.split('-'); 6 | return isLocaleChinese() 7 | ? `${texts?.[0]}-${texts?.[1]} 学年${ 8 | texts?.[2] === '1' 9 | ? '秋季学期' 10 | : texts?.[2] === '2' 11 | ? '春季学期' 12 | : '夏季学期' 13 | }` 14 | : `${ 15 | texts?.[2] === '1' ? 'Fall' : texts?.[2] === '2' ? 'Spring' : 'Summer' 16 | } ${texts?.[0]}-${texts?.[1]}`; 17 | }; 18 | 19 | export const serializeError = (err: any) => { 20 | if ((err as ApiError).reason) { 21 | const returnedError = err as ApiError; 22 | returnedError.extra = JSON.stringify(returnedError.extra); 23 | return returnedError; 24 | } else { 25 | const returnedError: ApiError = { 26 | reason: FailReason.UNEXPECTED_STATUS, 27 | }; 28 | return returnedError; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/helpers/preval/darkreader.preval.js: -------------------------------------------------------------------------------- 1 | // @preval 2 | 3 | const readFiles = require('./readFile.js'); 4 | 5 | module.exports = readFiles(['darkreader/darkreader.js']); 6 | -------------------------------------------------------------------------------- /src/helpers/preval/katex.preval.js: -------------------------------------------------------------------------------- 1 | // @preval 2 | 3 | const readFiles = require('./readFile.js'); 4 | 5 | module.exports = readFiles([ 6 | 'katex/dist/katex.min.js', 7 | 'katex/dist/contrib/auto-render.min.js', 8 | ]); 9 | -------------------------------------------------------------------------------- /src/helpers/preval/katexMathtexScript.preval.js: -------------------------------------------------------------------------------- 1 | // @preval 2 | 3 | const readFiles = require('./readFile.js'); 4 | 5 | module.exports = readFiles(['katex/dist/contrib/mathtex-script-type.min.js']); 6 | -------------------------------------------------------------------------------- /src/helpers/preval/katexStyles.preval.js: -------------------------------------------------------------------------------- 1 | // @preval 2 | 3 | const readFiles = require('./readFile.js'); 4 | const packageJson = require('../../../package.json'); 5 | 6 | const katexVersion = packageJson.dependencies.katex; 7 | 8 | const CDN_URL = `https://fastly.jsdelivr.net/npm/katex@${katexVersion}/dist/fonts/`; 9 | 10 | let cssFileContent = readFiles(['katex/dist/katex.min.css']); 11 | cssFileContent = cssFileContent.replaceAll('url(fonts/', `url(${CDN_URL}`); 12 | 13 | module.exports = cssFileContent; 14 | -------------------------------------------------------------------------------- /src/helpers/preval/readFile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const basePath = process.cwd().includes('ios') 5 | ? path.resolve(process.cwd(), '../node_modules') 6 | : path.resolve(process.cwd(), './node_modules'); 7 | 8 | function readFiles(filePaths) { 9 | return filePaths 10 | .map(filePath => { 11 | return fs.readFileSync(path.resolve(basePath, filePath), 'utf8'); 12 | }) 13 | .join('\n'); 14 | } 15 | 16 | module.exports = readFiles; 17 | -------------------------------------------------------------------------------- /src/helpers/reorder.ts: -------------------------------------------------------------------------------- 1 | export const sortByOrder = ( 2 | items: T[], 3 | order: string[], 4 | ) => { 5 | const itemMap = items.reduce((acc, item) => { 6 | acc.set(item.id, item); 7 | return acc; 8 | }, new Map()); 9 | 10 | const orderedItems: T[] = []; 11 | for (const itemId of order) { 12 | if (itemMap.has(itemId)) { 13 | orderedItems.push(itemMap.get(itemId)!); 14 | itemMap.delete(itemId); 15 | } 16 | } 17 | for (const item of itemMap.values()) { 18 | orderedItems.push(item); 19 | } 20 | 21 | return orderedItems; 22 | }; 23 | -------------------------------------------------------------------------------- /src/helpers/retry.ts: -------------------------------------------------------------------------------- 1 | export async function retry( 2 | fn: () => Promise, 3 | retries = 3, 4 | delay = 1000, 5 | err: Error | null = null, 6 | ): Promise { 7 | if (retries === 0) { 8 | return Promise.reject(err); 9 | } 10 | 11 | return fn().catch(async (err: Error) => { 12 | const backoff = delay * Math.pow(2, 3 - retries); 13 | const jitter = Math.random() * 100; 14 | 15 | return new Promise(resolve => setTimeout(resolve, backoff + jitter)).then( 16 | () => retry(fn, retries - 1, delay, err), 17 | ); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/update.ts: -------------------------------------------------------------------------------- 1 | import {Platform} from 'react-native'; 2 | import DeviceInfo from 'constants/DeviceInfo'; 3 | 4 | const tunaMirrorUrl = 5 | 'https://mirrors.tuna.tsinghua.edu.cn/github-release/robertying/learnX/LatestRelease'; 6 | 7 | export const getLatestRelease = async () => { 8 | const response = await fetch(`${tunaMirrorUrl}/latest.json`); 9 | const json = await response.json(); 10 | const version = json.version as string; 11 | 12 | if (Platform.OS === 'android') { 13 | const url = `${tunaMirrorUrl}/learnX-${await DeviceInfo.abi()}-v${version}.apk`; 14 | 15 | return { 16 | version, 17 | url, 18 | }; 19 | } else { 20 | const url = `${tunaMirrorUrl}/learnX-mac-v${version}.zip`; 21 | 22 | return { 23 | version, 24 | url, 25 | }; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useDetailNavigator.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import {SplitViewContext} from 'components/SplitView'; 3 | 4 | const useDetailNavigator = () => { 5 | const context = useContext(SplitViewContext); 6 | 7 | return context.detailNavigationContainerRef?.current; 8 | }; 9 | 10 | export default useDetailNavigator; 11 | -------------------------------------------------------------------------------- /src/hooks/useFilteredData.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import {Assignment, File, Notice} from 'data/types/state'; 3 | 4 | function useFilteredData({ 5 | data, 6 | fav, 7 | archived, 8 | hidden, 9 | }: { 10 | data: T[]; 11 | fav: string[]; 12 | archived: string[]; 13 | hidden: string[]; 14 | }) { 15 | const _all = useMemo( 16 | () => 17 | data.filter( 18 | i => !archived.includes(i.id) && !hidden.includes(i.courseId), 19 | ), 20 | [data, archived, hidden], 21 | ); 22 | 23 | const _unread = useMemo( 24 | () => 25 | _all.filter( 26 | i => 27 | (i as File).isNew || ((i as Notice).hasRead === false ? true : false), 28 | ), 29 | [_all], 30 | ); 31 | 32 | const _fav = useMemo(() => _all.filter(i => fav.includes(i.id)), [_all, fav]); 33 | 34 | const _hidden = useMemo( 35 | () => data.filter(i => hidden.includes(i.courseId)), 36 | [hidden, data], 37 | ); 38 | 39 | const _archived = useMemo( 40 | () => data.filter(i => archived.includes(i.id)), 41 | [data, archived], 42 | ); 43 | 44 | const _unfinished = useMemo( 45 | () => _all.filter(i => !(i as Assignment).submitted), 46 | [_all], 47 | ); 48 | 49 | const _finished = useMemo( 50 | () => _all.filter(i => (i as Assignment).submitted), 51 | [_all], 52 | ); 53 | 54 | return { 55 | all: _all, 56 | unread: _unread, 57 | fav: _fav, 58 | archived: _archived, 59 | hidden: _hidden, 60 | unfinished: _unfinished, 61 | finished: _finished, 62 | }; 63 | } 64 | 65 | export default useFilteredData; 66 | -------------------------------------------------------------------------------- /src/hooks/useNavigationAnimation.ts: -------------------------------------------------------------------------------- 1 | import {useLayoutEffect} from 'react'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {ParamListBase} from '@react-navigation/native'; 4 | import {ExtraParams} from 'screens/types'; 5 | 6 | const useNavigationAnimation = ({ 7 | navigation, 8 | route, 9 | }: NativeStackScreenProps) => { 10 | const disableAnimation = (route.params as ExtraParams)?.disableAnimation; 11 | 12 | useLayoutEffect(() => { 13 | if (disableAnimation && navigation) { 14 | navigation.setOptions({ 15 | animation: 'none', 16 | }); 17 | } 18 | }, [navigation, disableAnimation]); 19 | }; 20 | 21 | export default useNavigationAnimation; 22 | -------------------------------------------------------------------------------- /src/hooks/useSearch.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import Fuse, {IFuseOptions} from 'fuse.js'; 3 | import {Assignment, File, Notice} from 'data/types/state'; 4 | 5 | const noticeOptions: IFuseOptions = { 6 | keys: [ 7 | 'courseName', 8 | 'courseTeacherName', 9 | 'publisher', 10 | 'attachmentName', 11 | {name: 'title', weight: 2}, 12 | {name: 'content', weight: 2}, 13 | ], 14 | }; 15 | 16 | const assignmentOptions: IFuseOptions = { 17 | keys: [ 18 | 'courseName', 19 | 'courseTeacherName', 20 | 'attachmentName', 21 | 'submittedContent', 22 | 'submittedAttachmentName', 23 | 'graderName', 24 | 'gradeContent', 25 | 'gradeAttachmentName', 26 | 'answerContent', 27 | 'answerAttachmentName', 28 | {name: 'title', weight: 2}, 29 | {name: 'description', weight: 2}, 30 | ], 31 | }; 32 | 33 | const fileOptions: IFuseOptions = { 34 | keys: [ 35 | 'courseName', 36 | 'courseTeacherName', 37 | {name: 'title', weight: 2}, 38 | {name: 'description', weight: 2}, 39 | {name: 'fileType', weight: 2}, 40 | {name: 'category.title', weight: 2}, 41 | ], 42 | }; 43 | 44 | export default function useSearch( 45 | notices: Notice[], 46 | assignments: Assignment[], 47 | files: File[], 48 | query: string, 49 | ) { 50 | const noticeFuse = useMemo(() => new Fuse(notices, noticeOptions), [notices]); 51 | const assignmentFuse = useMemo( 52 | () => new Fuse(assignments, assignmentOptions), 53 | [assignments], 54 | ); 55 | const fileFuse = useMemo(() => new Fuse(files, fileOptions), [files]); 56 | 57 | return [ 58 | noticeFuse.search(query).map(i => i.item), 59 | assignmentFuse.search(query).map(i => i.item), 60 | fileFuse.search(query).map(i => i.item), 61 | ]; 62 | } 63 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import {ToastContext} from 'components/Toast'; 3 | 4 | const useToast = () => { 5 | const context = useContext(ToastContext); 6 | 7 | return context.toggleToast; 8 | }; 9 | 10 | export default useToast; 11 | -------------------------------------------------------------------------------- /src/screens/About.tsx: -------------------------------------------------------------------------------- 1 | import {Linking, ScrollView, StyleSheet} from 'react-native'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {Title, Text} from 'react-native-paper'; 4 | import DeviceInfo from 'constants/DeviceInfo'; 5 | import Colors from 'constants/Colors'; 6 | import SafeArea from 'components/SafeArea'; 7 | import useNavigationAnimation from 'hooks/useNavigationAnimation'; 8 | import {t} from 'helpers/i18n'; 9 | import {SettingsStackParams} from './types'; 10 | import packageJson from '../../package.json'; 11 | 12 | type Props = NativeStackScreenProps; 13 | 14 | const About: React.FC = props => { 15 | useNavigationAnimation(props); 16 | 17 | return ( 18 | 19 | 22 | {t('versionInformation')} 23 | 24 | {`v${packageJson.version} (build ${DeviceInfo.buildNo()})`} 25 | 26 | © 2025 Rui Ying 27 | Linking.openURL('https://beian.miit.gov.cn/')}> 30 | 浙ICP备20024838号-2A 31 | 32 | {t('maintainers')} 33 | Linking.openURL('https://github.com/robertying')}> 36 | Rui Ying (robertying) 37 | 38 | Linking.openURL('https://github.com/hezicyan')}> 41 | HeziCyan (hezicyan) 42 | 43 | 44 | {t('opensourceAt')}{' '} 45 | 48 | Linking.openURL('https://github.com/robertying/learnX') 49 | }> 50 | robertying/learnX 51 | 52 | 53 | {t('specialThanks')} 54 | 55 | {t('harryChen')}{' '} 56 | 59 | Linking.openURL('https://github.com/Harry-Chen/thu-learn-lib') 60 | }> 61 | Harry-Chen/thu-learn-lib 62 | 63 | 64 | {t('yayuXiao')} 65 | {t('opensourceDependencies')} 66 | {Object.keys(packageJson.dependencies).map(name => ( 67 | 68 | {name} 69 | 70 | ))} 71 | 72 | 73 | ); 74 | }; 75 | 76 | const styles = StyleSheet.create({ 77 | marginTop: { 78 | marginTop: 32, 79 | }, 80 | scrollView: { 81 | paddingHorizontal: 24, 82 | }, 83 | scrollViewPaddings: { 84 | paddingVertical: 32, 85 | }, 86 | text: { 87 | marginTop: 8, 88 | }, 89 | link: { 90 | color: Colors.theme, 91 | }, 92 | }); 93 | 94 | export default About; 95 | -------------------------------------------------------------------------------- /src/screens/Changelog.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef, useState} from 'react'; 2 | import {Linking, Platform, StyleSheet} from 'react-native'; 3 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 4 | import WebView, {WebViewNavigation} from 'react-native-webview'; 5 | import {ProgressBar} from 'react-native-paper'; 6 | import SafeArea from 'components/SafeArea'; 7 | import {useAppDispatch} from 'data/store'; 8 | import {setSetting} from 'data/actions/settings'; 9 | import useNavigationAnimation from 'hooks/useNavigationAnimation'; 10 | import {SettingsStackParams} from './types'; 11 | import packageJson from '../../package.json'; 12 | 13 | type Props = NativeStackScreenProps; 14 | 15 | const Changelog: React.FC = props => { 16 | const dispatch = useAppDispatch(); 17 | 18 | const webViewRef = useRef(null); 19 | const [progress, setProgress] = useState(0); 20 | 21 | const onNavigationStateChange = (e: WebViewNavigation) => { 22 | if (e.navigationType === 'click') { 23 | if (webViewRef.current) { 24 | webViewRef.current.stopLoading(); 25 | } 26 | Linking.openURL(e.url); 27 | } 28 | }; 29 | 30 | useNavigationAnimation(props); 31 | 32 | useEffect(() => { 33 | dispatch(setSetting('lastShowChangelogVersion', packageJson.version)); 34 | }, [dispatch]); 35 | 36 | return ( 37 | 38 | {progress ? ( 39 | 40 | ) : undefined} 41 | { 46 | // New architecture doesn't like native floats 47 | setProgress(parseFloat(nativeEvent.progress.toFixed(2))); 48 | }} 49 | decelerationRate={Platform.OS === 'ios' ? 'normal' : undefined} 50 | source={{ 51 | uri: 'https://github.com/robertying/learnX/releases', 52 | }} 53 | style={{backgroundColor: 'transparent'}} 54 | /> 55 | 56 | ); 57 | }; 58 | 59 | const styles = StyleSheet.create({ 60 | progressBar: { 61 | position: 'absolute', 62 | top: 0, 63 | left: 0, 64 | right: 0, 65 | zIndex: 99, 66 | }, 67 | }); 68 | 69 | export default Changelog; 70 | -------------------------------------------------------------------------------- /src/screens/CourseX.tsx: -------------------------------------------------------------------------------- 1 | import {useRef, useState} from 'react'; 2 | import {Linking, Platform, StyleSheet, View} from 'react-native'; 3 | import {IconButton, ProgressBar, useTheme} from 'react-native-paper'; 4 | import WebView, {WebViewNavigation} from 'react-native-webview'; 5 | import {useSafeAreaInsets} from 'react-native-safe-area-context'; 6 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 7 | import SafeArea from 'components/SafeArea'; 8 | import {CourseXStackParams} from './types'; 9 | 10 | type Props = NativeStackScreenProps; 11 | 12 | const CourseX: React.FC = ({route}) => { 13 | const courseId = route.params?.id ?? ''; 14 | 15 | const theme = useTheme(); 16 | const safeAreaInsets = useSafeAreaInsets(); 17 | 18 | const webViewRef = useRef(null); 19 | const [progress, setProgress] = useState(0); 20 | const [currentUrl, setCurrentUrl] = useState(''); 21 | const [canGoBack, setCanGoBack] = useState(false); 22 | const [canGoForward, setCanGoForward] = useState(false); 23 | 24 | const handleNavigation = (e: WebViewNavigation) => { 25 | setCurrentUrl(e.url); 26 | setCanGoBack(e.canGoBack); 27 | setCanGoForward(e.canGoForward); 28 | }; 29 | 30 | return ( 31 | 32 | {progress && progress !== 1 ? : null} 33 | { 43 | // New architecture doesn't like native floats 44 | setProgress(parseFloat(nativeEvent.progress.toFixed(2))); 45 | }} 46 | onNavigationStateChange={handleNavigation} 47 | /> 48 | 56 | webViewRef.current?.goBack()} 60 | /> 61 | webViewRef.current?.goForward()} 65 | /> 66 | webViewRef.current?.reload()} 70 | /> 71 | Linking.openURL(currentUrl)} 75 | /> 76 | 77 | 78 | ); 79 | }; 80 | 81 | const styles = StyleSheet.create({ 82 | webview: { 83 | backgroundColor: 'transparent', 84 | }, 85 | toolbar: { 86 | flexDirection: 'row', 87 | justifyContent: 'space-around', 88 | paddingHorizontal: 16, 89 | }, 90 | }); 91 | 92 | export default CourseX; 93 | -------------------------------------------------------------------------------- /src/screens/Files.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect} from 'react'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {StackActions} from '@react-navigation/native'; 4 | import FileCard from 'components/FileCard'; 5 | import SafeArea from 'components/SafeArea'; 6 | import FilterList from 'components/FilterList'; 7 | import {getAllFilesForCourses} from 'data/actions/files'; 8 | import {useAppDispatch, useAppSelector} from 'data/store'; 9 | import {File} from 'data/types/state'; 10 | import useFilteredData from 'hooks/useFilteredData'; 11 | import useDetailNavigator from 'hooks/useDetailNavigator'; 12 | import {FileStackParams} from './types'; 13 | 14 | type Props = NativeStackScreenProps; 15 | 16 | const Files: React.FC = ({navigation}) => { 17 | const detailNavigator = useDetailNavigator(); 18 | 19 | const dispatch = useAppDispatch(); 20 | const loggedIn = useAppSelector(state => state.auth.loggedIn); 21 | const courseIds = useAppSelector( 22 | state => state.courses.items.map(i => i.id), 23 | (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()), 24 | ); 25 | const hiddenCourseIds = useAppSelector(state => state.courses.hidden); 26 | const fileState = useAppSelector(state => state.files); 27 | const fetching = useAppSelector(state => state.files.fetching); 28 | 29 | const {all, unread, fav, archived, hidden} = useFilteredData({ 30 | data: fileState.items, 31 | fav: fileState.favorites, 32 | archived: fileState.archived, 33 | hidden: hiddenCourseIds, 34 | }); 35 | 36 | const handleRefresh = useCallback(() => { 37 | if (loggedIn) { 38 | dispatch(getAllFilesForCourses(courseIds)); 39 | } 40 | }, [courseIds, dispatch, loggedIn]); 41 | 42 | const handlePush = useCallback( 43 | (item: File) => { 44 | if (detailNavigator) { 45 | detailNavigator.dispatch( 46 | StackActions.replace('FileDetail', { 47 | ...item, 48 | disableAnimation: true, 49 | }), 50 | ); 51 | } else { 52 | navigation.push('FileDetail', item); 53 | } 54 | }, 55 | [detailNavigator, navigation], 56 | ); 57 | 58 | useEffect(() => { 59 | handleRefresh(); 60 | }, [handleRefresh]); 61 | 62 | return ( 63 | 64 | 78 | ); 79 | }; 80 | 81 | export default Files; 82 | -------------------------------------------------------------------------------- /src/screens/Help.tsx: -------------------------------------------------------------------------------- 1 | import {Linking, ScrollView, StyleSheet} from 'react-native'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {Title, Text} from 'react-native-paper'; 4 | import Colors from 'constants/Colors'; 5 | import SafeArea from 'components/SafeArea'; 6 | import useNavigationAnimation from 'hooks/useNavigationAnimation'; 7 | import {t} from 'helpers/i18n'; 8 | import {SettingsStackParams} from './types'; 9 | 10 | type Props = NativeStackScreenProps; 11 | 12 | const Help: React.FC = props => { 13 | useNavigationAnimation(props); 14 | 15 | return ( 16 | 17 | 20 | {t('githubRecommended')} 21 | 24 | Linking.openURL( 25 | 'https://github.com/robertying/learnX/issues/new/choose', 26 | ) 27 | }> 28 | {t('createNewGitHubIssue')} 29 | 30 | {t('emailNotRecommended')} 31 | Linking.openURL('mailto:learnX@ruiying.io')}> 34 | learnX@ruiying.io 35 | 36 | {t('issueTemplate')} 37 | {t('issueTemplateDescription')} 38 | {t('issueTemplateContent')} 39 | 40 | 41 | ); 42 | }; 43 | 44 | const styles = StyleSheet.create({ 45 | marginTop: { 46 | marginTop: 32, 47 | }, 48 | scrollView: { 49 | paddingHorizontal: 24, 50 | }, 51 | scrollViewPaddings: { 52 | paddingVertical: 32, 53 | }, 54 | text: { 55 | marginTop: 8, 56 | }, 57 | link: { 58 | color: Colors.theme, 59 | }, 60 | }); 61 | 62 | export default Help; 63 | -------------------------------------------------------------------------------- /src/screens/Maintainer.tsx: -------------------------------------------------------------------------------- 1 | import {Linking, ScrollView, StyleSheet} from 'react-native'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {Text} from 'react-native-paper'; 4 | import Colors from 'constants/Colors'; 5 | import SafeArea from 'components/SafeArea'; 6 | import useNavigationAnimation from 'hooks/useNavigationAnimation'; 7 | import {t} from 'helpers/i18n'; 8 | import {SettingsStackParams} from './types'; 9 | 10 | type Props = NativeStackScreenProps; 11 | 12 | const Maintainer: React.FC = props => { 13 | useNavigationAnimation(props); 14 | 15 | return ( 16 | 17 | 20 | {t('maintainerDescription')} 21 | Linking.openURL('mailto:learnX@ruiying.io')}> 24 | learnX@ruiying.io 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | const styles = StyleSheet.create({ 32 | scrollView: { 33 | paddingHorizontal: 24, 34 | }, 35 | scrollViewPaddings: { 36 | paddingVertical: 32, 37 | }, 38 | text: { 39 | marginTop: 8, 40 | }, 41 | link: { 42 | color: Colors.theme, 43 | }, 44 | }); 45 | 46 | export default Maintainer; 47 | -------------------------------------------------------------------------------- /src/screens/Notices.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect} from 'react'; 2 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 3 | import {StackActions} from '@react-navigation/native'; 4 | import NoticeCard from 'components/NoticeCard'; 5 | import FilterList from 'components/FilterList'; 6 | import SafeArea from 'components/SafeArea'; 7 | import {getAllNoticesForCourses} from 'data/actions/notices'; 8 | import {useAppDispatch, useAppSelector} from 'data/store'; 9 | import {Notice} from 'data/types/state'; 10 | import useFilteredData from 'hooks/useFilteredData'; 11 | import useDetailNavigator from 'hooks/useDetailNavigator'; 12 | import {NoticeStackParams} from './types'; 13 | 14 | type Props = NativeStackScreenProps; 15 | 16 | const Notices: React.FC = ({navigation}) => { 17 | const detailNavigator = useDetailNavigator(); 18 | 19 | const dispatch = useAppDispatch(); 20 | const loggedIn = useAppSelector(state => state.auth.loggedIn); 21 | const courseIds = useAppSelector( 22 | state => state.courses.items.map(i => i.id), 23 | (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()), 24 | ); 25 | const hiddenCourseIds = useAppSelector(state => state.courses.hidden); 26 | const noticeState = useAppSelector(state => state.notices); 27 | const fetching = useAppSelector(state => state.notices.fetching); 28 | 29 | const {all, unread, fav, archived, hidden} = useFilteredData({ 30 | data: noticeState.items, 31 | fav: noticeState.favorites, 32 | archived: noticeState.archived, 33 | hidden: hiddenCourseIds, 34 | }); 35 | 36 | const handleRefresh = useCallback(() => { 37 | if (loggedIn) { 38 | dispatch(getAllNoticesForCourses(courseIds)); 39 | } 40 | }, [courseIds, dispatch, loggedIn]); 41 | 42 | const handlePush = useCallback( 43 | (item: Notice) => { 44 | if (detailNavigator) { 45 | detailNavigator.dispatch( 46 | StackActions.replace('NoticeDetail', { 47 | ...item, 48 | disableAnimation: true, 49 | }), 50 | ); 51 | } else { 52 | navigation.push('NoticeDetail', item); 53 | } 54 | }, 55 | [detailNavigator, navigation], 56 | ); 57 | 58 | useEffect(() => { 59 | handleRefresh(); 60 | }, [handleRefresh]); 61 | 62 | return ( 63 | 64 | 78 | ); 79 | }; 80 | 81 | export default Notices; 82 | -------------------------------------------------------------------------------- /src/screens/SemesterSelection.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from 'react'; 2 | import {FlatList, StyleSheet} from 'react-native'; 3 | import {NativeStackScreenProps} from '@react-navigation/native-stack'; 4 | import TableCell from 'components/TableCell'; 5 | import SafeArea from 'components/SafeArea'; 6 | import {dataSource} from 'data/source'; 7 | import {useAppDispatch, useAppSelector} from 'data/store'; 8 | import { 9 | getAllSemesters, 10 | getCurrentSemester, 11 | setCurrentSemester, 12 | } from 'data/actions/semesters'; 13 | import {getSemesterTextFromId} from 'helpers/parse'; 14 | import useNavigationAnimation from 'hooks/useNavigationAnimation'; 15 | import {SettingsStackParams} from './types'; 16 | 17 | type Props = NativeStackScreenProps; 18 | 19 | const SemesterSelection: React.FC = props => { 20 | const dispatch = useAppDispatch(); 21 | const semesters = useAppSelector(state => state.semesters.items); 22 | const fetching = useAppSelector(state => state.semesters.fetching); 23 | const currentSemesterId = useAppSelector(state => state.semesters.current); 24 | 25 | const [latestSemester, setLatestSemester] = useState(null); 26 | 27 | const getLatestSemester = async () => { 28 | const {id} = await dataSource.getCurrentSemester(); 29 | setLatestSemester(id); 30 | }; 31 | 32 | const handleSelect = (id: string) => { 33 | dispatch(setCurrentSemester(id)); 34 | }; 35 | 36 | const handleRefresh = useCallback(() => { 37 | getLatestSemester(); 38 | dispatch(getCurrentSemester()); 39 | dispatch(getAllSemesters()); 40 | }, [dispatch]); 41 | 42 | useNavigationAnimation(props); 43 | 44 | useEffect(() => { 45 | handleRefresh(); 46 | }, [handleRefresh]); 47 | 48 | return ( 49 | 50 | handleSelect(latestSemester)} 62 | /> 63 | ) : null 64 | } 65 | renderItem={({item}) => ( 66 | handleSelect(item)} 71 | /> 72 | )} 73 | keyExtractor={item => item} 74 | refreshing={fetching} 75 | onRefresh={handleRefresh} 76 | /> 77 | 78 | ); 79 | }; 80 | 81 | const styles = StyleSheet.create({ 82 | padding: { 83 | paddingVertical: 32, 84 | }, 85 | }); 86 | 87 | export default SemesterSelection; 88 | -------------------------------------------------------------------------------- /src/screens/types.ts: -------------------------------------------------------------------------------- 1 | import {NavigatorScreenParams} from '@react-navigation/native'; 2 | import {Assignment, Course, File, Notice} from 'data/types/state'; 3 | 4 | export interface ExtraParams { 5 | disableAnimation?: boolean; 6 | } 7 | 8 | export type NoticeStackParams = { 9 | Notices: undefined; 10 | NoticeDetail: Notice & ExtraParams; 11 | FileDetail: File & ExtraParams; 12 | }; 13 | 14 | export type AssignmentStackParams = { 15 | Assignments: undefined; 16 | AssignmentDetail: Assignment & ExtraParams; 17 | FileDetail: File & ExtraParams; 18 | }; 19 | 20 | export type FileStackParams = { 21 | Files: undefined; 22 | FileDetail: File & ExtraParams; 23 | }; 24 | 25 | export type CourseStackParams = { 26 | Courses: undefined; 27 | CourseDetail: Course & ExtraParams; 28 | NoticeDetail: Notice & ExtraParams; 29 | AssignmentDetail: Assignment & ExtraParams; 30 | FileDetail: File & ExtraParams; 31 | }; 32 | 33 | export type SettingsStackParams = { 34 | Settings: undefined; 35 | CourseInformationSharing: ExtraParams; 36 | CalendarEvent: ExtraParams; 37 | SemesterSelection: ExtraParams; 38 | FileSettings: ExtraParams; 39 | Help: ExtraParams; 40 | About: ExtraParams; 41 | Changelog: ExtraParams; 42 | Maintainer: ExtraParams; 43 | }; 44 | 45 | export type MainTabParams = { 46 | NoticeStack: NavigatorScreenParams; 47 | AssignmentStack: NavigatorScreenParams; 48 | FileStack: NavigatorScreenParams; 49 | CourseStack: NavigatorScreenParams; 50 | SettingStack: NavigatorScreenParams; 51 | }; 52 | 53 | export type CourseXStackParams = { 54 | CourseX: {id: string} | undefined; 55 | }; 56 | 57 | export type SearchStackParams = { 58 | Search: 59 | | { 60 | query: string; 61 | } 62 | | undefined; 63 | NoticeDetail: Notice & ExtraParams; 64 | AssignmentDetail: Assignment & ExtraParams; 65 | FileDetail: File & ExtraParams; 66 | }; 67 | 68 | export type AssignmentSubmissionStackParams = { 69 | AssignmentSubmission: Assignment; 70 | FileDetail: File & ExtraParams; 71 | }; 72 | 73 | export type DetailStackParams = { 74 | EmptyDetail: undefined; 75 | NoticeDetail: Notice & ExtraParams; 76 | AssignmentDetail: Assignment & ExtraParams; 77 | FileDetail: File & ExtraParams; 78 | CourseDetail: Course & ExtraParams; 79 | AssignmentSubmission: Assignment; 80 | CourseX: {id: string} | undefined; 81 | CourseInformationSharing: ExtraParams; 82 | CalendarEvent: ExtraParams; 83 | SemesterSelection: ExtraParams; 84 | FileSettings: ExtraParams; 85 | Help: ExtraParams; 86 | About: ExtraParams; 87 | Changelog: ExtraParams; 88 | }; 89 | 90 | export type RootStackParams = { 91 | MainTab: NavigatorScreenParams; 92 | CourseXStack: NavigatorScreenParams; 93 | SearchStack: NavigatorScreenParams; 94 | AssignmentSubmissionStack: NavigatorScreenParams; 95 | Login: undefined; 96 | }; 97 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@react-native/typescript-config/tsconfig.json", 3 | "compilerOptions": { 4 | "noUnusedLocals": true, 5 | "baseUrl": "./src" 6 | } 7 | } 8 | --------------------------------------------------------------------------------