From d3135243411150f6dcddeb7ce44264d253a2e6e5 Mon Sep 17 00:00:00 2001 From: Robert Eggl Date: Sat, 27 Jul 2024 04:19:17 +0200 Subject: [PATCH] new animations and design, map search history --- .env | 1 - .eslintrc.json | 6 +- .gitignore | 1 - .vscode/extensions.json | 7 + .vscode/launch.json | 16 + .vscode/settings.json | 17 + android/app/build.gradle | 6 +- android/app/src/main/AndroidManifest.xml | 1 - android/sentry.properties | 4 - app.config.json | 83 ++ app.config.ts | 99 --- bun.lockb | Bin 801596 -> 763871 bytes ios/NeulandNext.xcodeproj/project.pbxproj | 293 ++----- .../xcshareddata/swiftpm/Package.resolved | 2 +- ios/NeulandNext/Info.plist | 29 +- ios/NeulandNext/NeulandNext.entitlements | 18 +- ios/NeulandNext/de.lproj/Info.plist | 172 ---- ios/NeulandNext/en.lproj/Info.plist | 172 ---- ios/Podfile.lock | 194 ++--- ios/TestFlight/WhatToTest.de-DE.txt | 13 +- ios/TestFlight/WhatToTest.en-US.txt | 13 +- ios/sentry.properties | 4 - metro.config.js | 6 +- package.json | 128 ++- src/api/anonymous-api.ts | 5 +- src/api/authenticated-api.ts | 73 -- src/api/cache.ts | 133 --- src/api/neuland-api.ts | 126 ++- src/api/thi-session-handler.ts | 62 +- src/app/(flow)/onboarding.tsx | 697 +++++++++++----- src/app/(flow)/whatsnew.tsx | 2 +- src/app/(food)/meal.tsx | 6 +- src/app/(food)/preferences.tsx | 6 +- src/app/(map)/advanced.ios.tsx | 401 +++++++++ src/app/(map)/advanced.tsx | 6 +- src/app/(pages)/calendar.tsx | 4 +- src/app/(pages)/event.tsx | 2 +- src/app/(pages)/events.tsx | 2 +- src/app/(pages)/exam.tsx | 3 +- src/app/(pages)/lecturers.tsx | 29 +- src/app/(pages)/library.tsx | 4 +- src/app/(pages)/libraryCode.tsx | 8 +- src/app/(pages)/news.tsx | 2 +- src/app/(tabs)/(food)/food.tsx | 7 +- src/app/(tabs)/(index)/index.tsx | 121 +-- src/app/(tabs)/(timetable)/timetable.tsx | 172 +--- src/app/(tabs)/_layout.android.tsx | 288 ------- src/app/(tabs)/_layout.tsx | 327 +++----- src/app/(tabs)/map.tsx | 52 +- .../(timetable)/{details.tsx => lecture.tsx} | 269 ++---- src/app/(user)/about.tsx | 101 ++- src/app/(user)/appicon.tsx | 5 +- src/app/(user)/changelog.tsx | 4 +- src/app/(user)/dashboard.tsx | 5 +- src/app/(user)/grades.tsx | 25 +- src/app/(user)/licenses.tsx | 43 +- src/app/(user)/login.tsx | 216 ++++- src/app/(user)/profile.tsx | 18 +- src/app/(user)/settings.tsx | 215 ++++- src/app/(user)/theme.tsx | 12 +- src/app/[...unmachted].tsx | 2 +- src/app/_layout.tsx | 785 +++++++++--------- src/assets/map-marker.png | Bin 0 -> 7523 bytes src/assets/splash.png | Bin 33596 -> 0 bytes src/components/Cards/BaseCard.tsx | 12 +- src/components/Cards/CalendarCard.tsx | 9 +- src/components/Cards/EventsCard.tsx | 9 +- src/components/Cards/FoodCard.tsx | 13 +- src/components/Cards/LibraryCard.tsx | 7 +- src/components/Cards/LoginCard.tsx | 10 +- src/components/Cards/PopUpCard.tsx | 11 +- src/components/Cards/TimetableCard.tsx | 12 +- .../Elements/Dashboard/HeaderRight.tsx | 21 +- src/components/Elements/Error/ActionBox.tsx | 47 ++ .../Elements/Error/ActionButtons.tsx | 108 +++ src/components/Elements/Error/CrashView.tsx | 156 ++++ .../{Universal => Error}/ErrorView.tsx | 142 ++-- .../Elements/Flow/HomeBottomSheet.tsx | 2 +- src/components/Elements/Flow/WhatsnewBox.tsx | 10 +- .../Elements/Flow/svgs/everything.tsx | 225 ----- src/components/Elements/Flow/svgs/logo.tsx | 27 +- .../Elements/Flow/svgs/logoText.tsx | 42 + .../Elements/Flow/svgs/logoTextFull.tsx | 34 + src/components/Elements/Flow/svgs/secure.tsx | 250 ------ src/components/Elements/Food/HeaderRight.tsx | 3 + src/components/Elements/Food/MealDay.tsx | 4 +- src/components/Elements/Food/MealEntry.tsx | 2 +- .../Elements/Layout/DefaultTabs.tsx | 146 ++++ .../Elements/Layout/MaterialTabs.tsx | 152 ++++ .../Elements/Map/BottomSheetDetailModal.tsx | 2 +- .../Elements/Map/BottomSheetMap.tsx | 383 +++++++-- src/components/Elements/Map/FloorPicker.tsx | 3 +- src/components/Elements/Map/FreeRoomsList.tsx | 4 +- src/components/Elements/Map/MapScreen.tsx | 143 ++-- src/components/Elements/Map/ModalSections.ts | 7 +- .../Elements/Map/SearchResultRow.tsx | 19 +- src/components/Elements/Rows/GradesRow.tsx | 3 +- .../Elements/Settings/LoginAlert.tsx | 103 --- src/components/Elements/Settings/NameBox.tsx | 63 +- src/components/Elements/Settings/index.ts | 3 +- .../Elements/Timetable/HeaderButtons.tsx | 10 +- .../Elements/Timetable/TimetableList.tsx | 34 +- .../Elements/Timetable/TimetableWeek.tsx | 25 +- .../Elements/Universal/Checkbox.tsx | 74 -- .../Elements/Universal/FormList.tsx | 3 + src/components/Elements/Universal/Icon.tsx | 26 +- .../Elements/Universal/LoginForm.tsx | 512 ++++++------ .../Elements/Universal/MaterialBottomTabs.ts | 13 - .../Universal/SingleSectionPicker.tsx | 6 +- .../Elements/Universal/WorkaroundStack.tsx | 49 +- src/components/allCards.tsx | 27 +- src/components/colors.ts | 10 +- src/components/contexts.ts | 40 +- src/components/provider.tsx | 76 +- src/{hooks => }/contexts/appIcon.ts | 29 +- src/{hooks => }/contexts/dashboard.ts | 28 +- src/contexts/flow.ts | 55 ++ src/{hooks => }/contexts/foodFilter.ts | 103 +-- src/{hooks => }/contexts/index.ts | 2 - src/{hooks => }/contexts/map.ts | 13 +- src/{hooks => }/contexts/routing.ts | 0 src/contexts/theme.ts | 25 + src/contexts/timetable.ts | 29 + src/{hooks => }/contexts/userKind.ts | 76 +- src/data/changelog.json | 6 +- src/data/licenses.json | 150 ++-- src/hooks/contexts/flow.ts | 127 --- src/hooks/contexts/notifications.ts | 153 ---- src/hooks/contexts/theme.ts | 73 -- src/hooks/contexts/timetable.ts | 57 -- src/hooks/index.ts | 2 - src/hooks/useNotification.ts | 87 -- src/localization/de/accessibility.json | 9 + src/localization/de/api.json | 30 + src/localization/de/api.ts | 11 - src/localization/de/common.json | 230 +++++ src/localization/de/common.ts | 225 ----- src/localization/de/flow.json | 55 ++ src/localization/de/flow.ts | 64 -- src/localization/de/food.json | 92 ++ src/localization/de/food.ts | 95 --- src/localization/de/index.ts | 17 - src/localization/de/navigation.json | 94 +++ src/localization/de/navigation.ts | 84 -- src/localization/de/settings.json | 182 ++++ src/localization/de/settings.ts | 192 ----- src/localization/de/timetable.json | 44 + src/localization/de/timetable.ts | 47 -- src/localization/en/accessibility.json | 9 + src/localization/en/api.json | 30 + src/localization/en/api.ts | 33 - src/localization/en/common.json | 232 ++++++ src/localization/en/common.ts | 224 ----- src/localization/en/flow.json | 55 ++ src/localization/en/flow.ts | 62 -- src/localization/en/food.json | 90 ++ src/localization/en/food.ts | 92 -- src/localization/en/index.ts | 17 - src/localization/en/navigation.json | 94 +++ src/localization/en/navigation.ts | 84 -- src/localization/en/settings.json | 183 ++++ src/localization/en/settings.ts | 189 ----- src/localization/en/timetable.json | 44 + src/localization/en/timetable.ts | 46 - src/localization/i18n.ts | 45 +- src/types/components.ts | 5 +- src/utils/animation-utils.ts | 80 ++ src/utils/api-utils.ts | 63 +- src/utils/app-utils.ts | 6 +- src/utils/calendar-utils.ts | 11 +- src/utils/food-utils.ts | 26 +- src/utils/grades-utils.ts | 4 +- src/utils/map-utils.ts | 43 +- src/utils/storage.ts | 21 + src/utils/timetable-utils.ts | 32 - src/utils/ui-utils.ts | 15 + 176 files changed, 6177 insertions(+), 6759 deletions(-) delete mode 100644 .env create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json delete mode 100644 android/sentry.properties create mode 100644 app.config.json delete mode 100644 app.config.ts delete mode 100644 ios/NeulandNext/de.lproj/Info.plist delete mode 100644 ios/NeulandNext/en.lproj/Info.plist delete mode 100644 ios/sentry.properties delete mode 100644 src/api/cache.ts create mode 100644 src/app/(map)/advanced.ios.tsx delete mode 100644 src/app/(tabs)/_layout.android.tsx rename src/app/(timetable)/{details.tsx => lecture.tsx} (58%) create mode 100644 src/assets/map-marker.png delete mode 100644 src/assets/splash.png create mode 100644 src/components/Elements/Error/ActionBox.tsx create mode 100644 src/components/Elements/Error/ActionButtons.tsx create mode 100644 src/components/Elements/Error/CrashView.tsx rename src/components/Elements/{Universal => Error}/ErrorView.tsx (72%) delete mode 100644 src/components/Elements/Flow/svgs/everything.tsx create mode 100644 src/components/Elements/Flow/svgs/logoText.tsx create mode 100644 src/components/Elements/Flow/svgs/logoTextFull.tsx delete mode 100644 src/components/Elements/Flow/svgs/secure.tsx create mode 100644 src/components/Elements/Layout/DefaultTabs.tsx create mode 100644 src/components/Elements/Layout/MaterialTabs.tsx delete mode 100644 src/components/Elements/Settings/LoginAlert.tsx delete mode 100644 src/components/Elements/Universal/Checkbox.tsx delete mode 100644 src/components/Elements/Universal/MaterialBottomTabs.ts rename src/{hooks => }/contexts/appIcon.ts (71%) rename src/{hooks => }/contexts/dashboard.ts (89%) create mode 100644 src/contexts/flow.ts rename src/{hooks => }/contexts/foodFilter.ts (55%) rename src/{hooks => }/contexts/index.ts (86%) rename src/{hooks => }/contexts/map.ts (78%) rename src/{hooks => }/contexts/routing.ts (100%) create mode 100644 src/contexts/theme.ts create mode 100644 src/contexts/timetable.ts rename src/{hooks => }/contexts/userKind.ts (55%) delete mode 100644 src/hooks/contexts/flow.ts delete mode 100644 src/hooks/contexts/notifications.ts delete mode 100644 src/hooks/contexts/theme.ts delete mode 100644 src/hooks/contexts/timetable.ts delete mode 100644 src/hooks/useNotification.ts create mode 100644 src/localization/de/accessibility.json create mode 100644 src/localization/de/api.json delete mode 100644 src/localization/de/api.ts create mode 100644 src/localization/de/common.json delete mode 100644 src/localization/de/common.ts create mode 100644 src/localization/de/flow.json delete mode 100644 src/localization/de/flow.ts create mode 100644 src/localization/de/food.json delete mode 100644 src/localization/de/food.ts delete mode 100644 src/localization/de/index.ts create mode 100644 src/localization/de/navigation.json delete mode 100644 src/localization/de/navigation.ts create mode 100644 src/localization/de/settings.json delete mode 100644 src/localization/de/settings.ts create mode 100644 src/localization/de/timetable.json delete mode 100644 src/localization/de/timetable.ts create mode 100644 src/localization/en/accessibility.json create mode 100644 src/localization/en/api.json delete mode 100644 src/localization/en/api.ts create mode 100644 src/localization/en/common.json delete mode 100644 src/localization/en/common.ts create mode 100644 src/localization/en/flow.json delete mode 100644 src/localization/en/flow.ts create mode 100644 src/localization/en/food.json delete mode 100644 src/localization/en/food.ts delete mode 100644 src/localization/en/index.ts create mode 100644 src/localization/en/navigation.json delete mode 100644 src/localization/en/navigation.ts create mode 100644 src/localization/en/settings.json delete mode 100644 src/localization/en/settings.ts create mode 100644 src/localization/en/timetable.json delete mode 100644 src/localization/en/timetable.ts create mode 100644 src/utils/animation-utils.ts create mode 100644 src/utils/storage.ts diff --git a/.env b/.env deleted file mode 100644 index d0526afb..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -EXPO_PUBLIC_NEULAND_API_ENDPOINT=https://neuland.app \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 25e27f91..7b457a76 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,7 @@ "tsconfigRootDir": "./", "noUnusedLocals": true }, - "plugins": ["react", "react-native"], + "plugins": ["react", "react-native", "i18next", "react-hooks"], "settings": { "react": { "version": "detect" @@ -28,7 +28,9 @@ "react-native/no-inline-styles": 2, "react-native/no-color-literals": 2, "react-native/no-raw-text": 2, - "react-native/no-single-element-style-arrays": 2 + "react-native/no-single-element-style-arrays": 2, + "i18next/no-literal-string": 2, + "react-hooks/rules-of-hooks": "error" }, "ignorePatterns": ["metro.config.js"] } diff --git a/.gitignore b/.gitignore index 1092794c..37d2a285 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ .DS_Store # vs code -.vscode/ .VSCodeCounter/ # bun diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..37da519b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "lokalise.i18n-ally", + "esbenp.prettier-vscode", + "eslint.vscode-eslint" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..afb36636 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "expo", + "request": "attach", + "name": "Debug Expo app", + "projectRoot": "${workspaceFolder}", + "bundlerPort": "8082", + "bundlerHost": "127.0.0.1" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..18c70169 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], + "i18n-ally.localesPaths": ["src/localization"], + "i18n-ally.autoDetection": false, + "i18n-ally.keystyle": "nested", + "i18n-ally.namespace": true +} diff --git a/android/app/build.gradle b/android/app/build.gradle index a48dae9d..cf8e1e84 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -77,8 +77,6 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea */ def jscFlavor = 'org.webkit:android-jsc:+' -apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") - android { ndkVersion rootProject.ext.ndkVersion @@ -90,8 +88,8 @@ android { applicationId 'app.neuland' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 101 - versionName "0.8.2" + versionCode 114 + versionName "0.8.3" } signingConfigs { debug { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f986f06b..bf82c0a2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - diff --git a/android/sentry.properties b/android/sentry.properties deleted file mode 100644 index 2ebefa45..00000000 --- a/android/sentry.properties +++ /dev/null @@ -1,4 +0,0 @@ -defaults.url=https://sentry.io/ -defaults.org=neuland-ingolstadt -defaults.project=neuland-next -# Using SENTRY_AUTH_TOKEN environment variable \ No newline at end of file diff --git a/app.config.json b/app.config.json new file mode 100644 index 00000000..0406da70 --- /dev/null +++ b/app.config.json @@ -0,0 +1,83 @@ +{ + "expo": { + "name": "Neuland Next", + "slug": "neuland-app-native", + "scheme": "neuland", + "owner": "neuland-ingolstadt", + "version": "0.8.3", + "githubUrl": "https://github.com/neuland-ingolstadt/neuland.app-native/", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "ios": { + "bundleIdentifier": "de.neuland-ingolstadt.neuland-app", + "buildNumber": "71", + "supportsTablet": true, + "userInterfaceStyle": "automatic", + "associatedDomains": [ + "webcredentials:neuland.app", + "activitycontinuation:neuland.app" + ], + "config": { + "usesNonExemptEncryption": false + }, + "infoPlist": { + "RCTAsyncStorageExcludeFromBackup": false, + "CFBundleAllowMixedLocalizations": true, + "CFBundleLocalizations": ["en", "de"], + "CFBundleDevelopmentRegion": "en", + "UIViewControllerBasedStatusBarAppearance": true + }, + "splash": { + "image": "./src/assets/splash/splashLight.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "image": "./src/assets/splash/splashDark.png", + "backgroundColor": "#000000" + } + }, + "icon": "./src/assets/appIcons/default.png" + }, + "android": { + "package": "app.neuland", + "userInterfaceStyle": "automatic", + "versionCode": 114, + "splash": { + "image": "./src/assets/splash/splashLight.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000", + "image": "./src/assets/splash/splashDark.png" + } + } + }, + "sdkVersion": "51.0.0", + "experiments": { + "tsconfigPaths": true + }, + "plugins": [ + [ + "expo-router", + { + "origin": "https://neuland.app" + } + ], + ["expo-secure-store"], + ["expo-localization"], + [ + "expo-local-authentication", + { + "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID." + } + ], + ["@maplibre/maplibre-react-native"], + "expo-build-properties" + ], + "extra": { + "eas": { + "projectId": "b0ef9e3f-3115-44b0-abc7-99dd75821353" + } + } + } +} diff --git a/app.config.ts b/app.config.ts deleted file mode 100644 index 7af9f5b9..00000000 --- a/app.config.ts +++ /dev/null @@ -1,99 +0,0 @@ -import packageInfo from './package.json' - -module.exports = { - expo: { - name: 'Neuland Next', - slug: 'neuland-app-native', - scheme: 'neuland', - owner: 'neuland-ingolstadt', - version: packageInfo.version, - githubUrl: 'https://github.com/neuland-ingolstadt/neuland.app-native/', - orientation: 'portrait', - userInterfaceStyle: 'automatic', - ios: { - bundleIdentifier: 'de.neuland-ingolstadt.neuland-app', - buildNumber: '19', - supportsTablet: true, - userInterfaceStyle: 'automatic', - associatedDomains: [ - 'webcredentials:neuland.app', - 'activitycontinuation:neuland.app', - ], - config: { - usesNonExemptEncryption: false, - }, - infoPlist: { - RCTAsyncStorageExcludeFromBackup: false, - CFBundleAllowMixedLocalizations: true, - CFBundleLocalizations: ['en', 'de'], - CFBundleDevelopmentRegion: 'en', - UIViewControllerBasedStatusBarAppearance: true, - }, - splash: { - image: './src/assets/splash/splashLight.png', - resizeMode: 'contain', - backgroundColor: '#ffffff', - dark: { - image: './src/assets/splash/splashDark.png', - backgroundColor: '#000000', - }, - }, - icon: './src/assets/appIcons/default.png', - }, - android: { - package: 'app.neuland', - userInterfaceStyle: 'automatic', - versionCode: 101, - splash: { - image: './src/assets/splash/splashLight.png', - resizeMode: 'contain', - backgroundColor: '#ffffff', - dark: { - backgroundColor: '#000000', - image: './src/assets/splash/splashDark.png', - }, - }, - }, - sdkVersion: '51.0.0', - experiments: { - tsconfigPaths: true, - }, - plugins: [ - [ - 'expo-router', - { - origin: 'https://neuland.app', - }, - ], - ['expo-secure-store'], - ['expo-localization'], - [ - 'expo-local-authentication', - { - faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID.', - }, - ], - [ - 'expo-location', - { - locationAlwaysAndWhenInUsePermission: - 'Allow $(PRODUCT_NAME) to use your location.', - }, - ], - [ - '@sentry/react-native/expo', - { - organization: 'neuland-ingolstadt', - project: 'neuland-next', - }, - ], - ['expo-build-properties'], - ['@maplibre/maplibre-react-native'], - ], - extra: { - eas: { - projectId: 'b0ef9e3f-3115-44b0-abc7-99dd75821353', - }, - }, - }, -} diff --git a/bun.lockb b/bun.lockb index 78f0f523b1ad4a692f41b6ee9a66046f3e89d0b3..d2fa1a310d1a617163d7c482350dedfe1de18471 100755 GIT binary patch delta 205366 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 QuI7Wyn^LfAe7xrTAI#|0$^ZZW delta 227050 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

Dndp7?X^XyE(a>CP31vpZHYII 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

Q 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

(rYw8iR@5UedMq9I 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 diff --git a/ios/NeulandNext.xcodeproj/project.pbxproj b/ios/NeulandNext.xcodeproj/project.pbxproj index bfc6f4f9..7ac818fc 100644 --- a/ios/NeulandNext.xcodeproj/project.pbxproj +++ b/ios/NeulandNext.xcodeproj/project.pbxproj @@ -11,29 +11,28 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - 42FBFFD18BC70219EF9F9554 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6D9C36C5590640DA61E545A1 /* PrivacyInfo.xcprivacy */; }; - 4DBFAAE1E5644C68B67780C7 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25124D82DC7147C1A383882B /* noop-file.swift */; }; + 79A805BC94BC0AD1674AEE08 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2DE9A45D19FFF21A2FC7974A /* PrivacyInfo.xcprivacy */; }; + 8D5068EC878C59C883999F54 /* libPods-NeulandNext.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 06990B0683C233C81D3FC54E /* libPods-NeulandNext.a */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; - B77BAE7A2C2AD20500BF7D99 /* MapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = 593828D4C3990B605265629B /* MapLibre */; }; + B7E3797A2C54A52E006A4193 /* MapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = EC742494CB1CE25F860AF0D3 /* MapLibre */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; - E56A183CCD4901F4129A1CB0 /* libPods-NeulandNext.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 555A011E96B69CD72515F1DD /* libPods-NeulandNext.a */; }; + CCE77C0024F64283949166B7 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4A762513EF4F1B924A16CC /* noop-file.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 06990B0683C233C81D3FC54E /* libPods-NeulandNext.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NeulandNext.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* NeulandNext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NeulandNext.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = NeulandNext/AppDelegate.h; sourceTree = ""; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NeulandNext/AppDelegate.mm; sourceTree = ""; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NeulandNext/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = NeulandNext/Info.plist; sourceTree = ""; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = NeulandNext/main.m; sourceTree = ""; }; - 25124D82DC7147C1A383882B /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "NeulandNext/noop-file.swift"; sourceTree = ""; }; - 4CF7375F2B7F45769EF88A59 /* NeulandNext-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "NeulandNext-Bridging-Header.h"; path = "NeulandNext/NeulandNext-Bridging-Header.h"; sourceTree = ""; }; - 5374F6E5C28A7EB4AC4A1B8D /* Pods-NeulandNext.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NeulandNext.debug.xcconfig"; path = "Target Support Files/Pods-NeulandNext/Pods-NeulandNext.debug.xcconfig"; sourceTree = ""; }; - 555A011E96B69CD72515F1DD /* libPods-NeulandNext.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NeulandNext.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 6D9C36C5590640DA61E545A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = NeulandNext/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1CD0802492E961D1BC7D308 /* Pods-NeulandNext.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NeulandNext.release.xcconfig"; path = "Target Support Files/Pods-NeulandNext/Pods-NeulandNext.release.xcconfig"; sourceTree = ""; }; + 2D4A762513EF4F1B924A16CC /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "NeulandNext/noop-file.swift"; sourceTree = ""; }; + 2DE9A45D19FFF21A2FC7974A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = NeulandNext/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 4F50E0A33A0E4558A8B767F1 /* NeulandNext-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "NeulandNext-Bridging-Header.h"; path = "NeulandNext/NeulandNext-Bridging-Header.h"; sourceTree = ""; }; + 72A79DCA58B8752024BEA846 /* Pods-NeulandNext.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NeulandNext.release.xcconfig"; path = "Target Support Files/Pods-NeulandNext/Pods-NeulandNext.release.xcconfig"; sourceTree = ""; }; + 82FE156074BAC87B9E65C2EB /* Pods-NeulandNext.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NeulandNext.debug.xcconfig"; path = "Target Support Files/Pods-NeulandNext/Pods-NeulandNext.debug.xcconfig"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = NeulandNext/SplashScreen.storyboard; sourceTree = ""; }; - B7A12D0E2C2C8ACB00FDAB26 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/Info.plist; sourceTree = ""; }; - B7A12D102C2C8ADB00FDAB26 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/Info.plist; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-NeulandNext/ExpoModulesProvider.swift"; sourceTree = ""; }; @@ -44,8 +43,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B77BAE7A2C2AD20500BF7D99 /* MapLibre in Frameworks */, - E56A183CCD4901F4129A1CB0 /* libPods-NeulandNext.a in Frameworks */, + B7E3797A2C54A52E006A4193 /* MapLibre in Frameworks */, + 8D5068EC878C59C883999F54 /* libPods-NeulandNext.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -59,12 +58,12 @@ 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.mm */, 13B07FB51A68108700A75B9A /* Images.xcassets */, - B7A12D0F2C2C8ACB00FDAB26 /* Info.plist */, + 13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB71A68108700A75B9A /* main.m */, - 25124D82DC7147C1A383882B /* noop-file.swift */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, - 4CF7375F2B7F45769EF88A59 /* NeulandNext-Bridging-Header.h */, - 6D9C36C5590640DA61E545A1 /* PrivacyInfo.xcprivacy */, + 2D4A762513EF4F1B924A16CC /* noop-file.swift */, + 4F50E0A33A0E4558A8B767F1 /* NeulandNext-Bridging-Header.h */, + 2DE9A45D19FFF21A2FC7974A /* PrivacyInfo.xcprivacy */, ); name = NeulandNext; sourceTree = ""; @@ -73,7 +72,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 555A011E96B69CD72515F1DD /* libPods-NeulandNext.a */, + 06990B0683C233C81D3FC54E /* libPods-NeulandNext.a */, ); name = Frameworks; sourceTree = ""; @@ -128,8 +127,8 @@ D65327D7A22EEC0BE12398D9 /* Pods */ = { isa = PBXGroup; children = ( - 5374F6E5C28A7EB4AC4A1B8D /* Pods-NeulandNext.debug.xcconfig */, - A1CD0802492E961D1BC7D308 /* Pods-NeulandNext.release.xcconfig */, + 82FE156074BAC87B9E65C2EB /* Pods-NeulandNext.debug.xcconfig */, + 72A79DCA58B8752024BEA846 /* Pods-NeulandNext.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -149,22 +148,17 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "NeulandNext" */; buildPhases = ( - 05CA246F897C38AE780565D0 /* [CP] Check Pods Manifest.lock */, - 4770EA3B2F9A4E615EFE9B20 /* [Expo] Configure project */, + AC187E7DAD84E11C58F32B3A /* [CP] Check Pods Manifest.lock */, + 910811D19BBBDAE542453C80 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 7DD2A0B2CBB9482199BAE73B /* Upload Debug Symbols to Sentry */, - 5521C5867CF044158CA46F7B /* Remove signature files (Xcode 15 workaround) */, - DE94DDD297BF42F2B5882A0E /* Remove signature files (Xcode 15 workaround) */, - 416B75F9501048ACA40DB650 /* Remove signature files (Xcode 15 workaround) */, - EF820F10D3E648A28C78DECD /* Remove signature files (Xcode 15 workaround) */, - 1515044FA4184334AD7857BB /* Remove signature files (Xcode 15 workaround) */, - 8CFEE477FDEE49A683B8FAB4 /* Remove signature files (Xcode 15 workaround) */, - C9F03030C79E4397B5A02662 /* Remove signature files (Xcode 15 workaround) */, - 592F16E83E40063A781DB66B /* [CP] Embed Pods Frameworks */, - 731AB96BC8B51EAE10CF30E7 /* [CP] Copy Pods Resources */, + 3563AF2B08A14DCAACDF586C /* Remove signature files (Xcode 15 workaround) */, + 02AF97E726504DDFAD70E08F /* Remove signature files (Xcode 15 workaround) */, + 051670571E954A2599A0996F /* Remove signature files (Xcode 15 workaround) */, + B236907FE49EC6684DE31FE8 /* [CP] Embed Pods Frameworks */, + 911A5352CB8168932B899129 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -172,7 +166,7 @@ ); name = NeulandNext; packageProductDependencies = ( - 593828D4C3990B605265629B /* MapLibre */, + EC742494CB1CE25F860AF0D3 /* MapLibre */, ); productName = NeulandNext; productReference = 13B07F961A680F5B00A75B9A /* NeulandNext.app */; @@ -187,9 +181,7 @@ LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { - DevelopmentTeam = FSXB76X6V2; LastSwiftMigration = 1250; - ProvisioningStyle = Automatic; }; }; }; @@ -200,11 +192,10 @@ knownRegions = ( en, Base, - de, ); mainGroup = 83CBB9F61A601CBA00E9B192; packageReferences = ( - E90CD075DD22378B5CD3BEC5 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */, + 59853ACF097D59D843B28705 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */, ); productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; projectDirPath = ""; @@ -223,7 +214,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, - 42FBFFD18BC70219EF9F9554 /* PrivacyInfo.xcprivacy in Resources */, + 79A805BC94BC0AD1674AEE08 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -243,31 +234,23 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; }; - 05CA246F897C38AE780565D0 /* [CP] Check Pods Manifest.lock */ = { + 02AF97E726504DDFAD70E08F /* Remove signature files (Xcode 15 workaround) */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Remove signature files (Xcode 15 workaround)"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-NeulandNext-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; }; - 1515044FA4184334AD7857BB /* Remove signature files (Xcode 15 workaround) */ = { + 051670571E954A2599A0996F /* Remove signature files (Xcode 15 workaround) */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -281,7 +264,7 @@ shellPath = /bin/sh; shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; }; - 416B75F9501048ACA40DB650 /* Remove signature files (Xcode 15 workaround) */ = { + 3563AF2B08A14DCAACDF586C /* Remove signature files (Xcode 15 workaround) */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -295,7 +278,7 @@ shellPath = /bin/sh; shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; }; - 4770EA3B2F9A4E615EFE9B20 /* [Expo] Configure project */ = { + 910811D19BBBDAE542453C80 /* [Expo] Configure project */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -314,39 +297,7 @@ shellPath = /bin/sh; shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-NeulandNext/expo-configure-project.sh\"\n"; }; - 5521C5867CF044158CA46F7B /* Remove signature files (Xcode 15 workaround) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Remove signature files (Xcode 15 workaround)"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; - }; - 592F16E83E40063A781DB66B /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-NeulandNext/Pods-NeulandNext-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NeulandNext/Pods-NeulandNext-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 731AB96BC8B51EAE10CF30E7 /* [CP] Copy Pods Resources */ = { + 911A5352CB8168932B899129 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -356,34 +307,11 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Brands.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Regular.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Solid.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", ); @@ -392,34 +320,11 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Brands.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Regular.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Solid.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialCommunityIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Octicons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", ); @@ -428,75 +333,45 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NeulandNext/Pods-NeulandNext-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 7DD2A0B2CBB9482199BAE73B /* Upload Debug Symbols to Sentry */ = { + AC187E7DAD84E11C58F32B3A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputPaths = ( - ); - name = "Upload Debug Symbols to Sentry"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; - }; - 8CFEE477FDEE49A683B8FAB4 /* Remove signature files (Xcode 15 workaround) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Remove signature files (Xcode 15 workaround)"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; - }; - C9F03030C79E4397B5A02662 /* Remove signature files (Xcode 15 workaround) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + inputFileListPaths = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "Remove signature files (Xcode 15 workaround)"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; - }; - DE94DDD297BF42F2B5882A0E /* Remove signature files (Xcode 15 workaround) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Remove signature files (Xcode 15 workaround)"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-NeulandNext-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - EF820F10D3E648A28C78DECD /* Remove signature files (Xcode 15 workaround) */ = { + B236907FE49EC6684DE31FE8 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-NeulandNext/Pods-NeulandNext-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", ); - name = "Remove signature files (Xcode 15 workaround)"; + name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then\n echo \"Remove signature files (Xcode 15 workaround)\";\n rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";\n fi"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NeulandNext/Pods-NeulandNext-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -508,29 +383,16 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, - 4DBFAAE1E5644C68B67780C7 /* noop-file.swift in Sources */, + CCE77C0024F64283949166B7 /* noop-file.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - B7A12D0F2C2C8ACB00FDAB26 /* Info.plist */ = { - isa = PBXVariantGroup; - children = ( - B7A12D0E2C2C8ACB00FDAB26 /* en */, - B7A12D102C2C8ADB00FDAB26 /* de */, - ); - name = Info.plist; - path = NeulandNext; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5374F6E5C28A7EB4AC4A1B8D /* Pods-NeulandNext.debug.xcconfig */; + baseConfigurationReference = 82FE156074BAC87B9E65C2EB /* Pods-NeulandNext.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; @@ -538,7 +400,7 @@ CODE_SIGN_ENTITLEMENTS = NeulandNext/NeulandNext.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FSXB76X6V2; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -546,13 +408,13 @@ "FB_SONARKIT_ENABLED=1", ); INFOPLIST_FILE = NeulandNext/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.8.2; + MARKETING_VERSION = 0.8.3; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -561,6 +423,11 @@ OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "de.neuland-ingolstadt.neuland-app"; PRODUCT_NAME = NeulandNext; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "NeulandNext/NeulandNext-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -571,7 +438,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A1CD0802492E961D1BC7D308 /* Pods-NeulandNext.release.xcconfig */; + baseConfigurationReference = 72A79DCA58B8752024BEA846 /* Pods-NeulandNext.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; @@ -579,17 +446,18 @@ CODE_SIGN_ENTITLEMENTS = NeulandNext/NeulandNext.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FSXB76X6V2; + ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; INFOPLIST_FILE = NeulandNext/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.8.2; + MARKETING_VERSION = 0.8.3; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -598,6 +466,11 @@ OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = "de.neuland-ingolstadt.neuland-app"; PRODUCT_NAME = NeulandNext; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "NeulandNext/NeulandNext-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -637,6 +510,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CXX = ""; + ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -664,7 +538,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -703,6 +580,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; CXX = ""; + ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -722,7 +600,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -754,7 +635,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - E90CD075DD22378B5CD3BEC5 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { + 59853ACF097D59D843B28705 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/maplibre/maplibre-gl-native-distribution"; requirement = { @@ -765,9 +646,9 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 593828D4C3990B605265629B /* MapLibre */ = { + EC742494CB1CE25F860AF0D3 /* MapLibre */ = { isa = XCSwiftPackageProductDependency; - package = E90CD075DD22378B5CD3BEC5 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */; + package = 59853ACF097D59D843B28705 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */; productName = MapLibre; }; /* End XCSwiftPackageProductDependency section */ diff --git a/ios/NeulandNext.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/NeulandNext.xcworkspace/xcshareddata/swiftpm/Package.resolved index d223fad1..1f7263d4 100644 --- a/ios/NeulandNext.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/NeulandNext.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b440cbd994821d1c59ef3fae39cdd359458004248a336c94f8a8b08f5fed3877", + "originHash" : "e70d3525c8e2819a8b34f22909815dab5c700c25a06c32388f3930f7b3627768", "pins" : [ { "identity" : "maplibre-gl-native-distribution", diff --git a/ios/NeulandNext/Info.plist b/ios/NeulandNext/Info.plist index d3736483..aa3962aa 100644 --- a/ios/NeulandNext/Info.plist +++ b/ios/NeulandNext/Info.plist @@ -2,11 +2,6 @@ - Allow $(PRODUCT_NAME) to access your location - - en - de - CADisableMinimumFrameDurationOnPhone CFBundleAllowMixedLocalizations @@ -48,29 +43,29 @@ UIPrerenderedIcon - Retro + RainbowDark CFBundleIconFiles - Retro + RainbowDark UIPrerenderedIcon - RainbowDark + RainbowMoonLight CFBundleIconFiles - RainbowDark + RainbowMoonLight UIPrerenderedIcon - RainbowMoonLight + Retro CFBundleIconFiles - RainbowMoonLight + Retro UIPrerenderedIcon @@ -91,7 +86,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.8.2 + 0.8.3 CFBundleSignature ???? CFBundleURLTypes @@ -111,7 +106,7 @@ CFBundleVersion - 1 + 71 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -126,11 +121,11 @@ NSFaceIDUsageDescription Allow $(PRODUCT_NAME) to use Face ID. NSLocationAlwaysAndWhenInUseUsageDescription - Allow $(PRODUCT_NAME) to use your location. + Allow $(PRODUCT_NAME) to access your location NSLocationAlwaysUsageDescription Allow $(PRODUCT_NAME) to access your location NSLocationWhenInUseUsageDescription - Allow $(PRODUCT_NAME) to access your location + Allow $(PRODUCT_NAME) to access your location. NSUserActivityTypes $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route @@ -145,10 +140,6 @@ RCTAsyncStorageExcludeFromBackup - UIBackgroundModes - - fetch - UILaunchStoryboardName SplashScreen UIRequiredDeviceCapabilities diff --git a/ios/NeulandNext/NeulandNext.entitlements b/ios/NeulandNext/NeulandNext.entitlements index 05fbf44d..bddeabf7 100644 --- a/ios/NeulandNext/NeulandNext.entitlements +++ b/ios/NeulandNext/NeulandNext.entitlements @@ -1,13 +1,11 @@ - - aps-environment - development - com.apple.developer.associated-domains - - webcredentials:neuland.app - activitycontinuation:neuland.app - - - \ No newline at end of file + + com.apple.developer.associated-domains + + webcredentials:neuland.app + activitycontinuation:neuland.app + + + diff --git a/ios/NeulandNext/de.lproj/Info.plist b/ios/NeulandNext/de.lproj/Info.plist deleted file mode 100644 index c1c756bb..00000000 --- a/ios/NeulandNext/de.lproj/Info.plist +++ /dev/null @@ -1,172 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleAllowMixedLocalizations - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Neuland Next - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIcons - - CFBundleAlternateIcons - - Default - - CFBundleIconFiles - - Default - - UIPrerenderedIcon - - - ModernDark - - CFBundleIconFiles - - ModernDark - - UIPrerenderedIcon - - - ModernGreen - - CFBundleIconFiles - - ModernGreen - - UIPrerenderedIcon - - - Retro - - CFBundleIconFiles - - Retro - - UIPrerenderedIcon - - - RainbowDark - - CFBundleIconFiles - - RainbowDark - - UIPrerenderedIcon - - - RainbowMoonLight - - CFBundleIconFiles - - RainbowMoonLight - - UIPrerenderedIcon - - - - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - de - - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.8.2 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - neuland - de.neuland-ingolstadt.neuland-app - - - - CFBundleURLSchemes - - exp+neuland-app-native - - - - CFBundleVersion - 1 - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSFaceIDUsageDescription - Erlaube $(PRODUCT_NAME) Face ID zu verwenden. - NSLocationAlwaysAndWhenInUseUsageDescription - Erlaube $(PRODUCT_NAME) deinen Standort zu verwenden. - NSLocationAlwaysUsageDescription - Erlaube $(PRODUCT_NAME) Zugriff auf deinen Standort. - NSLocationWhenInUseUsageDescription - Erlaube $(PRODUCT_NAME) Zugriff auf deinen Standort. - NSUserActivityTypes - - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - - RCTAsyncStorageExcludeFromBackup - - UIBackgroundModes - - fetch - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Automatic - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/NeulandNext/en.lproj/Info.plist b/ios/NeulandNext/en.lproj/Info.plist deleted file mode 100644 index d16592ac..00000000 --- a/ios/NeulandNext/en.lproj/Info.plist +++ /dev/null @@ -1,172 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleAllowMixedLocalizations - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Neuland Next - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIcons - - CFBundleAlternateIcons - - Default - - CFBundleIconFiles - - Default - - UIPrerenderedIcon - - - ModernDark - - CFBundleIconFiles - - ModernDark - - UIPrerenderedIcon - - - ModernGreen - - CFBundleIconFiles - - ModernGreen - - UIPrerenderedIcon - - - Retro - - CFBundleIconFiles - - Retro - - UIPrerenderedIcon - - - RainbowDark - - CFBundleIconFiles - - RainbowDark - - UIPrerenderedIcon - - - RainbowMoonLight - - CFBundleIconFiles - - RainbowMoonLight - - UIPrerenderedIcon - - - - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - de - - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.8.2 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - neuland - de.neuland-ingolstadt.neuland-app - - - - CFBundleURLSchemes - - exp+neuland-app-native - - - - CFBundleVersion - 1 - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSFaceIDUsageDescription - Allow $(PRODUCT_NAME) to use Face ID. - NSLocationAlwaysAndWhenInUseUsageDescription - Allow $(PRODUCT_NAME) to use your location. - NSLocationAlwaysUsageDescription - Allow $(PRODUCT_NAME) to access your location. - NSLocationWhenInUseUsageDescription - Allow $(PRODUCT_NAME) to access your location. - NSUserActivityTypes - - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - - RCTAsyncStorageExcludeFromBackup - - UIBackgroundModes - - fetch - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Automatic - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5750d74c..e8f1ee8c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,33 +1,27 @@ PODS: - - aptabase-react-native (0.3.9): + - aptabase-react-native (0.3.10): - React - boost (1.83.0) - - BVLinearGradient (2.8.3): - - React-Core - DoubleConversion (1.1.6) - EXApplication (5.9.1): - ExpoModulesCore - EXConstants (16.0.2): - ExpoModulesCore - EXJSONUtils (0.13.1) - - EXLocation (17.0.1): - - ExpoModulesCore - EXManifests (0.14.3): - ExpoModulesCore - - EXNotifications (0.28.9): + - Expo (51.0.24): - ExpoModulesCore - - Expo (51.0.18): - - ExpoModulesCore - - expo-dev-client (4.0.19): + - expo-dev-client (4.0.21): - EXManifests - expo-dev-launcher - expo-dev-menu - expo-dev-menu-interface - EXUpdatesInterface - - expo-dev-launcher (4.0.21): + - expo-dev-launcher (4.0.23): - DoubleConversion - EXManifests - - expo-dev-launcher/Main (= 4.0.21) + - expo-dev-launcher/Main (= 4.0.23) - expo-dev-menu - expo-dev-menu-interface - ExpoModulesCore @@ -53,7 +47,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-launcher/Main (4.0.21): + - expo-dev-launcher/Main (4.0.23): - DoubleConversion - EXManifests - expo-dev-launcher/Unsafe @@ -82,7 +76,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-launcher/Unsafe (4.0.21): + - expo-dev-launcher/Unsafe (4.0.23): - DoubleConversion - EXManifests - expo-dev-menu @@ -110,10 +104,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-menu (5.0.15): + - expo-dev-menu (5.0.17): - DoubleConversion - - expo-dev-menu/Main (= 5.0.15) - - expo-dev-menu/ReactNativeCompatibles (= 5.0.15) + - expo-dev-menu/Main (= 5.0.17) + - expo-dev-menu/ReactNativeCompatibles (= 5.0.17) - glog - hermes-engine - RCT-Folly (= 2024.01.01.00) @@ -134,7 +128,7 @@ PODS: - ReactCommon/turbomodule/core - Yoga - expo-dev-menu-interface (1.8.3) - - expo-dev-menu/Main (5.0.15): + - expo-dev-menu/Main (5.0.17): - DoubleConversion - EXManifests - expo-dev-menu-interface @@ -160,7 +154,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-menu/ReactNativeCompatibles (5.0.15): + - expo-dev-menu/ReactNativeCompatibles (5.0.17): - DoubleConversion - glog - hermes-engine @@ -181,7 +175,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-menu/SafeAreaView (5.0.15): + - expo-dev-menu/SafeAreaView (5.0.17): - DoubleConversion - ExpoModulesCore - glog @@ -203,7 +197,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - expo-dev-menu/Vendored (5.0.15): + - expo-dev-menu/Vendored (5.0.17): - DoubleConversion - expo-dev-menu/SafeAreaView - glog @@ -237,11 +231,11 @@ PODS: - ExpoModulesCore - ExpoFileSystem (17.0.1): - ExpoModulesCore - - ExpoFont (12.0.7): + - ExpoFont (12.0.9): - ExpoModulesCore - ExpoHaptics (13.0.1): - ExpoModulesCore - - ExpoHead (3.5.17): + - ExpoHead (3.5.20): - ExpoModulesCore - ExpoKeepAwake (13.0.2): - ExpoModulesCore @@ -251,7 +245,7 @@ PODS: - ExpoModulesCore - ExpoLocalization (15.0.3): - ExpoModulesCore - - ExpoModulesCore (1.12.18): + - ExpoModulesCore (1.12.20): - DoubleConversion - glog - hermes-engine @@ -276,8 +270,6 @@ PODS: - Yoga - ExpoSecureStore (13.0.2): - ExpoModulesCore - - ExpoSharing (12.0.1): - - ExpoModulesCore - ExpoSystemUI (3.0.7): - ExpoModulesCore - EXSplashScreen (0.27.5): @@ -302,9 +294,6 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - EXTaskManager (11.8.2): - - ExpoModulesCore - - UMAppLoader - EXUpdatesInterface (0.16.2): - ExpoModulesCore - FBLazyVector (0.74.3) @@ -313,13 +302,16 @@ PODS: - hermes-engine (0.74.3): - hermes-engine/Pre-built (= 0.74.3) - hermes-engine/Pre-built (0.74.3) - - maplibre-react-native (10.0.0-alpha.5): - - maplibre-react-native/DynamicLibrary (= 10.0.0-alpha.5) + - maplibre-react-native (10.0.0-alpha.10): + - maplibre-react-native/DynamicLibrary (= 10.0.0-alpha.10) - React - React-Core - - maplibre-react-native/DynamicLibrary (10.0.0-alpha.5): + - maplibre-react-native/DynamicLibrary (10.0.0-alpha.10): - React - React-Core + - MMKV (1.3.7): + - MMKVCore (~> 1.3.7) + - MMKVCore (1.3.7) - RCT-Folly (2024.01.01.00): - boost - DoubleConversion @@ -1252,6 +1244,28 @@ PODS: - React - react-native-drag-drop-ios (0.1.1): - React-Core + - react-native-mmkv (2.12.2): + - DoubleConversion + - glog + - hermes-engine + - MMKV (>= 1.3.3) + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-netinfo (11.3.1): - React-Core - react-native-pager-view (6.3.0): @@ -1275,8 +1289,11 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-safe-area-context (4.10.1): + - react-native-safe-area-context (4.10.5): + - React-Core + - react-native-shimmer (0.6.0): - React-Core + - Shimmer (~> 1) - react-native-view-shot (3.8.0): - React-Core - react-native-webview (13.8.6): @@ -1531,8 +1548,6 @@ PODS: - React-utils (= 0.74.3) - rn-quick-actions (0.0.3): - React - - RNCAsyncStorage (1.23.1): - - React-Core - RNDateTimePicker (8.0.1): - React-Core - RNDynamicAppIcon (1.1.0): @@ -1603,52 +1618,24 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNSentry (5.22.3): - - hermes-engine - - React-Core - - React-hermes - - Sentry/HybridSDK (= 8.26.0) - RNSVG (15.2.0): - React-Core - - RNVectorIcons (10.1.0): - - DoubleConversion - - glog - - hermes-engine - - RCT-Folly (= 2024.01.01.00) - - RCTRequired - - RCTTypeSafety - - React-Codegen - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-NativeModulesApple - - React-RCTFabric - - React-rendererdebug - - React-utils - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - - Sentry/HybridSDK (8.26.0) + - Shimmer (1.0.2) - SocketRocket (0.7.0) - - SweetSFSymbols (0.5.0): + - SweetSFSymbols (0.7.0): + - ExpoModulesCore + - SwiftUIReactNative (5.0.0): - ExpoModulesCore - - UMAppLoader (4.6.0) - Yoga (0.0.0) DEPENDENCIES: - "aptabase-react-native (from `../node_modules/@aptabase/react-native`)" - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXApplication (from `../node_modules/expo-application/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) - EXJSONUtils (from `../node_modules/expo-json-utils/ios`) - - EXLocation (from `../node_modules/expo-location/ios`) - EXManifests (from `../node_modules/expo-manifests/ios`) - - EXNotifications (from `../node_modules/expo-notifications/ios`) - Expo (from `../node_modules/expo`) - expo-dev-client (from `../node_modules/expo-dev-client/ios`) - expo-dev-launcher (from `../node_modules/expo-dev-launcher`) @@ -1669,10 +1656,8 @@ DEPENDENCIES: - ExpoLocalization (from `../node_modules/expo-localization/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - - ExpoSharing (from `../node_modules/expo-sharing/ios`) - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) - EXSplashScreen (from `../node_modules/expo-splash-screen/ios`) - - EXTaskManager (from `../node_modules/expo-task-manager/ios`) - EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) @@ -1707,9 +1692,11 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - react-native-context-menu-view (from `../node_modules/react-native-context-menu-view`) - react-native-drag-drop-ios (from `../node_modules/react-native-drag-drop-ios`) + - react-native-mmkv (from `../node_modules/react-native-mmkv`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - react-native-shimmer (from `../node_modules/react-native-shimmer`) - react-native-view-shot (from `../node_modules/react-native-view-shot`) - react-native-webview (from `../node_modules/react-native-webview`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) @@ -1736,23 +1723,22 @@ DEPENDENCIES: - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - rn-quick-actions (from `../node_modules/rn-quick-actions`) - - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDynamicAppIcon (from `../node_modules/react-native-dynamic-app-icon`) - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - - "RNSentry (from `../node_modules/@sentry/react-native`)" - RNSVG (from `../node_modules/react-native-svg`) - - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - SweetSFSymbols (from `../node_modules/sweet-sfsymbols/ios`) - - UMAppLoader (from `../node_modules/unimodules-app-loader/ios`) + - SwiftUIReactNative (from `../node_modules/swiftui-react-native/ios`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: - - Sentry + - MMKV + - MMKVCore + - Shimmer - SocketRocket EXTERNAL SOURCES: @@ -1760,8 +1746,6 @@ EXTERNAL SOURCES: :path: "../node_modules/@aptabase/react-native" boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" - BVLinearGradient: - :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" EXApplication: @@ -1770,12 +1754,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-constants/ios" EXJSONUtils: :path: "../node_modules/expo-json-utils/ios" - EXLocation: - :path: "../node_modules/expo-location/ios" EXManifests: :path: "../node_modules/expo-manifests/ios" - EXNotifications: - :path: "../node_modules/expo-notifications/ios" Expo: :path: "../node_modules/expo" expo-dev-client: @@ -1816,14 +1796,10 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" ExpoSecureStore: :path: "../node_modules/expo-secure-store/ios" - ExpoSharing: - :path: "../node_modules/expo-sharing/ios" ExpoSystemUI: :path: "../node_modules/expo-system-ui/ios" EXSplashScreen: :path: "../node_modules/expo-splash-screen/ios" - EXTaskManager: - :path: "../node_modules/expo-task-manager/ios" EXUpdatesInterface: :path: "../node_modules/expo-updates-interface/ios" FBLazyVector: @@ -1889,12 +1865,16 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-context-menu-view" react-native-drag-drop-ios: :path: "../node_modules/react-native-drag-drop-ios" + react-native-mmkv: + :path: "../node_modules/react-native-mmkv" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-pager-view: :path: "../node_modules/react-native-pager-view" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-shimmer: + :path: "../node_modules/react-native-shimmer" react-native-view-shot: :path: "../node_modules/react-native-view-shot" react-native-webview: @@ -1947,8 +1927,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" rn-quick-actions: :path: "../node_modules/rn-quick-actions" - RNCAsyncStorage: - :path: "../node_modules/@react-native-async-storage/async-storage" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" RNDynamicAppIcon: @@ -1961,34 +1939,27 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" - RNSentry: - :path: "../node_modules/@sentry/react-native" RNSVG: :path: "../node_modules/react-native-svg" - RNVectorIcons: - :path: "../node_modules/react-native-vector-icons" SweetSFSymbols: :path: "../node_modules/sweet-sfsymbols/ios" - UMAppLoader: - :path: "../node_modules/unimodules-app-loader/ios" + SwiftUIReactNative: + :path: "../node_modules/swiftui-react-native/ios" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - aptabase-react-native: c47bcbba34f4aa69de24b415150d22d5b52c927a + aptabase-react-native: 4b0d69af453e0b79e60b0bd926702ccb599cb681 boost: d3f49c53809116a5d38da093a8aa78bf551aed09 - BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3 DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXApplication: c08200c34daca7af7fd76ac4b9d606077410e8ad EXConstants: 409690fbfd5afea964e5e9d6c4eb2c2b59222c59 EXJSONUtils: 30c17fd9cc364d722c0946a550dfbf1be92ef6a4 - EXLocation: 43e9b582ca63a23c6f0a18d8cbe2145b3a388b55 EXManifests: c1fab4c3237675e7b0299ea8df0bcb14baca4f42 - EXNotifications: fcecd5a47fc8448d4f6173490b6fa8ee747f687d - Expo: 56b642d0930789fc847dc7f424d2d599dfe59a5e - expo-dev-client: bcca43a56437123873b32a36ea31568e4212c1ca - expo-dev-launcher: ab76344f7a72c7b6bb255280a66202655ecf1965 - expo-dev-menu: 9b9886b383163f14821e624f3eb51a2084491aa2 + Expo: 798848eae1daf13363d69790986146b08d0cf92f + expo-dev-client: 3a5d838e562927099488c094acba1818296c6bc6 + expo-dev-launcher: 0336379dae2faeb2da861c61977a6fbd02ab52f1 + expo-dev-menu: 11322d81c62f966399ec162383a8488b55cce366 expo-dev-menu-interface: be32c09f1e03833050f0ee290dcc86b3ad0e73e4 ExpoAsset: 323700f291684f110fb55f0d4022a3362ea9f875 ExpoBlur: fa53f874e7b208bc3756d1bf07903c12e790beb1 @@ -1996,25 +1967,25 @@ SPEC CHECKSUMS: ExpoClipboard: 23d203f5d4843699fbc45be1cc4fe1fbd811a6fa ExpoDevice: fc94f0e42ecdfd897e7590f2874fc64dfa7e9b1c ExpoFileSystem: 80bfe850b1f9922c16905822ecbf97acd711dc51 - ExpoFont: 43b69559cef3d773db57c7ae7edd3cb0aa0dc610 + ExpoFont: e7f2275c10ca8573c991e007329ad6bf98086485 ExpoHaptics: 5a3a88971af384255baf2504f38b41189cec6984 - ExpoHead: 439510c5bec19592d5a0d78860e0cb6c59e28a34 + ExpoHead: 3e8eacccdad1256f0643b657d89bf972c27afb1d ExpoKeepAwake: 3b8815d9dd1d419ee474df004021c69fdd316d08 ExpoLinearGradient: 8cec4a09426d8934c433e83cb36262d72c667fce ExpoLocalAuthentication: 9e02a56a4cf9868f0052656a93d4c94101a42ed7 ExpoLocalization: f04eeec2e35bed01ab61c72ee1768ec04d093d01 - ExpoModulesCore: 30e1ed4659356cb9a84e0e5ddf1d090d735973c1 + ExpoModulesCore: 5440e96a8ee014f4fd88e77264985fd0a65f5f8c ExpoSecureStore: 060cebcb956b80ddae09821610ac1aa9e1ac74cd - ExpoSharing: 8db05dd85081219f75989a3db2c92fe5e9741033 ExpoSystemUI: d4f065a016cae6721b324eb659cdee4d4cf0cb26 EXSplashScreen: fbf0ec78e9cee911df188bf17b4fe51d15a84b87 - EXTaskManager: 9c3520305c3aa1b4a12a7c6d1e3f85f2779c06e9 EXUpdatesInterface: 996527fd7d1a5d271eb523258d603f8f92038f24 FBLazyVector: 7e977dd099937dc5458851233141583abba49ff2 fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f hermes-engine: 1f547997900dd0752dc0cc0ae6dd16173c49e09b - maplibre-react-native: bcb8d0ed567f4a67f651e2d1a24521ef273c23de + maplibre-react-native: 94290e011a0ac4750cac7e897f4da37000633fd9 + MMKV: 36a22a9ec84c9bb960613a089ddf6f48be9312b0 + MMKVCore: 158e61c8516401a9fac730288acb29e6fc19bbf9 RCT-Folly: 02617c592a293bd6d418e0a88ff4ee1f88329b47 RCTDeprecation: 4c7eeb42be0b2e95195563c49be08d0b839d22b4 RCTRequired: d530a0f489699c8500e944fde963102c42dcd0c2 @@ -2041,9 +2012,11 @@ SPEC CHECKSUMS: React-Mapbuffer: 9f68550e7c6839d01411ac8896aea5c868eff63a react-native-context-menu-view: 30915369a9b5887904c571b616653acf3f1c8edb react-native-drag-drop-ios: 099293de149185e9116a057a8b6b98f95ce9b56e + react-native-mmkv: 8c9a677e64a1ac89b0c6cf240feea528318b3074 react-native-netinfo: bdb108d340cdb41875c9ced535977cac6d2ff321 react-native-pager-view: c1e29e1a6105a02807392ba822ad322447a72f55 - react-native-safe-area-context: dcab599c527c2d7de2d76507a523d20a0b83823d + react-native-safe-area-context: a240ad4b683349e48b1d51fed1611138d1bdad97 + react-native-shimmer: 1c06d2f0bc09e415e0a9691301d4515a4d2b8f3f react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 react-native-webview: 05bae3a03a1e4f59568dfc05286c0ebf8954106c React-nativeconfig: fa5de9d8f4dbd5917358f8ad3ad1e08762f01dcb @@ -2070,20 +2043,17 @@ SPEC CHECKSUMS: React-utils: a06061b3887c702235d2dac92dacbd93e1ea079e ReactCommon: f00e436b3925a7ae44dfa294b43ef360fbd8ccc4 rn-quick-actions: dbd80c5223df43389c11920e035d09f4b7694176 - RNCAsyncStorage: 826b603ae9c0f88b5ac4e956801f755109fa4d5c RNDateTimePicker: b6a9b35a785ecbe12b4e7d6de5439d0aa4614146 RNDynamicAppIcon: f7eb12d6c2e6e957eead111dacdb93af318396ac RNFlashList: b521ebdd7f9352673817f1d98e8bdc0c8cf8545b RNGestureHandler: 2282cfbcf86c360d29f44ace393203afd5c6cff7 RNReanimated: 35f9ac9c3ac42d0497ebd1cce5c39d7687a8493e RNScreens: b32a9ff15bea7fcdbe5dff6477bc503f792b1208 - RNSentry: 7e184247ee04989474d12cce0b7ef7bc4a71632f RNSVG: 43b64ed39c14ce830d840903774154ca0c1f27ec - RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90 - Sentry: 74a073c71c998117edb08f56f443c83570a31bed + Shimmer: c5374be1c2b0c9e292fb05b339a513cf291cac86 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - SweetSFSymbols: e758921afd1b48cb9d3ebf1528dbf718a88b6ff7 - UMAppLoader: f17a5ee8e85b536ace0fc254b447a37ed198d57e + SweetSFSymbols: 75e9d61f69fd934b0a32d87f739360a8eda22f4c + SwiftUIReactNative: 3241f6b3d9cf6dc29b3dc64f7d838770f19fd604 Yoga: 04f1db30bb810187397fa4c37dd1868a27af229c PODFILE CHECKSUM: a2c2df1b6d6fb8a577a08d33880679b64c476742 diff --git a/ios/TestFlight/WhatToTest.de-DE.txt b/ios/TestFlight/WhatToTest.de-DE.txt index b41e6064..6013f9f0 100644 --- a/ios/TestFlight/WhatToTest.de-DE.txt +++ b/ios/TestFlight/WhatToTest.de-DE.txt @@ -1,2 +1,11 @@ -- Zeigt Prüfungstermine im Kalender an -- Performance und Design Verbesserungen \ No newline at end of file +- Neues Onboarding und Login Design +- Neuer Suchverlauf bei Kartensuche +- Natives Design der erweiterten Raumsuche +- Verbesserte Kalender Performance +- Verbessertes Error Handling +- Verringert initale Ladezeit der App +- Verringert größe der App +- Behebt fehlende Übersetzungen +- Behebt Fehler beim Laden der Essensdaten +- Neue System Status Seite +- Neues Easter Egg \ No newline at end of file diff --git a/ios/TestFlight/WhatToTest.en-US.txt b/ios/TestFlight/WhatToTest.en-US.txt index 1d5e8196..578eb3d0 100644 --- a/ios/TestFlight/WhatToTest.en-US.txt +++ b/ios/TestFlight/WhatToTest.en-US.txt @@ -1,2 +1,11 @@ -- Displays exam dates in the calendar -- Performance and design improvements \ No newline at end of file +- New onboarding and login design +- New search history for map search +- Native design of the extended room search +- Improved calendar performance +- Improved error handling +- Reduced initial loading time of the app +- Reduced size of the app +- Fixes missing translations +- Fixes errors when loading the meal data +- New system status page +- New Easter Egg \ No newline at end of file diff --git a/ios/sentry.properties b/ios/sentry.properties deleted file mode 100644 index 2ebefa45..00000000 --- a/ios/sentry.properties +++ /dev/null @@ -1,4 +0,0 @@ -defaults.url=https://sentry.io/ -defaults.org=neuland-ingolstadt -defaults.project=neuland-next -# Using SENTRY_AUTH_TOKEN environment variable \ No newline at end of file diff --git a/metro.config.js b/metro.config.js index 004297d2..21b4c767 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,7 +1,5 @@ -// This replaces `const { getDefaultConfig } = require('expo/metro-config');` -const { getSentryExpoConfig } = require('@sentry/react-native/metro') +const { getDefaultConfig } = require('expo/metro-config') -// This replaces `const config = getDefaultConfig(__dirname);` -const config = getSentryExpoConfig(__dirname) +const config = getDefaultConfig(__dirname) module.exports = config diff --git a/package.json b/package.json index 3d92ffb1..2e886c5c 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { - "version": "0.8.2", + "version": "0.8.3", "name": "neuland", "main": "expo-router/entry", "homepage": "https://github.com/neuland-ingolstadt/neuland.app-native", "private": true, "scripts": { "start": "EXPO_USE_FAST_RESOLVER=1 expo", - "tunnel": "expo start -c --tunnel", + "atlas": "EXPO_UNSTABLE_ATLAS=true npx expo export --platform all", "android": "expo run:android", - "ios": "expo run:ios", - "build:android": "expo prebuild --platform android && eas build --platform android --local", + "ios": "expo run:ios --device", + "build:android": "bun licences && expo prebuild --platform android && eas build --platform android --local", "build:ios": "xcodebuild archive -workspace ios/NeulandNext.xcworkspace -scheme NeulandNext -configuration Release -archivePath ios/build/NeulandNext.xcarchive", "export:ios": "xcodebuild -exportArchive -archivePath ios/build/NeulandNext.xcarchive -exportPath ios/build/NeulandNext.ipa -exportOptionsPlist ios/exportOptions.plist && xcrun altool --upload-app -f ios/build/NeulandNext.ipa/NeulandNext.ipa -t ios -u $(security find-generic-password -s 'AppleAppStoreUpload' -g 2>&1 | grep -E 'acct' | awk -F= '{print $2}' | tr -d '\"') -p $(security find-generic-password -w -s 'AppleAppStoreUpload')", - "ship:ios": "bun build:ios && bun export:ios", + "ship:ios": "bun licences && bun build:ios && bun export:ios", "build:all": "bun licences && bun build:ios && bun build:android", "format": "prettier --write .", "lint": "eslint .", @@ -21,119 +21,107 @@ "prepare": "husky" }, "dependencies": { - "@alessiocancian/react-native-actionsheet": "^3.2.0", - "@aptabase/react-native": "^0.3.9", - "@babel/runtime": "^7.23.6", - "@expo/vector-icons": "^14.0.0", - "@gorhom/bottom-sheet": "^4", + "@aptabase/react-native": "^0.3.10", + "@babel/runtime": "^7.25.0", + "@expo/vector-icons": "^14.0.2", + "@gorhom/bottom-sheet": "^5.0.0-alpha.11", "@kichiyaki/react-native-barcode-generator": "^0.6.7", - "@maplibre/maplibre-react-native": "^10.0.0-alpha.5", + "@maplibre/maplibre-react-native": "^10.0.0-alpha.10", "@material/material-color-utilities": "^0.3.0", - "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.0.1", "@react-native-community/netinfo": "11.3.1", - "@sentry/react-native": "~5.22.0", "@shopify/flash-list": "1.6.4", - "@tanstack/query-async-storage-persister": "^5.17.19", - "@tanstack/react-query": "^5.17.19", - "@tanstack/react-query-persist-client": "^5.17.19", + "@tanstack/query-sync-storage-persister": "^5.51.17", + "@tanstack/react-query": "^5.51.18", + "@tanstack/react-query-persist-client": "^5.51.18", "color": "^4.2.3", - "expo": "^51.0.17", + "expo": "~51.0.24", + "expo-application": "~5.9.1", "expo-blur": "~13.0.2", "expo-brightness": "~12.0.1", - "expo-build-properties": "~0.12.3", + "expo-build-properties": "~0.12.4", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", - "expo-dev-client": "~4.0.19", "expo-device": "~6.0.2", "expo-haptics": "~13.0.1", "expo-linear-gradient": "~13.0.2", - "expo-linking": "~6.3.1", "expo-local-authentication": "~14.0.1", "expo-localization": "~15.0.3", - "expo-location": "~17.0.1", "expo-navigation-bar": "~3.0.7", - "expo-notifications": "~0.28.7", - "expo-router": "~3.5.17", + "expo-router": "~3.5.20", "expo-secure-store": "~13.0.2", - "expo-sharing": "~12.0.1", "expo-splash-screen": "~0.27.5", - "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", - "expo-task-manager": "~11.8.2", "fuse.js": "^7.0.0", + "graphql": "^16.9.0", "graphql-request": "^6.1.0", - "graphql-tag": "^2.12.6", - "i18next": "^23.8.0", - "metro": "~0.80.4", - "moment": "^2.29.4", + "i18next": "^23.12.2", + "metro": "~0.80.9", + "moment": "^2.30.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-i18next": "^14.0.0", + "react-i18next": "^15.0.0", "react-native": "0.74.3", "react-native-collapsible": "^1.6.1", - "react-native-context-menu-view": "^1.14.1", + "react-native-context-menu-view": "^1.16.0", "react-native-drag-drop-ios": "^0.1.1", "react-native-drag-sort": "^2.4.4", - "react-native-dropdown-select-list": "^2.0.5", "react-native-dynamic-app-icon": "^1.1.0", - "react-native-gesture-handler": "~2.16.1", - "react-native-linear-gradient": "^2.8.3", - "react-native-onboarding-swiper": "https://github.com/neuland-ingolstadt/react-native-onboarding-swiper.git", + "react-native-gesture-handler": "~2.16.2", + "react-native-mmkv": "^2.12.2", "react-native-pager-view": "6.3.0", - "react-native-paper": "https://github.com/neuland-ingolstadt/react-native-paper.git", + "react-native-paper": "https://github.com/neuland-ingolstadt/react-native-paper.git#0.1", "react-native-reanimated": "~3.10.1", - "react-native-root-toast": "^3.5.1", - "react-native-safe-area-context": "4.10.1", + "react-native-root-toast": "^3.6.0", + "react-native-safe-area-context": "4.10.5", "react-native-screens": "3.31.1", - "react-native-select-dropdown": "^3.3.4", - "react-native-shimmer-placeholder": "^2.0.9", + "react-native-select-dropdown": "^3.4.0", + "react-native-shimmer": "https://github.com/ssukru/react-native-shimmer.git#0218ea6386cc4a70e061deebcbd6e2c6c8051b3b", "react-native-svg": "15.2.0", - "react-native-vector-icons": "^10.0.3", "react-native-view-shot": "^3.8.0", "react-native-webview": "13.8.6", "react-native-week-view": "https://github.com/neuland-ingolstadt/react-native-week-view.git#0.2", "rn-quick-actions": "^0.0.3", - "sanitize-html": "^2.11.0", - "sweet-sfsymbols": "0.5.0" + "sanitize-html": "^2.13.0", + "sweet-sfsymbols": "^0.7.0", + "swiftui-react-native": "^6.3.0" }, "devDependencies": { - "@babel/core": "^7.0.0-0", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0-0", - "@babel/plugin-proposal-optional-chaining": "^7.0.0-0", - "@babel/plugin-transform-arrow-functions": "^7.0.0-0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", - "@babel/plugin-transform-template-literals": "^7.0.0-0", - "@expo/ngrok": "^4.1.0", - "@trivago/prettier-plugin-sort-imports": "^4.2.1", - "@types/color": "^3.0.5", + "expo-dev-client": "~4.0.21", + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.21.0", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-template-literals": "^7.24.7", + "@expo/ngrok": "^4.1.3", + "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/color": "^3.0.6", "@types/geojson": "^7946.0.14", - "@types/prop-types": "^15", - "@types/react": "~18.2.45", - "@types/react-native-actionsheet": "^2", - "@types/sanitize-html": "^2", - "@typescript-eslint/eslint-plugin": "^6.6.0", - "@typescript-eslint/parser": "^6.10.0", - "ajv": "^8.12.0", - "babel-plugin-formatjs": "^10.5.7", + "@types/prop-types": "^15.7.12", + "@types/react": "~18.2.79", + "@types/sanitize-html": "^2.11.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "ajv": "^8.17.1", + "babel-plugin-formatjs": "^10.5.16", "eslint": "^8.57.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "^43.0.1", - "eslint-plugin-import": "^2.29.0", + "eslint-plugin-i18next": "^6.0.9", + "eslint-plugin-import": "^2.29.1", "eslint-plugin-n": "^16.6.2", - "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-promise": "^6.6.0", "eslint-plugin-react": "latest", + "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-native": "^4.1.0", - "expo-atlas": "^0.3.0", - "husky": "^9.0.6", - "lint-staged": ">=15", + "expo-atlas": "^0.3.11", + "husky": "^9.1.4", + "lint-staged": "^15.2.7", "prettier": "3.2.5", "prop-types": "^15.8.1", "typescript": "~5.3.3" }, - "trustedDependencies": [ - "@sentry/cli" - ], "lint-staged": { "**/*.{js,jsx,ts,tsx,json,yml}": [ "npx prettier --write" diff --git a/src/api/anonymous-api.ts b/src/api/anonymous-api.ts index 854da035..3147a2ad 100644 --- a/src/api/anonymous-api.ts +++ b/src/api/anonymous-api.ts @@ -1,8 +1,6 @@ import packageInfo from '../../package.json' -const ENDPOINT_HOST: string = - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing - process.env.EXPO_PUBLIC_THI_API_ENDPOINT || 'hiplan.thi.de' +const ENDPOINT_HOST: string = 'hiplan.thi.de' const ENDPOINT_URL = '/webservice/zits_s_40_test/index.php' const USER_AGENT = `neuland.app-native/${packageInfo.version} (+${packageInfo.homepage})` @@ -32,6 +30,7 @@ export class AnonymousAPIClient { * Submits an API request to the THI backend using a WebSocket proxy */ async request(params: Record): Promise { + // @ts-expect-error cannot verify environment variable const apiKey = process.env.EXPO_PUBLIC_THI_API_KEY ?? '' const headers = new Headers({ Host: ENDPOINT_HOST, diff --git a/src/api/authenticated-api.ts b/src/api/authenticated-api.ts index a67a7c96..3f547954 100644 --- a/src/api/authenticated-api.ts +++ b/src/api/authenticated-api.ts @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import courseShortNames from '@/data/course-short-names.json' -import { type CourseShortNames } from '@/types/data' import { type AvailableLibrarySeats, type Exams, @@ -23,40 +21,6 @@ export interface PersonalData { } } -/** - * Determines the users faculty. - * @param {PersonalData} data Personal data - * @returns {string} Faculty name (e.g. `Informatik`) - */ -function extractFacultyFromPersonalData(data: PersonalData): string | null { - if (data?.persdata?.stg == null) { - return null - } - const shortNames: CourseShortNames = courseShortNames - const shortName = data.persdata.stg - const faculty = Object.keys(shortNames).find((faculty) => - (courseShortNames as Record)[faculty].includes( - shortName - ) - ) - - return faculty ?? null -} - -/** - * Determines the users SPO version. - * @param {PersonalData} data Personal data - * @returns {string} - */ -function extractSpoFromPersonalData(data: PersonalData): string | null { - if (data?.persdata?.po_url == null) { - return null - } - - const split = data.persdata.po_url.split('/').filter((x) => x.length > 0) - return split[split.length - 1] -} - /** * Client for accessing the API as a particular user. * @@ -110,43 +74,6 @@ export class AuthenticatedAPIClient extends AnonymousAPIClient { return res } - /** - * Extracts the faculty from the personal data - * @returns {Promise} Promise that resolves with the faculty of the user - */ - async getFaculty(): Promise { - const data = await this.getPersonalData() - return extractFacultyFromPersonalData(data) - } - - /** - * Extracts the SPO version from the personal data - * @returns {Promise} Promise that resolves with the SPO version of the user - */ - async getSpoName(): Promise { - const data = await this.getPersonalData() - return extractSpoFromPersonalData(data) - } - - /** - * Extracts the full name from the personal data - * @returns {Promise} Promise that resolves with the full name of the user - */ - async getFullName(): Promise { - const data = await this.getPersonalData() - const fullName = data?.persdata?.vname + ' ' + data?.persdata?.name - return fullName - } - - /** - * Extracts the bib number from the personal data - * @returns {Promise} Promise that resolves with the bib number of the user - */ - async getLibraryNumber(): Promise { - const data = await this.getPersonalData() - return data?.persdata?.bibnr - } - /** * Fetches the timetable for a specific date * @param {Date} date Date to fetch the timetable for diff --git a/src/api/cache.ts b/src/api/cache.ts deleted file mode 100644 index 23c8a980..00000000 --- a/src/api/cache.ts +++ /dev/null @@ -1,133 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -const CHECK_INTERVAL = 10000 - -/** - * A class representing a cache stored in local storage. - */ -export default class LocalStorageCache { - private readonly namespace: string - private readonly ttl: number - private readonly interval: NodeJS.Timeout - - /** - * Creates a new instance of LocalStorageCache. - * @param {string} namespace - The namespace for the cache. - * @param {number} ttl - The time-to-live (in milliseconds) for the cache. - */ - constructor({ namespace, ttl }: { namespace: string; ttl: number }) { - this.namespace = namespace - this.ttl = ttl - this.interval = setInterval(() => { - void this.checkExpiry() - }, CHECK_INTERVAL) - } - - /** - * Checks the expiry of all items in the cache and removes expired items. - * @returns {Promise} A promise that resolves when the check is complete. - */ - private async checkExpiry(): Promise { - try { - const keys = await AsyncStorage.getAllKeys() - const filteredKeys = keys.filter((x) => - x.startsWith(`${this.namespace}-`) - ) - - for (const key of filteredKeys) { - const item = await AsyncStorage.getItem(key) - if (item === null) { - continue - } - const json = item - const expiry = JSON.parse(json).expiry - - if (expiry < Date.now()) { - await AsyncStorage.removeItem(key) - } - } - } catch (error) { - console.error('Error checking cache expiry:', error) - } - } - - /** - * Stops the cache from checking for expired items. - * @returns {void} - */ - public close(): void { - clearInterval(this.interval) - } - - /** - * Gets the value associated with the given key from the cache. - * @param {string} key - The key to get the value for. - * @returns {Promise} A promise that resolves with the value associated with the key, or undefined if the key is not found or the value has expired. - */ - public async get(key: string): Promise { - try { - const json = await AsyncStorage.getItem(`${this.namespace}-${key}`) - if (json == null) { - return undefined - } - const { value, expiry } = JSON.parse(json) - if (expiry > Date.now()) { - return value - } else { - return undefined - } - } catch (error) { - console.error('Error getting cached value:', error) - return undefined - } - } - - /** - * Sets the value associated with the given key in the cache. - * @param {string} key - The key to set the value for. - * @param {*} value - The value to set. - * @returns {Promise} A promise that resolves when the value is set. - */ - public async set(key: string, value: any): Promise { - try { - await AsyncStorage.setItem( - `${this.namespace}-${key}`, - JSON.stringify({ - value, - expiry: Date.now() + this.ttl, - }) - ) - } catch (error) { - console.error('Error setting cached value:', error) - } - } - - /** - * Deletes the value associated with the given key from the cache. - * @param {string} key - The key to delete the value for. - * @returns {Promise} A promise that resolves when the value is deleted. - */ - public async delete(key: string): Promise { - try { - await AsyncStorage.removeItem(`${this.namespace}-${key}`) - } catch (error) { - console.error('Error deleting cached value:', error) - } - } - - /** - * Deletes all items in the cache. - * @returns {Promise} A promise that resolves when all items are deleted. - */ - public async flushAll(): Promise { - try { - const keys = await AsyncStorage.getAllKeys() - const cacheKeys = keys.filter((x) => - x.startsWith(`${this.namespace}-`) - ) - await AsyncStorage.multiRemove(cacheKeys) - } catch (error) { - console.error('Error flushing cache:', error) - } - } -} diff --git a/src/api/neuland-api.ts b/src/api/neuland-api.ts index e9fe0dde..784b39e0 100644 --- a/src/api/neuland-api.ts +++ b/src/api/neuland-api.ts @@ -66,73 +66,67 @@ class NeulandAPIClient { return await this.performGraphQLQuery(gql` query { food(locations: [${locations.map((x) => `"${x}"`).join(',')}]) { - timestamp - meals { - variants { - name { - de - en - } - additional - prices { - student - employee - guest - } - id - allergens - flags - nutrition { - kj - kcal - fat - fatSaturated - carbs - sugar - fiber - protein - salt - } - originalLanguage - static - restaurant - parent { - id - category - } - - } - name { - de - en - } - id - category - prices { - student - employee - guest - } - allergens - flags - nutrition { - kj - kcal - fat - fatSaturated - carbs - sugar - fiber - protein - salt - } - originalLanguage - static - restaurant - } + foodData { + timestamp + meals { + name { + de + en + } + id + category + prices { + student + employee + guest + } + allergens + flags + nutrition { + kj + kcal + fat + fatSaturated + carbs + sugar + fiber + protein + salt + } + variants { + name { + de + en + } + additional + id + allergens + flags + originalLanguage + static + restaurant + parent { + id + category + } + prices { + student + employee + guest + } + } + originalLanguage + static + restaurant + } + } + errors { + location + message + } + } } - } - `) + `) } /** diff --git a/src/api/thi-session-handler.ts b/src/api/thi-session-handler.ts index 613b7467..37f94732 100644 --- a/src/api/thi-session-handler.ts +++ b/src/api/thi-session-handler.ts @@ -1,9 +1,6 @@ -import { useNotification } from '@/hooks' -import { convertToMajorMinorPatch } from '@/utils/app-utils' -import AsyncStorage from '@react-native-async-storage/async-storage' +import { storage } from '@/utils/storage' import * as SecureStore from 'expo-secure-store' -import packageInfo from '../../package.json' import API from './anonymous-api' const SESSION_EXPIRES = 3 * 60 * 60 * 1000 @@ -55,7 +52,7 @@ export async function createSession( throw new Error('Session is not a string') } - await AsyncStorage.setItem('sessionCreated', Date.now().toString()) + storage.set('sessionCreated', Date.now().toString()) await save('session', session) if (stayLoggedIn) { @@ -68,14 +65,11 @@ export async function createSession( /** * Logs in the user as a guest. */ -export async function createGuestSession(): Promise { - await forgetSession() +export async function createGuestSession(forget = true): Promise { + if (forget) { + await forgetSession() + } await save('session', 'guest') - await AsyncStorage.setItem('isOnboarded', 'true') - await AsyncStorage.setItem( - `isUpdated-${convertToMajorMinorPatch(packageInfo.version)}`, - 'true' - ) } /** @@ -91,9 +85,7 @@ export async function callWithSession( method: (session: string) => Promise ): Promise { let session = await load('session') - const sessionCreated = parseInt( - (await AsyncStorage.getItem('sessionCreated')) ?? '0' - ) + const sessionCreated = parseInt(storage.getString('sessionCreated') ?? '0') // redirect user if he never had a session if (session == null) { throw new NoSessionError() @@ -125,8 +117,8 @@ export async function callWithSession( session = newSession await save('session', session) - await AsyncStorage.setItem('sessionCreated', Date.now().toString()) - await AsyncStorage.setItem('isStudent', isStudent.toString()) + storage.set('sessionCreated', Date.now().toString()) + storage.set('isStudent', isStudent.toString()) } catch (e) { throw new NoSessionError() } @@ -152,14 +144,8 @@ export async function callWithSession( ) session = newSession await save('session', session) - await AsyncStorage.setItem( - 'sessionCreated', - Date.now().toString() - ) - await AsyncStorage.setItem( - 'isStudent', - isStudent.toString() - ) + storage.set('sessionCreated', Date.now().toString()) + storage.set('isStudent', isStudent.toString()) } catch (e) { throw new NoSessionError() } @@ -185,7 +171,7 @@ export async function callWithSession( */ export async function obtainSession(router: object): Promise { let session = await load('session') - const age = parseInt((await AsyncStorage.getItem('sessionCreated')) ?? '0') + const age = parseInt(storage.getString('sessionCreated') ?? '0') const username = await load('username') const password = await load('password') @@ -207,8 +193,8 @@ export async function obtainSession(router: object): Promise { ) session = newSession await save('session', session) - await AsyncStorage.setItem('sessionCreated', Date.now().toString()) - await AsyncStorage.setItem('isStudent', isStudent.toString()) + storage.set('sessionCreated', Date.now().toString()) + storage.set('isStudent', isStudent.toString()) } catch (e) { console.log('Failed to log in again') console.error(e) @@ -222,7 +208,6 @@ export async function obtainSession(router: object): Promise { * Logs out the user by deleting the session from localStorage. */ export async function forgetSession(): Promise { - const { cancelAll } = useNotification() const session = await load('session') if (session === null) { console.log('No session to forget') @@ -234,24 +219,27 @@ export async function forgetSession(): Promise { } catch (e) { console.error(e) } - // clear all AsyncStorage data await Promise.all([ SecureStore.deleteItemAsync('session'), SecureStore.deleteItemAsync('username'), SecureStore.deleteItemAsync('password'), + SecureStore.deleteItemAsync('userFullName'), + SecureStore.deleteItemAsync('userType'), ]) // clear all AsyncStorage data except analytics try { - const analytics = await AsyncStorage.getItem('analytics') - await AsyncStorage.clear() - if (analytics != null) { - await AsyncStorage.setItem('analytics', analytics) + const keys = storage.getAllKeys() + for (const key of keys) { + if ( + key !== 'analytics' && + key !== 'isOnboardedv1' && + !key.startsWith('isUpdated-') + ) { + storage.delete(key) + } } } catch (e) { console.error(e) } - - // cancel all scheduled notifications - await cancelAll() } diff --git a/src/app/(flow)/onboarding.tsx b/src/app/(flow)/onboarding.tsx index 12da5471..53af6b13 100644 --- a/src/app/(flow)/onboarding.tsx +++ b/src/app/(flow)/onboarding.tsx @@ -1,232 +1,494 @@ -import OnboardingBox from '@/components/Elements/Flow/OnboardingBox' -import EverythingSVG from '@/components/Elements/Flow/svgs/everything' +/* eslint-disable react-hooks/rules-of-hooks */ +import WhatsNewBox from '@/components/Elements/Flow/WhatsnewBox' import LogoSVG from '@/components/Elements/Flow/svgs/logo' -import SecureSVG from '@/components/Elements/Flow/svgs/secure' -import LoginForm from '@/components/Elements/Universal/LoginForm' +import LogoTextSVG from '@/components/Elements/Flow/svgs/logoText' import { type Colors } from '@/components/colors' -import { IMPRINT_URL, PRIVACY_URL } from '@/utils/app-utils' +import { FlowContext, UserKindContext } from '@/components/contexts' +import { PRIVACY_URL, USER_GUEST } from '@/utils/app-utils' import { getContrastColor } from '@/utils/ui-utils' import { useTheme } from '@react-navigation/native' -import React, { useRef } from 'react' +import * as Haptics from 'expo-haptics' +import { router } from 'expo-router' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Linking, StyleSheet, Text, View } from 'react-native' -import Onboarding from 'react-native-onboarding-swiper' +import { + Dimensions, + Linking, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withDelay, + withSequence, + withTiming, +} from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import Shimmer from 'react-native-shimmer' export default function OnboardingScreen(): JSX.Element { - const onboardingRef = useRef(null) - const colors = useTheme().colors as Colors + const flow = React.useContext(FlowContext) + const userkind = React.useContext(UserKindContext) const { t } = useTranslation('flow') - return ( - - onboardingRef.current - // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. - ?.goToPage(3, false) - } - controlStatusBar={false} - showDone={false} - nextLabel={t('onboarding.navigation.next')} - skipLabel={t('onboarding.navigation.skip')} - pages={[ - { - backgroundColor: colors.background, - image: ( - - - - ), - title: ( - - { + return ( + + { + if (Platform.OS === 'ios') { + void Haptics.selectionAsync() + } + flow.setOnboarded(true) + flow.setUpdated(true) + flow.setAnalyticsAllowed(true) + + if (userkind.userKind === USER_GUEST) { + router.navigate('login') + router.setParams({ fromOnboarding: 'true' }) + } else { + router.replace('/') + } + }} + disabled={buttonDisabled} + > + + {t('whatsnew.continue')} + + + + ) + } + + const cardsOpacity = data.map(() => useSharedValue(0)) + const cardsTranslateY = data.map(() => useSharedValue(20)) + const legalOpacity = useSharedValue(0) + const legalTranslateY = useSharedValue(20) + const logoOpacity = useSharedValue(0) + const textTranslateY = useSharedValue(20) + const textOpacity = useSharedValue(0) + const cardsViewHeight = useSharedValue(0) + const textLogoOpacity = useSharedValue(1) + const logoMargin = useSharedValue(1) + const [isWhobbleDisabled, setWhobbleDisabled] = useState(true) + + const CardsElement = (): JSX.Element => { + return ( + + {data.map(({ title, description, icon }, index) => { + const rotation = useSharedValue(0) + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [{ rotateZ: `${rotation.value}deg` }], + } + }) + + const handlePress = (): void => { + if (Platform.OS === 'ios') { + void Haptics.impactAsync( + Haptics.ImpactFeedbackStyle.Light + ) + } + const direction = Math.random() > 0.5 ? 1 : -1 + rotation.value = withSequence( + withTiming(direction * -1.5, { + duration: 100, + easing: Easing.linear, + }), + withTiming(direction * 1, { + duration: 100, + easing: Easing.linear, + }), + withTiming(direction * -0.5, { + duration: 100, + easing: Easing.linear, + }), + withTiming(0, { + duration: 100, + easing: Easing.linear, + }) + ) + } + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: cardsOpacity[index].value, + transform: [ + { translateY: cardsTranslateY[index].value }, + ], + })) + + return ( + { + if (!isWhobbleDisabled) { + handlePress() + } + }} + key={index} + > + + + + + + + ) + })} + + ) + } + + const LegalArea = (): JSX.Element => { + const animatedStyle = useAnimatedStyle(() => ({ + opacity: legalOpacity.value, + transform: [{ translateY: legalTranslateY.value }], + })) + + return ( + + + + {t('onboarding.links.agree1')} + { + void Linking.openURL(PRIVACY_URL) + }} + > + {t('onboarding.links.privacy')} + + {t('onboarding.links.agree2')} + + + + + ) + } + + const colors = useTheme().colors as Colors + + const insets = useSafeAreaInsets() + const window = Dimensions.get('window') + + const textLogoAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: textLogoOpacity.value, + } + }) + + const logoAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: logoOpacity.value, + } + }) + + const textAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ translateY: textTranslateY.value }], + opacity: textOpacity.value, + } + }) + + useEffect(() => { + logoOpacity.value = withDelay( + 250, + withTiming( + 1, + { duration: 1300, easing: Easing.out(Easing.quad) }, + () => { + textTranslateY.value = withTiming(0, { + duration: 800, + easing: Easing.out(Easing.quad), + }) + + textOpacity.value = withTiming( + 1, + { + duration: 900, + easing: Easing.out(Easing.quad), + }, + () => { + logoMargin.value = withDelay( + 1250, + withTiming(0, { + duration: 1200, + easing: Easing.out(Easing.quad), + }) + ) + textLogoOpacity.value = withDelay( + 1250, + withTiming(0, { + duration: 600, + easing: Easing.out(Easing.quad), + }) + ) + logoOpacity.value = withDelay( + 800, + withTiming( + 0, { - color: colors.text, + duration: 800, + easing: Easing.out(Easing.quad), }, - ]} - > - {t('onboarding.page1.title')} - - - - - {t('onboarding.page1.subtitle')} - - - { + cardsViewHeight.value = withTiming( + window.height * 0.4, { - color: colors.primary, - }, - ]} - onPress={() => { - void Linking.openURL(PRIVACY_URL) - }} - > - {t('onboarding.links.privacy')} - - - - - - - { - void Linking.openURL(IMPRINT_URL) - }} - > - {t('onboarding.links.imprint')} - - - - - ), - }, - { - backgroundColor: colors.background, - image: ( - - - - ), - title: ( - - - {t('onboarding.page2.title')} - - - - - ), - }, - { - backgroundColor: colors.background, - image: ( - - - - ), - title: ( - - - {t('onboarding.page3.title')} - - - - - ), - }, - { - backgroundColor: colors.primary, - image: <>, - title: ( - - - - - - - {t('onboarding.links.agree1')} - - { - void Linking.openURL(PRIVACY_URL) - }} - > - {t('onboarding.links.privacypolicy')} - - - {t('onboarding.links.agree2')} - - - - - ), - }, - ]} - /> + duration: 50, + easing: Easing.out(Easing.quad), + } + ) + const initialDelay = 800 + data.forEach((_, index) => { + const delay = + initialDelay + index * 100 + + cardsOpacity[index].value = + withDelay( + delay, + withTiming(1, { + duration: 500, + }) + ) + + cardsTranslateY[index].value = + withDelay( + delay, + withTiming(0, { + duration: 500, + }) + ) + }) + runOnJS(setWhobbleDisabled)(false) + + legalOpacity.value = withDelay( + 1400, + withTiming(1, { + duration: 500, + easing: Easing.out(Easing.quad), + }) + ) + + legalTranslateY.value = withDelay( + 1400, + withTiming( + 0, + { + duration: 500, + easing: Easing.out( + Easing.quad + ), + }, + (isFinished) => { + if (isFinished === true) { + runOnJS( + setButtonDisabled + )(false) + } + } + ) + ) + } + ) + ) + } + ) + } + ) + ) + }, []) + + const logoFadeOutAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: logoOpacity.value, + height: 150 * logoMargin.value, + marginTop: logoMargin.value * window.height * 0.5, + marginBottom: logoMargin.value * 40, + } + }) + + const cardsViewAnimatedStyle = useAnimatedStyle(() => { + return { + minHeight: cardsViewHeight.value, + } + }) + + const [buttonDisabled, setButtonDisabled] = useState(true) + const scaleFontSize = (size: number): number => { + const guidelineBaseWidth = 475 + return size * (window.width / guidelineBaseWidth) + } + const scaledHeading = scaleFontSize(33) + const isIos = Platform.OS === 'ios' + + return ( + <> + + + + + + + + {t('onboarding.page1.title')} + + + + {'Neuland Next'} + + + + + + + + + + + + + + + ) } const styles = StyleSheet.create({ - page: { - alignItems: 'center', - gap: 25, - paddingHorizontal: 16, + logoTextGroup: { flex: 1, justifyContent: 'center' }, + buttonContainer: {}, + boxesContainer: { + paddingTop: 20, + justifyContent: 'center', }, - header: { - fontSize: 30, - fontWeight: 'bold', - textAlign: 'center', + boxes: { + gap: 12, + marginHorizontal: 40, }, - secondaryContainer: { - gap: 10, - alignItems: 'center', + + button: { + borderRadius: 7, + paddingVertical: 14, + paddingHorizontal: 24, + width: '50%', + alignSelf: 'center', }, - secondaryText: { - fontSize: 18, - fontWeight: '500', + buttonText: { + textAlign: 'center', + fontWeight: '700', + fontSize: 16, }, - linkContainer: { - flexDirection: 'row', - gap: 10, - justifyContent: 'center', + page: { + flex: 1, alignItems: 'center', }, - linkText: { - fontSize: 14, - }, - linkText3: { - fontSize: 15, - }, + linkPrivacy: { - fontSize: 15, fontWeight: 'bold', }, privacyRow: { @@ -238,15 +500,22 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', flex: 1, textAlign: 'center', + flexShrink: 1, }, - logo: { - height: 200, - flexGrow: 1, + heading1: { fontWeight: 'bold', textAlign: 'center', marginTop: 20 }, + heading2: { fontWeight: 'bold', textAlign: 'center' }, + cardsContainer: { + flexGrow: 0.5, }, - loginContainer: { - minHeight: 400, - minWidth: 350, - marginBottom: 60, - gap: 80, + legalContainer: { + flex: 1, + width: '95%', + justifyContent: 'center', + }, + fullLogoContainer: { + position: 'absolute', + bottom: 30, + width: '100%', + alignItems: 'center', }, }) diff --git a/src/app/(flow)/whatsnew.tsx b/src/app/(flow)/whatsnew.tsx index 3e61662a..d9a4882c 100644 --- a/src/app/(flow)/whatsnew.tsx +++ b/src/app/(flow)/whatsnew.tsx @@ -122,7 +122,7 @@ export default function WhatsNewScreen(): JSX.Element { styles.button, ]} onPress={() => { - flow.toggleUpdated() + flow.setUpdated(true) router.navigate('/') }} > diff --git a/src/app/(food)/meal.tsx b/src/app/(food)/meal.tsx index 9821effe..bf5fc910 100644 --- a/src/app/(food)/meal.tsx +++ b/src/app/(food)/meal.tsx @@ -9,14 +9,10 @@ import { } from '@/components/contexts' import allergenMap from '@/data/allergens.json' import flagMap from '@/data/mensa-flags.json' -import { - USER_EMPLOYEE, - USER_GUEST, - USER_STUDENT, -} from '@/hooks/contexts/userKind' import { type LanguageKey } from '@/localization/i18n' import { type FormListSections } from '@/types/components' import { type Meal } from '@/types/neuland-api' +import { USER_EMPLOYEE, USER_GUEST, USER_STUDENT } from '@/utils/app-utils' import { formatPrice, mealName } from '@/utils/food-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { trackEvent } from '@aptabase/react-native' diff --git a/src/app/(food)/preferences.tsx b/src/app/(food)/preferences.tsx index 433cc2d4..332ddaaa 100644 --- a/src/app/(food)/preferences.tsx +++ b/src/app/(food)/preferences.tsx @@ -52,7 +52,7 @@ export default function FoodPreferences(): JSX.Element { selectedRestaurants, toggleSelectedRestaurant, showStatic, - toggleShowStatic, + setShowStatic, foodLanguage, toggleFoodLanguage, } = useContext(FoodFilterContext) @@ -92,8 +92,8 @@ export default function FoodPreferences(): JSX.Element { diff --git a/src/app/(map)/advanced.ios.tsx b/src/app/(map)/advanced.ios.tsx new file mode 100644 index 00000000..d1684a59 --- /dev/null +++ b/src/app/(map)/advanced.ios.tsx @@ -0,0 +1,401 @@ +import API from '@/api/authenticated-api' +import { NoSessionError } from '@/api/thi-session-handler' +import ErrorView from '@/components/Elements/Error/ErrorView' +import { FreeRoomsList } from '@/components/Elements/Map/FreeRoomsList' +import Divider from '@/components/Elements/Universal/Divider' +import PlatformIcon from '@/components/Elements/Universal/Icon' +import { type Colors } from '@/components/colors' +import { useRefreshByUser } from '@/hooks' +import { type AvailableRoom } from '@/types/utils' +import { networkError } from '@/utils/api-utils' +import { formatISODate, formatISOTime } from '@/utils/date-utils' +import { + BUILDINGS, + BUILDINGS_ALL, + DURATION_PRESET, + filterRooms, + getNextValidDate, +} from '@/utils/map-utils' +import { LoadingState } from '@/utils/ui-utils' +import DateTimePicker from '@react-native-community/datetimepicker' +import { useTheme } from '@react-navigation/native' +import { useQuery } from '@tanstack/react-query' +import { useRouter } from 'expo-router' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' +import { Picker, useBinding } from 'swiftui-react-native' + +const DURATIONS = [ + '00:15', + '00:30', + '00:45', + '01:00', + '01:30', + '02:00', + '02:30', + '03:00', + '03:30', + '04:00', + '04:30', + '05:00', + '05:30', + '06:00', +] + +const ALL_BUILDINGS = [BUILDINGS_ALL, ...BUILDINGS] + +export default function AdvancedSearch(): JSX.Element { + const colors = useTheme().colors as Colors + const router = useRouter() + const { t } = useTranslation('common') + + const { startDate, wasModified } = getNextValidDate() + const building = useBinding(BUILDINGS_ALL) + const [date, setDate] = useState(formatISODate(startDate)) + + const [time, setTime] = useState(formatISOTime(startDate)) + + /** + * Checks if the provided date and time are equal to the start date. + * + * This function compares the hours and minutes from a time string in the format "HH:MM", + * and a date string in the format "YYYY-MM-DD" against a global `startDate` object of type Date. + * It returns true if the hours and minutes of `startDate` match the provided time, + * and the date part of `startDate` matches the provided date string. + * + * @returns {boolean} True if the date and time match the start date, false otherwise. + */ + const isDateAndTimeEqualToStart = (): boolean => { + return ( + startDate.getHours() === parseInt(time.split(':')[0], 10) && + startDate.getMinutes() === parseInt(time.split(':')[1], 10) && + startDate.toISOString().split('T')[0] === date + ) + } + + const duration = useBinding(DURATION_PRESET) + + const [filterState, setFilterState] = useState( + LoadingState.LOADING + ) + const { data, error, isLoading, isError, isPaused, refetch } = useQuery({ + queryKey: ['freeRooms', date], + queryFn: async () => + await API.getFreeRooms(new Date(date + 'T' + time)), + staleTime: 1000 * 60 * 60, // 60 minutes + gcTime: 1000 * 60 * 60 * 24 * 4, // 4 days + retry(failureCount, error) { + if (error instanceof NoSessionError) { + router.replace('user/login') + return false + } + return failureCount < 3 + }, + }) + const [rooms, setRooms] = useState(null) + + useEffect(() => { + const fetchRooms = async (): Promise => { + try { + const validateDate = new Date(date) + if (isNaN(validateDate.getTime())) { + throw new Error('Invalid date') + } + if (data === undefined) { + return + } + + const rooms = await filterRooms( + data, + date, + time, + building.value, + duration.value + ) + if (rooms == null) { + throw new Error('Error while filtering rooms') + } else { + setRooms(rooms) + setFilterState(LoadingState.LOADED) + } + } catch (error) { + setFilterState(LoadingState.ERROR) + console.error(error) + } + } + + setFilterState(LoadingState.LOADING) + setTimeout(() => { + void fetchRooms() + }) + }, [date, time, building.value, duration.value, data]) + + const { refetchByUser } = useRefreshByUser(refetch) + + return ( + <> + + + + {t('pages.rooms.options.title')} + + + + + {t('pages.rooms.options.date')} + + + { + setDate(formatISODate(selectedDate)) + }} + minimumDate={new Date()} + maximumDate={ + new Date( + new Date().setDate( + new Date().getDate() + 90 + ) + ) + } + /> + + + + + + {t('pages.rooms.options.time')} + + + { + setTime(formatISOTime(selectedDate)) + }} + /> + + + + + {t('pages.rooms.options.duration')} + + + + {DURATIONS.map((option) => ( + {option} + ))} + + + + + + {t('pages.rooms.options.building')} + + + + {ALL_BUILDINGS.map((option) => ( + {option} + ))} + + + + {wasModified && isDateAndTimeEqualToStart() && ( + + + + + {t('pages.rooms.modified.title')} + + + + + {t('pages.rooms.modified.description', { + date, + time, + })} + + + )} + + {t('pages.rooms.results')} + + + + {filterState === LoadingState.LOADING || + isLoading ? ( + + ) : isPaused ? ( + { + void refetchByUser() + }} + inModal + /> + ) : isError || + filterState === LoadingState.ERROR ? ( + { + void refetchByUser() + }} + inModal + /> + ) : filterState === LoadingState.LOADED ? ( + + ) : null} + + + + + + ) +} + +const styles = StyleSheet.create({ + sectionContainer: { + paddingBottom: 20, + }, + scrollView: { + padding: 12, + }, + sectionHeader: { + fontSize: 13, + + fontWeight: 'normal', + textTransform: 'uppercase', + marginBottom: 4, + }, + optionTitle: { + fontSize: 15, + }, + section: { + marginBottom: 16, + borderRadius: 8, + }, + loadingIndicator: { + paddingVertical: 30, + }, + optionsRow: { + flexDirection: 'row', + alignItems: 'center', + + justifyContent: 'space-between', + paddingHorizontal: 15, + paddingVertical: 6, + }, + adjustContainer: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + paddingHorizontal: 10, + paddingTop: 10, + gap: 5, + }, + adjustedTitle: { + fontSize: 16, + marginLeft: 5, + fontWeight: '500', + }, + adjustText: { + padding: 10, + fontSize: 15, + }, +}) diff --git a/src/app/(map)/advanced.tsx b/src/app/(map)/advanced.tsx index 00620174..a5a7981e 100644 --- a/src/app/(map)/advanced.tsx +++ b/src/app/(map)/advanced.tsx @@ -1,11 +1,11 @@ import API from '@/api/authenticated-api' import { NoSessionError } from '@/api/thi-session-handler' +import ErrorView from '@/components/Elements/Error/ErrorView' import { FreeRoomsList } from '@/components/Elements/Map/FreeRoomsList' import Divider from '@/components/Elements/Universal/Divider' import Dropdown, { DropdownButton, } from '@/components/Elements/Universal/Dropdown' -import ErrorView from '@/components/Elements/Universal/ErrorView' import { type Colors } from '@/components/colors' import { useRefreshByUser } from '@/hooks' import { type AvailableRoom } from '@/types/utils' @@ -70,8 +70,8 @@ export default function AdvancedSearch(): JSX.Element { const startDate = getNextValidDate() const [building, setBuilding] = useState(BUILDINGS_ALL) - const [date, setDate] = useState(formatISODate(startDate)) - const [time, setTime] = useState(formatISOTime(startDate)) + const [date, setDate] = useState(formatISODate(startDate.startDate)) + const [time, setTime] = useState(formatISOTime(startDate.startDate)) const [duration, setDuration] = useState(DURATION_PRESET) const [showDate, setShowDate] = useState(Platform.OS === 'ios') diff --git a/src/app/(pages)/calendar.tsx b/src/app/(pages)/calendar.tsx index 38b32423..cdf3c68e 100644 --- a/src/app/(pages)/calendar.tsx +++ b/src/app/(pages)/calendar.tsx @@ -1,13 +1,13 @@ import { NoSessionError } from '@/api/thi-session-handler' +import ErrorView from '@/components/Elements/Error/ErrorView' import { CalendarRow, ExamRow } from '@/components/Elements/Rows/CalendarRow' import Divider from '@/components/Elements/Universal/Divider' -import ErrorView from '@/components/Elements/Universal/ErrorView' import ToggleRow from '@/components/Elements/Universal/ToggleRow' import { type Colors } from '@/components/colors' import { UserKindContext } from '@/components/contexts' import { useRefreshByUser } from '@/hooks' -import { USER_GUEST } from '@/hooks/contexts/userKind' import { guestError, networkError } from '@/utils/api-utils' +import { USER_GUEST } from '@/utils/app-utils' import { calendar, loadExamList } from '@/utils/calendar-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { useTheme } from '@react-navigation/native' diff --git a/src/app/(pages)/event.tsx b/src/app/(pages)/event.tsx index c2ce0f58..63bd29b9 100644 --- a/src/app/(pages)/event.tsx +++ b/src/app/(pages)/event.tsx @@ -116,7 +116,7 @@ export default function ClEventDetail(): JSX.Element { ? { title: 'Instagram', icon: { - ios: 'logo-instagram', + ios: 'instagram', android: 'instagram', iosFallback: true, }, diff --git a/src/app/(pages)/events.tsx b/src/app/(pages)/events.tsx index 6e5aebdb..c4c7a077 100644 --- a/src/app/(pages)/events.tsx +++ b/src/app/(pages)/events.tsx @@ -1,6 +1,6 @@ +import ErrorView from '@/components/Elements/Error/ErrorView' import CLEventRow from '@/components/Elements/Rows/EventRow' import Divider from '@/components/Elements/Universal/Divider' -import ErrorView from '@/components/Elements/Universal/ErrorView' import { type Colors } from '@/components/colors' import { useRefreshByUser } from '@/hooks' import { networkError } from '@/utils/api-utils' diff --git a/src/app/(pages)/exam.tsx b/src/app/(pages)/exam.tsx index 89953b62..256aca2a 100644 --- a/src/app/(pages)/exam.tsx +++ b/src/app/(pages)/exam.tsx @@ -110,8 +110,7 @@ export default function ExamDetail(): JSX.Element { - All information without guarantee. Binding information is - only available directly from the THI. + {t('pages.exam.footer')} diff --git a/src/app/(pages)/lecturers.tsx b/src/app/(pages)/lecturers.tsx index fb5d595a..f93ad5d9 100644 --- a/src/app/(pages)/lecturers.tsx +++ b/src/app/(pages)/lecturers.tsx @@ -1,13 +1,12 @@ import API from '@/api/authenticated-api' import { NoSessionError } from '@/api/thi-session-handler' +import ErrorView from '@/components/Elements/Error/ErrorView' import LecturerRow from '@/components/Elements/Rows/LecturerRow' import Divider from '@/components/Elements/Universal/Divider' -import ErrorView from '@/components/Elements/Universal/ErrorView' import ToggleRow from '@/components/Elements/Universal/ToggleRow' import { type Colors } from '@/components/colors' import { UserKindContext } from '@/components/contexts' import { useRefreshByUser } from '@/hooks' -import { USER_GUEST, USER_STUDENT } from '@/hooks/contexts/userKind' import { type Lecturers } from '@/types/thi-api' import { type NormalizedLecturer } from '@/types/utils' import { @@ -16,6 +15,7 @@ import { guestError, networkError, } from '@/utils/api-utils' +import { USER_GUEST, USER_STUDENT } from '@/utils/app-utils' import { normalizeLecturers } from '@/utils/lecturers-utils' import { PAGE_BOTTOM_SAFE_AREA, PAGE_PADDING } from '@/utils/style-utils' import { showToast } from '@/utils/ui-utils' @@ -60,7 +60,7 @@ export default function LecturersCard(): JSX.Element { const [localSearch, setLocalSearch] = useState('') const [isSearchBarFocused, setLocalSearchBarFocused] = useState(false) const [faculty, setFaculty] = useState(null) - const [faculityData, setFaculityData] = useState([]) + const [facultyData, setFacultyData] = useState([]) const headerHeight = useHeaderHeight() function setPage(page: number): void { @@ -148,7 +148,7 @@ export default function LecturersCard(): JSX.Element { setFilteredLecturers(filtered) } - }, [localSearch]) + }, [allLecturersResult?.data, localSearch]) useEffect(() => { let filtered: NormalizedLecturer[] = [] if (faculty !== null) { @@ -159,7 +159,7 @@ export default function LecturersCard(): JSX.Element { lecturer.organisation.includes(faculty) ) ?? [] setDisplayedProfessors(false) - setFaculityData(filtered) + setFacultyData(filtered) return } @@ -172,7 +172,7 @@ export default function LecturersCard(): JSX.Element { ) ?? [] setDisplayedProfessors(true) - setFaculityData(filtered) + setFacultyData(filtered) } }, [faculty, allLecturersResult.data]) @@ -207,7 +207,7 @@ export default function LecturersCard(): JSX.Element { if (localSearch.length === 0 && allLecturersResult.data != null) { setFilteredLecturers(allLecturersResult.data) } - }, [localSearch]) + }, [allLecturersResult.data, localSearch]) useEffect(() => { if ( @@ -217,7 +217,13 @@ export default function LecturersCard(): JSX.Element { ) { void showToast(t('toast.paused')) } - }, [allLecturersResult.isPaused, personalLecturersResult.isPaused]) + }, [ + allLecturersResult.data, + allLecturersResult.isPaused, + personalLecturersResult.data, + personalLecturersResult.isPaused, + t, + ]) useLayoutEffect(() => { navigation.setOptions({ @@ -251,7 +257,7 @@ export default function LecturersCard(): JSX.Element { }, }, }) - }, [navigation]) + }, [colors.text, navigation, t]) const LecturerList = ({ lecturers, @@ -300,6 +306,7 @@ export default function LecturersCard(): JSX.Element { {isPersonal ? ( @@ -376,6 +384,7 @@ export default function LecturersCard(): JSX.Element { onRefresh={() => { void refetchByUserPersonal() }} + isCritical={false} /> ) : ( (null) diff --git a/src/app/(pages)/libraryCode.tsx b/src/app/(pages)/libraryCode.tsx index d22ba580..1a38e2b9 100644 --- a/src/app/(pages)/libraryCode.tsx +++ b/src/app/(pages)/libraryCode.tsx @@ -1,13 +1,9 @@ -import ErrorView from '@/components/Elements/Universal/ErrorView' +import ErrorView from '@/components/Elements/Error/ErrorView' import FormList from '@/components/Elements/Universal/FormList' import { type Colors } from '@/components/colors' import { UserKindContext } from '@/components/contexts' +import { USER_EMPLOYEE, USER_GUEST, USER_STUDENT } from '@/contexts/userKind' import { useRefreshByUser } from '@/hooks' -import { - USER_EMPLOYEE, - USER_GUEST, - USER_STUDENT, -} from '@/hooks/contexts/userKind' import { type FormListSections } from '@/types/components' import { getPersonalData, diff --git a/src/app/(pages)/news.tsx b/src/app/(pages)/news.tsx index e653ea1f..7b911636 100644 --- a/src/app/(pages)/news.tsx +++ b/src/app/(pages)/news.tsx @@ -1,6 +1,6 @@ import API from '@/api/authenticated-api' +import ErrorView from '@/components/Elements/Error/ErrorView' import Divider from '@/components/Elements/Universal/Divider' -import ErrorView from '@/components/Elements/Universal/ErrorView' import PlatformIcon from '@/components/Elements/Universal/Icon' import { type Colors } from '@/components/colors' import { useRefreshByUser } from '@/hooks' diff --git a/src/app/(tabs)/(food)/food.tsx b/src/app/(tabs)/(food)/food.tsx index afa0980c..290b7861 100644 --- a/src/app/(tabs)/(food)/food.tsx +++ b/src/app/(tabs)/(food)/food.tsx @@ -1,7 +1,7 @@ +import ErrorView from '@/components/Elements/Error/ErrorView' import { MealDay } from '@/components/Elements/Food' import { AllergensBanner } from '@/components/Elements/Food/AllergensBanner' import { FoodHeaderRight } from '@/components/Elements/Food/HeaderRight' -import ErrorView from '@/components/Elements/Universal/ErrorView' import { type Colors } from '@/components/colors' import { FoodFilterContext } from '@/components/contexts' import { useRefreshByUser } from '@/hooks' @@ -83,7 +83,7 @@ export function FoodScreen(): JSX.Element { if (isPaused && data != null) { void showToast(t('toast.paused')) } - }, [isPaused]) + }, [data, isPaused, t]) const pagerViewRef = useRef(null) function setPage(page: number): void { @@ -329,6 +329,7 @@ export function FoodScreen(): JSX.Element { export default function FoodRootScreen(): JSX.Element { const [isPageOpen, setIsPageOpen] = useState(false) const navigation = useNavigation() + const { t } = useTranslation('navigation') useEffect(() => { setIsPageOpen(true) }, []) @@ -343,7 +344,7 @@ export default function FoodRootScreen(): JSX.Element { <> {/* eslint-disable-next-line react-native/no-raw-text */} - Food + {t('navigation.food')} diff --git a/src/app/(tabs)/(index)/index.tsx b/src/app/(tabs)/(index)/index.tsx index 1216e63c..5ad01443 100644 --- a/src/app/(tabs)/(index)/index.tsx +++ b/src/app/(tabs)/(index)/index.tsx @@ -1,21 +1,15 @@ import NeulandAPI from '@/api/neuland-api' import PopUpCard from '@/components/Cards/PopUpCard' import { IndexHeaderRight } from '@/components/Elements/Dashboard/HeaderRight' -import { HomeBottomSheet } from '@/components/Elements/Flow/HomeBottomSheet' -import ErrorView from '@/components/Elements/Universal/ErrorView' +import ErrorView from '@/components/Elements/Error/ErrorView' import WorkaroundStack from '@/components/Elements/Universal/WorkaroundStack' import { type Colors } from '@/components/colors' import { DashboardContext } from '@/components/contexts' import { PAGE_BOTTOM_SAFE_AREA, PAGE_PADDING } from '@/utils/style-utils' -import { - BottomSheetModal, - BottomSheetModalProvider, - BottomSheetScrollView, -} from '@gorhom/bottom-sheet' +import { type BottomSheetModal } from '@gorhom/bottom-sheet' import { useTheme } from '@react-navigation/native' import { MasonryFlashList } from '@shopify/flash-list' import { useQuery } from '@tanstack/react-query' -import * as Notifications from 'expo-notifications' import { router, useNavigation } from 'expo-router' import Head from 'expo-router/head' import React, { useEffect, useRef, useState } from 'react' @@ -31,37 +25,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' export default function HomeRootScreen(): JSX.Element { const colors = useTheme().colors as Colors - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [notification, setNotification] = useState(undefined) - const notificationListener = useRef() - const responseListener = useRef() - - useEffect(() => { - notificationListener.current = - Notifications.addNotificationReceivedListener((notification) => { - setNotification(notification) - }) - - responseListener.current = - Notifications.addNotificationResponseReceivedListener( - (response) => { - console.log(response) - } - ) - - return () => { - Notifications.removeNotificationSubscription( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - notificationListener.current - ) - Notifications.removeNotificationSubscription( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - responseListener.current - ) - } - }, []) - const [isPageOpen, setIsPageOpen] = useState(false) useEffect(() => { @@ -74,27 +37,6 @@ export default function HomeRootScreen(): JSX.Element { const navigation = useNavigation() const isFocused = useNavigation().isFocused() const bottomSheetModalRef = useRef(null) - const BottomSheet = (): JSX.Element => { - return ( - - - - - - ) - } useEffect(() => { // @ts-expect-error - no types for tabPress @@ -111,39 +53,37 @@ export default function HomeRootScreen(): JSX.Element { <> {/* eslint-disable-next-line react-native/no-raw-text */} - Dashboard + {'Dashboard'} - - - - {Platform.OS === 'ios' ? ( - <>} - largeTitle={true} - transparent={false} - headerRightElement={IndexHeaderRight} - /> - ) : ( - - )} - - + + + {Platform.OS === 'ios' ? ( + <>} + largeTitle={true} + transparent={false} + headerRightElement={IndexHeaderRight} + /> + ) : ( + + )} + ) } @@ -200,6 +140,7 @@ function HomeScreen(): JSX.Element { onButtonPress={() => { router.push('(user)/dashboard') }} + isCritical={false} /> ) : ( @@ -245,7 +186,7 @@ const styles = StyleSheet.create({ page: { flex: 1, }, - errorContainer: { paddingTop: 110 }, + errorContainer: { paddingTop: 110, flex: 1 }, item: { marginVertical: 6, }, diff --git a/src/app/(tabs)/(timetable)/timetable.tsx b/src/app/(tabs)/(timetable)/timetable.tsx index 82f4a415..6fefe47b 100644 --- a/src/app/(tabs)/(timetable)/timetable.tsx +++ b/src/app/(tabs)/(timetable)/timetable.tsx @@ -1,12 +1,12 @@ +import ErrorView from '@/components/Elements/Error/ErrorView' import TimetableList from '@/components/Elements/Timetable/TimetableList' import TimetableWeek from '@/components/Elements/Timetable/TimetableWeek' -import ErrorView from '@/components/Elements/Universal/ErrorView' import { type Colors } from '@/components/colors' import { TimetableContext, UserKindContext } from '@/components/contexts' import { useRefreshByUser } from '@/hooks' -import { USER_GUEST } from '@/hooks/contexts/userKind' import { type Exam, type FriendlyTimetableEntry } from '@/types/utils' import { guestError, networkError } from '@/utils/api-utils' +import { USER_GUEST } from '@/utils/app-utils' import { loadExamList } from '@/utils/calendar-utils' import { getFriendlyTimetable } from '@/utils/timetable-utils' import { useTheme } from '@react-navigation/native' @@ -19,7 +19,6 @@ export interface ITimetableViewProps { friendlyTimetable: FriendlyTimetableEntry[] exams: Exam[] } - export type CalendarMode = '3days' | 'list' export const loadTimetable = async (): Promise => { const timetable = await getFriendlyTimetable(new Date(), true) @@ -49,8 +48,8 @@ export default function TimetableScreen(): JSX.Element { } = useQuery({ queryKey: ['timetable', userKind], queryFn: loadTimetable, - staleTime: 1000 * 60 * 10, // 10 minutes - gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week + staleTime: 1000 * 60 * 10, + gcTime: 1000 * 60 * 60 * 24 * 7, retry(failureCount, error) { const ignoreErrors = [ '"Time table does not exist" (-202)', @@ -59,7 +58,7 @@ export default function TimetableScreen(): JSX.Element { if (ignoreErrors.includes(error?.message)) { return false } - return failureCount < 3 + return false }, enabled: userKind !== USER_GUEST, }) @@ -67,168 +66,12 @@ export default function TimetableScreen(): JSX.Element { const { data: exams } = useQuery({ queryKey: ['exams'], queryFn: loadExamList, - staleTime: 1000 * 60 * 10, // 10 minutes - gcTime: 1000 * 60 * 60 * 24, // 24 hours + staleTime: 1000 * 60 * 10, + gcTime: 1000 * 60 * 60 * 24, enabled: userKind !== USER_GUEST, }) const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch) - // useEffect(() => { - // const updateNotifications = async (): Promise => { - // if (timetable === undefined || timetable.length === 0) return - // console.log('Updating notifications') - // await updateAllNotifications() - // console.log('Updated notifications') - // } - - // const timeoutId = setTimeout(() => { - // void InteractionManager.runAfterInteractions(() => { - // void updateNotifications() - // }) - // }, 1000) - - // return () => { - // clearTimeout(timeoutId) - // } - // }, [timetable, i18n.language]) - - // async function updateAllNotifications(): Promise { - // if (timetable === undefined) return - // const setupLectures = Object.keys(timetableNotifications) - // if (setupLectures.length === 0) return - // const configuredLanguage = - // timetableNotifications[setupLectures[0]].language - - // const today = new Date() - - // const filteredTimetable = timetable.filter((lecture) => { - // const lectureDate = new Date(lecture.startDate) - // return lectureDate > today - // }) - - // const setupTimetable = filteredTimetable.filter((lecture) => - // setupLectures.includes(lecture.shortName) - // ) - - // if (configuredLanguage !== i18n.language) { - // await updateNotificationLanguage(setupLectures, setupTimetable) - // return - // } - - // // Create a hash map of new lectures - // const newLecturesMap = setupTimetable.reduce< - // Record - // >((map, lecture) => { - // const key = generateKey( - // lecture.shortName, - // lecture.startDate, - // lecture.rooms[0] - // ) - // map[key] = lecture - // return map - // }, {}) - - // setupLectures.forEach((lectureName) => { - // const oldLectures = timetableNotifications[lectureName].elements - - // oldLectures.forEach((oldLecture) => { - // const key = generateKey( - // lectureName, - // oldLecture.startDateTime, - // oldLecture.room - // ) - // const matchingNewLecture = newLecturesMap[key] - - // if (matchingNewLecture !== undefined) { - // // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - // delete newLecturesMap[key] - // } else { - // // Remove the old lecture from the notification - // removeNotification(oldLecture.id, lectureName) - // } - // }) - // }) - - // const newLectureGroups = Object.values(newLecturesMap).reduce< - // Record - // >((map, lecture) => { - // const key = lecture.shortName - // // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - // if (!map[key]) { - // map[key] = [] - // } - // map[key].push(lecture) - // return map - // }, {}) - - // const promises = Object.entries(newLectureGroups).map( - // async ([lectureName, lectures]) => { - // const minsBeforeLecture = getMinsBeforeLecture(lectureName) - // const notificationPromises = Object.values(lectures).map( - // async (lecture) => { - // return await scheduleLectureNotification( - // lecture.name, - // lecture.rooms.join(', '), - // getMinsBeforeLecture(lectureName), - // lecture.startDate, - // t - // ) - // } - // ) - - // const notifications = await Promise.all(notificationPromises) - // const flatNotifications = notifications.flat() - - // updateTimetableNotifications( - // lectureName, - // flatNotifications, - // minsBeforeLecture, - // i18n.language as LanguageKey - // ) - // } - // ) - - // await Promise.all(promises) - // } - - // async function updateNotificationLanguage( - // setupLectures: string[], - // setupTimetable: FriendlyTimetableEntry[] - // ): Promise { - // const promises = setupLectures.map(async (lectureName) => { - // const mins = getMinsBeforeLecture(lectureName) - // const notificationPromises = Object.values(setupTimetable) - // .filter((lecture) => lecture.shortName === lectureName) - // .map(async (lecture) => { - // const startDate = new Date(lecture.startDate) - // const alertDate = new Date( - // startDate.getTime() - mins * 60000 - // ) - // return await scheduleLectureNotification( - // lecture.name, - // lecture.rooms.join(', '), - // mins, - // alertDate, - // t - // ) - // }) - // const notifications = await Promise.all(notificationPromises) - // const flatNotifications = notifications.flat() - - // updateTimetableNotifications( - // lectureName, - // flatNotifications, - // mins, - // i18n.language as LanguageKey - // ) - // }) - - // await Promise.all(promises) - // } - - // function getMinsBeforeLecture(name: string): number { - // return timetableNotifications[name].mins - // } const LoadingView = (): JSX.Element => { return ( @@ -296,6 +139,7 @@ export default function TimetableScreen(): JSX.Element { onRefresh={() => { void refetchByUser() }} + isCritical={false} /> ) } else if (userKind === USER_GUEST) { diff --git a/src/app/(tabs)/_layout.android.tsx b/src/app/(tabs)/_layout.android.tsx deleted file mode 100644 index 9467fb13..00000000 --- a/src/app/(tabs)/_layout.android.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import PlatformIcon from '@/components/Elements/Universal/Icon' -import { MaterialBottomTabs } from '@/components/Elements/Universal/MaterialBottomTabs' -import { type Colors } from '@/components/colors' -import { - DashboardContext, - FlowContext, - FoodFilterContext, -} from '@/components/contexts' -import changelog from '@/data/changelog.json' -import i18n from '@/localization/i18n' -import { convertToMajorMinorPatch } from '@/utils/app-utils' -import Aptabase from '@aptabase/react-native' -import { type Theme, useTheme } from '@react-navigation/native' -import Color from 'color' -import * as NavigationBar from 'expo-navigation-bar' -import { usePathname, useRouter } from 'expo-router' -import * as SplashScreen from 'expo-splash-screen' -import React, { useContext, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { Easing } from 'react-native' -// @ts-expect-error no types -import Shortcuts, { type ShortcutItem } from 'rn-quick-actions' - -import { humanLocations } from '../(food)/meal' -import packageInfo from '../../../package.json' - -export default function HomeLayout(): JSX.Element { - const theme: Theme = useTheme() - const isDark = theme.dark - const router = useRouter() - const colors = theme.colors as Colors - const flow = React.useContext(FlowContext) - const { t } = useTranslation('navigation') - const { selectedRestaurants } = useContext(FoodFilterContext) - const aptabaseKey = process.env.EXPO_PUBLIC_APTABASE_KEY - const { analyticsAllowed, initializeAnalytics } = - React.useContext(FlowContext) - const { shownDashboardEntries } = React.useContext(DashboardContext) - const [isFirstRun, setIsFirstRun] = React.useState(true) - if (flow.isOnboarded === false) { - router.navigate('(flow)/onboarding') - void SplashScreen.hideAsync() - } - - const isChangelogAvailable = Object.keys(changelog.version).some( - (version) => version === convertToMajorMinorPatch(packageInfo.version) - ) - - const { isOnboarded } = React.useContext(FlowContext) - - if ( - flow.isUpdated === false && - isChangelogAvailable && - flow.isOnboarded !== false - ) { - router.navigate('(flow)/whatsnew') - void SplashScreen.hideAsync() - } - - useEffect(() => { - const prepare = async (): Promise => { - await SplashScreen.preventAutoHideAsync() - - if (shownDashboardEntries !== null && isOnboarded === true) { - await SplashScreen.hideAsync() - } - } - void prepare() - }, [shownDashboardEntries, isOnboarded]) - - const pathname = usePathname() - - useEffect(() => { - const prepare = async (): Promise => { - const tabsPaths = ['/', '/timetable', '/map', '/food'] - const isTab = tabsPaths.includes(pathname) - - await NavigationBar.setBackgroundColorAsync( - isTab - ? isDark - ? Color(colors.card) - .mix(Color(colors.primary), 0.04) - .hex() - : Color(colors.card) - .mix(Color(colors.primary), 0.1) - .hex() - : colors.background - ) - await NavigationBar.setButtonStyleAsync( - theme.dark ? 'light' : 'dark' - ) - } - - void prepare() - }, [theme.dark, pathname]) - - const shortcuts = [ - { - id: 'timetable', - type: 'timetable', - title: t('navigation.timetable'), - symbolName: 'calendar', - iconName: 'calendar_month', - data: { - path: '(tabs)/timetable', - }, - }, - { - id: 'map', - type: 'map', - title: t('navigation.map'), - data: { - path: '(tabs)/map', - }, - symbolName: 'map', - iconName: 'map', - }, - { - id: 'food', - type: 'food', - title: - selectedRestaurants.length !== 1 - ? t('navigation.food') - : humanLocations[ - selectedRestaurants[0] as keyof typeof humanLocations - ], - data: { - path: '(tabs)/food', - }, - symbolName: 'fork.knife', - iconName: 'silverware_fork_knife', - }, - ] - - useEffect(() => { - function processShortcut(item: ShortcutItem): void { - router.navigate(item.data.path as string) - router.setParams({ fromAppShortcut: 'true' }) - } - - const shortcutSubscription = - Shortcuts.onShortcutPressed(processShortcut) - Shortcuts.setShortcuts(shortcuts) - - return () => { - if (shortcutSubscription != null) shortcutSubscription.remove() - } - }, [selectedRestaurants, router, shortcuts, i18n.language]) - - useEffect(() => { - if (isFirstRun) { - setIsFirstRun(false) - return - } - if (aptabaseKey != null && analyticsAllowed === true) { - Aptabase.init(aptabaseKey, { - host: 'https://analytics.neuland.app', - }) - initializeAnalytics() - } else if (aptabaseKey != null && analyticsAllowed === false) { - Aptabase.init('') - } else { - console.log('Analytics not yet initialized') - } - }, [analyticsAllowed]) - - return ( - - ( - - ), - }} - /> - - ( - - ), - }} - /> - - ( - - ), - }} - /> - ( - - ), - }} - /> - - ) -} diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index c118f859..2d98c866 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,23 +1,22 @@ -import PlatformIcon from '@/components/Elements/Universal/Icon' +import DefaultTabs from '@/components/Elements/Layout/DefaultTabs' +import MaterialTabs from '@/components/Elements/Layout/MaterialTabs' import { type Colors } from '@/components/colors' import { AppIconContext, - DashboardContext, FlowContext, FoodFilterContext, } from '@/components/contexts' import changelog from '@/data/changelog.json' -import i18n from '@/localization/i18n' import { convertToMajorMinorPatch } from '@/utils/app-utils' import Aptabase from '@aptabase/react-native' import { type Theme, useTheme } from '@react-navigation/native' -import { BlurView } from 'expo-blur' +import Color from 'color' import * as NavigationBar from 'expo-navigation-bar' -import { Tabs, usePathname, useRouter } from 'expo-router' +import { Redirect, usePathname, useRouter } from 'expo-router' import * as SplashScreen from 'expo-splash-screen' import React, { useContext, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Platform, StyleSheet } from 'react-native' +import { Platform } from 'react-native' // @ts-expect-error no types import Shortcuts, { type ShortcutItem } from 'rn-quick-actions' @@ -27,133 +26,93 @@ import packageInfo from '../../../package.json' export default function HomeLayout(): JSX.Element { const theme: Theme = useTheme() + const isDark = theme.dark const router = useRouter() const colors = theme.colors as Colors const flow = React.useContext(FlowContext) const { t } = useTranslation('navigation') const { selectedRestaurants } = useContext(FoodFilterContext) - const { appIcon, toggleAppIcon } = useContext(AppIconContext) - const aptabaseKey = process.env.EXPO_PUBLIC_APTABASE_KEY - const { analyticsAllowed, initializeAnalytics } = + const { appIcon, setAppIcon } = useContext(AppIconContext) + // @ts-expect-error: Env types are not defined + const aptabaseKey = process.env.EXPO_PUBLIC_APTABASE_KEY as string + const { analyticsAllowed, initializeAnalytics, analyticsInitialized } = React.useContext(FlowContext) - const { shownDashboardEntries } = React.useContext(DashboardContext) - const [isFirstRun, setIsFirstRun] = React.useState(true) - if (flow.isOnboarded === false) { - router.navigate('(flow)/onboarding') - void SplashScreen.hideAsync() - } - - const isChangelogAvailable = Object.keys(changelog.version).some( - (version) => version === convertToMajorMinorPatch(packageInfo.version) - ) - const { isOnboarded } = React.useContext(FlowContext) - - if ( - flow.isUpdated === false && - isChangelogAvailable && - flow.isOnboarded !== false - ) { - router.navigate('(flow)/whatsnew') - void SplashScreen.hideAsync() - } + const pathname = usePathname() useEffect(() => { const prepare = async (): Promise => { - await SplashScreen.preventAutoHideAsync() - - if (shownDashboardEntries !== null && isOnboarded === true) { + if (isOnboarded === true) { await SplashScreen.hideAsync() } } void prepare() - }, [shownDashboardEntries, isOnboarded]) - - const pathname = usePathname() + }, [isOnboarded]) useEffect(() => { + // Android only: Sets the navigation bar color based on the current screen to match TabBar or Background color const prepare = async (): Promise => { const tabsPaths = ['/', '/timetable', '/map', '/food'] const isTab = tabsPaths.includes(pathname) await NavigationBar.setBackgroundColorAsync( - isTab ? colors.card : colors.background + isTab + ? isDark + ? Color(colors.card) + .mix(Color(colors.primary), 0.04) + .hex() + : Color(colors.card) + .mix(Color(colors.primary), 0.1) + .hex() + : colors.background ) await NavigationBar.setButtonStyleAsync( theme.dark ? 'light' : 'dark' ) } - if (Platform.OS !== 'android') return - void prepare() - }, [theme.dark, pathname]) - - const BlurTab = (): JSX.Element => ( - - ) + if (Platform.OS === 'android') { + void prepare() + } + }, [theme.dark, pathname, isDark, colors]) - const shortcuts = [ - { - id: 'timetable', - type: 'timetable', - title: t('navigation.timetable'), - symbolName: 'calendar', - iconName: 'calendar_month', - data: { - path: '(tabs)/timetable', + useEffect(() => { + const shortcuts = [ + { + id: 'timetable', + type: 'timetable', + title: t('navigation.timetable'), + symbolName: 'calendar', + iconName: 'calendar_month', + data: { + path: '(tabs)/timetable', + }, }, - }, - { - id: 'map', - type: 'map', - title: t('navigation.map'), - data: { - path: '(tabs)/map', + { + id: 'map', + type: 'map', + title: t('navigation.map'), + data: { + path: '(tabs)/map', + }, + symbolName: 'map', + iconName: 'map', }, - symbolName: 'map', - iconName: 'map', - }, - { - id: 'food', - type: 'food', - title: - selectedRestaurants.length !== 1 - ? t('navigation.food') - : humanLocations[ - selectedRestaurants[0] as keyof typeof humanLocations - ], - data: { - path: '(tabs)/food', + { + id: 'food', + type: 'food', + title: + selectedRestaurants.length !== 1 + ? t('navigation.food') + : humanLocations[ + selectedRestaurants[0] as keyof typeof humanLocations + ], + data: { + path: '(tabs)/food', + }, + symbolName: 'fork.knife', + iconName: 'silverware_fork_knife', }, - symbolName: 'fork.knife', - iconName: 'silverware_fork_knife', - }, - ...(Platform.OS === 'ios' - ? [ - { - id: 'appIcon', - type: 'appIcon', - title: 'App Icon', - subtitle: t( - // @ts-expect-error no types - `appIcon.names.${appIcon}`, - { - ns: 'settings', - } - ), - data: { - path: '(user)/appicon', - }, - symbolName: 'paintpalette', - }, - ] - : []), - ] - - useEffect(() => { + ] function processShortcut(item: ShortcutItem): void { router.navigate(item.data.path as string) router.setParams({ fromAppShortcut: 'true' }) @@ -166,154 +125,56 @@ export default function HomeLayout(): JSX.Element { return () => { if (shortcutSubscription != null) shortcutSubscription.remove() } - }, [selectedRestaurants, router, shortcuts, appIcon, i18n.language]) + }, [selectedRestaurants, router, appIcon, t]) useEffect(() => { - if (isFirstRun) { - setIsFirstRun(false) - return - } + console.log('Analytics allowed:', analyticsAllowed) if (aptabaseKey != null && analyticsAllowed === true) { Aptabase.init(aptabaseKey, { host: 'https://analytics.neuland.app', }) + // we need to mark the analytics as initialized to trigger the initial events sent in provider.tsx initializeAnalytics() - } else if (aptabaseKey != null && analyticsAllowed === false) { - Aptabase.init('') + console.log('Initialized analytics') + } else if ( + aptabaseKey != null && + analyticsAllowed === false && + analyticsInitialized + ) { + Aptabase.dispose() + console.log('Disposed analytics') } else { - console.log('Analytics not yet initialized') + console.log('Analytics not initialized / allowed') } }, [analyticsAllowed]) - useEffect(() => { if (Platform.OS !== 'ios') return - if (!appIcons.includes(appIcon)) { - toggleAppIcon('default') + if (!appIcons.includes(appIcon ?? 'default')) { + setAppIcon('default') } }, [appIcon]) - return ( - <> - - ( - - ), - - tabBarStyle: { position: 'absolute' }, - tabBarBackground: () => - Platform.OS === 'ios' ? : null, - }} - /> - - ( - - ), - tabBarStyle: { position: 'absolute' }, - tabBarBackground: () => - Platform.OS === 'ios' ? : null, - }} - /> - - + } - tabBarHideOnKeyboard: true, - tabBarIcon: ({ color, size }) => ( - - ), - }} - /> + const isChangelogAvailable = Object.keys(changelog.version).some( + (version) => version === convertToMajorMinorPatch(packageInfo.version) + ) - ( - - ), + if ( + flow.isUpdated === false && + isChangelogAvailable && + flow.isOnboarded !== false + ) { + router.navigate('(flow)/whatsnew') + void SplashScreen.hideAsync() + } - tabBarStyle: { position: 'absolute' }, - tabBarBackground: () => - Platform.OS === 'ios' ? : null, - }} - /> - - + return Platform.OS === 'android' ? ( + + ) : ( + ) } - -const styles = StyleSheet.create({ - blurTab: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - }, -}) diff --git a/src/app/(tabs)/map.tsx b/src/app/(tabs)/map.tsx index 4a9dabec..9b929f74 100644 --- a/src/app/(tabs)/map.tsx +++ b/src/app/(tabs)/map.tsx @@ -1,15 +1,17 @@ /* eslint-disable react-native/no-color-literals */ import MapScreen from '@/components/Elements/Map/MapScreen' -import { MapContext } from '@/hooks/contexts/map' -import { type ClickedMapElement } from '@/types/map' +import { MapContext } from '@/contexts/map' +import { type ClickedMapElement, type SearchResult } from '@/types/map' import { type AvailableRoom, type FriendlyTimetableEntry } from '@/types/utils' +import { storage } from '@/utils/storage' import Maplibre from '@maplibre/maplibre-react-native' -import type * as Location from 'expo-location' import Head from 'expo-router/head' import React, { useEffect, useState } from 'react' -import { StyleSheet, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { Platform, StyleSheet, View } from 'react-native' export default function MapRootScreen(): JSX.Element { + const { t } = useTranslation(['navigation']) const [isPageOpen, setIsPageOpen] = useState(false) useEffect(() => { setIsPageOpen(true) @@ -27,9 +29,33 @@ export default function MapRootScreen(): JSX.Element { const [nextLecture, setNextLecture] = useState< FriendlyTimetableEntry[] | null >(null) - const [location, setLocation] = useState< - Location.LocationObject | null | 'notGranted' - >(null) + + const [searchHistory, setSearchHistory] = useState([]) + const updateSearchHistory = (newHistory: SearchResult[]): void => { + setSearchHistory(newHistory) + + const jsonValue = JSON.stringify(newHistory) + storage.set('mapSearchHistory', jsonValue) + } + + const loadSearchHistory = async (): Promise => { + const jsonValue = storage.getString('mapSearchHistory') + if (jsonValue != null) { + try { + const parsedValue = JSON.parse(jsonValue) as SearchResult[] + setSearchHistory(parsedValue) + } catch (error) { + console.info('Failed to parse search history:', error) + } + } else { + setSearchHistory([]) + } + } + // Load search history on component mount + useEffect(() => { + void loadSearchHistory() + }, []) + const contextValue = { localSearch, setLocalSearch, @@ -39,18 +65,22 @@ export default function MapRootScreen(): JSX.Element { setAvailableRooms, currentFloor, setCurrentFloor, - location, - setLocation, nextLecture, setNextLecture, + searchHistory, + setSearchHistory, + updateSearchHistory, + } + + if (Platform.OS === 'android') { + void Maplibre.requestAndroidLocationPermissions() } - void Maplibre.requestAndroidLocationPermissions() return ( <> {/* eslint-disable-next-line react-native/no-raw-text */} - Map + {t('navigation.map')} diff --git a/src/app/(timetable)/details.tsx b/src/app/(timetable)/lecture.tsx similarity index 58% rename from src/app/(timetable)/details.tsx rename to src/app/(timetable)/lecture.tsx index c71b9648..af598082 100644 --- a/src/app/(timetable)/details.tsx +++ b/src/app/(timetable)/lecture.tsx @@ -1,42 +1,52 @@ +import ErrorView from '@/components/Elements/Error/ErrorView' import DetailsBody from '@/components/Elements/Timetable/DetailsBody' import DetailsRow from '@/components/Elements/Timetable/DetailsRow' import DetailsSymbol from '@/components/Elements/Timetable/DetailsSymbol' import Separator from '@/components/Elements/Timetable/Separator' import ShareCard from '@/components/Elements/Timetable/ShareCard' -import ErrorView from '@/components/Elements/Universal/ErrorView' import FormList from '@/components/Elements/Universal/FormList' import PlatformIcon, { chevronIcon } from '@/components/Elements/Universal/Icon' import ShareButton from '@/components/Elements/Universal/ShareButton' import { type Colors } from '@/components/colors' import { RouteParamsContext } from '@/components/contexts' import { type FormListSections, type SectionGroup } from '@/types/components' +import { type FriendlyTimetableEntry } from '@/types/utils' import { formatFriendlyDate, formatFriendlyTime } from '@/utils/date-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { trackEvent } from '@aptabase/react-native' import { useTheme } from '@react-navigation/native' -import { useRouter } from 'expo-router' -import * as Sharing from 'expo-sharing' +import { Buffer } from 'buffer' +import { useLocalSearchParams, useRouter } from 'expo-router' import moment from 'moment' -import React, { useContext } from 'react' +import React, { useContext, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { + Pressable, + ScrollView, + Share, + StyleSheet, + Text, + View, +} from 'react-native' import ViewShot, { captureRef } from 'react-native-view-shot' export default function TimetableDetails(): JSX.Element { const router = useRouter() const { updateRouteParams } = useContext(RouteParamsContext) - - // const { hasPermission, askForPermission } = useNotification() - const colors = useTheme().colors as Colors - const { lecture } = useContext(RouteParamsContext) - + const { lecture: lectureParam } = useLocalSearchParams() const { t } = useTranslation('timetable') - + const lectureString = Array.isArray(lectureParam) + ? lectureParam[0] + : lectureParam + const shareRef = useRef(null) + const lecture: FriendlyTimetableEntry | null = + lectureString === undefined + ? null + : JSON.parse(Buffer.from(lectureString, 'base64').toString()) if (lecture === null) { return } - // const today = new Date() const startDate = new Date(lecture.startDate) const endDate = new Date(lecture.endDate) @@ -44,59 +54,6 @@ export default function TimetableDetails(): JSX.Element { const examSplit = lecture.exam.split('-').slice(-1)[0].trim() const exam = `${examSplit[0].toUpperCase()}${examSplit.slice(1)}` - const shareRef = React.useRef(null) - // const [rawTimetable, setRawTimetable] = useState( - // [] - // ) - - // const [notificationsUpdating, setNotificationsUpdating] = useState(false) - - // async function load(): Promise { - // try { - // const timetable = await getFriendlyTimetable(today, true) - // const filteredTimetable = timetable.filter( - // (lecture) => - // lecture.shortName === lecture?.shortName && - // lecture.startDate >= today - // ) - - // setRawTimetable(filteredTimetable) - // } catch (e) { - // console.log(e) - // } - // } - - // useEffect(() => { - // void load() - // }, []) - // async function setupNotifications(mins: number): Promise { - // if (lecture?.shortName === undefined) { - // throw new Error('Event is undefined') - // } - // setNotificationsUpdating(true) - // deleteTimetableNotifications(lecture.shortName) - // const notificationPromises = rawTimetable.map(async (lecture) => { - // const startDate = new Date(lecture.startDate) - // return await scheduleLectureNotification( - // lecture.name, - // lecture.rooms.join(', '), - // mins, - // startDate, - // t - // ) - // }) - // const notifications = await Promise.all(notificationPromises) - // const flatNotifications = notifications.flat() - - // updateTimetableNotifications( - // lecture.shortName, - // flatNotifications, - // mins, - // i18n.language as LanguageKey - // ) - // setNotificationsUpdating(false) - // } - async function shareEvent(): Promise { try { const uri = await captureRef(shareRef, { @@ -106,18 +63,15 @@ export default function TimetableDetails(): JSX.Element { trackEvent('Share', { type: 'lecture', }) - await Sharing.shareAsync(uri, { - mimeType: 'image/png', - dialogTitle: t('misc.share', { ns: 'common' }), + + await Share.share({ + url: uri, }) } catch (e) { console.log(e) } } - // const notification = timetableNotifications[lecture.shortName] - // const minsBefore = notification != null ? notification.mins : undefined - interface HtmlItem { title: 'overview.goal' | 'overview.content' | 'overview.literature' html: string | null @@ -178,72 +132,9 @@ export default function TimetableDetails(): JSX.Element { }, ] - // const actionSheetRef = useRef(null) - - // /** - // * Shows the action sheet for setting up notifications - // * @returns {Promise} A promise that resolves when the action sheet has been shown. - // */ - // const showActionSheet = async (): Promise => { - // let has = await hasPermission() - // if (!has) { - // has = await askForPermission() - // } - - // if (!has) { - // notificationAlert(t) - // return - // } - - // if (actionSheetRef.current != null) { - // actionSheetRef.current.show() - // } - // } - - // const options = [ - // { value: 5, label: t('notificatons.five') }, - // { value: 15, label: t('notificatons.fifteen') }, - // { value: 30, label: t('notificatons.thirty') }, - // { value: 60, label: t('notificatons.sixty') }, - // ] - - // const filteredOptions = options.filter( - // (option) => option.value !== minsBefore - // ) - - // filteredOptions.push( - // ...(notification != null - // ? [{ value: 0, label: t('misc.disable', { ns: 'common' }) }] - // : []), - // { value: -1, label: t('misc.cancel', { ns: 'common' }) } - // ) - return ( <> - {/* option.label)} - cancelButtonIndex={filteredOptions.length - 1} - destructiveButtonIndex={notification != null ? 3 : -1} - onPress={(index) => { - const selectedValue = filteredOptions[index].value - if (selectedValue > 0) { - void setupNotifications(selectedValue) - } else if (selectedValue === 0) { - deleteTimetableNotifications(lecture.shortName) - } - trackEvent('Notification', { - type: 'lecture', - minsBefore: selectedValue.toString(), - }) - }} - /> */} + {} @@ -355,57 +246,7 @@ export default function TimetableDetails(): JSX.Element { - {/* { - showActionSheet().catch((error) => { - console.error(error) - }) - }} - style={styles.bellPressable} - hitSlop={10} - > - {!notificationsUpdating ? ( - - ) : ( - - )} - {minsBefore != null && ( - - {notification.mins + ' min'} - - )} - */} + {} @@ -431,24 +272,37 @@ export default function TimetableDetails(): JSX.Element { {lecture.rooms.map((room, i) => ( - { - router.navigate( - '(tabs)/map' - ) - updateRouteParams(room) - }} - > - + { + router.navigate( + '(tabs)/map' + ) + updateRouteParams(room) }} > - {room} - - + + {room} + + + {i < + lecture.rooms.length - + 1 && ( + + {', '} + + )} + ))} @@ -538,23 +392,12 @@ export const styles = StyleSheet.create({ roomContainer: { display: 'flex', flexDirection: 'row', - gap: 4, }, viewShot: { zIndex: -1, position: 'absolute', transform: [{ translateX: -1000 }], }, - // bellPressable: { - // flexDirection: 'column', - // alignItems: 'center', - // justifyContent: 'space-around', - // minWidth: 40, - // height: '100%', - // }, - // bellTime: { - // fontSize: 12, - // }, dateRow: { flex: 1, flexDirection: 'row', diff --git a/src/app/(user)/about.tsx b/src/app/(user)/about.tsx index 78da8169..bd7315a6 100644 --- a/src/app/(user)/about.tsx +++ b/src/app/(user)/about.tsx @@ -5,10 +5,11 @@ import SingleSectionPicker from '@/components/Elements/Universal/SingleSectionPi import { type Colors } from '@/components/colors' import { AppIconContext, FlowContext } from '@/components/contexts' import { type FormListSections } from '@/types/components' -import { IMPRINT_URL, PRIVACY_URL } from '@/utils/app-utils' +import { IMPRINT_URL, PRIVACY_URL, STATUS_URL } from '@/utils/app-utils' import { PAGE_BOTTOM_SAFE_AREA, PAGE_PADDING } from '@/utils/style-utils' import { trackEvent } from '@aptabase/react-native' import { useTheme } from '@react-navigation/native' +import * as Application from 'expo-application' import * as Haptics from 'expo-haptics' import { useRouter } from 'expo-router' import React, { useContext, useState } from 'react' @@ -25,24 +26,69 @@ import { View, } from 'react-native' -import { version } from '../../../package.json' - export default function About(): JSX.Element { const router = useRouter() const colors = useTheme().colors as Colors const { t } = useTranslation(['settings']) - const { analyticsAllowed, toggleAnalytics } = React.useContext(FlowContext) + const { analyticsAllowed, setAnalyticsAllowed } = + React.useContext(FlowContext) const { unlockedAppIcons, addUnlockedAppIcon } = useContext(AppIconContext) + const version = `${Application.nativeApplicationVersion}` + const versionWithCode = `${version} (${Application.nativeBuildVersion})` + const [displayVersion, setDisplayVersion] = useState(version) + + const toggleVersion = (): void => { + setDisplayVersion((prev) => + prev === version ? versionWithCode : version + ) + } + const sections: FormListSections[] = [ { - header: t('about.formlist.legal.title'), + header: 'App', items: [ { - title: t('about.formlist.legal.privacy'), + title: 'Version', + value: displayVersion, + onPress: toggleVersion, + selectable: true, + }, + { + title: 'Changelog', + icon: chevronIcon, + onPress: () => { + router.push('(user)/changelog') + }, + }, + { + title: 'Feedback', + icon: { + ios: 'envelope', + android: 'mail', + }, + onPress: async () => + await Linking.openURL( + 'mailto:app-feedback@informatik.sexy?subject=Feedback%20Neuland-Next' + ), + }, + { + title: 'System Status', icon: { - ios: 'hand.raised', - android: 'lock_open', + ios: 'bubble.left.and.exclamationmark.bubble.right', + android: 'sync_problem', + }, + onPress: () => { + void Linking.openURL(STATUS_URL) }, + }, + ], + }, + { + header: t('about.formlist.legal.title'), + items: [ + { + title: t('about.formlist.legal.privacy'), + icon: linkIcon, onPress: async () => await Linking.openURL(PRIVACY_URL), }, { @@ -51,7 +97,7 @@ export default function About(): JSX.Element { onPress: async () => await Linking.openURL(IMPRINT_URL), }, { - title: t('navigation.licenses', { ns: 'navigation' }), + title: t('navigation.licenses.title', { ns: 'navigation' }), icon: chevronIcon, onPress: () => { router.push('(user)/licenses') @@ -59,20 +105,10 @@ export default function About(): JSX.Element { }, ], }, + { header: t('about.formlist.about.title'), items: [ - { - title: 'Feedback', - icon: { - ios: 'envelope', - android: 'mail', - }, - onPress: async () => - await Linking.openURL( - 'mailto:app-feedback@informatik.sexy?subject=Feedback%20Neuland-Next' - ), - }, { title: 'Github', icon: { @@ -93,22 +129,6 @@ export default function About(): JSX.Element { }, ], }, - { - header: 'App', - items: [ - { - title: 'Version', - value: version, - }, - { - title: 'Changelog', - icon: chevronIcon, - onPress: () => { - router.push('(user)/changelog') - }, - }, - ], - }, ] const handlePress = (): void => { setPressCount(pressCount + 1) @@ -177,7 +197,7 @@ export default function About(): JSX.Element { styles.header, ]} > - Neuland Next + {'Neuland Next'} - Native Version + {'Native Version'} @@ -203,7 +223,7 @@ export default function About(): JSX.Element { styles.text, ]} > - Neuland Ingolstadt e.V. + {'Neuland Ingolstadt e.V.'} @@ -220,7 +240,8 @@ export default function About(): JSX.Element { diff --git a/src/app/(user)/appicon.tsx b/src/app/(user)/appicon.tsx index 1a538c8a..319699ab 100644 --- a/src/app/(user)/appicon.tsx +++ b/src/app/(user)/appicon.tsx @@ -36,8 +36,7 @@ export const appIcons = Object.keys(iconImages) export default function AppIconPicker(): JSX.Element { const colors = useTheme().colors as Colors - const { appIcon, toggleAppIcon, unlockedAppIcons } = - useContext(AppIconContext) + const { appIcon, setAppIcon, unlockedAppIcons } = useContext(AppIconContext) const { t } = useTranslation(['settings']) const categories: Record = { exclusive: ['cat', 'retro'], @@ -82,7 +81,7 @@ export default function AppIconPicker(): JSX.Element { icon ) ) - toggleAppIcon(icon) + setAppIcon(icon) } catch (e) { console.log(e) } diff --git a/src/app/(user)/changelog.tsx b/src/app/(user)/changelog.tsx index 3f7cb5a7..f1d8c8de 100644 --- a/src/app/(user)/changelog.tsx +++ b/src/app/(user)/changelog.tsx @@ -33,7 +33,7 @@ export default function Theme(): JSX.Element { header: `Version ${key}`, items: sorted.version[key].map((item) => ({ title: item.title[i18n.language as LanguageKey], - icon: item.icon, + icon: item.icon as any, })), })), ] @@ -56,7 +56,7 @@ export default function Theme(): JSX.Element { ) }} > - GitHub + {'GitHub'} . diff --git a/src/app/(user)/dashboard.tsx b/src/app/(user)/dashboard.tsx index 3b921447..9d2eb600 100644 --- a/src/app/(user)/dashboard.tsx +++ b/src/app/(user)/dashboard.tsx @@ -4,10 +4,9 @@ import { type Card, type ExtendedCard } from '@/components/allCards' import { type Colors } from '@/components/colors' import { DashboardContext, UserKindContext } from '@/components/contexts' import { cardIcons } from '@/components/icons' -import { getDefaultDashboardOrder } from '@/hooks/contexts/dashboard' -import { USER_GUEST } from '@/hooks/contexts/userKind' +import { getDefaultDashboardOrder } from '@/contexts/dashboard' import { type MaterialIcon } from '@/types/material-icons' -import { arraysEqual } from '@/utils/app-utils' +import { USER_GUEST, arraysEqual } from '@/utils/app-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { showToast } from '@/utils/ui-utils' import { useTheme } from '@react-navigation/native' diff --git a/src/app/(user)/grades.tsx b/src/app/(user)/grades.tsx index 3b8c6ef5..9fb00044 100644 --- a/src/app/(user)/grades.tsx +++ b/src/app/(user)/grades.tsx @@ -1,13 +1,17 @@ import NeulandAPI from '@/api/neuland-api' import { NoSessionError } from '@/api/thi-session-handler' +import ErrorView from '@/components/Elements/Error/ErrorView' import GradesRow from '@/components/Elements/Rows/GradesRow' import Divider from '@/components/Elements/Universal/Divider' -import ErrorView from '@/components/Elements/Universal/ErrorView' import SectionView from '@/components/Elements/Universal/SectionsView' import { type Colors } from '@/components/colors' import { useRefreshByUser } from '@/hooks' import { type GradeAverage } from '@/types/utils' -import { networkError } from '@/utils/api-utils' +import { + extractSpoName, + getPersonalData, + networkError, +} from '@/utils/api-utils' import { loadGradeAverage, loadGrades } from '@/utils/grades-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { LoadingState } from '@/utils/ui-utils' @@ -39,12 +43,14 @@ export default function GradesSCreen(): JSX.Element { * Loads the average grade from the API and sets the state accordingly. * @returns {Promise} A promise that resolves when the average grade has been loaded. */ - async function loadAverageGrade(): Promise { + async function loadAverageGrade( + spoName: string | undefined + ): Promise { if (isSpoLoading) { return } try { - const average = await loadGradeAverage(spoWeights) + const average = await loadGradeAverage(spoWeights, spoName) if (average.result !== undefined && average.result !== null) { setGradeAverage(average) setAverageLoadingState(LoadingState.LOADED) @@ -85,9 +91,18 @@ export default function GradesSCreen(): JSX.Element { return failureCount < 3 }, }) + + const { data: personalData } = useQuery({ + queryKey: ['personalData'], + queryFn: getPersonalData, + staleTime: 1000 * 60 * 60 * 12, // 12 hours + gcTime: 1000 * 60 * 60 * 24 * 60, // 60 days + }) + const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch) useEffect(() => { - void loadAverageGrade() + const spoName = extractSpoName(personalData) + void loadAverageGrade(spoName) }, [spoWeights, grades?.finished]) return ( diff --git a/src/app/(user)/licenses.tsx b/src/app/(user)/licenses.tsx index 9a97727e..676e07f3 100644 --- a/src/app/(user)/licenses.tsx +++ b/src/app/(user)/licenses.tsx @@ -6,8 +6,8 @@ import licenses from '@/data/licenses.json' import { type FormListSections } from '@/types/components' import { MODAL_BOTTOM_MARGIN, PAGE_PADDING } from '@/utils/style-utils' import { useTheme } from '@react-navigation/native' -import { useRouter } from 'expo-router' -import React from 'react' +import { useNavigation, useRouter } from 'expo-router' +import React, { useLayoutEffect } from 'react' import { useTranslation } from 'react-i18next' import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native' @@ -17,6 +17,31 @@ export default function Licenses(): JSX.Element { const colors = useTheme().colors as Colors const numberRegex = /\d+(\.\d+)*/ const atRegex = /(?:@)/gi + const navigation = useNavigation() + const [localSearch, setLocalSearch] = React.useState('') + + useLayoutEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + placeholder: t('navigation.licenses.search', { + ns: 'navigation', + }), + + ...Platform.select({ + android: { + headerIconColor: colors.text, + hintTextColor: colors.text, + textColor: colors.text, + }, + }), + + onChangeText: (event: { nativeEvent: { text: string } }) => { + const text = event.nativeEvent.text + setLocalSearch(text) + }, + }, + }) + }, [navigation]) const licensesStaticFiltered = Object.entries(licensesStatic) .filter( @@ -30,6 +55,13 @@ export default function Licenses(): JSX.Element { const licensesList = Object.entries(licensesCombined) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + // also sort by search + .filter(([key, value]) => { + if (localSearch === '') { + return true + } + return key.toLowerCase().includes(localSearch.toLowerCase()) + }) .map(([key, value]) => { const version = key.match(numberRegex) const nameWithoutVersion = key @@ -54,13 +86,16 @@ export default function Licenses(): JSX.Element { const sections: FormListSections[] = [ { - header: t('navigation.licenses', { ns: 'navigation' }), + header: t('navigation.licenses.title', { ns: 'navigation' }), items: [...licensesList], }, ] return ( <> - + diff --git a/src/app/(user)/login.tsx b/src/app/(user)/login.tsx index 58c32bef..4aa9bff8 100644 --- a/src/app/(user)/login.tsx +++ b/src/app/(user)/login.tsx @@ -1,16 +1,30 @@ import LoginForm from '@/components/Elements/Universal/LoginForm' import { type Colors } from '@/components/colors' +import { PRIVACY_URL } from '@/utils/app-utils' import { useTheme } from '@react-navigation/native' -import React, { useEffect, useState } from 'react' +import * as Haptics from 'expo-haptics' +import { router, useLocalSearchParams } from 'expo-router' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Dimensions, Keyboard, KeyboardAvoidingView, + Linking, Platform, + Pressable, StyleSheet, + Text, TouchableWithoutFeedback, View, } from 'react-native' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' const useIsFloatingKeyboard = (): boolean => { const windowWidth = Dimensions.get('window').width @@ -32,41 +46,199 @@ const useIsFloatingKeyboard = (): boolean => { return floating } +function shuffleArray(array: string[]): string[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] // Swap elements + } + return array +} + +const textsEN = shuffleArray([ + 'to view your grades', + 'to search for free rooms', + 'to get map suggestions', + 'to book a library seat', + 'to view your timetable', + 'to see your exam schedule', + 'to search all lectureres', + 'to view your lectureres', + 'to view the latest THI news', + 'to check your printer balance', +]) + +const textsDE = shuffleArray([ + 'um deine Noten zu sehen', + 'um freie Räume zu suchen', + 'um Karten Vorschläge zu erhalten', + 'um einen Bibliotheksplatz zu buchen', + 'um deinen Stundenplan zu sehen', + 'um deinen Prüfungsplan zu sehen', + 'um alle Dozenten zu suchen', + 'um deine Dozenten zu sehen', + 'um die THI-News anzuzeigen', + 'um dein Drucker Guthaben zu prüfen', +]) export default function Login(): JSX.Element { const colors = useTheme().colors as Colors const floatingKeyboard = useIsFloatingKeyboard() + const { t, i18n } = useTranslation('flow') + const [currentTextIndex, setCurrentTextIndex] = useState(0) + const currentTextIndexRef = useRef(currentTextIndex) + const textOpacity = useSharedValue(1) + const textTranslateY = useSharedValue(0) + const texts3 = i18n.language === 'de' ? textsDE : textsEN + const shouldVibrate = Platform.OS === 'ios' + + const { fromOnboarding } = useLocalSearchParams<{ + fromOnboarding: string + }>() + + const navigateHome = (): void => { + if (fromOnboarding === 'true') { + router.dismissAll() + router.replace('/') + return + } + router.navigate('/') + } + + useEffect(() => { + currentTextIndexRef.current = currentTextIndex + }, [currentTextIndex]) + + const goToNextText = (): void => { + textOpacity.value = withTiming(0, { duration: 300 }, () => { + const nextIndex = (currentTextIndex + 1) % texts3.length + runOnJS(setCurrentTextIndex)(nextIndex) + + textTranslateY.value = 5 + textOpacity.value = withTiming(1, { duration: 300 }) + textTranslateY.value = withTiming(0, { duration: 600 }) + }) + } + + useEffect(() => { + const interval = setInterval(goToNextText, 3500) + + return () => { + clearInterval(interval) + } + }, [currentTextIndex, texts3, textOpacity, textTranslateY]) + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: textOpacity.value, + transform: [{ translateY: textTranslateY.value }], + } + }) + + const insets = useSafeAreaInsets() return ( <> - - - + + + + + + {t('login.title1')} + + { + goToNextText() + if (shouldVibrate) { + void Haptics.selectionAsync() + } + }} + > + + + {texts3[currentTextIndex]} + + + + + - + - - - + + + + + { + void Linking.openURL(PRIVACY_URL) + }} + > + + {t('onboarding.links.privacy')} + + + + + ) } const styles = StyleSheet.create({ - container: { flex: 1, justifyContent: 'center' }, - gradient: { - height: '100%', - width: '100%', + keyboardContainer: { + flex: 1, + justifyContent: 'space-evenly', + }, + container: { + flex: 1, + width: '90%', + alignSelf: 'center', + }, + + loginContainer: {}, + header1: { + fontSize: 42, + fontWeight: 'bold', + textAlign: 'left', + }, + + header3: { + fontSize: 26, + textAlign: 'left', + marginTop: 10, + fontWeight: '400', + minHeight: 30, + }, + linkContainer: { + bottom: 70, + position: 'absolute', + gap: 6, + alignSelf: 'center', + alignItems: 'center', }, - loginContainer: { - minHeight: 320, - marginHorizontal: 30, - marginBottom: 60, + privacyLink: { + textAlign: 'center', + fontSize: 14, }, }) diff --git a/src/app/(user)/profile.tsx b/src/app/(user)/profile.tsx index c842c23b..7d686697 100644 --- a/src/app/(user)/profile.tsx +++ b/src/app/(user)/profile.tsx @@ -1,17 +1,14 @@ import { NoSessionError } from '@/api/thi-session-handler' -import ErrorView from '@/components/Elements/Universal/ErrorView' +import ErrorView from '@/components/Elements/Error/ErrorView' import FormList from '@/components/Elements/Universal/FormList' import PlatformIcon, { chevronIcon } from '@/components/Elements/Universal/Icon' import { type Colors } from '@/components/colors' -import { - DashboardContext, - ThemeContext, - UserKindContext, -} from '@/components/contexts' +import { DashboardContext, UserKindContext } from '@/components/contexts' +import { queryClient } from '@/components/provider' import { useRefreshByUser } from '@/hooks' -import { USER_STUDENT } from '@/hooks/contexts/userKind' import { type FormListSections } from '@/types/components' import { getPersonalData, networkError, performLogout } from '@/utils/api-utils' +import { USER_STUDENT } from '@/utils/app-utils' import { PAGE_PADDING } from '@/utils/style-utils' import { useTheme } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' @@ -38,7 +35,6 @@ export default function Profile(): JSX.Element { const router = useRouter() const colors = useTheme().colors as Colors const { toggleUserKind, userKind } = useContext(UserKindContext) - const { toggleAccentColor } = useContext(ThemeContext) const { resetOrder } = useContext(DashboardContext) const { t } = useTranslation('settings') @@ -116,8 +112,8 @@ export default function Profile(): JSX.Element { onPress: () => { performLogout( toggleUserKind, - toggleAccentColor, - resetOrder + resetOrder, + queryClient ).catch((e) => { console.log(e) }) @@ -308,7 +304,7 @@ export default function Profile(): JSX.Element { }} /> - Logout + {t('profile.logout.button')} diff --git a/src/app/(user)/settings.tsx b/src/app/(user)/settings.tsx index 65023a0b..afdbe0d7 100644 --- a/src/app/(user)/settings.tsx +++ b/src/app/(user)/settings.tsx @@ -1,31 +1,34 @@ import { NoSessionError } from '@/api/thi-session-handler' +import LogoTextSVG from '@/components/Elements/Flow/svgs/logoText' import { Avatar, NameBox } from '@/components/Elements/Settings' import FormList from '@/components/Elements/Universal/FormList' import PlatformIcon, { linkIcon } from '@/components/Elements/Universal/Icon' import { type Colors } from '@/components/colors' -import { - DashboardContext, - ThemeContext, - UserKindContext, -} from '@/components/contexts' +import { DashboardContext, UserKindContext } from '@/components/contexts' +import { queryClient } from '@/components/provider' +import { type UserKindContextType } from '@/contexts/userKind' import { useRefreshByUser } from '@/hooks' -import { - USER_GUEST, - USER_STUDENT, - type UserKindContextType, -} from '@/hooks/contexts/userKind' import { type FormListSections } from '@/types/components' +import { type MaterialIcon } from '@/types/material-icons' +import { + animatedHapticFeedback, + useRandomColor, + withBouncing, +} from '@/utils/animation-utils' import { getPersonalData, performLogout } from '@/utils/api-utils' +import { USER_GUEST, USER_STUDENT } from '@/utils/app-utils' +import { storage } from '@/utils/storage' import { getContrastColor, getInitials } from '@/utils/ui-utils' -import AsyncStorage from '@react-native-async-storage/async-storage' +import { trackEvent } from '@aptabase/react-native' import { useTheme } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' import { useRouter } from 'expo-router' -import React, { useContext } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert, + Dimensions, Linking, Platform, Pressable, @@ -35,16 +38,84 @@ import { Text, View, } from 'react-native' +import Animated, { + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import Shimmer from 'react-native-shimmer' export default function Settings(): JSX.Element { const { userKind, userFullName } = useContext(UserKindContext) - const { toggleAccentColor } = useContext(ThemeContext) const { resetOrder } = useContext(DashboardContext) - + const insets = useSafeAreaInsets() + const window = Dimensions.get('window') + const width = window.width - insets.left - insets.right + const height = window.height - insets.top - insets.bottom const router = useRouter() - const colors = useTheme().colors as Colors + const theme = useTheme() + const colors = theme.colors as Colors const { t, i18n } = useTranslation(['settings']) + const bottomBoundX = 0 + const logoWidth = 171 + const logoHeight = 18 + const topBoundX = width - logoWidth + const [tapCount, setTapCount] = useState(0) + const translateX = useSharedValue(0) + const translateY = useSharedValue(0) + const scrollY = useRef(0) + const logoRotation = useSharedValue(0) + const velocity = 110 + + const { color, randomizeColor } = useRandomColor() + + useEffect(() => { + const { bottomBoundY, topBoundY } = getBounds() + if (isBouncing) { + trackEvent('EasterEgg', { easterEgg: 'settingsLogoBounce' }) + + translateX.value = withBouncing( + velocity, + bottomBoundX, + topBoundX, + randomizeColor + ) + translateY.value = withBouncing( + velocity, + bottomBoundY, + topBoundY, + randomizeColor + ) + } else { + cancelAnimation(translateX) + cancelAnimation(translateY) + } + }, [tapCount]) + + const logoBounceAnimation = useAnimatedStyle(() => { + return { + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + ], + } + }) + + const wobbleAnimation = useAnimatedStyle(() => { + return { + transform: [{ rotateZ: `${logoRotation.value}deg` }], + } + }) + + const getBounds = (): { topBoundY: number; bottomBoundY: number } => { + const topBoundY = height - logoHeight + scrollY.current - 5 + const bottomBoundY = 0 + scrollY.current + return { topBoundY, bottomBoundY } + } const languageAlert = (): void => { const newLocale = i18n.language === 'en' ? 'de' : 'en' @@ -61,7 +132,7 @@ export default function Settings(): JSX.Element { text: t('menu.formlist.language.confirm'), style: 'destructive', onPress: () => { - void AsyncStorage.setItem('language', newLocale) + storage.set('language', newLocale) void i18n.changeLanguage(newLocale) }, }, @@ -84,8 +155,8 @@ export default function Settings(): JSX.Element { onPress: () => { performLogout( toggleUserKind, - toggleAccentColor, - resetOrder + resetOrder, + queryClient ).catch((e) => { console.log(e) }) @@ -98,8 +169,8 @@ export default function Settings(): JSX.Element { const { data, error, isLoading, isSuccess, refetch, isError } = useQuery({ queryKey: ['personalData'], queryFn: getPersonalData, - staleTime: 1000 * 60 * 60 * 12, // 12 hours - gcTime: 1000 * 60 * 60 * 24 * 60, // 60 days + staleTime: 1000 * 60 * 60 * 12, + gcTime: 1000 * 60 * 60 * 24 * 60, retry(failureCount, error) { if (error instanceof NoSessionError) { router.replace('user/login') @@ -115,6 +186,21 @@ export default function Settings(): JSX.Element { const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch) const { toggleUserKind } = React.useContext(UserKindContext) + const handlePress = (): void => { + setTapCount(tapCount + 1) + animatedHapticFeedback() + if (tapCount < 1) { + const rotationDegree = 5 + + logoRotation.value = withSequence( + withTiming(-rotationDegree, { duration: 50 }), + withTiming(rotationDegree, { duration: 100 }), + withTiming(0, { duration: 50 }) + ) + } + } + + const isBouncing = tapCount === 2 const sections: FormListSections[] = [ { @@ -181,7 +267,7 @@ export default function Settings(): JSX.Element { title: 'App Icon', icon: { ios: 'star.square.on.square', - android: null, + android: '' as MaterialIcon, }, onPress: () => { router.push('(user)/appicon') @@ -250,6 +336,10 @@ export default function Settings(): JSX.Element { }, ] + const logoInactiveOpacity = isBouncing ? 0 : 1 + const logoActiveOpacity = isBouncing ? 1 : 0 + const logoActiveHeight = isBouncing ? 18 : 0 + return ( ) : undefined } + onScroll={(event) => { + scrollY.current = event.nativeEvent.contentOffset.y + setTapCount(0) + }} + contentContainerStyle={styles.contentContainer} > {t('menu.copyright', { year: new Date().getFullYear() })} + + { + setTapCount(0) + }} + disabled={!isBouncing} + hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} + > + + + + + + { + handlePress() + }} + disabled={isBouncing} + accessibilityLabel={t('button.settingsLogo', { + ns: 'accessibility', + })} + hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} + > + + + + + ) } const styles = StyleSheet.create({ wrapper: { paddingTop: 20, paddingHorizontal: 16 }, - + bounceContainer: { + zIndex: 10, + position: 'absolute', + }, copyrigth: { fontSize: 12, textAlign: 'center', - marginBottom: 20, + marginBottom: -10, marginTop: 20, }, container: { @@ -506,4 +654,15 @@ const styles = StyleSheet.create({ fontSize: 20, fontWeight: 'bold', }, + shimmerContainer: { + alignItems: 'center', + alignSelf: 'center', + }, + contentContainer: { + paddingBottom: 60, + }, + whobbleContainer: { + alignItems: 'center', + paddingTop: 20, + }, }) diff --git a/src/app/(user)/theme.tsx b/src/app/(user)/theme.tsx index c0f12dde..49389689 100644 --- a/src/app/(user)/theme.tsx +++ b/src/app/(user)/theme.tsx @@ -20,7 +20,7 @@ import { export default function Theme(): JSX.Element { const colors = useTheme().colors as Colors const deviceTheme = useTheme() - const { accentColor, toggleAccentColor, theme, toggleTheme } = + const { accentColor, setAccentColor, theme, setTheme } = useContext(ThemeContext) const { t } = useTranslation(['settings']) @@ -41,7 +41,7 @@ export default function Theme(): JSX.Element { { - toggleAccentColor(code) + setAccentColor(code) if (Platform.OS === 'ios') { void Haptics.selectionAsync() } @@ -52,6 +52,10 @@ export default function Theme(): JSX.Element { marginHorizontal: 15, }, ]} + accessibilityLabel={t( + // @ts-expect-error cannot verify that code is a valid key + `theme.colors.${code}` + )} > void} + selectedItem={theme ?? 'auto'} + action={setTheme as (item: string) => void} /> diff --git a/src/app/[...unmachted].tsx b/src/app/[...unmachted].tsx index fac4ceb0..bd7924f0 100644 --- a/src/app/[...unmachted].tsx +++ b/src/app/[...unmachted].tsx @@ -1,4 +1,4 @@ -import ErrorView from '@/components/Elements/Universal/ErrorView' +import ErrorView from '@/components/Elements/Error/ErrorView' import { trackEvent } from '@aptabase/react-native' import { router, useNavigation, usePathname } from 'expo-router' import React, { useEffect, useLayoutEffect } from 'react' diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index e129382c..1b7a61cb 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,61 +1,27 @@ +import CrashView from '@/components/Elements/Error/CrashView' import PlatformIcon from '@/components/Elements/Universal/Icon' import { type Colors } from '@/components/colors' import { ThemeContext } from '@/components/contexts' import Provider from '@/components/provider' import i18n from '@/localization/i18n' +import { storage } from '@/utils/storage' import { getStatusBarStyle } from '@/utils/ui-utils' -import AsyncStorage from '@react-native-async-storage/async-storage' import { useTheme } from '@react-navigation/native' -import * as Sentry from '@sentry/react-native' import { getLocales } from 'expo-localization' -import { Stack, useNavigationContainerRef, useRouter } from 'expo-router' +import { Stack, useRouter } from 'expo-router' +import { Try } from 'expo-router/build/views/Try' import React, { useContext, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { - AppState, - LogBox, - Platform, - Pressable, - useColorScheme, -} from 'react-native' - -LogBox.ignoreLogs(['new NativeEventEmitter']) - -const sentryDsn = process.env.EXPO_PUBLIC_SENTRY_DSN -const routingInstrumentation = new Sentry.ReactNavigationInstrumentation() - -Sentry.init({ - dsn: sentryDsn, - enabled: !__DEV__, - integrations: [ - new Sentry.ReactNativeTracing({ - // Pass instrumentation to be used as `routingInstrumentation` - routingInstrumentation, - }), - ], - beforeSend(event) { - delete event.contexts?.app?.device_app_hash - return event - }, -}) +import { AppState, Platform, Pressable, useColorScheme } from 'react-native' function RootLayout(): JSX.Element { const router = useRouter() const { theme: appTheme } = useContext(ThemeContext) - const { t } = useTranslation('navigation') - - const ref = useNavigationContainerRef() - - React.useEffect(() => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (ref) { - routingInstrumentation.registerNavigationContainer(ref) - } - }, [ref]) + const { t } = useTranslation(['navigation']) useEffect(() => { const loadLanguage = async (): Promise => { - const savedLanguage = await AsyncStorage.getItem('language') + const savedLanguage = storage.getString('language') if ( savedLanguage !== null && Platform.OS === 'android' && @@ -94,381 +60,390 @@ function RootLayout(): JSX.Element { const colors = useTheme().colors as Colors const isOsDark = useColorScheme() === 'dark' + return ( <> - - + + + + + + + + + + + + ({ + title: 'App Icon', + animation: 'slide_from_right', + presentation: + route.params?.fromAppShortcut === 'true' + ? 'modal' + : undefined, + })} + /> + + - - - - - - - - - - - - ({ - title: 'App Icon', - animation: 'slide_from_right', - presentation: - route.params?.fromAppShortcut === 'true' - ? 'modal' - : undefined, - })} - /> - - - - - - - - - - - - - - + + + + + + + + + + + + ( - { - router.push('(pages)/libraryCode') + headerRight: () => ( + { + router.push('(pages)/libraryCode') + }} + accessibilityLabel={t('button.libraryBarcode', { + ns: 'accessibility', + })} + > + - - - ), - }} - /> - - - - - - + /> + + ), + }} + /> + + + + + + ) } const ProviderComponent = (): JSX.Element => { return ( - - - + + + + + ) } -export default Sentry.wrap(ProviderComponent) +export default ProviderComponent diff --git a/src/assets/map-marker.png b/src/assets/map-marker.png new file mode 100644 index 0000000000000000000000000000000000000000..5f5afa9d7f53ae5ac018fce359f33d96f6c18641 GIT binary patch literal 7523 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 literal 0 HcmV?d00001 diff --git a/src/assets/splash.png b/src/assets/splash.png deleted file mode 100644 index 951cc8177c3c278c9091c76a52e904846a20d588..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33596 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 void + onPressRoute: string removable?: boolean children?: React.ReactNode } const BaseCard: React.FC = ({ title, - onPress, + onPressRoute, children, removable = true, // ugly but more efficient than iterating over all cards }) => { @@ -62,7 +62,9 @@ const BaseCard: React.FC = ({ return ( { + router.navigate(onPressRoute) + }} delayLongPress={300} onLongPress={() => {}} > @@ -78,7 +80,9 @@ const BaseCard: React.FC = ({ e.nativeEvent.name === t('contextMenu.reset') && resetOrder(userKind ?? 'guest') }} - onPreviewPress={onPress} + onPreviewPress={() => { + router.navigate(onPressRoute) + }} > { }, [calendar, exams]) return ( - { - router.push('calendar') - }} - > + { - const router = useRouter() const colors = useTheme().colors as Colors const { t } = useTranslation('navigation') @@ -23,12 +21,7 @@ const EventsCard = (): JSX.Element => { }) return ( - { - router.push('events') - }} - > + {Boolean(isSuccess) && data !== undefined && ( { const { t, i18n } = useTranslation('food') - - const router = useRouter() const colors = useTheme().colors as Colors const { selectedRestaurants, @@ -85,7 +83,7 @@ const FoodCard = (): JSX.Element => { foodLanguage, i18n.language as LanguageKey ), - price: getUserSpecificPrice(x, userKind), + price: getUserSpecificPrice(x, userKind ?? USER_GUEST), })), ...(hiddenEntriesCount > 0 ? [ @@ -141,12 +139,7 @@ const FoodCard = (): JSX.Element => { }, [selectedRestaurants]) return ( - { - router.replace('food') - }} - > + {Boolean(isSuccess) && data !== undefined && data.length > 0 && ( {foodEntries.length === 0 && ( diff --git a/src/components/Cards/LibraryCard.tsx b/src/components/Cards/LibraryCard.tsx index 91c3c1a2..8b602a8d 100644 --- a/src/components/Cards/LibraryCard.tsx +++ b/src/components/Cards/LibraryCard.tsx @@ -48,12 +48,7 @@ const LibraryCard = (): JSX.Element => { } } return ( - { - router.push('library') - }} - > + {loadingState === LoadingState.LOADED && ( { - const router = useRouter() const colors = useTheme().colors as Colors const { t } = useTranslation('navigation') return ( - { - router.push('login') - }} - > + = ({ data }) => { + const { hiddenAnnouncements, hideAnnouncement } = + useContext(DashboardContext) + const colors = useTheme().colors as Colors + const { t } = useTranslation('navigation') + if (data === undefined) { return <> } - - const { hiddenAnnouncements, hideAnnouncement } = - useContext(DashboardContext) const filter = (data: Announcement[]): Announcement[] => { const now = new Date() const activeAnnouncements = data.filter( @@ -36,8 +38,7 @@ const PopUpCard: React.FC = ({ data }) => { return activeAnnouncements } const filtered = filter(data) - const colors = useTheme().colors as Colors - const { t } = useTranslation('navigation') + return filtered != null && filtered.length > 0 ? ( { diff --git a/src/components/Cards/TimetableCard.tsx b/src/components/Cards/TimetableCard.tsx index 3747f83d..ae5b5f66 100644 --- a/src/components/Cards/TimetableCard.tsx +++ b/src/components/Cards/TimetableCard.tsx @@ -8,7 +8,7 @@ import { getFriendlyTimetable } from '@/utils/timetable-utils' import { LoadingState } from '@/utils/ui-utils' import { useTheme } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' -import { useFocusEffect, useRouter } from 'expo-router' +import { useFocusEffect } from 'expo-router' import React, { useCallback, useContext, useState } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' @@ -16,7 +16,6 @@ import { StyleSheet, Text, View } from 'react-native' import BaseCard from './BaseCard' const TimetableCard = (): JSX.Element => { - const router = useRouter() const colors = useTheme().colors as Colors const { userKind } = useContext(UserKindContext) const [loadingState, setLoadingState] = useState(LoadingState.LOADING) @@ -34,7 +33,7 @@ const TimetableCard = (): JSX.Element => { } const { data: timetable } = useQuery({ - queryKey: ['timetable', userKind], + queryKey: ['timetable'], queryFn: loadTimetable, staleTime: 1000 * 60 * 10, // 10 minutes gcTime: 1000 * 60 * 60 * 24, // 24 hours, @@ -86,12 +85,7 @@ const TimetableCard = (): JSX.Element => { const currentTime = new Date() return ( - { - router.push('timetable') - }} - > + {loadingState === LoadingState.LOADED && ( { const isDark = useTheme().dark const { userFullName, userKind } = useContext(UserKindContext) - const { toggleUserKind } = useContext(UserKindContext) - const { toggleAccentColor } = useContext(ThemeContext) const { resetOrder } = useContext(DashboardContext) const { data: persData } = useQuery({ @@ -87,8 +79,8 @@ export const IndexHeaderRight = (): JSX.Element => { onPress: () => { performLogout( toggleUserKind, - toggleAccentColor, - resetOrder + resetOrder, + queryClient ).catch((e) => { console.log(e) }) @@ -105,6 +97,7 @@ export const IndexHeaderRight = (): JSX.Element => { delayLongPress={300} onLongPress={() => {}} hitSlop={10} + accessibilityLabel={t('navigation.settings')} > + + {t('error.crash.steps')} + + + + + ) +} + +const styles = StyleSheet.create({ + errorDetail: { + fontSize: 17, + textAlign: 'center', + fontWeight: '500', + paddingBottom: 30, + }, + boxContainer: { + alignItems: 'center', + gap: 15, + borderRadius: 12, + padding: 25, + }, +}) diff --git a/src/components/Elements/Error/ActionButtons.tsx b/src/components/Elements/Error/ActionButtons.tsx new file mode 100644 index 00000000..ddb906f0 --- /dev/null +++ b/src/components/Elements/Error/ActionButtons.tsx @@ -0,0 +1,108 @@ +import { type Colors } from '@/components/colors' +import { STATUS_URL } from '@/utils/app-utils' +import { useTheme } from '@react-navigation/native' +import * as Application from 'expo-application' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { + Linking, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' + +export const FeedbackButton = ({ + error, + crash, +}: { + error: Error + crash: boolean +}): JSX.Element => { + const { t } = useTranslation('common') + const colors = useTheme().colors as Colors + const platform = Platform.OS + const appVersion = `${Application.nativeApplicationVersion} (${Application.nativeBuildVersion})` + const subject = crash ? 'App-Crash' : 'App-Error' + const mailContent = `mailto:app-feedback@informatik.sexy?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(`Schritte zur Reproduktion:\nSonstiges:\n\nApp Version: ${appVersion}\nPlatform: ${platform}\nFehler: ${error.message}`)}` + + const sendMail = (): void => { + Linking.openURL(mailContent).catch((err) => { + console.error('Error opening mail client:', err) + }) + } + return ( + { + sendMail() + }} + > + + + {t('error.crash.feedback')} + + + + ) +} + +export const StatusButton = (): JSX.Element => { + const { t } = useTranslation('common') + const colors = useTheme().colors as Colors + + return ( + { + void Linking.openURL(STATUS_URL) + }} + > + + + {t('error.crash.status')} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 10, + alignItems: 'center', + alignSelf: 'center', + }, + refreshButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 30, + }, + + actionButtonText: { + fontSize: 15, + fontWeight: '600', + }, +}) diff --git a/src/components/Elements/Error/CrashView.tsx b/src/components/Elements/Error/CrashView.tsx new file mode 100644 index 00000000..0c3dabaf --- /dev/null +++ b/src/components/Elements/Error/CrashView.tsx @@ -0,0 +1,156 @@ +import { type Colors } from '@/components/colors' +import { trackEvent } from '@aptabase/react-native' +import { useTheme } from '@react-navigation/native' +import { type ErrorBoundaryProps, usePathname } from 'expo-router' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Pressable, StyleSheet, Text, View } from 'react-native' + +import LogoTextSVG from '../Flow/svgs/logoText' +import PlatformIcon from '../Universal/Icon' +import StatusBox from './ActionBox' + +export const ErrorButton = ({ + onPress, +}: { + onPress: () => void +}): JSX.Element => { + const { t } = useTranslation('common') + const colors = useTheme().colors as Colors + return ( + + + + {t('error.crash.reload')} + + + + ) +} + +export default function CrashView({ + error, + retry, +}: ErrorBoundaryProps): JSX.Element { + const colors = useTheme().colors as Colors + const { t } = useTranslation('common') + const path = usePathname() + trackEvent('ErrorView', { + title: error.message, + path, + crash: false, + }) + + const handlePress = (): void => { + retry().catch((error) => { + console.info('Error while retrying', error) + }) + } + + return ( + + + + + + {t('error.crash.title')} + + + {t('error.crash.description')} + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + topContainer: { alignItems: 'center', gap: 20 }, + + innerContainer: { + flex: 1, + justifyContent: 'space-evenly', + alignItems: 'center', + width: '85%', + alignSelf: 'center', + paddingVertical: 20, + }, + errorTitle: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 8, + marginTop: 8, + textAlign: 'center', + }, + logoutContainer: { + borderRadius: 10, + alignItems: 'center', + alignSelf: 'center', + }, + refreshButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 30, + }, + refreshButtonText: { + fontSize: 16, + fontWeight: '600', + }, + errorInfo: { + fontSize: 18, + textAlign: 'center', + }, + logoContainer: { + bottom: 30, + position: 'absolute', + alignSelf: 'center', + }, +}) diff --git a/src/components/Elements/Universal/ErrorView.tsx b/src/components/Elements/Error/ErrorView.tsx similarity index 72% rename from src/components/Elements/Universal/ErrorView.tsx rename to src/components/Elements/Error/ErrorView.tsx index 770f1dde..6a10dae3 100644 --- a/src/components/Elements/Universal/ErrorView.tsx +++ b/src/components/Elements/Error/ErrorView.tsx @@ -1,21 +1,23 @@ import { type Colors } from '@/components/colors' import { type MaterialIcon } from '@/types/material-icons' import { guestError, networkError, permissionError } from '@/utils/api-utils' +import { trackEvent } from '@aptabase/react-native' import { useTheme } from '@react-navigation/native' -import { router } from 'expo-router' +import { router, usePathname } from 'expo-router' import React from 'react' import { useTranslation } from 'react-i18next' import { Platform, Pressable, RefreshControl, + ScrollView, StyleSheet, Text, View, } from 'react-native' -import { ScrollView } from 'react-native-gesture-handler' -import PlatformIcon from './Icon' +import PlatformIcon from '../Universal/Icon' +import StatusBox from './ActionBox' export default function ErrorView({ title, @@ -25,7 +27,8 @@ export default function ErrorView({ onButtonPress, onRefresh, refreshing, - inModal, + inModal = false, + isCritical = true, }: { title: string message?: string @@ -35,9 +38,11 @@ export default function ErrorView({ onRefresh?: () => any refreshing?: boolean inModal?: boolean + isCritical?: boolean }): JSX.Element { const colors = useTheme().colors as Colors const { t } = useTranslation('common') + const path = usePathname() const getIcon = (): MaterialIcon | any => { const ios = Platform.OS === 'ios' switch (title) { @@ -62,6 +67,22 @@ export default function ErrorView({ } } + const shouldTrack = + !( + networkError === title || + guestError === title || + permissionError === title + ) && isCritical + + const showBox = !inModal && shouldTrack + if (shouldTrack) { + trackEvent('ErrorView', { + title, + path, + crash: false, + }) + } + const getTitle = (): string => { switch (title) { case networkError: @@ -161,92 +182,76 @@ export default function ErrorView({ } // eslint-disable-next-line react-native/no-inline-styles contentContainerStyle={{ - ...(inModal ?? false + ...(inModal ? styles.innerContainerModal : styles.innerContainer), backgroundColor: inModal ?? false ? colors.card : undefined, borderRadius: inModal ?? false ? 10 : 0, }} > - - - - {getTitle().slice(0, 150)} - - - {getMessage()} - - - {refreshing != null && title !== guestError && ( + + + + {getTitle().slice(0, 150)} + + + {getMessage()} + + + + + {refreshing != null && title !== guestError && ( + {t('error.pull')} )} + {showBox && ( + + )} ) } -const baseStyles = StyleSheet.create({ - baseContainer: { - justifyContent: 'center', - alignItems: 'center', - gap: 12, - paddingHorizontal: 20, - }, - errorContainer: { - gap: 12, - alignItems: 'center', - }, -}) - const styles = StyleSheet.create({ + topContainer: { alignItems: 'center', gap: 20 }, innerContainerModal: { - ...baseStyles.baseContainer, + paddingHorizontal: 25, + flex: 1, paddingTop: 50, + paddingBottom: 25, }, innerContainer: { - ...baseStyles.baseContainer, - paddingTop: 150, + paddingHorizontal: 25, + flex: 1, + paddingBottom: Platform.OS === 'ios' ? 50 : 0, // iOS has transparent tab bar so we need to add padding }, errorContainer: { - ...baseStyles.errorContainer, - paddingBottom: 64, - }, - errorContainerModal: { - ...baseStyles.errorContainer, - paddingBottom: 30, + gap: 12, + justifyContent: 'space-evenly', + flex: 1, }, + errorTitle: { fontSize: 18, fontWeight: 'bold', @@ -272,14 +277,15 @@ const styles = StyleSheet.create({ fontWeight: '600', }, errorInfo: { - fontSize: 15, + fontSize: 16, fontWeight: '500', textAlign: 'center', marginTop: 12, }, errorFooter: { - fontSize: 14, + fontSize: 16, textAlign: 'center', + fontWeight: '600', marginTop: 16, }, }) diff --git a/src/components/Elements/Flow/HomeBottomSheet.tsx b/src/components/Elements/Flow/HomeBottomSheet.tsx index 8ed64ca2..2d0e1063 100644 --- a/src/components/Elements/Flow/HomeBottomSheet.tsx +++ b/src/components/Elements/Flow/HomeBottomSheet.tsx @@ -33,7 +33,7 @@ export const HomeBottomSheet = ({ color: colors.text, }} > - coming soon + {'Report a problem'} diff --git a/src/components/Elements/Flow/WhatsnewBox.tsx b/src/components/Elements/Flow/WhatsnewBox.tsx index a84bda93..d6386820 100644 --- a/src/components/Elements/Flow/WhatsnewBox.tsx +++ b/src/components/Elements/Flow/WhatsnewBox.tsx @@ -24,12 +24,15 @@ interface WhatsNewBoxProps { const WhatsNewBox: FC = ({ title, description, icon }) => { const colors = useTheme().colors as Colors return ( - + = ({ title, description, icon }) => { styles.description, ]} numberOfLines={4} - adjustsFontSizeToFit={true} > {description} @@ -72,7 +74,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, paddingVertical: 15, width: '100%', - gap: 20, + gap: 18, }, textContainer: { flexDirection: 'column', @@ -84,7 +86,7 @@ const styles = StyleSheet.create({ textAlign: 'left', }, description: { - fontSize: 14, + fontSize: 14.5, textAlign: 'left', }, }) diff --git a/src/components/Elements/Flow/svgs/everything.tsx b/src/components/Elements/Flow/svgs/everything.tsx deleted file mode 100644 index 74673f06..00000000 --- a/src/components/Elements/Flow/svgs/everything.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import * as React from 'react' -import Svg, { Circle, Path, Polygon } from 'react-native-svg' - -function EverythingSVG({ - size, - primary, -}: { - size: number - primary: string -}): JSX.Element { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -export default EverythingSVG diff --git a/src/components/Elements/Flow/svgs/logo.tsx b/src/components/Elements/Flow/svgs/logo.tsx index ecb93d6e..72394d0c 100644 --- a/src/components/Elements/Flow/svgs/logo.tsx +++ b/src/components/Elements/Flow/svgs/logo.tsx @@ -1,26 +1,23 @@ import { type Colors } from '@/components/colors' import { useTheme } from '@react-navigation/native' import React from 'react' -import { Path, Svg } from 'react-native-svg' +import { G, Path, Svg } from 'react-native-svg' export default function LogoSVG({ size }: { size: number }): JSX.Element { const colors = useTheme().colors as Colors return ( - - - + + + + + + + + + + + ) } diff --git a/src/components/Elements/Flow/svgs/logoText.tsx b/src/components/Elements/Flow/svgs/logoText.tsx new file mode 100644 index 00000000..fbb296b4 --- /dev/null +++ b/src/components/Elements/Flow/svgs/logoText.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import Svg, { G, Path, Rect } from 'react-native-svg' + +export default function LogoTextSVG({ + size, + color, +}: { + size: number + color: string +}): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/Elements/Flow/svgs/logoTextFull.tsx b/src/components/Elements/Flow/svgs/logoTextFull.tsx new file mode 100644 index 00000000..c1e1f355 --- /dev/null +++ b/src/components/Elements/Flow/svgs/logoTextFull.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import Svg, { G, Path } from 'react-native-svg' + +export default function LogoTextFullSVG({ + size, + color, +}: { + size: number + color: string +}): JSX.Element { + return ( + + + + + + + + + + + + ) +} diff --git a/src/components/Elements/Flow/svgs/secure.tsx b/src/components/Elements/Flow/svgs/secure.tsx deleted file mode 100644 index 68a739f9..00000000 --- a/src/components/Elements/Flow/svgs/secure.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import * as React from 'react' -import Svg, { Circle, Path, Polygon, Rect } from 'react-native-svg' - -function SecureSVG({ - size, - primary, -}: { - size: number - primary: string -}): JSX.Element { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -export default SecureSVG diff --git a/src/components/Elements/Food/HeaderRight.tsx b/src/components/Elements/Food/HeaderRight.tsx index eaedeebd..703ba64d 100644 --- a/src/components/Elements/Food/HeaderRight.tsx +++ b/src/components/Elements/Food/HeaderRight.tsx @@ -2,11 +2,13 @@ import { type Colors } from '@/components/colors' import { useTheme } from '@react-navigation/native' import { router } from 'expo-router' import React from 'react' +import { useTranslation } from 'react-i18next' import { Pressable, StyleSheet, View } from 'react-native' import PlatformIcon from '../Universal/Icon' export const FoodHeaderRight = (): JSX.Element => { + const { t } = useTranslation(['accessibility']) const colors = useTheme().colors as Colors return ( { }} hitSlop={10} style={styles.headerButton} + accessibilityLabel={t('button.foodPreferences')} > - No meals found for this day. + {t('dashboard.empty')} diff --git a/src/components/Elements/Food/MealEntry.tsx b/src/components/Elements/Food/MealEntry.tsx index 34563692..9f178016 100644 --- a/src/components/Elements/Food/MealEntry.tsx +++ b/src/components/Elements/Food/MealEntry.tsx @@ -1,7 +1,7 @@ import { humanLocations } from '@/app/(food)/meal' import { type Colors } from '@/components/colors' import { FoodFilterContext, UserKindContext } from '@/components/contexts' -import { type UserKindContextType } from '@/hooks/contexts/userKind' +import { type UserKindContextType } from '@/contexts/userKind' import { type LanguageKey } from '@/localization/i18n' import { type Meal } from '@/types/neuland-api' import { diff --git a/src/components/Elements/Layout/DefaultTabs.tsx b/src/components/Elements/Layout/DefaultTabs.tsx new file mode 100644 index 00000000..87e2da14 --- /dev/null +++ b/src/components/Elements/Layout/DefaultTabs.tsx @@ -0,0 +1,146 @@ +import PlatformIcon from '@/components/Elements/Universal/Icon' +import { type Colors } from '@/components/colors' +import { type Theme } from '@react-navigation/native' +import { BlurView } from 'expo-blur' +import { Tabs } from 'expo-router' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Platform, StyleSheet } from 'react-native' + +const DefaultTabs = ({ theme }: { theme: Theme }): JSX.Element => { + const colors = theme.colors as Colors + const { t } = useTranslation('navigation') + const BlurTab = (): JSX.Element => ( + + ) + + return ( + <> + + ( + + ), + + tabBarStyle: { position: 'absolute' }, + tabBarBackground: () => + Platform.OS === 'ios' ? : null, + }} + /> + + ( + + ), + tabBarStyle: { position: 'absolute' }, + tabBarBackground: () => + Platform.OS === 'ios' ? : null, + }} + /> + + ( + + ), + }} + /> + + ( + + ), + + tabBarStyle: { position: 'absolute' }, + tabBarBackground: () => + Platform.OS === 'ios' ? : null, + }} + /> + + + ) +} + +const styles = StyleSheet.create({ + blurTab: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}) + +export default DefaultTabs diff --git a/src/components/Elements/Layout/MaterialTabs.tsx b/src/components/Elements/Layout/MaterialTabs.tsx new file mode 100644 index 00000000..bbef0c4f --- /dev/null +++ b/src/components/Elements/Layout/MaterialTabs.tsx @@ -0,0 +1,152 @@ +import PlatformIcon from '@/components/Elements/Universal/Icon' +import { type Colors } from '@/components/colors' +import { type Theme } from '@react-navigation/native' +import Color from 'color' +import { withLayoutContext } from 'expo-router' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Easing } from 'react-native' +import { + type MaterialBottomTabNavigationOptions, + createMaterialBottomTabNavigator, // @ts-expect-error no types +} from 'react-native-paper/react-navigation' + +const { Navigator } = createMaterialBottomTabNavigator() +const MaterialBottomTabs = withLayoutContext< + // @ts-expect-error Missing arguments in type + MaterialBottomTabNavigationOptions, + typeof Navigator +>(Navigator) + +const MaterialTabs = ({ theme }: { theme: Theme }): JSX.Element => { + const isDark = theme.dark + const colors = theme.colors as Colors + const { t } = useTranslation('navigation') + + return ( + + ( + + ), + }} + /> + + ( + + ), + }} + /> + + ( + + ), + }} + /> + ( + + ), + }} + /> + + ) +} + +export default MaterialTabs diff --git a/src/components/Elements/Map/BottomSheetDetailModal.tsx b/src/components/Elements/Map/BottomSheetDetailModal.tsx index 02665d1a..5dd9d27b 100644 --- a/src/components/Elements/Map/BottomSheetDetailModal.tsx +++ b/src/components/Elements/Map/BottomSheetDetailModal.tsx @@ -140,7 +140,7 @@ export const BottomSheetDetailModal = ({ }} android={{ name: 'expand_more', - size: 24, + size: 18, }} style={Platform.select({ android: { diff --git a/src/components/Elements/Map/BottomSheetMap.tsx b/src/components/Elements/Map/BottomSheetMap.tsx index a4b5ea3b..f8beaca9 100644 --- a/src/components/Elements/Map/BottomSheetMap.tsx +++ b/src/components/Elements/Map/BottomSheetMap.tsx @@ -1,15 +1,16 @@ -/* eslint-disable react-native/no-color-literals */ import { type Colors } from '@/components/colors' import { AppIconContext, UserKindContext } from '@/components/contexts' -import { MapContext } from '@/hooks/contexts/map' -import { USER_GUEST } from '@/hooks/contexts/userKind' -import { SEARCH_TYPES } from '@/types/map' +import { MapContext } from '@/contexts/map' +import { SEARCH_TYPES, type SearchResult } from '@/types/map' +import { USER_GUEST } from '@/utils/app-utils' import { formatFriendlyDate, formatFriendlyTime } from '@/utils/date-utils' import { PAGE_BOTTOM_SAFE_AREA, PAGE_PADDING } from '@/utils/style-utils' import { getContrastColor, showToast } from '@/utils/ui-utils' import { trackEvent } from '@aptabase/react-native' -import BottomSheet, { BottomSheetTextInput } from '@gorhom/bottom-sheet' +import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet' import { useTheme } from '@react-navigation/native' +import Color from 'color' +import { selectionAsync } from 'expo-haptics' import { useRouter } from 'expo-router' import Fuse from 'fuse.js' import { type FeatureCollection } from 'geojson' @@ -18,15 +19,18 @@ import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert, + Animated, + Easing, + LayoutAnimation, Linking, Platform, Pressable, SectionList, StyleSheet, Text, - TextInput, View, } from 'react-native' +import { Swipeable, TextInput } from 'react-native-gesture-handler' import { type SharedValue } from 'react-native-reanimated' import Divider from '../Universal/Divider' @@ -86,16 +90,21 @@ const MapBottomSheet: React.FC = ({ allRooms, }) => { const router = useRouter() - const colors = useTheme().colors as Colors + const theme = useTheme() + const colors = theme.colors as Colors + const isDark = theme.dark const { t, i18n } = useTranslation('common') const { userKind } = useContext(UserKindContext) const { localSearch, setLocalSearch, + clickedElement, setClickedElement, availableRooms, nextLecture, setCurrentFloor, + searchHistory, + updateSearchHistory, } = useContext(MapContext) const { unlockedAppIcons, addUnlockedAppIcon } = useContext(AppIconContext) @@ -111,7 +120,6 @@ const MapBottomSheet: React.FC = ({ threshold: 0.4, useExtendedSearch: true, }) - const [searchResultsExact, searchResultsFuzzy] = useMemo(() => { const results = fuse.search(localSearch.trim().toUpperCase()) const roomResults = results.map((result) => ({ @@ -133,6 +141,27 @@ const MapBottomSheet: React.FC = ({ return [exactMatches, fuzzyMatches] }, [localSearch, allRooms]) + function addToSearchHistory(newHistory: SearchResult): void { + const newSearchHistory = searchHistory.filter( + (history) => history.title !== newHistory.title + ) + + newSearchHistory.unshift(newHistory) + + if (newSearchHistory.length > 5) { + newSearchHistory.length = 5 + } + + updateSearchHistory(newSearchHistory) + } + + function deleteSearchHistoryItem(element: SearchResult): void { + const newSearchHistory = searchHistory.filter( + (history) => history.title !== element.title + ) + updateSearchHistory(newSearchHistory) + } + useEffect(() => { if ( localSearch.toLocaleLowerCase() === 'neuland' && @@ -159,6 +188,28 @@ const MapBottomSheet: React.FC = ({ } }, [localSearch]) const textInputRef = useRef(null) + const [searchFocused, setSearchFocused] = React.useState(false) + const searchbarBackground = isDark + ? Color(colors.card).lighten(0.6).hex() + : Color(colors.card).darken(0.03).hex() + const cancelWidth = useRef(new Animated.Value(0)).current + const cancelOpacity = useRef(new Animated.Value(0)).current + + const animate = (toValue: number): void => { + Animated.timing(cancelWidth, { + toValue, + duration: 250, + easing: Easing.inOut(Easing.quad), + useNativeDriver: false, + }).start() + Animated.timing(cancelOpacity, { + toValue: toValue === 0 ? 0 : 1, + duration: 250, + easing: Easing.inOut(Easing.quad), + useNativeDriver: false, + }).start() + } + const width = t('misc.cancel').length * 11 return ( = ({ textInputRef.current?.blur() } }} + enableDynamicSizing={false} > - + - {Platform.OS !== 'ios' ? ( - + = ({ setLocalSearch(text) }} onFocus={() => { - bottomSheetRef.current?.snapToIndex(2) - }} - /> - ) : ( - { - setLocalSearch(text) + setSearchFocused(true) + animate(width) + + bottomSheetRef.current?.expand() }} - onFocus={() => { - bottomSheetRef.current?.snapToIndex(2) + onBlur={() => { + setSearchFocused(false) + animate(0) }} onEndEditing={() => { - bottomSheetRef.current?.collapse() + if (clickedElement === null) { + console.log( + 'clickedElement is null - snapping to 1' + ) + bottomSheetRef.current?.snapToIndex(1) + } else { + console.log( + 'clickedElement is not null - snapping to 0' + ) + bottomSheetRef.current?.close() + } }} /> + + + { + setLocalSearch('') + textInputRef.current?.blur() + // bottomSheetRef.current?.snapToIndex(1) + }} + style={styles.cancelButton} + > + + {t('misc.cancel')} + + + + + + {searchFocused && + localSearch === '' && + searchHistory.length !== 0 && ( + <> + + + + {t( + 'pages.map.details.room.history' + )} + + + + {searchHistory?.map( + (history, index) => ( + + ( + { + LayoutAnimation.configureNext( + LayoutAnimation + .Presets + .easeInEaseOut + ) + if ( + Platform.OS === + 'ios' + ) { + void selectionAsync() + } + deleteSearchHistoryItem( + history + ) + }} + > + + + )} + > + + + + + {index !== + searchHistory.length - + 1 && ( + + )} + + ) + )} + + + + )} + + {searchFocused && localSearch === '' && ( + + {t('pages.map.search.placeholder')} + )} {localSearch !== '' ? ( @@ -268,6 +462,7 @@ const MapBottomSheet: React.FC = ({ handlePresentModalPress } bottomSheetRef={bottomSheetRef} + updateSearchHistory={addToSearchHistory} /> )} ItemSeparatorComponent={() => ( @@ -297,25 +492,7 @@ const MapBottomSheet: React.FC = ({ {t('pages.map.search.noResults')} ) - ) : userKind === USER_GUEST ? ( - - { - router.push('login') - }} - > - - {t('pages.map.details.room.signIn')} - - - - - ) : ( + ) : searchFocused ? null : ( <> {nextLecture !== null && nextLecture.length > 0 && ( @@ -512,26 +689,28 @@ const MapBottomSheet: React.FC = ({ 'pages.map.details.room.availableRooms' )} - { - router.push('(map)/advanced') - }} - hitSlop={{ - top: 10, - right: 10, - bottom: 10, - left: 10, - }} - > - { + router.push('(map)/advanced') + }} + hitSlop={{ + top: 10, + right: 10, + bottom: 10, + left: 10, }} > - {t('misc.more')} - - + + {t('misc.more')} + + + )} = ({ ...styles.radius, }} > - {availableRooms === null ? ( + {userKind === USER_GUEST ? ( + + {t('pages.map.details.room.signIn')} + + ) : availableRooms === null ? ( = ({ { room.capacity }{' '} - seats) + {t( + 'pages.rooms.options.seats' + )} + ) @@ -712,7 +903,7 @@ const MapBottomSheet: React.FC = ({ )} - + ) } @@ -758,9 +949,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, marginBottom: 10, fontSize: 17, + flex: 1, + }, + historyRow: { + paddingVertical: 5, + paddingHorizontal: 12, + + width: '100%', }, suggestionRow: { - paddingVertical: 10, + paddingVertical: 14, paddingHorizontal: 12, flexDirection: 'row', }, @@ -793,6 +991,7 @@ const styles = StyleSheet.create({ }, radius: { borderRadius: 14, + overflow: 'hidden', }, noResults: { textAlign: 'center', @@ -800,9 +999,10 @@ const styles = StyleSheet.create({ fontSize: 16, }, header: { - fontSize: 15, - marginTop: 12, - marginBottom: 6, + fontWeight: '500', + fontSize: 20, + paddingTop: 8, + marginBottom: 2, textAlign: 'left', }, loadingMargin: { @@ -818,8 +1018,29 @@ const styles = StyleSheet.create({ fontSize: 15, paddingStart: 4, }, - guestContainer: { - paddingTop: 15, - gap: 35, + searchHint: { + paddingTop: 60, + }, + swipeableActionContainer: { + justifyContent: 'center', + alignItems: 'center', + width: 70, + }, + cancelContainer: { justifyContent: 'center' }, + cancelButton: { + paddingLeft: 10, + paddingRight: 2, + + alignSelf: 'center', + }, + cancelButtonText: { + textAlign: 'center', + fontSize: 15, + fontWeight: '600', + }, + inputContainer: { + flexDirection: 'row', + height: 40, + marginBottom: 10, }, }) diff --git a/src/components/Elements/Map/FloorPicker.tsx b/src/components/Elements/Map/FloorPicker.tsx index 0c4daddd..9add818d 100644 --- a/src/components/Elements/Map/FloorPicker.tsx +++ b/src/components/Elements/Map/FloorPicker.tsx @@ -1,5 +1,5 @@ import { type Colors } from '@/components/colors' -import { MapContext } from '@/hooks/contexts/map' +import { MapContext } from '@/contexts/map' import { useTheme } from '@react-navigation/native' import * as Haptics from 'expo-haptics' import React, { useContext } from 'react' @@ -157,6 +157,7 @@ const FloorPicker: React.FC = ({ onPress={() => { setCameraTriggerKey((prev) => prev + 1) }} + accessibilityLabel="Center on current location" > = ({ rooms }) => { ) : ( - No free rooms found + {t('pages.rooms.noRooms.title')} - Try changing the filters + {t('pages.rooms.noRooms.subtitle')} ) diff --git a/src/components/Elements/Map/MapScreen.tsx b/src/components/Elements/Map/MapScreen.tsx index 1800c43c..1d9a48b9 100644 --- a/src/components/Elements/Map/MapScreen.tsx +++ b/src/components/Elements/Map/MapScreen.tsx @@ -5,18 +5,17 @@ import { UnavailableSessionError, } from '@/api/thi-session-handler' import { loadTimetable } from '@/app/(tabs)/(timetable)/timetable' +import ErrorView from '@/components/Elements/Error/ErrorView' import { BottomSheetDetailModal } from '@/components/Elements/Map/BottomSheetDetailModal' import MapBottomSheet from '@/components/Elements/Map/BottomSheetMap' import FloorPicker from '@/components/Elements/Map/FloorPicker' -import ErrorView from '@/components/Elements/Universal/ErrorView' import { type Colors } from '@/components/colors' import { RouteParamsContext, UserKindContext } from '@/components/contexts' -import { MapContext } from '@/hooks/contexts/map' -import { USER_GUEST } from '@/hooks/contexts/userKind' -import i18n from '@/localization/i18n' +import { MapContext } from '@/contexts/map' import { type FeatureProperties, Gebaeude } from '@/types/asset-api' import { type RoomData, SEARCH_TYPES } from '@/types/map' import { type FriendlyTimetableEntry } from '@/types/utils' +import { USER_GUEST } from '@/utils/app-utils' import { formatISODate, formatISOTime } from '@/utils/date-utils' import { BUILDINGS, @@ -28,13 +27,16 @@ import { getCenter, getCenterSingle, getIcon, - getNextValidDate, } from '@/utils/map-utils' import { LoadingState, showToast } from '@/utils/ui-utils' import { trackEvent } from '@aptabase/react-native' import type BottomSheet from '@gorhom/bottom-sheet' import { type BottomSheetModal } from '@gorhom/bottom-sheet' -import MapLibreGL from '@maplibre/maplibre-react-native' +import MapLibreGL, { + type CameraRef, + type MapViewRef, + type UserLocationRef, +} from '@maplibre/maplibre-react-native' import { useTheme } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' import { useNavigation } from 'expo-router' @@ -71,7 +73,6 @@ import Animated, { withTiming, } from 'react-native-reanimated' import Toast from 'react-native-root-toast' -import { Path, Svg } from 'react-native-svg' import packageInfo from '../../../../package.json' import { modalSection } from './ModalSections' @@ -86,7 +87,7 @@ const MapScreen = (): JSX.Element => { const { userKind, userFaculty } = useContext(UserKindContext) const { routeParams, updateRouteParams } = useContext(RouteParamsContext) const [mapCenter, setMapCenter] = useState(INGOLSTADT_CENTER) - const { t } = useTranslation('common') + const { t, i18n } = useTranslation('common') const bottomSheetRef = useRef(null) const bottomSheetModalRef = useRef(null) const currentPosition = useSharedValue(0) @@ -103,17 +104,17 @@ const MapScreen = (): JSX.Element => { } = useContext(MapContext) const [disableFollowUser, setDisableFollowUser] = useState(false) const [showAllFloors, setShowAllFloors] = useState(false) - const mapRef = useRef(null) - const cameraRef = useRef(null) - const locationRef = useRef(null) + const mapRef = useRef(null) + const cameraRef = useRef(null) + const locationRef = useRef(null) const currentDate = new Date() enum Locations { IN = 'Ingolstadt', ND = 'Neuburg', } - const lightStyle = 'https://maps.opheys.dev/styles/light/style.json' - const darkStyle = 'https://maps.opheys.dev/styles/dark/style.json' + const lightStyle = 'https://tile.neuland.app/styles/light/style.json' + const darkStyle = 'https://tile.neuland.app/styles/dark/style.json' type LocationsType = Record const locations: LocationsType = Locations @@ -121,7 +122,7 @@ const MapScreen = (): JSX.Element => { const [tabBarPressed, setTabBarPressed] = useState(false) const opacity = useSharedValue(1) - // needed for Android + // required for android void MapLibreGL.setAccessToken(null) const toggleShowAllFloors = (): void => { @@ -408,7 +409,7 @@ const MapScreen = (): JSX.Element => { return } try { - const dateObj = getNextValidDate() + const dateObj = new Date() const date = formatISODate(dateObj) const time = formatISOTime(dateObj) const rooms = await filterRooms(roomStatusData, date, time) @@ -677,6 +678,22 @@ const MapScreen = (): JSX.Element => { } }, [regionChange, isVisible, opacity]) + const showFiltered = useCallback(() => { + return ( + filteredGeoJSON != null && + filteredGeoJSON.features.length > 0 && + !showAllFloors + ) + }, [filteredGeoJSON, showAllFloors]) + + const showAvailableFiltered = useCallback(() => { + return ( + availableFilteredGeoJSON != null && + availableFilteredGeoJSON.features.length > 0 && + !showAllFloors + ) + }, [availableFilteredGeoJSON, showAllFloors]) + return ( <> @@ -687,11 +704,7 @@ const MapScreen = (): JSX.Element => { backgroundColor: colors.background, }} > - mapRef.current?.render()} - refreshing={true} - /> + )} {mapLoadState === LoadingState.LOADING && ( @@ -741,6 +754,14 @@ const MapScreen = (): JSX.Element => { : undefined } > + {/* @ts-expect-error - The type definitions are incorrect */} + { animated={true} showsUserHeadingIndicator /> - - {filteredGeoJSON != null && ( + {clickedElement !== null && ( + + + + )} + {showFiltered() && ( { room: e.features[0].properties?.Raum, origin: 'MapClick', }) - handlePresentModalPress() }} hitbox={{ width: 0, height: 0 }} @@ -787,14 +831,16 @@ const MapScreen = (): JSX.Element => { )} - {availableFilteredGeoJSON != null && ( + {showAvailableFiltered() && ( { ...layerStyles.availableRooms, fillColor: colors.primary, }} + layerIndex={200} /> { ...layerStyles.availableRoomsOutline, lineColor: colors.primary, }} + layerIndex={250} /> )} - {clickedElement != null && ( - - - - - - )} <> {overlayError === null && ( @@ -849,7 +877,13 @@ const MapScreen = (): JSX.Element => { - + { void Linking.openURL( @@ -876,7 +910,12 @@ const MapScreen = (): JSX.Element => { handleSheetChangesModal={handleSheetChangesModal} currentPositionModal={currentPositionModal} roomData={roomData} - modalSection={modalSection(roomData, locations, t)} + modalSection={modalSection( + roomData, + locations, + t, + i18n.language + )} /> ) @@ -904,11 +943,19 @@ const styles = StyleSheet.create({ osmContainer: { height: 30, right: 0, - top: -19, + top: -24, marginRight: 4, alignItems: 'flex-end', zIndex: 99, position: 'absolute', - gap: 2, + }, +}) + +// @ts-expect-error - The type definitions are incorrect +const mapStyle = MapLibreGL.StyleSheet.create({ + mapMarker: { + iconImage: 'map-marker', + iconSize: 0.17, + iconAnchor: 'bottom', }, }) diff --git a/src/components/Elements/Map/ModalSections.ts b/src/components/Elements/Map/ModalSections.ts index 24c9e4bb..c40a5596 100644 --- a/src/components/Elements/Map/ModalSections.ts +++ b/src/components/Elements/Map/ModalSections.ts @@ -13,13 +13,16 @@ import { type TFunction } from 'i18next' * @param {RoomData} roomData - Data for the room * @param {any} t - Translation function * @param {any} locations - Locations + * @param {string} language - Language * @returns {FormListSections[]} * */ export const modalSection = ( roomData: RoomData, locations: any, - t: TFunction + t: TFunction, + language: string ): FormListSections[] => { + const roomTypeKey = language === 'de' ? 'Funktion_de' : 'Funktion_en' if ( roomData.type === SEARCH_TYPES.ROOM && ((roomData.occupancies !== null && @@ -99,7 +102,7 @@ export const modalSection = ( { title: t('pages.map.details.room.type'), value: - roomData?.properties?.Funktion_en ?? + roomData?.properties?.[roomTypeKey] ?? t('misc.unknown'), }, { diff --git a/src/components/Elements/Map/SearchResultRow.tsx b/src/components/Elements/Map/SearchResultRow.tsx index b3268729..2a4ed4f0 100644 --- a/src/components/Elements/Map/SearchResultRow.tsx +++ b/src/components/Elements/Map/SearchResultRow.tsx @@ -1,10 +1,11 @@ import { type Colors } from '@/components/colors' -import { MapContext } from '@/hooks/contexts/map' +import { MapContext } from '@/contexts/map' import { type SearchResult } from '@/types/map' import { getContrastColor } from '@/utils/ui-utils' import { trackEvent } from '@aptabase/react-native' import { TouchableOpacity } from '@gorhom/bottom-sheet' import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' import { Keyboard, StyleSheet, Text, View } from 'react-native' import PlatformIcon from '../Universal/Icon' @@ -15,15 +16,19 @@ const ResultRow: React.FC<{ colors: Colors handlePresentModalPress: () => void bottomSheetRef: React.RefObject + updateSearchHistory: (result: SearchResult) => void }> = ({ result, index, colors, handlePresentModalPress, bottomSheetRef, + updateSearchHistory, }): JSX.Element => { const { setClickedElement, setLocalSearch, setCurrentFloor } = useContext(MapContext) + const { i18n } = useTranslation() + const roomTypeKey = i18n.language === 'de' ? 'Funktion_de' : 'Funktion_en' return ( @@ -72,7 +76,7 @@ const ResultRow: React.FC<{ /> - + - {result.subtitle} + {result.item.properties?.[roomTypeKey] ?? result.subtitle} @@ -98,6 +102,7 @@ const styles = StyleSheet.create({ searchRowContainer: { flexDirection: 'row', paddingVertical: 8, + alignItems: 'center', }, searchIconContainer: { marginRight: 14, @@ -115,6 +120,10 @@ const styles = StyleSheet.create({ suggestionSubtitle: { fontWeight: '400', fontSize: 14, + maxWidth: '90%', + }, + flex: { + flex: 1, }, }) export default ResultRow diff --git a/src/components/Elements/Rows/GradesRow.tsx b/src/components/Elements/Rows/GradesRow.tsx index e1ad25b5..f5d41c5d 100644 --- a/src/components/Elements/Rows/GradesRow.tsx +++ b/src/components/Elements/Rows/GradesRow.tsx @@ -33,7 +33,8 @@ const GradesRow = ({ }} numberOfLines={2} > - ECTS: {item.ects ?? t('grades.none')} + {'ECTS:'} + {item.ects ?? t('grades.none')} } diff --git a/src/components/Elements/Settings/LoginAlert.tsx b/src/components/Elements/Settings/LoginAlert.tsx deleted file mode 100644 index ff892f03..00000000 --- a/src/components/Elements/Settings/LoginAlert.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * A component that displays an alert when login fails. - * @param {string} errorMsg - The error message to display. - * @param {function} resetAlert - A function to reset the login failure state. - * @returns {JSX.Element} - A JSX element that displays the login failure alert. - */ -import { type Colors } from '@/components/colors' -import { useTheme } from '@react-navigation/native' -import React from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' - -import PlatformIcon from '../Universal/Icon' - -const LoginAlert = ({ - errorMsg, - resetAlert, - errorTitle, -}: { - errorMsg: string - resetAlert: () => void - errorTitle?: string -}): JSX.Element => { - const colors = useTheme().colors as Colors - - return ( - - - - - - {errorTitle ?? 'Login failed'} - - - - - - - - - {errorMsg} - - - - - ) -} - -export default LoginAlert - -const styles = StyleSheet.create({ - container: { alignItems: 'center', paddingBottom: 10 }, - failureContainer: { - width: '100%', - justifyContent: 'center', - - paddingVertical: 10, - paddingHorizontal: 15, - borderRadius: 5, - }, - failureText: { - fontSize: 16, - fontWeight: 'bold', - }, - resetButtom: { - marginLeft: 'auto', - alignSelf: 'center', - padding: 1, - }, - errorText: { - width: '90%', - alignItems: 'flex-start', - }, - errorMsg: { - marginTop: 4, - }, - outerContainer: { - flexDirection: 'row', - }, - innerContainer: { - flexDirection: 'row', - alignItems: 'center', - }, -}) diff --git a/src/components/Elements/Settings/NameBox.tsx b/src/components/Elements/Settings/NameBox.tsx index 6576965a..250308ad 100644 --- a/src/components/Elements/Settings/NameBox.tsx +++ b/src/components/Elements/Settings/NameBox.tsx @@ -1,15 +1,11 @@ import { type Colors } from '@/components/colors' import { useTheme } from '@react-navigation/native' -import Color from 'color' -import { LinearGradient } from 'expo-linear-gradient' import React, { type ReactNode } from 'react' import { StyleSheet, Text, View } from 'react-native' -import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder' interface NameBoxProps { children: ReactNode title: string - loaded: boolean subTitle1: string subTitle2?: string } @@ -27,17 +23,10 @@ interface NameBoxProps { const NameBox = ({ children, title, - loaded, subTitle1, subTitle2, }: NameBoxProps): JSX.Element => { const colors = useTheme().colors as Colors - const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient) - const shimmerColor = [ - Color(colors.labelTertiaryColor).lighten(0.4).hex(), - Color(colors.labelTertiaryColor).lighten(0.5).hex(), - Color(colors.labelTertiaryColor).lighten(0.4).hex(), - ] return ( <> @@ -52,44 +41,28 @@ const NameBox = ({ > {title} - + {subTitle1} + + + {subTitle2 !== '' && ( - {subTitle1} + {subTitle2} - - - {subTitle2 !== '' && ( - - {subTitle2} - - )} - + )} ) @@ -98,18 +71,6 @@ const NameBox = ({ export default NameBox const styles = StyleSheet.create({ - shimmerContainer1: { - width: 100, - height: 12.5, - }, - shimmerContainer2: { - width: 130, - height: 12.5, - marginTop: 3, - }, - shimmer: { - borderRadius: 3, - }, subtitle: { fontSize: 12, overflow: 'hidden', diff --git a/src/components/Elements/Settings/index.ts b/src/components/Elements/Settings/index.ts index 043cc453..dfe9bae3 100644 --- a/src/components/Elements/Settings/index.ts +++ b/src/components/Elements/Settings/index.ts @@ -1,5 +1,4 @@ import Avatar from './Avatar' -import LoginAlert from './LoginAlert' import NameBox from './NameBox' -export { Avatar, NameBox, LoginAlert } +export { Avatar, NameBox } diff --git a/src/components/Elements/Timetable/HeaderButtons.tsx b/src/components/Elements/Timetable/HeaderButtons.tsx index f7f6cd05..4509e1fb 100644 --- a/src/components/Elements/Timetable/HeaderButtons.tsx +++ b/src/components/Elements/Timetable/HeaderButtons.tsx @@ -3,6 +3,7 @@ import { TimetableContext } from '@/components/contexts' import { trackEvent } from '@aptabase/react-native' import { useTheme } from '@react-navigation/native' import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' import { Platform, Pressable } from 'react-native' import PlatformIcon from '../Universal/Icon' @@ -11,6 +12,7 @@ export function HeaderLeft(): JSX.Element { const colors = useTheme().colors as Colors const { timetableMode, setTimetableMode } = useContext(TimetableContext) const marginRight = Platform.OS === 'ios' ? 0 : 10 + const { t } = useTranslation(['accessibility']) return ( + >(null) - const { timetableNotifications } = useContext(NotificationContext) - const { updateLecture } = useContext(RouteParamsContext) const { t } = useTranslation('timetable') useLayoutEffect(() => { @@ -105,9 +101,13 @@ export default function TimetableList({ * Functions */ function showEventDetails(entry: FriendlyTimetableEntry): void { - updateLecture(entry) - router.push({ - pathname: '(timetable)/details', + const base64Event = Buffer.from(JSON.stringify(entry)).toString( + 'base64' + ) + + router.navigate({ + pathname: 'lecture', + params: { lecture: base64Event }, }) } @@ -198,21 +198,6 @@ export default function TimetableList({ > {item.rooms?.join(', ')} - {timetableNotifications[item.shortName] !== - undefined && ( - - )} @@ -331,6 +316,7 @@ export default function TimetableList({ ios: 'fireworks', android: 'celebration', }} + isCritical={false} /> ) : ( { navigation.setOptions({ @@ -348,23 +347,25 @@ export default function TimetableWeek({ } function showEventDetails(entry: WeekViewEvent): void { + const base64Event = Buffer.from(JSON.stringify(entry)).toString( + 'base64' + ) if (entry.eventType === 'exam') { - const base64Event = Buffer.from(JSON.stringify(entry)).toString( - 'base64' - ) const navigateToPage = (): void => { - router.push({ - pathname: '(pages)/exam', + router.navigate({ + pathname: 'exam', params: { examEntry: base64Event }, }) } navigateToPage() - return + } else if (entry.eventType === 'lecture') { + router.navigate({ + pathname: 'lecture', + params: { + lecture: base64Event, + }, + }) } - updateLecture(entry as unknown as FriendlyTimetableEntry) - router.push({ - pathname: '(timetable)/details', - }) } return ( diff --git a/src/components/Elements/Universal/Checkbox.tsx b/src/components/Elements/Universal/Checkbox.tsx deleted file mode 100644 index 5ad17337..00000000 --- a/src/components/Elements/Universal/Checkbox.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable react-native/no-color-literals */ -import { getContrastColor } from '@/utils/ui-utils' -import { Ionicons } from '@expo/vector-icons' -import React from 'react' -import { Pressable, StyleSheet, type ViewStyle } from 'react-native' - -interface CheckboxProps { - checked: boolean - onChange: (checked: boolean) => void - style?: ViewStyle - activeButtonStyle?: ViewStyle - inactiveButtonStyle?: ViewStyle -} - -/** - * Checkbox component that allows the user to select or deselect an option. - * @param checked - A boolean value that determines whether the checkbox is checked or not. - * @param onChange - A function that is called when the checkbox is pressed. It takes a boolean parameter that represents the new checked state. - * @param style - An optional style object that can be used to override the default styles of the checkbox. - * @param activeButtonStyle - An optional style object that can be used to override the default styles of the checkbox when it is checked. - * @param inactiveButtonStyle - An optional style object that can be used to override the default styles of the checkbox when it is unchecked. - * @returns A JSX.Element that represents the Checkbox component. - */ -export function Checkbox({ - checked, - onChange, - style = {}, - activeButtonStyle = {}, - inactiveButtonStyle = {}, -}: CheckboxProps): JSX.Element { - return ( - { - onChange(!checked) - }} - > - {checked && ( - - )} - - ) -} - -const styles = StyleSheet.create({ - checkboxBase: { - width: 20, - height: 20, - justifyContent: 'center', - alignContent: 'center', - alignItems: 'center', - borderRadius: 4, - borderWidth: 1, - }, - checkboxChecked: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - checkboxUnchecked: { - backgroundColor: '#ffffff', - borderColor: '#8E8E93', - }, -}) diff --git a/src/components/Elements/Universal/FormList.tsx b/src/components/Elements/Universal/FormList.tsx index c34c8807..da185788 100644 --- a/src/components/Elements/Universal/FormList.tsx +++ b/src/components/Elements/Universal/FormList.tsx @@ -87,6 +87,9 @@ const FormList: React.FC = ({ sections }) => { 'normal', }, ]} + selectable={ + item.selectable ?? false + } > {item.value} diff --git a/src/components/Elements/Universal/Icon.tsx b/src/components/Elements/Universal/Icon.tsx index c2587d1e..ca3a0d40 100644 --- a/src/components/Elements/Universal/Icon.tsx +++ b/src/components/Elements/Universal/Icon.tsx @@ -1,5 +1,5 @@ import { type MaterialIcon } from '@/types/material-icons' -import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons' +import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons' import React from 'react' import { type ColorValue, Platform, StyleSheet, Text, View } from 'react-native' import SweetSFSymbol from 'sweet-sfsymbols' @@ -41,12 +41,12 @@ interface PlatformIconProps { } export const linkIcon = { ios: 'safari', - android: 'link', + android: 'link' as MaterialIcon, } export const chevronIcon = { ios: 'chevron.forward', - android: 'chevron_right', + android: 'chevron_right' as MaterialIcon, } const PlatformIcon = ({ @@ -57,11 +57,18 @@ const PlatformIcon = ({ }: PlatformIconProps): JSX.Element => { if (Platform.OS === 'ios') { return ios.fallback ?? false ? ( - ) : ( @@ -117,7 +124,7 @@ export default PlatformIcon const communityIcons: string[] = ['instagram', 'github'] -type CommunityIcon = 'instagram' | 'github' +export type CommunityIcon = 'instagram' | 'github' | 'map-marker' const styles = StyleSheet.create({ androidIcon: { @@ -132,4 +139,7 @@ const styles = StyleSheet.create({ communityIcon: { paddingTop: 50, }, + iosFallbackOffset: { + marginRight: -2, + }, }) diff --git a/src/components/Elements/Universal/LoginForm.tsx b/src/components/Elements/Universal/LoginForm.tsx index 0a4ab808..6c8c45f5 100644 --- a/src/components/Elements/Universal/LoginForm.tsx +++ b/src/components/Elements/Universal/LoginForm.tsx @@ -1,99 +1,81 @@ -import API from '@/api/authenticated-api' import { createGuestSession, createSession } from '@/api/thi-session-handler' -import { LoginAlert } from '@/components/Elements/Settings' import { type Colors } from '@/components/colors' +import { DashboardContext, UserKindContext } from '@/components/contexts' +import { getPersonalData, trimErrorMsg } from '@/utils/api-utils' import { - DashboardContext, - FlowContext, - UserKindContext, -} from '@/components/contexts' -import { USER_EMPLOYEE, USER_STUDENT } from '@/hooks/contexts/userKind' -import { trimErrorMsg } from '@/utils/api-utils' + STATUS_URL, + USER_EMPLOYEE, + USER_GUEST, + USER_STUDENT, +} from '@/utils/app-utils' import { getContrastColor } from '@/utils/ui-utils' import { useTheme } from '@react-navigation/native' +import { useQuery } from '@tanstack/react-query' +import Color from 'color' import * as Haptics from 'expo-haptics' -import { useRouter } from 'expo-router' import * as SecureStore from 'expo-secure-store' import React, { useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, - Dimensions, - Keyboard, - KeyboardAvoidingView, + Alert, + Linking, Platform, StyleSheet, Text, TextInput, TouchableOpacity, - TouchableWithoutFeedback, View, } from 'react-native' import Toast from 'react-native-root-toast' -const useIsFloatingKeyboard = (): boolean => { - const windowWidth = Dimensions.get('window').width - const [floating, setFloating] = useState(false) - useEffect(() => { - const onKeyboardWillChangeFrame = (event: any): void => { - setFloating(event.endCoordinates.width !== windowWidth) - } - - Keyboard.addListener( - 'keyboardWillChangeFrame', - onKeyboardWillChangeFrame - ) - return () => { - Keyboard.removeAllListeners('keyboardWillChangeFrame') - } - }, [windowWidth]) - - return floating -} - -const LoginForm = (): JSX.Element => { +const LoginForm = ({ + navigateHome, +}: { + navigateHome: () => void +}): JSX.Element => { const ORIGINAL_ERROR_WRONG_CREDENTIALS = 'Wrong credentials' const ORGINAL_ERROR_MISSING = 'Wrong or missing parameter' const KNOWN_BACKEND_ERRORS = ['Response is not valid JSON'] const ORIGINAL_ERROR_NO_CONNECTION = 'Network request failed' const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [infoMsg, setInfoMsg] = useState('') - const [notice, setNotice] = useState('') - const router = useRouter() const colors = useTheme().colors as Colors - const { toggleOnboarded, isOnboarded, toggleUpdated, toggleAnalytics } = - React.useContext(FlowContext) - const { toggleUserKind, updateUserFullName } = + const isDark = useTheme().dark + const { userKind, toggleUserKind, updateUserFullName } = React.useContext(UserKindContext) const [loading, setLoading] = useState(false) const { t } = useTranslation('flow') - const floatingKeyboard = useIsFloatingKeyboard() const { resetOrder } = useContext(DashboardContext) - const resetInfo = (): void => { - setInfoMsg('') - setNotice('') - } + + const { data: personalData, refetch: refetchPersonalData } = useQuery({ + queryKey: ['personalData'], + queryFn: getPersonalData, + staleTime: 1000 * 60 * 60 * 12, // 12 hours + gcTime: 1000 * 60 * 60 * 24 * 60, // 60 days + enabled: false, + }) async function login(): Promise { + let showStatus = true try { setLoading(true) const userKind = await createSession(username, password, true) if (userKind) { - updateUserFullName((await API.getFullName()) ?? username) + await refetchPersonalData() + const userFullName = + personalData?.vname + ' ' + personalData?.name + updateUserFullName(userFullName) } else { updateUserFullName(username) } toggleUserKind(userKind) - toggleUpdated() - if (isOnboarded === false) { - toggleAnalytics() - } - toggleOnboarded() resetOrder(userKind ? USER_STUDENT : USER_EMPLOYEE) - Haptics.notificationAsync( - Haptics.NotificationFeedbackType.Success - ).catch(() => {}) + if (Platform.OS === 'ios') { + void Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ) + } Toast.show(t('login.toast'), { duration: Toast.durations.LONG, @@ -103,39 +85,67 @@ const LoginForm = (): JSX.Element => { hideOnPress: true, delay: 0, }) - router.navigate('(tabs)') + navigateHome() } catch (e) { const error = e as Error const message = trimErrorMsg(error.message) - setLoading(false) - setNotice(t('login.alert.error.title')) + + let title = t('login.alert.error.title') + let msg = t('login.alert.error.generic') + if (message.includes(ORIGINAL_ERROR_WRONG_CREDENTIALS)) { - setInfoMsg(t('login.alert.error.wrongCredentials')) + title = t('login.alert.error.wrongCredentials.title') + msg = t('login.alert.error.wrongCredentials.message') + showStatus = false + setPassword('') } else if (message.includes(ORIGINAL_ERROR_NO_CONNECTION)) { - setInfoMsg(t('login.alert.error.noConnection')) + title = t('login.alert.error.noConnection.title') + msg = t('login.alert.error.noConnection.message') + showStatus = false } else if (message.includes(ORGINAL_ERROR_MISSING)) { - setInfoMsg(t('login.alert.error.missing')) + msg = t('login.alert.error.missing') + showStatus = false } else if ( KNOWN_BACKEND_ERRORS.some((error) => message.includes(error)) ) { - setInfoMsg(t('login.alert.error.backend')) - } else { - setInfoMsg(t('login.alert.error.generic')) + msg = t('login.alert.error.backend') } + Alert.alert( + title, + msg, + [ + { text: 'OK' }, + ...(showStatus + ? [ + { + text: t('error.crash.status', { + ns: 'common', + }), + onPress: async () => + await Linking.openURL(STATUS_URL), + }, + ] + : []), + ], + { + cancelable: false, + } + ) } } async function guestLogin(): Promise { setLoading(true) - await createGuestSession() - toggleUserKind(undefined) - toggleUpdated() - if (isOnboarded === false) { - toggleAnalytics() + + try { + await createGuestSession(userKind !== USER_GUEST) + } catch (error) { + console.error('Failed to create guest session', error) } - toggleOnboarded() - router.navigate('(tabs)') + + toggleUserKind(undefined) + navigateHome() } async function load(key: string): Promise { @@ -151,8 +161,15 @@ const LoginForm = (): JSX.Element => { if (savedUsername !== null && savedPassword !== null) { setUsername(savedUsername) setPassword(savedPassword) - setNotice(t('login.alert.restored.title')) - setInfoMsg(t('login.alert.restored.message')) + + Alert.alert( + t('login.alert.restored.title'), + t('login.alert.restored.message'), + [{ text: 'OK' }], + { + cancelable: false, + } + ) } } @@ -160,179 +177,160 @@ const LoginForm = (): JSX.Element => { } }, []) + const signInDisabled = + username.trim() === '' || password.trim() === '' || loading + const disabledBackgroundColor = isDark + ? Color(colors.primary).darken(0.3).hex() + : Color(colors.primary).lighten(0.3).hex() + const disabledTextColor = isDark + ? Color(getContrastColor(colors.primary)).lighten(0.1).hex() + : Color(getContrastColor(colors.primary)).darken(0.1).hex() return ( - <> - + - - - + {'THI Account'} + + + + {t('login.username')} + + { + setUsername(text) + }} + clearButtonMode="while-editing" + selectionColor={colors.primary} + autoCapitalize="none" + autoCorrect={false} + textContentType="oneTimeCode" + /> + + + + {t('login.password')} + + + { + setPassword(text) + }} + onSubmitEditing={() => { + if (username !== '') { + login().catch((error: Error) => { + console.log(error) + }) + } + }} + selectionColor={colors.primary} + selectTextOnFocus={true} + autoCapitalize="none" + secureTextEntry={true} + clearButtonMode="while-editing" + autoComplete="current-password" + textContentType="password" + autoCorrect={false} + /> + + { + login().catch((error: Error) => { + console.log(error) + }) + }} + style={[ + styles.loginButton, + { + backgroundColor: signInDisabled + ? disabledBackgroundColor + : colors.primary, + }, + ]} + > + {loading ? ( + + ) : ( + + {t('login.button')} + + )} + + + { + guestLogin().catch((error: Error) => { + console.log(error) + }) + }} + > + - - {t('login.title')} - - - {infoMsg !== '' ? ( - - ) : null} - - - {t('login.username')} - - - { - setUsername(text) - }} - clearButtonMode="while-editing" - selectionColor={colors.primary} - autoCapitalize="none" - autoComplete="username" - textContentType="username" - /> - - - - - {t('login.password')} - - - { - setPassword(text) - }} - onSubmitEditing={() => { - if (username !== '') { - login().catch( - (error: Error) => { - console.log(error) - } - ) - } - }} - selectionColor={colors.primary} - selectTextOnFocus={true} - autoCapitalize="none" - secureTextEntry={true} - clearButtonMode="while-editing" - autoComplete="current-password" - textContentType="password" - /> - - - { - login().catch((error: Error) => { - console.log(error) - }) - }} - style={[ - styles.loginButton, - { backgroundColor: colors.primary }, - ]} - > - {loading ? ( - - ) : ( - - {t('login.button')} - - )} - - - { - guestLogin().catch((error: Error) => { - console.log(error) - }) - }} - > - - {t('login.guest')} - - - - - - - - + {t('login.guest')} + + + + + ) } @@ -351,51 +349,47 @@ const styles = StyleSheet.create({ width: '100%', maxWidth: 400, paddingHorizontal: 25, - paddingVertical: 20, justifyContent: 'center', + paddingTop: 30, + paddingBottom: 30, }, header: { - fontSize: 22, - fontWeight: 'bold', - marginBottom: 12, - marginTop: 25, - alignSelf: 'center', + fontSize: 23, + fontWeight: '600', + textAlign: 'left', + + marginBottom: 14, }, loginButton: { height: 40, justifyContent: 'center', paddingHorizontal: 20, marginTop: 25, - borderRadius: 5, + borderRadius: 7, alignItems: 'center', }, textInput: { fontSize: 16, - paddingVertical: 8, + paddingVertical: 10, paddingHorizontal: 10, - }, - textInputContainer: { + borderRadius: 7, borderWidth: 1, - borderRadius: 5, }, + guestContainer: { - paddingTop: 3, + paddingTop: 24, alignItems: 'center', - marginBottom: 16, }, guestText: { - fontSize: 14, - marginTop: 10, - marginBottom: 8, - }, - keyboardContainer: { - flex: 1, + fontSize: 14.5, }, + userNameContainer: { paddingTop: 3, }, userNameLabel: { paddingBottom: 5, + fontSize: 15, }, passwordContainer: { paddingTop: 15, diff --git a/src/components/Elements/Universal/MaterialBottomTabs.ts b/src/components/Elements/Universal/MaterialBottomTabs.ts deleted file mode 100644 index 843d459c..00000000 --- a/src/components/Elements/Universal/MaterialBottomTabs.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { withLayoutContext } from 'expo-router' -import { - type MaterialBottomTabNavigationOptions, - createMaterialBottomTabNavigator, -} from 'react-native-paper/react-navigation' - -const { Navigator } = createMaterialBottomTabNavigator() - -export const MaterialBottomTabs = withLayoutContext< - // @ts-expect-error Missing arguments in type - MaterialBottomTabNavigationOptions, - typeof Navigator ->(Navigator) diff --git a/src/components/Elements/Universal/SingleSectionPicker.tsx b/src/components/Elements/Universal/SingleSectionPicker.tsx index 6fba2fb1..69aaad41 100644 --- a/src/components/Elements/Universal/SingleSectionPicker.tsx +++ b/src/components/Elements/Universal/SingleSectionPicker.tsx @@ -8,7 +8,8 @@ import PlatformIcon from './Icon' interface SectionPickerProps { title: string selectedItem: boolean - action: () => void + action: (state: boolean) => void + state: boolean } /** @@ -16,6 +17,7 @@ interface SectionPickerProps { * @param {string} title - The title of the item. * @param {boolean} selectedItem - Whether the item is selected. * @param {() => void} action - The function to be called when the item is selected. + * @param {boolean} state - The state of the item. * @returns {JSX.Element} - The MultiSectionPicker component. * @example * = ({ { - action() + action(!selectedItem) }} style={({ pressed }) => [ { opacity: pressed ? 0.8 : 1 }, diff --git a/src/components/Elements/Universal/WorkaroundStack.tsx b/src/components/Elements/Universal/WorkaroundStack.tsx index 3429578d..92a328b0 100644 --- a/src/components/Elements/Universal/WorkaroundStack.tsx +++ b/src/components/Elements/Universal/WorkaroundStack.tsx @@ -3,7 +3,6 @@ import { type HeaderButtonProps } from '@react-navigation/native-stack/lib/types import React, { type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Platform } from 'react-native' -import { SafeAreaProvider } from 'react-native-safe-area-context' export interface WorkaroundStackProps { name: string @@ -43,31 +42,29 @@ function WorkaroundStack({ const Stack = createNativeStackNavigator() return ( - - - - - + + + ) } diff --git a/src/components/allCards.tsx b/src/components/allCards.tsx index d5896c13..69c3696f 100644 --- a/src/components/allCards.tsx +++ b/src/components/allCards.tsx @@ -1,9 +1,4 @@ -import { - USER_EMPLOYEE, - USER_GUEST, - USER_STUDENT, -} from '@/hooks/contexts/userKind' -import { useRouter } from 'expo-router' +import { USER_EMPLOYEE, USER_GUEST, USER_STUDENT } from '@/utils/app-utils' import React from 'react' import { @@ -16,8 +11,6 @@ import { } from './Cards' import LibraryCard from './Cards/LibraryCard' -const router = useRouter() - export const AllCards: Card[] = [ { key: 'timetable', @@ -53,27 +46,13 @@ export const AllCards: Card[] = [ key: 'lecturers', removable: true, default: [USER_STUDENT, USER_EMPLOYEE], - card: () => ( - { - router.push('lecturers') - }} - /> - ), + card: () => , }, { key: 'news', removable: true, default: [USER_STUDENT, USER_EMPLOYEE], - card: () => ( - { - router.push('news') - }} - /> - ), + card: () => , }, { key: 'login', diff --git a/src/components/colors.ts b/src/components/colors.ts index 8d001d1f..17cf090c 100644 --- a/src/components/colors.ts +++ b/src/components/colors.ts @@ -18,6 +18,8 @@ interface StaticThemeColors { card: string notification: string inputBackground: string + contrast: string + cardContrast: string } export interface Colors extends StaticThemeColors { @@ -80,7 +82,9 @@ export const lightColors: StaticThemeColors = { datePickerBackground: '#ebebec', card: '#ffffff', notification: '#ff0000', - inputBackground: '#ffffffab', + inputBackground: '#e9e9e9', + contrast: '#ffffff', + cardContrast: '#eeeeee', } export const darkColors: StaticThemeColors = { @@ -92,5 +96,7 @@ export const darkColors: StaticThemeColors = { datePickerBackground: '#2a2a2c', card: '#1c1c1d', notification: '#ff0000', - inputBackground: '#7574725f', + inputBackground: '#383838', + contrast: '#000000', + cardContrast: '#1c1c1d', } diff --git a/src/components/contexts.ts b/src/components/contexts.ts index 4c1f61b1..09b20db9 100644 --- a/src/components/contexts.ts +++ b/src/components/contexts.ts @@ -1,15 +1,14 @@ -import { type AppIconHook } from '@/hooks/contexts/appIcon' -import { type Dashboard } from '@/hooks/contexts/dashboard' -import { type FlowHook } from '@/hooks/contexts/flow' -import { type FoodFilter } from '@/hooks/contexts/foodFilter' -import { type Notifications } from '@/hooks/contexts/notifications' -import { type RouteParams } from '@/hooks/contexts/routing' -import { type ThemeHook } from '@/hooks/contexts/theme' +import { type AppIconHook } from '@/contexts/appIcon' +import { type Dashboard } from '@/contexts/dashboard' +import { type FlowHook } from '@/contexts/flow' +import { type FoodFilter } from '@/contexts/foodFilter' +import { type RouteParams } from '@/contexts/routing' +import { type ThemeHook } from '@/contexts/theme' import { DEFAULT_TIMETABLE_MODE, type TimetableHook, -} from '@/hooks/contexts/timetable' -import { type UserKindContextType } from '@/hooks/contexts/userKind' +} from '@/contexts/timetable' +import { type UserKindContextType } from '@/contexts/userKind' import { createContext } from 'react' export const RouteParamsContext = createContext({ @@ -29,13 +28,13 @@ export const FoodFilterContext = createContext({ initAllergenSelection: () => {}, toggleSelectedPreferences: () => {}, toggleSelectedRestaurant: () => {}, - toggleShowStatic: () => {}, + setShowStatic: () => {}, toggleFoodLanguage: () => {}, }) export const UserKindContext = createContext({ userKind: 'student', - userFaculty: 'unknown', + userFaculty: undefined, userFullName: '', toggleUserKind: () => {}, updateUserFullName: () => {}, @@ -43,15 +42,15 @@ export const UserKindContext = createContext({ export const ThemeContext = createContext({ theme: 'auto', - toggleTheme: () => {}, + setTheme: () => {}, accentColor: 'blue', - toggleAccentColor: () => {}, + setAccentColor: () => {}, }) export const AppIconContext = createContext({ appIcon: 'default', unlockedAppIcons: [], - toggleAppIcon: () => {}, + setAppIcon: () => {}, addUnlockedAppIcon: () => {}, }) @@ -68,11 +67,11 @@ export const DashboardContext = createContext({ export const FlowContext = createContext({ isOnboarded: true, - toggleOnboarded: () => {}, + setOnboarded: () => {}, isUpdated: true, - toggleUpdated: () => {}, + setUpdated: () => {}, analyticsAllowed: false, - toggleAnalytics: () => {}, + setAnalyticsAllowed: () => {}, analyticsInitialized: false, initializeAnalytics: () => {}, }) @@ -83,10 +82,3 @@ export const TimetableContext = createContext({ selectedDate: new Date(), setSelectedDate: () => {}, }) - -export const NotificationContext = createContext({ - timetableNotifications: {}, - updateTimetableNotifications: () => {}, - deleteTimetableNotifications: () => {}, - removeNotification: () => {}, -}) diff --git a/src/components/provider.tsx b/src/components/provider.tsx index 51967505..3b7540a7 100644 --- a/src/components/provider.tsx +++ b/src/components/provider.tsx @@ -1,18 +1,16 @@ +import { DEFAULT_TIMETABLE_MODE, useTimetable } from '@/contexts/timetable' import { useAppState, useOnlineManager } from '@/hooks' -import { useTimetable } from '@/hooks/contexts/timetable' import i18n from '@/localization/i18n' +import { syncStoragePersister } from '@/utils/storage' import { trackEvent } from '@aptabase/react-native' -import AsyncStorage from '@react-native-async-storage/async-storage' import { DarkTheme, DefaultTheme, ThemeProvider, } from '@react-navigation/native' -import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' import { QueryClient, focusManager } from '@tanstack/react-query' import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import { usePathname } from 'expo-router' -import 'expo-status-bar' import React, { useEffect } from 'react' import { type AppStateStatus, @@ -32,15 +30,13 @@ import { useRouteParams, useTheme, useUserKind, -} from '../hooks/contexts' -import { useNotifications } from '../hooks/contexts/notifications' +} from '../contexts' import { type AppTheme, accentColors, darkColors, lightColors } from './colors' import { AppIconContext, DashboardContext, FlowContext, FoodFilterContext, - NotificationContext, RouteParamsContext, ThemeContext, TimetableContext, @@ -66,10 +62,6 @@ export const queryClient = new QueryClient({ }, }) -const asyncStoragePersister = createAsyncStoragePersister({ - storage: AsyncStorage, -}) - /** * Provider component that wraps the entire app and provides context for theme, user kind, and food filter. * @param children - The child components to be wrapped by the Provider. @@ -90,7 +82,6 @@ export default function Provider({ const appIcon = useAppIcon() const pathname = usePathname() const timetableHook = useTimetable() - const notifications = useNotifications() useOnlineManager() useAppState(onAppStateChange) @@ -101,7 +92,8 @@ export default function Provider({ */ const getPrimary = (scheme: 'light' | 'dark'): string => { try { - const primary = accentColors[themeHook.accentColor][scheme] + const primary = + accentColors[themeHook.accentColor ?? 'blue'][scheme] return primary } catch (e) { return accentColors.blue[scheme] @@ -142,7 +134,7 @@ export default function Provider({ return } trackEvent('AccentColor', { - color: themeHook.accentColor, + color: themeHook.accentColor ?? 'blue', }) }, [themeHook.accentColor, flow.analyticsInitialized]) @@ -151,7 +143,7 @@ export default function Provider({ return } trackEvent('Theme', { - theme: themeHook.theme, + theme: themeHook.theme ?? 'auto', }) }, [themeHook.accentColor, flow.analyticsInitialized]) @@ -161,7 +153,7 @@ export default function Provider({ } if (Platform.OS === 'ios') { trackEvent('AppIcon', { - appIcon: appIcon.appIcon, + appIcon: appIcon.appIcon ?? 'default', }) } }, [appIcon.appIcon, flow.analyticsInitialized]) @@ -243,9 +235,10 @@ export default function Provider({ return } trackEvent('TimetableMode', { - timetableMode: timetableHook.timetableMode, + timetableMode: + timetableHook.timetableMode ?? DEFAULT_TIMETABLE_MODE, }) - }, [flow.analyticsAllowed, flow.analyticsInitialized]) + }, [timetableHook.timetableMode, flow.analyticsInitialized]) useEffect(() => { const subscription = Appearance.addChangeListener(() => {}) @@ -262,44 +255,39 @@ export default function Provider({ subscription.remove() } }, [themeHook.theme]) - return ( - - - - - + + + + - - - - - {children} - - - - - - - - - + + {children} + + + + + + + + diff --git a/src/hooks/contexts/appIcon.ts b/src/contexts/appIcon.ts similarity index 71% rename from src/hooks/contexts/appIcon.ts rename to src/contexts/appIcon.ts index 90f9dcf7..825bdddb 100644 --- a/src/hooks/contexts/appIcon.ts +++ b/src/contexts/appIcon.ts @@ -1,10 +1,11 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' +import { storage } from '@/utils/storage' import { useEffect, useState } from 'react' +import { useMMKVString } from 'react-native-mmkv' export interface AppIconHook { - appIcon: string + appIcon: string | undefined unlockedAppIcons: string[] - toggleAppIcon: (name: string) => void + setAppIcon: (name: string) => void addUnlockedAppIcon: (name: string) => void } @@ -14,18 +15,13 @@ export interface AppIconHook { * @returns ThemeHook object with accentColor and toggleAccentColor properties. */ export function useAppIcon(): AppIconHook { - const [appIcon, setAppIcon] = useState('default') + const [appIcon, setAppIcon] = useMMKVString('appIcon') const [unlockedAppIcons, setUnlockedAppIcons] = useState([]) useEffect(() => { const loadAsyncStorageData = async (): Promise => { try { - const icon = await AsyncStorage.getItem('appIcon') - if (icon != null) { - setAppIcon(icon) - } - const unlockedAppIcons = - await AsyncStorage.getItem('unlockedAppIcons') + const unlockedAppIcons = storage.getString('unlockedAppIcons') if (unlockedAppIcons != null) { const parsedIcons = JSON.parse(unlockedAppIcons) if ( @@ -49,15 +45,6 @@ export function useAppIcon(): AppIconHook { void loadAsyncStorageData() }, []) - /** - * Function to toggle the app icon of the app. - * @param name - The name of the new app icon. - */ - function toggleAppIcon(name: string): void { - setAppIcon(name) - void AsyncStorage.setItem('appIcon', name) - } - /** * Function to add a new unlocked theme. * @param name - The name of the new unlocked theme. @@ -65,7 +52,7 @@ export function useAppIcon(): AppIconHook { function addUnlockedAppIcon(name: string): void { const newUnlockedAppIcons = new Set([...unlockedAppIcons, name]) setUnlockedAppIcons(Array.from(newUnlockedAppIcons)) - void AsyncStorage.setItem( + storage.set( 'unlockedAppIcons', JSON.stringify(Array.from(newUnlockedAppIcons)) ) @@ -74,7 +61,7 @@ export function useAppIcon(): AppIconHook { return { appIcon, unlockedAppIcons, - toggleAppIcon, + setAppIcon, addUnlockedAppIcon, } } diff --git a/src/hooks/contexts/dashboard.ts b/src/contexts/dashboard.ts similarity index 89% rename from src/hooks/contexts/dashboard.ts rename to src/contexts/dashboard.ts index 8317509f..54989517 100644 --- a/src/hooks/contexts/dashboard.ts +++ b/src/contexts/dashboard.ts @@ -1,5 +1,5 @@ import { AllCards, type Card } from '@/components/allCards' -import AsyncStorage from '@react-native-async-storage/async-storage' +import { storage } from '@/utils/storage' import { useEffect, useState } from 'react' import { useUserKind } from './userKind' @@ -10,7 +10,7 @@ import { useUserKind } from './userKind' * @returns An object containing two arrays of Card objects, one for the cards that should be shown by default and one for the hidden cards. */ export function getDefaultDashboardOrder(userKind: string | undefined): { - shown: Card[] | null // null is used to identify the loading state to hide splash screen + shown: Card[] hidden: Card[] } { const filter = (x: Card): boolean => x.default.includes(userKind ?? 'guest') @@ -40,18 +40,14 @@ export function useDashboard(): Dashboard { >([]) const [hiddenAnnouncements, setHiddenAnnouncements] = useState([]) const { userKind } = useUserKind() - useEffect(() => { async function load(): Promise { - const [ - personalDashboard, - personalDashboardHidden, - hiddenAnnouncements, - ] = await Promise.all([ - AsyncStorage.getItem('personalDashboard'), - AsyncStorage.getItem('personalDashboardHidden'), - AsyncStorage.getItem('hiddenAnnouncements'), - ]) + const personalDashboard = storage.getString('personalDashboard') + const personalDashboardHidden = storage.getString( + 'personalDashboardHidden' + ) + const hiddenAnnouncements = storage.getString('hiddenAnnouncements') + if (hiddenAnnouncements != null) { setHiddenAnnouncements( JSON.parse(hiddenAnnouncements) as string[] @@ -86,11 +82,11 @@ export function useDashboard(): Dashboard { entries: Card[], hiddenEntries: Card[] ): void { - void AsyncStorage.setItem( + storage.set( 'personalDashboard', JSON.stringify(entries.map((x) => x.key)) ) - void AsyncStorage.setItem( + storage.set( 'personalDashboardHidden', JSON.stringify(hiddenEntries.map((x) => x.key)) ) @@ -99,7 +95,7 @@ export function useDashboard(): Dashboard { } function updateDashboardOrder(entries: Card[]): void { - void AsyncStorage.setItem( + storage.set( 'personalDashboard', JSON.stringify(entries.map((x) => x.key)) ) @@ -132,7 +128,7 @@ export function useDashboard(): Dashboard { setHiddenAnnouncements((prev) => { const newHiddenAnnouncements = [...prev] newHiddenAnnouncements.push(id) - void AsyncStorage.setItem( + storage.set( 'hiddenAnnouncements', JSON.stringify(newHiddenAnnouncements) ) diff --git a/src/contexts/flow.ts b/src/contexts/flow.ts new file mode 100644 index 00000000..a1e7b2a9 --- /dev/null +++ b/src/contexts/flow.ts @@ -0,0 +1,55 @@ +import { convertToMajorMinorPatch } from '@/utils/app-utils' +import { useState } from 'react' +import { useMMKVBoolean } from 'react-native-mmkv' + +import packageInfo from '../../package.json' + +export interface FlowHook { + isOnboarded: boolean | undefined + setOnboarded: (value: boolean) => void + + isUpdated: boolean | undefined + setUpdated: (value: boolean) => void + + analyticsAllowed: boolean | undefined + setAnalyticsAllowed: (value: boolean) => void + + analyticsInitialized: boolean + initializeAnalytics: () => void +} + +/** + * A custom React hook that provides access to the flow state. + * @returns An object containing the flow state and functions to update it. + */ +export function useFlow(): FlowHook { + const [isOnboarded, setOnboarded] = useMMKVBoolean('isOnboardedv1') + const [isUpdated, setUpdated] = useMMKVBoolean( + `isUpdated-${convertToMajorMinorPatch(packageInfo.version)}` + ) + const [analyticsAllowed, setAnalyticsAllowed] = useMMKVBoolean('analytics') + const [analyticsInitialized, setAnalyticsInitialized] = + useState(false) + + /** + * Function to initialize analytics. + * This state is not stored in MMKV, as it is only valid for the current session. + * It is used to correctly enable and trigger the events on app start in provider.tsx. + */ + function initializeAnalytics(): void { + if (analyticsAllowed === true && !analyticsInitialized) { + setAnalyticsInitialized(true) + } + } + + return { + isOnboarded, + setOnboarded, + isUpdated, + setUpdated, + analyticsAllowed, + setAnalyticsAllowed, + analyticsInitialized, + initializeAnalytics, + } +} diff --git a/src/hooks/contexts/foodFilter.ts b/src/contexts/foodFilter.ts similarity index 55% rename from src/hooks/contexts/foodFilter.ts rename to src/contexts/foodFilter.ts index 47b02dc4..6da2f32e 100644 --- a/src/hooks/contexts/foodFilter.ts +++ b/src/contexts/foodFilter.ts @@ -1,19 +1,20 @@ import { type LanguageKey } from '@/localization/i18n' -import AsyncStorage from '@react-native-async-storage/async-storage' +import { storage } from '@/utils/storage' import { useEffect, useState } from 'react' +import { useMMKVBoolean } from 'react-native-mmkv' export type FoodLanguage = LanguageKey | 'default' export interface FoodFilter { selectedRestaurants: string[] preferencesSelection: string[] allergenSelection: string[] - showStatic: boolean + showStatic: boolean | undefined foodLanguage: FoodLanguage toggleSelectedRestaurant: (name: string) => void toggleSelectedAllergens: (name: string) => void initAllergenSelection: () => void toggleSelectedPreferences: (name: string) => void - toggleShowStatic: () => void + setShowStatic: (value: boolean) => void toggleFoodLanguage: (language: string) => void } @@ -29,51 +30,32 @@ export function useFoodFilter(): FoodFilter { const [allergenSelection, setAllergenSelection] = useState([ 'not-configured', ]) - const [showStatic, setShowStatic] = useState(false) + const [showStatic, setShowStatic] = useMMKVBoolean('showStatic') const [foodLanguage, setFoodLanguage] = useState('default') useEffect(() => { - void Promise.all([ - AsyncStorage.getItem('selectedUserAllergens'), - AsyncStorage.getItem('selectedUserPreferences'), - AsyncStorage.getItem('selectedRestaurantLocations'), - AsyncStorage.getItem('showStatic'), - AsyncStorage.getItem('foodLanguage'), - ]).then( - ([ - allergensData, - preferencesData, - restaurantsData, - showStaticData, - foodLanguageData, - ]) => { - if (allergensData != null) { - setAllergenSelection(JSON.parse(allergensData) as string[]) - } else { - setAllergenSelection(['not-configured']) - } - if (preferencesData != null) { - setPreferencesSelection( - JSON.parse(preferencesData) as string[] - ) - } - if (restaurantsData != null) { - setSelectedRestaurants( - JSON.parse(restaurantsData) as string[] - ) - } - if (showStaticData != null) { - setShowStatic(JSON.parse(showStaticData) as boolean) - } - if (foodLanguageData != null) { - setFoodLanguage( - JSON.parse(foodLanguageData) as FoodLanguage - ) - } else { - setFoodLanguage('default') - } - } - ) + const allergensData = storage.getString('selectedUserAllergens') + const preferencesData = storage.getString('selectedUserPreferences') + const restaurantsData = storage.getString('selectedRestaurantLocations') + const foodLanguageData = storage.getString('foodLanguage') + + if (allergensData != null) { + setAllergenSelection(JSON.parse(allergensData) as string[]) + } else { + setAllergenSelection(['not-configured']) + } + if (preferencesData != null) { + setPreferencesSelection(JSON.parse(preferencesData) as string[]) + } + if (restaurantsData != null) { + setSelectedRestaurants(JSON.parse(restaurantsData) as string[]) + } + + if (foodLanguageData != null) { + setFoodLanguage(foodLanguageData as FoodLanguage) + } else { + setFoodLanguage('default') + } }, []) /** @@ -88,10 +70,7 @@ export function useFoodFilter(): FoodFilter { } setSelectedRestaurants(newSelection) - void AsyncStorage.setItem( - 'selectedRestaurantLocations', - JSON.stringify(newSelection) - ) + storage.set('selectedRestaurantLocations', JSON.stringify(newSelection)) } /** @@ -111,10 +90,7 @@ export function useFoodFilter(): FoodFilter { } setAllergenSelection(newSelection) - void AsyncStorage.setItem( - 'selectedUserAllergens', - JSON.stringify(newSelection) - ) + storage.set('selectedUserAllergens', JSON.stringify(newSelection)) } /** @@ -123,7 +99,7 @@ export function useFoodFilter(): FoodFilter { function initAllergenSelection(): void { setAllergenSelection([]) - void AsyncStorage.setItem('selectedUserAllergens', JSON.stringify([])) + storage.set('selectedUserAllergens', JSON.stringify([])) } /** * Enables or disables a preference. @@ -137,20 +113,7 @@ export function useFoodFilter(): FoodFilter { } setPreferencesSelection(newSelection) - void AsyncStorage.setItem( - 'selectedUserPreferences', - JSON.stringify(newSelection) - ) - } - - /** - * Enables or disables the static food. - * @param {boolean} value - */ - function toggleShowStatic(): void { - const newSelection = !showStatic - setShowStatic(newSelection) - void AsyncStorage.setItem('showStatic', JSON.stringify(newSelection)) + storage.set('selectedUserPreferences', JSON.stringify(newSelection)) } /** @@ -161,7 +124,7 @@ export function useFoodFilter(): FoodFilter { */ function toggleFoodLanguage(language: string): void { setFoodLanguage(language as FoodLanguage) - void AsyncStorage.setItem('foodLanguage', JSON.stringify(language)) + storage.set('foodLanguage', language) } return { @@ -174,7 +137,7 @@ export function useFoodFilter(): FoodFilter { toggleSelectedAllergens, initAllergenSelection, toggleSelectedPreferences, - toggleShowStatic, + setShowStatic, toggleFoodLanguage, } } diff --git a/src/hooks/contexts/index.ts b/src/contexts/index.ts similarity index 86% rename from src/hooks/contexts/index.ts rename to src/contexts/index.ts index c7414e09..96bf3e61 100644 --- a/src/hooks/contexts/index.ts +++ b/src/contexts/index.ts @@ -2,7 +2,6 @@ import { useAppIcon } from './appIcon' import { useDashboard } from './dashboard' import { useFlow } from './flow' import { useFoodFilter } from './foodFilter' -import { useNotifications } from './notifications' import { useRouteParams } from './routing' import { useTheme } from './theme' import { useTimetable } from './timetable' @@ -17,5 +16,4 @@ export { useRouteParams, useAppIcon, useTimetable, - useNotifications, } diff --git a/src/hooks/contexts/map.ts b/src/contexts/map.ts similarity index 78% rename from src/hooks/contexts/map.ts rename to src/contexts/map.ts index dd4c13ab..8fd04d2e 100644 --- a/src/hooks/contexts/map.ts +++ b/src/contexts/map.ts @@ -1,6 +1,5 @@ -import { type ClickedMapElement } from '@/types/map' +import { type ClickedMapElement, type SearchResult } from '@/types/map' import { type AvailableRoom, type FriendlyTimetableEntry } from '@/types/utils' -import { type LocationObject } from 'expo-location' import { createContext } from 'react' interface MapContextType { @@ -14,8 +13,8 @@ interface MapContextType { setNextLecture: (_: FriendlyTimetableEntry[] | null) => void currentFloor: { floor: string; manual: boolean } | null setCurrentFloor: (_: { floor: string; manual: boolean }) => void - location: LocationObject | null | 'notGranted' - setLocation: (_: LocationObject | null | 'notGranted') => void + searchHistory: SearchResult[] + updateSearchHistory: (_: SearchResult[]) => void } export const MapContext = createContext({ @@ -31,9 +30,9 @@ export const MapContext = createContext({ currentFloor: null, setCurrentFloor: (_: { floor: string; manual: boolean }) => {}, - location: null, - setLocation: (_: LocationObject | null | 'notGranted') => {}, - nextLecture: null, setNextLecture: (_: FriendlyTimetableEntry[] | null) => {}, + + searchHistory: [], + updateSearchHistory: (_: SearchResult[]) => {}, }) diff --git a/src/hooks/contexts/routing.ts b/src/contexts/routing.ts similarity index 100% rename from src/hooks/contexts/routing.ts rename to src/contexts/routing.ts diff --git a/src/contexts/theme.ts b/src/contexts/theme.ts new file mode 100644 index 00000000..c81dff06 --- /dev/null +++ b/src/contexts/theme.ts @@ -0,0 +1,25 @@ +import { useMMKVString } from 'react-native-mmkv' + +export interface ThemeHook { + accentColor: string | undefined + theme: string | undefined // ('light' | 'dark' | 'auto') as string + setAccentColor: (name: string) => void + setTheme: (theme: string) => void +} + +/** + * Custom hook that manages the theme and accent color of the app. + * Uses AsyncStorage to persist the accent color across app sessions. + * @returns ThemeHook object with accentColor and toggleAccentColor properties. + */ +export function useTheme(): ThemeHook { + const [accentColor, setAccentColor] = useMMKVString('accentColor') + const [theme, setTheme] = useMMKVString('theme') + + return { + accentColor, + setAccentColor, + theme, + setTheme, + } +} diff --git a/src/contexts/timetable.ts b/src/contexts/timetable.ts new file mode 100644 index 00000000..d8fcf2d7 --- /dev/null +++ b/src/contexts/timetable.ts @@ -0,0 +1,29 @@ +import { type CalendarMode } from '@/app/(tabs)/(timetable)/timetable' +import { useState } from 'react' +import { useMMKVString } from 'react-native-mmkv' + +export interface TimetableHook { + timetableMode: string | undefined + setTimetableMode: (mode: CalendarMode) => void + selectedDate: Date + setSelectedDate: (date: Date) => void +} + +export const DEFAULT_TIMETABLE_MODE: CalendarMode = 'list' + +/** + * Custom hook that manages the users timetable mode. + * Uses AsyncStorage to persist the mode across app sessions. + * @returns TimetableHook object with mode and setMode properties. + */ +export function useTimetable(): TimetableHook { + const [timetableMode, setTimetableMode] = useMMKVString('timetableMode') + const [selectedDate, setSelectedDate] = useState(new Date()) + + return { + timetableMode, + setTimetableMode, + selectedDate, + setSelectedDate, + } +} diff --git a/src/hooks/contexts/userKind.ts b/src/contexts/userKind.ts similarity index 55% rename from src/hooks/contexts/userKind.ts rename to src/contexts/userKind.ts index cef11989..09edeb31 100644 --- a/src/hooks/contexts/userKind.ts +++ b/src/contexts/userKind.ts @@ -1,15 +1,16 @@ -import API from '@/api/authenticated-api' +import { + extractFacultyFromPersonalData, + getPersonalData, +} from '@/utils/api-utils' +import { USER_EMPLOYEE, USER_GUEST, USER_STUDENT } from '@/utils/app-utils' import * as SecureStore from 'expo-secure-store' import { useEffect, useState } from 'react' - -export const USER_STUDENT = 'student' -export const USER_EMPLOYEE = 'employee' -export const USER_GUEST = 'guest' +import { useMMKVString } from 'react-native-mmkv' export interface UserKindContextType { userKind: 'guest' | 'student' | 'employee' | undefined userFullName: string - userFaculty: string + userFaculty: string | undefined toggleUserKind: (userKind: boolean | undefined) => void updateUserFullName: (userName: string) => void } @@ -21,55 +22,56 @@ export interface UserKindContextType { export function useUserKind(): { userKind: 'guest' | 'student' | 'employee' | undefined userFullName: string - userFaculty: string + userFaculty: string | undefined toggleUserKind: (userKind: boolean | undefined) => void updateUserFullName: (userName: string) => void } { - const [userKind, setUserKind] = useState< - 'guest' | 'student' | 'employee' | undefined - >(undefined) - const [userFaculty, setUserFaculty] = useState('') + const [userKind, setUserKind] = useMMKVString('userType') as [ + 'student' | 'employee' | 'guest' | undefined, + (value: 'student' | 'employee' | 'guest' | undefined) => void, + ] + const [userFaculty, setUserFaculty] = useMMKVString('userFaculty') const [userFullName, setUserFullName] = useState('') - // Load user kind from SecureStore on mount. - // Using SecureStore instead of AsyncStorage because it is temporary workaround for the session handler. useEffect(() => { - const loadFaculty = async (): Promise => { - return await API.getFaculty() - } - - const loadUserTypeAndName = async (): Promise<[string | null]> => { - const [userType, userFullName] = await Promise.all([ - SecureStore.getItemAsync('userType'), - SecureStore.getItemAsync('userFullName'), - ]) - - if (userType != null) { - setUserKind(userType as 'student' | 'employee' | 'guest') - } else { - setUserKind(USER_GUEST) + const loadData = async (): Promise => { + // loads the user type from the SecureStore. Can be removed in future versions. + if (userKind === undefined) { + const storedUserType = + await SecureStore.getItemAsync('userType') + if (storedUserType != null) { + await SecureStore.deleteItemAsync('userType') + setUserKind( + storedUserType as 'student' | 'employee' | 'guest' + ) + } else { + setUserKind(USER_GUEST) + } } + const userFullName = await SecureStore.getItemAsync('userFullName') if (userFullName != null) { setUserFullName(userFullName) } - - return [userType] } - const loadData = async (): Promise => { - const [userType] = await loadUserTypeAndName() + void loadData() + }, []) - if (userType === USER_STUDENT) { - const userFaculty = await loadFaculty() - if (userFaculty != null) { - setUserFaculty(userFaculty) + useEffect(() => { + const loadData = async (): Promise => { + if (userFaculty === undefined && userKind === USER_STUDENT) { + const persData = await getPersonalData() + const faculty = extractFacultyFromPersonalData(persData) + if (faculty !== null) { + setUserFaculty(faculty) + } else { + setUserFaculty(undefined) } } } - void loadData() - }, []) + }, [userKind]) /** * Function to toggle the user kind. diff --git a/src/data/changelog.json b/src/data/changelog.json index 85c830b0..046f98c9 100644 --- a/src/data/changelog.json +++ b/src/data/changelog.json @@ -87,7 +87,7 @@ }, "icon": { "ios": "calendar", - "android": "calendar" + "android": "today" } }, { @@ -161,7 +161,7 @@ }, "icon": { "ios": "calendar", - "android": "calendar" + "android": "calendar_month" } }, { @@ -189,7 +189,7 @@ }, "icon": { "ios": "lock.shield", - "android": "chart_pie" + "android": "query_stats" } } ], diff --git a/src/data/licenses.json b/src/data/licenses.json index d537f3d5..f2202042 100644 --- a/src/data/licenses.json +++ b/src/data/licenses.json @@ -1,17 +1,11 @@ { - "@alessiocancian/react-native-actionsheet@3.2.0": { - "licenses": "MIT", - "repository": "https://github.com/alessiocancian/react-native-actionsheet", - "licenseUrl": "https://github.com/alessiocancian/react-native-actionsheet/raw/master/LICENSE", - "parents": "neuland" - }, - "@aptabase/react-native@0.3.9": { + "@aptabase/react-native@0.3.10": { "licenses": "MIT", "repository": "https://github.com/aptabase/aptabase-react-native", "licenseUrl": "https://github.com/aptabase/aptabase-react-native/raw/master/LICENSE", "parents": "neuland" }, - "@babel/runtime@7.24.7": { + "@babel/runtime@7.25.0": { "licenses": "MIT", "repository": "https://github.com/babel/babel", "licenseUrl": "https://github.com/babel/babel/raw/master/LICENSE", @@ -23,7 +17,7 @@ "licenseUrl": "https://github.com/expo/vector-icons/raw/master/LICENSE", "parents": "neuland" }, - "@gorhom/bottom-sheet@4.6.3": { + "@gorhom/bottom-sheet@5.0.0-alpha.11": { "licenses": "MIT", "repository": "https://github.com/gorhom/react-native-bottom-sheet", "licenseUrl": "https://github.com/gorhom/react-native-bottom-sheet/raw/master/LICENSE", @@ -35,16 +29,16 @@ "licenseUrl": "https://github.com/Kichiyaki/react-native-barcode-generator/raw/master/LICENSE", "parents": "neuland" }, - "@maplibre/maplibre-react-native@10.0.0-alpha.5": { + "@maplibre/maplibre-react-native@10.0.0-alpha.10": { "licenses": "MIT", "repository": "https://github.com/maplibre/maplibre-react-native", "licenseUrl": "https://github.com/maplibre/maplibre-react-native/raw/master/LICENSE.md", "parents": "neuland" }, - "@react-native-async-storage/async-storage@1.23.1": { - "licenses": "MIT", - "repository": "https://github.com/react-native-async-storage/async-storage", - "licenseUrl": "https://github.com/react-native-async-storage/async-storage/raw/master/LICENSE", + "@material/material-color-utilities@0.3.0": { + "licenses": "Apache-2.0", + "repository": "https://github.com/material-foundation/material-color-utilities", + "licenseUrl": "https://github.com/material-foundation/material-color-utilities/raw/master/LICENSE", "parents": "neuland" }, "@react-native-community/datetimepicker@8.0.1": { @@ -59,31 +53,25 @@ "licenseUrl": "https://github.com/react-native-netinfo/react-native-netinfo/raw/master/LICENSE", "parents": "neuland" }, - "@sentry/react-native@5.22.3": { - "licenses": "MIT", - "repository": "https://github.com/getsentry/sentry-react-native", - "licenseUrl": "https://github.com/getsentry/sentry-react-native/raw/master/LICENSE.md", - "parents": "neuland" - }, "@shopify/flash-list@1.6.4": { "licenses": "MIT", "repository": "https://github.com/Shopify/flash-list", "licenseUrl": "https://github.com/Shopify/flash-list/raw/master/LICENSE.md", "parents": "neuland" }, - "@tanstack/query-async-storage-persister@5.45.0": { + "@tanstack/query-sync-storage-persister@5.51.17": { "licenses": "MIT", "repository": "https://github.com/TanStack/query", "licenseUrl": "https://github.com/TanStack/query/raw/master/LICENSE", "parents": "neuland" }, - "@tanstack/react-query-persist-client@5.45.1": { + "@tanstack/react-query-persist-client@5.51.18": { "licenses": "MIT", "repository": "https://github.com/TanStack/query", "licenseUrl": "https://github.com/TanStack/query/raw/master/LICENSE", "parents": "neuland" }, - "@tanstack/react-query@5.45.1": { + "@tanstack/react-query@5.51.18": { "licenses": "MIT", "repository": "https://github.com/TanStack/query", "licenseUrl": "https://github.com/TanStack/query/raw/master/LICENSE", @@ -95,37 +83,37 @@ "licenseUrl": "https://github.com/Qix-/color/raw/master/LICENSE", "parents": "neuland" }, - "expo-blur@13.0.2": { + "expo-application@5.9.1": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-brightness@12.0.1": { + "expo-blur@13.0.2": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-build-properties@0.12.3": { + "expo-brightness@12.0.1": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-clipboard@6.0.3": { + "expo-build-properties@0.12.4": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-constants@16.0.2": { + "expo-clipboard@6.0.3": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-dev-client@4.0.19": { + "expo-constants@16.0.2": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", @@ -149,12 +137,6 @@ "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-linking@6.3.1": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, "expo-local-authentication@14.0.1": { "licenses": "MIT", "repository": "https://github.com/expo/expo", @@ -167,25 +149,13 @@ "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-location@17.0.1": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, - "expo-navigation-bar@3.0.6": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, - "expo-notifications@0.28.9": { + "expo-navigation-bar@3.0.7": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-router@3.5.17": { + "expo-router@3.5.20": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", @@ -197,37 +167,19 @@ "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-sharing@12.0.1": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, "expo-splash-screen@0.27.5": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo-status-bar@1.12.1": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, - "expo-system-ui@3.0.6": { - "licenses": "MIT", - "repository": "https://github.com/expo/expo", - "licenseUrl": "https://github.com/expo/expo", - "parents": "neuland" - }, - "expo-task-manager@11.8.2": { + "expo-system-ui@3.0.7": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", "parents": "neuland" }, - "expo@51.0.16": { + "expo@51.0.24": { "licenses": "MIT", "repository": "https://github.com/expo/expo", "licenseUrl": "https://github.com/expo/expo", @@ -245,13 +197,13 @@ "licenseUrl": "https://github.com/jasonkuhrt/graphql-request/raw/master/LICENSE", "parents": "neuland" }, - "graphql-tag@2.12.6": { + "graphql@16.9.0": { "licenses": "MIT", - "repository": "https://github.com/apollographql/graphql-tag", - "licenseUrl": "https://github.com/apollographql/graphql-tag/raw/master/LICENSE", + "repository": "https://github.com/graphql/graphql-js", + "licenseUrl": "https://github.com/graphql/graphql-js/raw/master/LICENSE", "parents": "neuland" }, - "i18next@23.11.5": { + "i18next@23.12.2": { "licenses": "MIT", "repository": "https://github.com/i18next/i18next", "licenseUrl": "https://github.com/i18next/i18next/raw/master/LICENSE", @@ -275,7 +227,7 @@ "licenseUrl": "https://github.com/facebook/react/raw/master/LICENSE", "parents": "neuland" }, - "react-i18next@14.1.2": { + "react-i18next@15.0.0": { "licenses": "MIT", "repository": "https://github.com/i18next/react-i18next", "licenseUrl": "https://github.com/i18next/react-i18next/raw/master/LICENSE", @@ -305,12 +257,6 @@ "licenseUrl": "https://github.com/mochixuan/react-native-drag-sort", "parents": "neuland" }, - "react-native-dropdown-select-list@2.0.5": { - "licenses": "MIT", - "repository": "https://github.com/danish1658/react-native-dropdown-select-list", - "licenseUrl": "https://github.com/danish1658/react-native-dropdown-select-list/raw/master/LICENSE", - "parents": "neuland" - }, "react-native-dynamic-app-icon@1.1.0": { "licenses": "MIT", "repository": "https://github.com/idearockers/react-native-dynamic-app-icon", @@ -323,16 +269,10 @@ "licenseUrl": "https://github.com/software-mansion/react-native-gesture-handler/raw/master/LICENSE", "parents": "neuland" }, - "react-native-linear-gradient@2.8.3": { - "licenses": "MIT", - "repository": "https://github.com/react-native-linear-gradient/react-native-linear-gradient", - "licenseUrl": "https://github.com/react-native-linear-gradient/react-native-linear-gradient/raw/master/LICENSE", - "parents": "neuland" - }, - "react-native-onboarding-swiper@1.2.0": { - "licenses": "MIT", - "repository": "https://github.com/jfilter/react-native-onboarding-swiper", - "licenseUrl": "https://github.com/jfilter/react-native-onboarding-swiper/raw/master/LICENSE", + "react-native-mmkv@2.12.2": { + "licenses": "(MIT AND BSD-3-Clause)", + "repository": "https://github.com/mrousavy/react-native-mmkv", + "licenseUrl": "https://github.com/mrousavy/react-native-mmkv/raw/master/LICENSE", "parents": "neuland" }, "react-native-pager-view@6.3.0": { @@ -341,6 +281,12 @@ "licenseUrl": "https://github.com/callstack/react-native-pager-view/raw/master/LICENSE", "parents": "neuland" }, + "react-native-paper@5.12.3": { + "licenses": "MIT", + "repository": "https://github.com/callstack/react-native-paper", + "licenseUrl": "https://github.com/callstack/react-native-paper/raw/master/LICENSE.md", + "parents": "neuland" + }, "react-native-reanimated@3.10.1": { "licenses": "MIT", "repository": "https://github.com/software-mansion/react-native-reanimated", @@ -353,7 +299,7 @@ "licenseUrl": "https://github.com/magicismight/react-native-root-toast/raw/master/LICENSE.txt", "parents": "neuland" }, - "react-native-safe-area-context@4.10.1": { + "react-native-safe-area-context@4.10.5": { "licenses": "MIT", "repository": "https://github.com/th3rdwave/react-native-safe-area-context", "licenseUrl": "https://github.com/th3rdwave/react-native-safe-area-context/raw/master/LICENSE", @@ -371,10 +317,10 @@ "licenseUrl": "https://github.com/AdelRedaa97/react-native-select-dropdown/raw/master/LICENSE", "parents": "neuland" }, - "react-native-shimmer-placeholder@2.0.9": { + "react-native-shimmer@0.6.0": { "licenses": "MIT", - "repository": "https://github.com/tomzaku/react-native-shimmer-placeholder", - "licenseUrl": "https://github.com/tomzaku/react-native-shimmer-placeholder/raw/master/LICENSE", + "repository": "https://github.com/oblador/react-native-shimmer", + "licenseUrl": "https://github.com/oblador/react-native-shimmer/raw/master/LICENSE", "parents": "neuland" }, "react-native-svg@15.2.0": { @@ -383,12 +329,6 @@ "licenseUrl": "https://github.com/react-native-community/react-native-svg/raw/master/LICENSE", "parents": "neuland" }, - "react-native-vector-icons@10.1.0": { - "licenses": "MIT", - "repository": "https://github.com/oblador/react-native-vector-icons", - "licenseUrl": "https://github.com/oblador/react-native-vector-icons/raw/master/LICENSE", - "parents": "neuland" - }, "react-native-view-shot@3.8.0": { "licenses": "MIT", "repository": "https://github.com/gre/react-native-view-shot", @@ -407,7 +347,7 @@ "licenseUrl": "https://github.com/hoangnm/react-native-week-view/raw/master/LICENSE", "parents": "neuland" }, - "react-native@0.74.2": { + "react-native@0.74.3": { "licenses": "MIT", "repository": "https://github.com/facebook/react-native", "licenseUrl": "https://github.com/facebook/react-native/raw/master/LICENSE", @@ -431,10 +371,16 @@ "licenseUrl": "https://github.com/apostrophecms/sanitize-html/raw/master/LICENSE", "parents": "neuland" }, - "sweet-sfsymbols@0.5.0": { + "sweet-sfsymbols@0.7.0": { "licenses": "MIT", "repository": "https://github.com/andrew-levy/sweet-sfsymbols", "licenseUrl": "https://github.com/andrew-levy/sweet-sfsymbols", "parents": "neuland" + }, + "swiftui-react-native@6.3.0": { + "licenses": "MIT", + "repository": "https://github.com/andrew-levy/swiftui-react-native", + "licenseUrl": "https://github.com/andrew-levy/swiftui-react-native/raw/master/LICENSE", + "parents": "neuland" } } diff --git a/src/hooks/contexts/flow.ts b/src/hooks/contexts/flow.ts deleted file mode 100644 index d5f1c883..00000000 --- a/src/hooks/contexts/flow.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { convertToMajorMinorPatch } from '@/utils/app-utils' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { useEffect, useState } from 'react' - -import packageInfo from '../../../package.json' - -export interface FlowHook { - isOnboarded: boolean | null - toggleOnboarded: () => void - - isUpdated: boolean | null - toggleUpdated: () => void - - analyticsAllowed: boolean | null - toggleAnalytics: () => void - - analyticsInitialized: boolean - initializeAnalytics: () => void -} - -/** - * A custom React hook that provides access to the flow state. - * @returns An object containing the `isOnboarded`, `toggleOnboarded`, `isUpdated`, and `toggleUpdated` properties. - */ -export function useFlow(): FlowHook { - const [isOnboarded, setOnboarded] = useState(null) - const [isUpdated, setUpdated] = useState(null) - const [analyticsAllowed, setAnalyticsAllowed] = useState(false) - const [analyticsInitialized, setAnalyticsInitialized] = - useState(false) - useEffect(() => { - const loadAsyncStorageData = async (): Promise => { - try { - const [onboardedKey, updatedKey, analyticsKey] = - await Promise.all([ - AsyncStorage.getItem('isOnboarded'), - AsyncStorage.getItem( - `isUpdated-${convertToMajorMinorPatch( - packageInfo.version - )}` - ), - AsyncStorage.getItem('analytics'), - ]) - - if (onboardedKey === 'true') { - setOnboarded(true) - } else if (onboardedKey === null) { - setOnboarded(false) - } - - if (updatedKey === 'true') { - setUpdated(true) - } else if (updatedKey === null) { - setUpdated(false) - } - - if ( - analyticsKey === 'true' || - (analyticsKey === null && onboardedKey === 'true') - ) { - setAnalyticsAllowed(true) - } else if (analyticsKey === 'false') { - setAnalyticsAllowed(false) - } - } catch (error) { - console.error( - 'Error while retrieving flow data from AsyncStorage:', - error - ) - } - } - void loadAsyncStorageData() - }, []) - - /** - * Function to toggle the flow state of the app to show the onboarding screen. - */ - function toggleOnboarded(): void { - setOnboarded(true) - void AsyncStorage.setItem('isOnboarded', 'true') - } - - /** - * Function to toggle the flow state of the app to show the update screen. - */ - function toggleUpdated(): void { - setUpdated(true) - void AsyncStorage.setItem( - `isUpdated-${convertToMajorMinorPatch(packageInfo.version)}`, - 'true' - ) - } - - /** - * Function to toggle the flow state of the app to disable analytics. - */ - function toggleAnalytics(): void { - if (analyticsAllowed) { - setAnalyticsAllowed(false) - void AsyncStorage.setItem('analytics', 'false') - } else { - setAnalyticsAllowed(true) - void AsyncStorage.setItem('analytics', 'true') - } - } - - /** - * Function to initialize analytics. - */ - function initializeAnalytics(): void { - if (analyticsAllowed && !analyticsInitialized) { - void AsyncStorage.setItem('analytics', 'true') - setAnalyticsInitialized(true) - } - } - - return { - isOnboarded, - toggleOnboarded, - isUpdated, - toggleUpdated, - analyticsAllowed, - toggleAnalytics, - analyticsInitialized, - initializeAnalytics, - } -} diff --git a/src/hooks/contexts/notifications.ts b/src/hooks/contexts/notifications.ts deleted file mode 100644 index 921c55d6..00000000 --- a/src/hooks/contexts/notifications.ts +++ /dev/null @@ -1,153 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { cancelScheduledNotificationAsync } from 'expo-notifications' -import { useEffect, useState } from 'react' - -export interface TimetableEntry { - mins: number - language: 'de' | 'en' - elements: LectureData[] -} - -export interface LectureData { - startDateTime: Date - room: string - id: string -} - -export interface Notifications { - timetableNotifications: Record - updateTimetableNotifications: ( - name: string, - elements: LectureData[], - mins: number, - language: 'de' | 'en' - ) => void - deleteTimetableNotifications: (name: string) => void - removeNotification: (id: string, name: string) => void -} - -/** - * Custom hook that manages the route parameters of the app. - * @returns RouteParamsHook object with routeParams and updateRouteParams properties. - */ -export function useNotifications(): Notifications { - const [timetable, setTimetable] = useState>( - {} - ) - - /** - * Updates the timetable object and saves it to AsyncStorage. - * @param name Name of the timetable entry - * @param elements Array of lecture data - * @param mins Minutes before the lecture to send the notification - * @param language Language of the timetable entry - * @returns void - */ - function updateTimetable( - name: string, - elements: LectureData[], - mins: number, - language: 'de' | 'en' - ): void { - const timetableObject = { ...timetable } - - // updates or creates timetable entry depending on whether it already exists - if ( - timetableObject[name] !== undefined && - timetableObject[name].language === language && - timetableObject[name].mins === mins - ) { - timetableObject[name].elements = [ - ...timetableObject[name].elements, - ...elements, - ] - } else { - timetableObject[name] = { mins, language, elements } - } - - setTimetable(timetableObject) - void AsyncStorage.setItem( - 'timetableNotifications', - JSON.stringify(timetableObject) - ) - } - - /** - * Deletes a timetable entry from the timetable object and saves it to AsyncStorage. - * @param name Name of the timetable entry - * @returns void - */ - function deleteTimetableNotifications(name: string): void { - const timetableObject = { ...timetable } - if (timetableObject[name] !== undefined) { - const cancelPromises = timetableObject[name].elements.map( - async (element): Promise => { - await cancelScheduledNotificationAsync(element.id) - } - ) - void Promise.all(cancelPromises) - - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete timetableObject[name] - setTimetable(timetableObject) - void AsyncStorage.setItem( - 'timetableNotifications', - JSON.stringify(timetableObject) - ) - } - } - - /** - * Removes a notification from the timetable object and cancels it. - * @param id Id of the notification - * @param name Name of the timetable entry - * @returns void - */ - function removeNotification(id: string, name: string): void { - void cancelScheduledNotificationAsync(id) - const timetableObject = { ...timetable } - const timetableEntry = timetableObject[name] - if (timetableEntry !== undefined) { - const newElements = timetableEntry.elements.filter( - (element) => element.id !== id - ) - timetableObject[name] = { ...timetableEntry, elements: newElements } - setTimetable(timetableObject) - void AsyncStorage.setItem( - 'timetableNotifications', - JSON.stringify(timetableObject) - ) - } - } - - useEffect(() => { - const loadAsyncStorageData = async (): Promise => { - try { - const data = await AsyncStorage.getItem( - 'timetableNotifications' - ) - if (data === null) { - return - } - const parsedData = JSON.parse(data) - if (typeof parsedData !== 'object' || parsedData === null) { - throw new Error('Data is not an object') - } - setTimetable(parsedData as Record) - } catch (error) { - console.error( - 'Error while retrieving data from AsyncStorage:', - error - ) - } - } - void loadAsyncStorageData() - }, []) - - return { - timetableNotifications: timetable, - updateTimetableNotifications: updateTimetable, - deleteTimetableNotifications, - removeNotification, - } -} diff --git a/src/hooks/contexts/theme.ts b/src/hooks/contexts/theme.ts deleted file mode 100644 index 06de040d..00000000 --- a/src/hooks/contexts/theme.ts +++ /dev/null @@ -1,73 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { useEffect, useState } from 'react' - -export interface ThemeHook { - accentColor: string - theme: 'light' | 'dark' | 'auto' - toggleAccentColor: (name: string) => void - toggleTheme: (theme: 'light' | 'dark' | 'auto') => void -} - -/** - * Custom hook that manages the theme and accent color of the app. - * Uses AsyncStorage to persist the accent color across app sessions. - * @returns ThemeHook object with accentColor and toggleAccentColor properties. - */ -export function useTheme(): ThemeHook { - const [accentColor, setAccentColor] = useState('blue') - const [theme, setTheme] = useState<'light' | 'dark' | 'auto'>('auto') - - useEffect(() => { - const loadAsyncStorageData = async (): Promise => { - try { - const keys = ['accentColor', 'theme'] - const [accentColorData, themeData] = await Promise.all( - keys.map(async (key) => await AsyncStorage.getItem(key)) - ) - - if (accentColorData != null) { - setAccentColor(accentColorData) - } else { - setAccentColor('blue') - } - - if (themeData != null) { - setTheme(themeData as 'light' | 'dark' | 'auto') - } else { - setTheme('auto') - } - } catch (error) { - console.error( - 'Error while retrieving theme data from AsyncStorage:', - error - ) - } - } - void loadAsyncStorageData() - }, []) - - /** - * Function to toggle the accent color of the app. - * @param name - The name of the new accent color. - */ - function toggleAccentColor(name: string): void { - setAccentColor(name) - void AsyncStorage.setItem('accentColor', name) - } - - /** - * Function to toggle the theme of the app. - * @param theme - The new theme to be set. - */ - function toggleTheme(theme: 'light' | 'dark' | 'auto'): void { - setTheme(theme) - void AsyncStorage.setItem('theme', theme) - } - - return { - accentColor, - toggleAccentColor, - theme, - toggleTheme, - } -} diff --git a/src/hooks/contexts/timetable.ts b/src/hooks/contexts/timetable.ts deleted file mode 100644 index 99ccc24f..00000000 --- a/src/hooks/contexts/timetable.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type CalendarMode } from '@/app/(tabs)/(timetable)/timetable' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { useEffect, useState } from 'react' - -export interface TimetableHook { - timetableMode: CalendarMode - setTimetableMode: (mode: CalendarMode) => void - selectedDate: Date - setSelectedDate: (date: Date) => void -} - -export const DEFAULT_TIMETABLE_MODE: CalendarMode = 'list' - -/** - * Custom hook that manages the users timetable mode. - * Uses AsyncStorage to persist the mode across app sessions. - * @returns TimetableHook object with mode and setMode properties. - */ -export function useTimetable(): TimetableHook { - const [timetableMode, setMode] = useState( - DEFAULT_TIMETABLE_MODE - ) - const [selectedDate, setSelectedDate] = useState(new Date()) - - useEffect(() => { - const loadAsyncStorageData = async (): Promise => { - try { - const data = (await AsyncStorage.getItem( - 'timetableMode' - )) as CalendarMode - if (data != null) { - setTimetableMode(data) - } else { - setTimetableMode(DEFAULT_TIMETABLE_MODE) - } - } catch (error) { - console.error( - 'Error while retrieving timetable data from AsyncStorage:', - error - ) - } - } - void loadAsyncStorageData() - }, []) - - function setTimetableMode(mode: CalendarMode): void { - setMode(mode) - void AsyncStorage.setItem('timetableMode', mode) - } - - return { - timetableMode, - setTimetableMode, - selectedDate, - setSelectedDate, - } -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f3b862cf..086d05da 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,5 @@ import { useAppState } from './useAppState' import { useInterval } from './useInterval' -import { useNotification } from './useNotification' import { useOnlineManager } from './useOnlineManager' import { useRefreshByUser } from './useRefreshByUser' import { useRefreshOnFocus } from './useRefreshOnFocus' @@ -8,7 +7,6 @@ import { useRefreshOnFocus } from './useRefreshOnFocus' export { useAppState, useInterval, - useNotification, useOnlineManager, useRefreshByUser, useRefreshOnFocus, diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts deleted file mode 100644 index 65c751d7..00000000 --- a/src/hooks/useNotification.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as Device from 'expo-device' -import * as Notifications from 'expo-notifications' -import { - type NotificationContentInput, - type NotificationTriggerInput, -} from 'expo-notifications' -import { t } from 'i18next' - -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), -}) - -export interface NotificationHook { - getScheduled: () => Promise - hasPermission: () => Promise - askForPermission: () => Promise - schedule: (options: { - content?: NotificationContentInput - trigger: NotificationTriggerInput - }) => Promise - cancelAll: () => Promise -} - -export const useNotification = (): NotificationHook => { - const getScheduled = async (): Promise => { - return await Notifications.getAllScheduledNotificationsAsync() - } - - const hasPermission = async (): Promise => { - if (Device.isDevice) { - const { status } = await Notifications.getPermissionsAsync() - return status === 'granted' - } else { - alert('Must use physical device for Push Notifications') - } - - return false - } - - const askForPermission = async (): Promise => { - if (Device.isDevice) { - const { status: existingStatus } = - await Notifications.getPermissionsAsync() - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync({ - ios: { - allowAlert: true, - allowBadge: true, - allowSound: true, - allowAnnouncements: true, - }, - }) - return status === 'granted' - } - } - return false - } - - const schedule = async (options: { - content?: NotificationContentInput - trigger: NotificationTriggerInput - }): Promise => { - await Notifications.scheduleNotificationAsync({ - content: { - title: t('notification_reminder_title'), - body: t('notification_reminder_body'), - }, - ...options, - }) - } - - const cancelAll = async (): Promise => { - await Notifications.cancelAllScheduledNotificationsAsync() - } - - return { - getScheduled, - hasPermission, - askForPermission, - schedule, - cancelAll, - } -} diff --git a/src/localization/de/accessibility.json b/src/localization/de/accessibility.json new file mode 100644 index 00000000..bdc38787 --- /dev/null +++ b/src/localization/de/accessibility.json @@ -0,0 +1,9 @@ +{ + "button": { + "foodPreferences": "Essenspräferenzen", + "timetableMode": "Stundenplanansicht wechseln", + "timetableBack": "Zu heute springen", + "settingsLogo": "Neuland Logo", + "libraryBarcode": "Bibliotheksnummer als Barcode" + } +} diff --git a/src/localization/de/api.json b/src/localization/de/api.json new file mode 100644 index 00000000..49b3b96d --- /dev/null +++ b/src/localization/de/api.json @@ -0,0 +1,30 @@ +{ + "lecturerFunctions": { + "Laboringenieur(in)": "Laboringenieur(in)", + "Lehrbeauftragte(r)": "Lehrbeauftragte(r)", + "Lehrkraft für besondere Aufgaben": "Lehrkraft für besondere Aufgaben", + "Professor(in)": "Professor(in)", + "Wiss. Mitarbeiter(in) mit Deputat": "Wiss. Mitarbeiter(in) mit Deputat", + "Wissenschaftl. Mitarbeiter(in)": "Wissenschaftl. Mitarbeiter(in)", + "unbestimmt": "unspecified" + }, + "lecturerOrganizations": { + "Fakultät Business School": "Fakultät Business School", + "Fakultät Elektro- und Informationstechnik": "Fakultät Elektro- und Informationstechnik", + "Fakultät Informatik": "Fakultät Informatik", + "Fakultät Maschinenbau": "Fakultät Maschinenbau", + "Fakultät Wirtschaftsingenieurwesen": "Fakultät Wirtschaftsingenieurwesen", + "Institut für Akademische Weiterbildung": "Institut für Akademische Weiterbildung", + "Nachhaltige Infrastruktur": "Nachhaltige Infrastruktur", + "Sprachenzentrum": "Sprachenzentrum", + "Verwaltung": "Verwaltung", + "Zentrum für Angewandte Forschung": "Zentrum für Angewandte Forschung" + }, + "roomTypes": { + "PC-Pool": "PC-Pool", + "Seminarraum": "Seminarraum", + "Labor": "Labor", + "Kleiner Hörsaal": "Kleiner Hörsaal", + "Großer Hörsaal": "Großer Hörsaal" + } +} diff --git a/src/localization/de/api.ts b/src/localization/de/api.ts deleted file mode 100644 index 150b23c0..00000000 --- a/src/localization/de/api.ts +++ /dev/null @@ -1,11 +0,0 @@ -export default { - lecturerFunctions: { - // no translation needed -> using the default value from the API - }, - lecturerOrganizations: { - // no translation needed -> using the default value from the API - }, - roomTypes: { - // no translation needed -> using the default value from the API - }, -} diff --git a/src/localization/de/common.json b/src/localization/de/common.json new file mode 100644 index 00000000..964a7124 --- /dev/null +++ b/src/localization/de/common.json @@ -0,0 +1,230 @@ +{ + "toast": { + "clipboard": "in Zwischenablage kopiert", + "paused": "Keine Internetverbindung", + "roomNotFound": "Raum nicht gefunden", + "mapOverlay": "Fehler beim Laden des Overlays", + "dashboard": "Drag & Drop zum Sortieren" + }, + "error": { + "title": "Ein Fehler ist aufgetreten", + "description": "Beim Laden der Daten ist ein Fehler aufgetreten.", + "refreshPull": "Ein Fehler ist beim Laden der Daten aufgetreten.\nZiehe zum Aktualisieren nach unten.", + "button": "Erneut versuchen", + "noSession": "Nicht angemeldet.", + "pull": "Ziehe zum Aktualisieren nach unten", + "network": { + "title": "Keine Internetverbindung", + "description": "Bitte überprüfe deine Internetverbindung." + }, + "guest": { + "title": "Anmeldung erforderlich", + "description": "Um diese Funktion zu nutzen, musst du dich mit deinem THI Account anmelden.", + "button": "Anmelden" + }, + "permission": { + "title": "Funktion nicht verfügbar", + "description": "Diese Funktion ist für deine Benutzergruppe nicht verfügbar." + }, + "map": { + "mapLoadError": "Fehler beim Laden der Karte", + "mapOverlay": "Fehler beim Laden des Overlays" + }, + "noMeals": "Keine Gerichte verfügbar", + "crash": { + "title": "Interner Fehler", + "description": "Es ist ein unerwarteter interner Fehler aufgetreten.", + "steps": "Prüfe die Systemstatusseite und sende uns Feedback, um uns bei der Fehlerbehebung zu unterstützen.", + "reload": "App neu laden", + "feedback": "Feedback senden", + "status": "Systemstatus prüfen" + } + }, + "dates": { + "until": "bis", + "notYet": "Termin noch nicht bekannt", + "ends": "endet", + "today": "Heute", + "tomorrow": "Morgen", + "thisWeek": "Diese Woche", + "nextWeek": "Nächste Woche" + }, + "misc": { + "share": "Teilen", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "delete": "Löschen", + "disable": "Deaktivieren", + "more": "mehr", + "unknown": "Unbekannt" + }, + "pages": { + "calendar": { + "exams": { + "title": "Prüfungen", + "error": "Kein Studierender", + "errorSubtitle": "Melde dich an, um deine Prüfungen zu sehen.", + "noExams": { + "title": "Keine Prüfungen gefunden", + "subtitle": "Nach der Anmeldung zu Prüfungen werden sie hier angezeigt." + } + }, + "calendar": { + "link": "https://www.thi.de/studium/pruefung/semestertermine/", + "noData": { + "title": "Keine Daten gefunden", + "subtitle": "Bitte versuche es später erneut." + } + }, + "footer": { + "part1": "Alle Informationen ohne Gewähr. Verbindliche Informationen sind direkt auf der ", + "part2": "Universitätswebsite", + "part3": " verfügbar." + } + }, + "lecturers": { + "results": "Suchergebnisse", + "personal": "Persönliche", + "faculty": "Fakultät", + "professors": "Professoren", + "error": { + "title": "Keine Dozenten gefunden", + "subtitle": "Konfiguriere deinen Stundenplan, um die persönlichen Dozenten anzuzeigen." + } + }, + "lecturer": { + "details": { + "title": "Titel", + "organization": "Organisation", + "function": "Funktion" + }, + "contact": { + "room": "Raum", + "title": "Kontakt", + "phone": "Telefon", + "office": "Sprechstunde", + "exam": "Einsichtnahme" + } + }, + "exam": { + "details": { + "date": "Datum", + "room": "Raum", + "seat": "Platz", + "tools": "Hilfsmittel" + }, + "about": { + "title": "Über", + "type": "Art", + "examiner": "Prüfer:in", + "registration": "Angemeldet", + "notes": "Notizen" + }, + "footer": "Alle Angaben ohne Gewähr. Verbindliche Informationen sind nur direkt von der THI erhältlich." + }, + "event": { + "date": "Datum", + "location": "Ort", + "organizer": "Veranstalter", + "description": "Beschreibung", + "begin": "Beginn", + "end": "Ende", + "shareMessage": "Schau dir dieses Event an: {{title}} von {{organizer}} um {{date}}\nhttps://neuland.app/events" + }, + "map": { + "search": { + "hint": "Campus durchsuchen...", + "placeholder": "Suche nach Räumen, Gebäuden oder Einrichtungen", + "recent": "Kürzlich gesucht", + "clear": "Verlauf löschen", + "noResults": "Keine Suchergebnisse", + "results": "Suchergebnisse", + "fuzzy": "Vorschläge" + }, + "easterEgg": { + "title": "Easter Egg", + "message": "Du hast das exklusive retro App-Icon \"neuland.app\" freigeschaltet!", + "confirm": "Nice!" + }, + "noAvailableRooms": "Keine freien Räume verfügbar", + "details": { + "room": { + "details": "Raumdetails", + "title": "Raum", + "capacity": "Kapazität", + "availability": "Verfügbarkeit", + "timeLeft": "Verbleibende Zeit", + "building": "Gebäude", + "timeSpan": "Zeitspanne", + "floor": "Etage", + "type": "Typ", + "available": "Verfügbar", + "notAvailable": "Nicht verfügbar", + "availableRooms": "Verfügbare Räume", + "nextLecture": "Nächste Vorlesung", + "availableRoomsTitle": "Verfügbare Räume", + "history": "Zuletzt gesucht", + "signIn": "Melden dich an, um verfügbare Räume anzuzeigen", + "equipment": "Ausstattung" + }, + "building": { + "free": "Freie Räume", + "total": "Räume insgesamt", + "floors": "Etagen" + }, + "osm": "Karten Daten von OpenStreetMap" + } + }, + "rooms": { + "options": { + "title": "Such Optionen", + "building": "Gebäude", + "date": "Datum", + "time": "Zeit", + "duration": "Dauer", + "seats": "Plätze" + }, + "modified": { + "title": "Filter angepasst", + "description": "Die THI ist derzeit geschlossen. Daher wurden die Filter auf die nächste Öffnungszeit angepasst." + }, + "results": "Verfügbare Räume", + "noRooms": { + "title": "Keine Räume gefunden", + "subtitle": "Versuche die Filteroptionen zu ändern." + } + }, + "library": { + "reservations": { + "title": "Reservierungen", + "seat": "Platz", + "id": "Reservierungsnummer", + "alert": { + "title": "Reservierung löschen", + "message": "Möchtest du diese Reservierung wirklich löschen?" + } + }, + "available": { + "title": "Verfügbare Plätze", + "reserve": "Reservieren", + "seatsAvailable": "{{available}} / {{total}} verfügbar", + "ratelimit": "Du kannst keine weiteren Plätze reservieren.", + "noSeats": "Keine weiteren Plätze verfügbar.", + "footer": "Bis zu 5 aktive Reservierungen innerhalb von sieben Tagen erlaubt. Stornierung ist vor Beginn der Reservierung möglich.", + "book": "Platz buchen", + "room": "Raum" + }, + "code": { + "number": "Nummer", + "footer": "Verwende diesen Barcode, um dich an den Bibliotheksterminals anzumelden und Bücher auszuleihen." + } + } + }, + "notification": { + "permission": { + "title": "Benachrichtigungen", + "description": "Öffne die System-Einstellungen, um Benachrichtigungen zu aktivieren.", + "button": "Einstellungen" + } + } +} diff --git a/src/localization/de/common.ts b/src/localization/de/common.ts deleted file mode 100644 index 29600eeb..00000000 --- a/src/localization/de/common.ts +++ /dev/null @@ -1,225 +0,0 @@ -export default { - toast: { - clipboard: 'in Zwischenablage kopiert', - paused: 'Keine Internetverbindung', - roomNotFound: 'Raum nicht gefunden', - mapOverlay: 'Fehler beim Laden des Overlays', - dashboard: 'Drag & Drop zum Sortieren', - }, - error: { - title: 'Ein Fehler ist aufgetreten', - description: 'Beim Laden der Daten ist ein Fehler aufgetreten.', - refreshPull: - 'Ein Fehler ist beim Laden der Daten aufgetreten.\nZiehe zum Aktualisieren nach unten.', - button: 'Erneut versuchen', - noSession: 'Nicht angemeldet.', - pull: 'Ziehe zum Aktualisieren nach unten', - network: { - title: 'Keine Internetverbindung', - description: 'Bitte überprüfe deine Internetverbindung.', - }, - guest: { - title: 'Anmeldung erforderlich', - description: - 'Um diese Funktion zu nutzen, musst du dich mit deinem THI Account anmelden.', - button: 'Anmelden', - }, - permission: { - title: 'Funktion nicht verfügbar', - description: - 'Diese Funktion ist für deine Benutzergruppe nicht verfügbar.', - }, - map: { - mapLoadError: 'Fehler beim Laden der Karte', - mapOverlay: 'Fehler beim Laden des Overlays', - }, - noMeals: 'Keine Gerichte verfügbar', - }, - - dates: { - until: 'bis', - notYet: 'Termin noch nicht bekannt', - ends: 'endet', - today: 'Heute', - tomorrow: 'Morgen', - thisWeek: 'Diese Woche', - nextWeek: 'Nächste Woche', - }, - misc: { - share: 'Teilen', - cancel: 'Abbrechen', - confirm: 'Bestätigen', - delete: 'Löschen', - disable: 'Deaktivieren', - more: 'mehr', - unknown: 'Unbekannt', - }, - pages: { - calendar: { - exams: { - title: 'Prüfungen', - error: 'Kein Studierender', - errorSubtitle: 'Melde dich an, um deine Prüfungen zu sehen.', - noExams: { - title: 'Keine Prüfungen gefunden', - subtitle: - 'Nach der Anmeldung zu Prüfungen werden sie hier angezeigt.', - }, - }, - calendar: { - link: 'https://www.thi.de/studium/pruefung/semestertermine/', - noData: { - title: 'Keine Daten gefunden', - subtitle: 'Bitte versuche es später erneut.', - }, - }, - footer: { - part1: 'Alle Informationen ohne Gewähr. Verbindliche Informationen sind direkt auf der ', - part2: 'Universitätswebsite', - part3: ' verfügbar.', - }, - }, - lecturers: { - results: 'Suchergebnisse', - personal: 'Persönliche', - faculty: 'Fakultät', - professors: 'Professoren', - error: { - title: 'Keine Dozenten gefunden', - subtitle: - 'Konfiguriere deinen Stundenplan, um die persönlichen Dozenten anzuzeigen.', - }, - }, - lecturer: { - details: { - title: 'Titel', - organization: 'Organisation', - function: 'Funktion', - }, - contact: { - room: 'Raum', - title: 'Kontakt', - phone: 'Telefon', - office: 'Sprechstunde', - exam: 'Einsichtnahme', - }, - }, - exam: { - details: { - date: 'Datum', - room: 'Raum', - seat: 'Platz', - tools: 'Hilfsmittel', - }, - about: { - title: 'Über', - type: 'Art', - examiner: 'Prüfer:in', - registration: 'Angemeldet', - notes: 'Notizen', - }, - }, - event: { - date: 'Datum', - location: 'Ort', - organizer: 'Veranstalter', - description: 'Beschreibung', - begin: 'Beginn', - end: 'Ende', - shareMessage: - 'Schau dir dieses Event an: {{title}} von {{organizer}} um {{date}}\nhttps://neuland.app/events', - }, - map: { - search: { - placeholder: 'Suche nach Räumen, Gebäuden, ...', - recent: 'Kürzlich gesucht', - clear: 'Verlauf löschen', - noResults: 'Keine Suchergebnisse', - results: 'Suchergebnisse', - fuzzy: 'Vorschläge', - }, - - easterEgg: { - title: 'Easter Egg', - message: - 'Du hast das exklusive retro App-Icon "neuland.app" freigeschaltet!', - confirm: 'Nice!', - }, - noAvailableRooms: 'Keine freien Räume verfügbar', - details: { - room: { - details: 'Raumdetails', - title: 'Raum', - capacity: 'Kapazität', - availability: 'Verfügbarkeit', - timeLeft: 'Verbleibende Zeit', - building: 'Gebäude', - timeSpan: 'Zeitspanne', - floor: 'Etage', - type: 'Typ', - available: 'Verfügbar', - notAvailable: 'Nicht verfügbar', - availableRooms: 'Verfügbare Räume', - nextLecture: 'Nächste Vorlesung', - availableRoomsTitle: 'Verfügbare Räume', - signIn: 'Melden dich an, um verfügbare Räume anzuzeigen', - }, - building: { - free: 'Freie Räume', - total: 'Räume insgesamt', - floors: 'Etagen', - }, - location: { - title: 'Standort', - alert: 'Um deinen aktuellen Standort anzuzeigen, musst du die Standortberechtigung aktivieren.', - button: 'Einstellungen', - }, - osm: 'Karten Daten von OpenStreetMap', - }, - }, - rooms: { - options: { - title: 'Such Optionen', - building: 'Gebäude', - date: 'Datum', - time: 'Zeit', - duration: 'Dauer', - seats: 'Plätze', - }, - results: 'Verfügbare Räume', - }, - library: { - reservations: { - title: 'Reservierungen', - seat: 'Platz', - id: 'Reservierungsnummer', - alert: { - title: 'Reservierung löschen', - message: 'Möchtest du diese Reservierung wirklich löschen?', - }, - }, - available: { - title: 'Verfügbare Plätze', - reserve: 'Reservieren', - seatsAvailable: '{{available}} / {{total}} verfügbar', - ratelimit: 'Du kannst keine weiteren Plätze reservieren.', - noSeats: 'Keine weiteren Plätze verfügbar.', - footer: 'Bis zu 5 aktive Reservierungen innerhalb von sieben Tagen erlaubt. Stornierung ist vor Beginn der Reservierung möglich.', - book: 'Platz buchen', - room: 'Raum', - }, - code: { - number: 'Nummer', - footer: 'Verwende diesen Barcode zum anmelden an den Bibliotheksterminals, um Bücher auszuleihen.', - }, - }, - }, - notification: { - permission: { - title: 'Benachrichtigungen', - description: - 'Öffne die System-Einstellungen, um Benachrichtigungen zu aktivieren.', - button: 'Einstellungen', - }, - }, -} diff --git a/src/localization/de/flow.json b/src/localization/de/flow.json new file mode 100644 index 00000000..ee07f406 --- /dev/null +++ b/src/localization/de/flow.json @@ -0,0 +1,55 @@ +{ + "whatsnew": { + "title": "Was ist neu?", + "version": "in Version {{version}}", + "continue": "Weiter" + }, + "onboarding": { + "links": { + "privacy": "Datenschutzerklärung", + "agree1": "Durch Fortfahren stimmst du der ", + "agree2": " zu.", + "safety": "Datensicherheit" + }, + "page1": { + "title": "Willkommen bei" + }, + "cards": { + "title1": "Alles an einem Ort", + "description1": "Neuland Next vereint alles, was du für dein Studium brauchst, an einem Ort.", + "title2": "Von Studierenden für Studierende", + "description2": "Diese App ist ein Open-Source-Projekt von Neuland Ingolstadt e.V. und ist eine Alternative zur offiziellen THI-App.", + "title3": "Sicherheit steht an erster Stelle", + "description3": "Durch die Nutzung der offiziellen und verschlüsselten Dienste von der THI sind deine Daten streng geschützt und weder für uns noch für Dritte zugänglich." + } + }, + "login": { + "title1": "Anmelden", + "title2": "mit deinem THI-Account", + "toast": "Anmeldung erfolgreich", + "alert": { + "error": { + "title": "Anmeldung fehlgeschlagen", + "wrongCredentials": { + "title": "Falsche Anmeldedaten", + "message": "Deine Anmeldedaten sind falsch. Bitte versuche es erneut." + }, + "missing": "Bitte fülle alle Felder aus.", + "generic": "Beim Verbinden mit dem Server ist ein Fehler aufgetreten.", + "backend": "Das THI-Backend ist derzeit nicht verfügbar. Bitte versuche es später erneut.", + "noConnection": { + "title": "Keine Netzwerkverbindung", + "message": "Bitte überprüfe deine Internetverbindung und versuche es erneut." + } + }, + "restored": { + "title": "Anmeldedaten wiederhergestellt", + "message": "Frühere Anmeldedaten wurden im iOS-Schlüsselbund gefunden." + } + }, + "username": "Benutzername", + "password": "Passwort", + "button": "Anmelden", + "guest": "oder als Gast fortfahren" + } +} diff --git a/src/localization/de/flow.ts b/src/localization/de/flow.ts deleted file mode 100644 index 095ae5b7..00000000 --- a/src/localization/de/flow.ts +++ /dev/null @@ -1,64 +0,0 @@ -export default { - whatsnew: { - title: 'Was ist neu?', - version: 'in Version {{version}}', - continue: 'Weiter', - }, - onboarding: { - links: { - privacy: 'Datenschutz', - privacypolicy: 'Datenschutzerklärung', - agree1: 'Durch Fortfahren stimmst du unserer ', - agree2: ' zu.', - imprint: 'Impressum', - }, - page1: { - title: 'Willkommen bei\nNeuland Next', - subtitle: 'Wische, um mehr zu erfahren', - }, - page2: { - title: 'Alles an einem Ort', - text: - `Neuland Next vereint alle wichtigen Informationen über dein Studium in einer App.\n\n` + - `Passe dein Dashboard nach deinen Bedürfnissen an und erhalte einen schnellen Überblick über deinen Stundenplan, Noten und mehr.\n\n` + - `Die interaktive Karte zeigt dir alle wichtigen Orte auf dem Campus.`, - }, - page3: { - title: 'Datensicherheit', - text: - `Neuland Next ist ein Open-Source-Projekt und wird von Studenten für Studenten entwickelt.\n\n` + - `Als Alternative zur offiziellen THI-App schützen wir deine Daten streng. ` + - `Um deine persönlichen Daten abzurufen, verwenden wir die offizielle und verschlüsselte API der THI.\n\n` + - `Dein Passwort und deine Daten sind daher weder für uns noch für Dritte zugänglich.`, - }, - navigation: { - next: 'Weiter', - skip: 'Überspringen', - }, - }, - login: { - title: 'Anmelden mit THI-Account', - toast: 'Anmeldung erfolgreich', - alert: { - error: { - title: 'Anmeldung fehlgeschlagen', - wrongCredentials: 'Deine Anmeldedaten sind falsch.', - missing: 'Bitte fülle alle Felder aus.', - generic: - 'Beim Verbinden mit dem Server ist ein Fehler aufgetreten.', - backend: - 'Das THI-Backend ist derzeit nicht verfügbar. Bitte versuche es später erneut.', - noConnection: 'Netzwerkanfrage fehlgeschlagen', - }, - restored: { - title: 'Anmeldedaten wiederhergestellt', - message: - 'Frühere Anmeldedaten wurden im iOS-Schlüsselbund gefunden.', - }, - }, - username: 'THI-Benutzername', - password: 'Passwort', - button: 'Anmelden', - guest: 'oder als Gast fortfahren', - }, -} diff --git a/src/localization/de/food.json b/src/localization/de/food.json new file mode 100644 index 00000000..7bc47385 --- /dev/null +++ b/src/localization/de/food.json @@ -0,0 +1,92 @@ +{ + "empty": { + "allergens": "Keine passenden Allergene gefunden.", + "noAllergens": "Allergene nicht verfügbar", + "flags": "Keine passenden Vorlieben gefunden.", + "config": "Keine Allergene festgelegt. Tippe hier zum Konfigurieren deiner Allergien, um nicht verbindliche Informationen über Allergene anzuzeigen." + }, + "preferences": { + "formlist": { + "allergens": "Allergene", + "flags": "Vorlieben", + "static": "Dauerhafte Gerichte", + "language": "Essenssprache" + }, + "footer": "Wir übernehmen keine Verantwortung für die Korrektheit und Genauigkeit der Daten. Bitte überprüfe die Daten im Restaurant vor dem Verzehr. Du kannst auch die Datenquelle jeder Mahlzeit in der Detailansicht überprüfen.", + "languages": { + "de": "Deutsch", + "en": "Englisch", + "auto": "Default" + } + }, + "price": { + "guests": "für Gäste", + "students": "für Studierende", + "employees": "für Mitarbeitende", + "unknown": "nicht verfügbar" + }, + "dashboard": { + "oneMore": "und eine weitere Mahlzeit", + "manyMore": "und {{count}} weitere Gerichte", + "empty": "Der Speiseplan für heute ist leer." + }, + "categories": { + "soup": "Suppe", + "salad": "Salat", + "main": "Essen", + "special": "Aktion" + }, + "details": { + "footer": "Wir übernehmen keine Verantwortung für die Korrektheit der Daten. Bitte überprüfe die Richtigkeit der Daten mit dem jeweiligen Restaurant, bevor du etwas zu dir nimmst.", + "translated": "Dieses Gericht wurde automatisch übersetzt. ", + + "formlist": { + "prices": { + "title": "Preise", + "student": "Student", + "employee": "Mitarbeiter", + "guest": "Gast" + }, + "nutrition": { + "title": "Ernährung", + "footer": "Werte gelten pro Mahlzeit und können variieren.", + "energy": "Energie", + "fat": "Fett", + "saturated": "Gesättigte Fettsäuren", + "carbs": "Kohlenhydrate", + "sugar": "Zucker", + "fiber": "Ballaststoffe", + "protein": "Protein", + "salt": "Salz" + }, + "about": { + "title": "Über", + "category": "Kategorie", + "source": "Datenquelle" + }, + "variants": "Varianten", + "allergenFooter": "Wir können die Richtigkeit und Vollständigkeit der Angaben nicht garantieren. ({{allergens}})", + "flagsFooter": "Tippe auf einen Eintrag, um deine Vorlieben zu aktualisieren.", + + "alert": { + "allergen": { + "title": "Allergene aktualisieren", + "message": { + "add": "Möchtest du {{allergen}} zu deinen Allergenen hinzufügen?", + "remove": "Möchtest du {{allergen}} von deinen Allergenen entfernen?" + } + }, + "flag": { + "title": "Einstellungen aktualisieren", + "message": { + "add": "Möchtest du {{flag}} zu deinen Vorlieben hinzufügen?", + "remove": "Möchtest du {{flag}} von deinen Vorlieben entfernen?" + } + } + } + }, + "share": { + "message": "Schau dir \"{{meal}}\" ({{price}}) bei {{location}} an.\nhttps://neuland.app/food/{{id}}" + } + } +} diff --git a/src/localization/de/food.ts b/src/localization/de/food.ts deleted file mode 100644 index d7ce302e..00000000 --- a/src/localization/de/food.ts +++ /dev/null @@ -1,95 +0,0 @@ -export default { - empty: { - allergens: 'Keine passenden Allergene gefunden.', - noAllergens: 'Allergene nicht verfügbar', - flags: 'Keine passenden Vorlieben gefunden.', - config: 'Keine Allergene festgelegt. Tippe hier zum Konfigurieren deiner Allergien, um nicht verbindliche Informationen über Allergene anzuzeigen.', - }, - preferences: { - formlist: { - allergens: 'Allergene', - flags: 'Vorlieben', - static: 'Dauerhafte Gerichte', - language: 'Essenssprache', - }, - footer: 'Wir übernehmen keine Verantwortung für die Korrektheit und Genauigkeit der Daten. Bitte überprüfe die Daten im Restaurant vor dem Verzehr. Du kannst auch die Datenquelle jeder Mahlzeit in der Detailansicht überprüfen.', - languages: { - de: 'Deutsch', - en: 'Englisch', - auto: 'Default', - }, - }, - price: { - guests: 'für Gäste', - students: 'für Studierende', - employees: 'für Mitarbeitende', - unknown: 'nicht verfügbar', - }, - dashboard: { - oneMore: 'und eine weitere Mahlzeit', - manyMore: 'und {{count}} weitere Gerichte', - empty: 'Der Speiseplan für heute ist leer.', - }, - categories: { - soup: 'Suppe', - salad: 'Salat', - main: 'Essen', - special: 'Aktion', - }, - details: { - footer: 'Wir übernehmen keine Verantwortung für die Korrektheit der Daten. Bitte überprüfe die Richtigkeit der Daten mit dem jeweiligen Restaurant, bevor du etwas zu dir nimmst.', - translated: 'Dieses Gericht wurde automatisch übersetzt. ', - - formlist: { - prices: { - title: 'Preise', - student: 'Student', - employee: 'Mitarbeiter', - guest: 'Gast', - }, - nutrition: { - title: 'Ernährung', - footer: 'Werte gelten pro Mahlzeit und können variieren.', - energy: 'Energie', - fat: 'Fett', - saturated: 'Gesättigte Fettsäuren', - carbs: 'Kohlenhydrate', - sugar: 'Zucker', - fiber: 'Ballaststoffe', - protein: 'Protein', - salt: 'Salz', - }, - about: { - title: 'Über', - category: 'Kategorie', - source: 'Datenquelle', - }, - variants: 'Varianten', - allergenFooter: - 'Wir können die Richtigkeit und Vollständigkeit der Angaben nicht garantieren. ({{allergens}})', - flagsFooter: - 'Tippe auf einen Eintrag, um deine Vorlieben zu aktualisieren.', - - alert: { - allergen: { - title: 'Allergene aktualisieren', - message: { - add: 'Möchtest du {{allergen}} zu deinen Allergenen hinzufügen?', - remove: 'Möchtest du {{allergen}} von deinen Allergenen entfernen?', - }, - }, - flag: { - title: 'Einstellungen aktualisieren', - message: { - add: 'Möchtest du {{flag}} zu deinen Vorlieben hinzufügen?', - remove: 'Möchtest du {{flag}} von deinen Vorlieben entfernen?', - }, - }, - }, - }, - share: { - message: - 'Schau dir "{{meal}}" ({{price}}) bei {{location}} an.\nhttps://neuland.app/food/{{id}}', - }, - }, -} diff --git a/src/localization/de/index.ts b/src/localization/de/index.ts deleted file mode 100644 index 0cfb5c0a..00000000 --- a/src/localization/de/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import api from './api' -import common from './common' -import flow from './flow' -import food from './food' -import navigation from './navigation' -import settings from './settings' -import timetable from './timetable' - -export default { - common, - settings, - navigation, - food, - flow, - timetable, - api, -} diff --git a/src/localization/de/navigation.json b/src/localization/de/navigation.json new file mode 100644 index 00000000..5722568d --- /dev/null +++ b/src/localization/de/navigation.json @@ -0,0 +1,94 @@ +{ + "navigation": { + "dashboard": "Neuland Next", + "timetable": "Stundenplan", + "map": "Karte", + "food": "Essen", + "campusMap": "Campus Karte", + "settings": "Einstellungen", + "preferences": "Präferenzen", + "flags": "Kennzeichnungen", + "flagsSearch": "Suche Kennzeichnungen", + "allergensSearch": "Suche Allergene", + "allergens": "Allergene", + "details": "Details", + "theme": "Design", + "profile": "Profil", + "about": "Über", + "advancedSearch": "Erweiterte Suche", + "calendar": "Kalender", + "examDetails": "Prüfungsdetails", + "lecturers": { + "title": "Dozenten", + "search": "Suche alle Dozenten" + }, + "lecturer": "Dozenten-Details", + "grades": "Noten", + "news": "THI Neuigkeiten", + "library": "Bibliothek", + "libraryCode": "Bibliotheksnummer", + "notifications": "Benachrichtigungen", + "licenses": { + "title": "Lizenzen", + "search": "Suche Lizenzen" + }, + "license": "Lizenz", + "login": "Anmelden" + }, + "cards": { + "titles": { + "timetable": "Stundenplan", + "calendar": "Kalender", + "rooms": "Räume", + "lecturers": "Dozenten", + "news": "THI Neuigkeiten", + "library": "Bibliothek", + "events": "Campus Life", + "reimanns": "Reimanns", + "canisius": "Canisius Konvikt", + "mensa": "Mensa Ingolstadt", + "mensaNeuburg": "Theke Neuburg", + "food": "Essen", + "login": "Anmelden" + }, + "announcements": { + "readMore": "Tippe, um mehr zu erfahren." + }, + "events": { + "by": "von {{name}}" + }, + "calendar": { + "exam": "Prüfung: {{name}}", + "ends": "endet " + }, + "timetable": { + "startingSoon": "startet in {{count}} min", + "startingSoon_plural": "startet in {{count}} mins", + "ongoing": "endet um {{time}}", + "endingSoon": "endet in {{count}} min", + "endingSoon_plural": "endet in {{count}} mins" + }, + "login": { + "title": "Stundenplan, Noten, Raum-Status und mehr", + "message": "Melde dich an, um alle Funktionen freizuschalten." + } + }, + "contextMenu": { + "reset": "Karten zurücksetzen", + "hide": "Karte ausblenden", + "settings": "Dashboard Einstellungen" + }, + "unmatched": { + "title": "Nicht gefunden", + "error": { + "title": "Nicht gefunden", + "message": "Die angeforderte Seite ist nicht verfügbar.", + "button": "Zurück" + } + }, + "profile": { + "error": { + "button": "Primuss" + } + } +} diff --git a/src/localization/de/navigation.ts b/src/localization/de/navigation.ts deleted file mode 100644 index b82bbaa7..00000000 --- a/src/localization/de/navigation.ts +++ /dev/null @@ -1,84 +0,0 @@ -export default { - navigation: { - timetable: 'Stundenplan', - map: 'Karte', - food: 'Essen', - campusMap: 'Campus Karte', - settings: 'Einstellungen', - preferences: 'Präferenzen', - flags: 'Kennzeichnungen', - flagsSearch: 'Suche Kennzeichnungen', - allergensSearch: 'Suche Allergene', - allergens: 'Allergene', - details: 'Details', - theme: 'Design', - profile: 'Profil', - about: 'Über', - advancedSearch: 'Erweiterte Suche', - calendar: 'Kalender', - examDetails: 'Prüfungsdetails', - lecturers: { - title: 'Dozenten', - search: 'Suche alle Dozenten', - }, - lecturer: 'Dozenten-Details', - grades: 'Noten', - news: 'THI Neuigkeiten', - library: 'Bibliothek', - libraryCode: 'Bibliotheksnummer', - notifications: 'Benachrichtigungen', - licenses: 'Lizenzen', - license: 'Lizenz', - }, - cards: { - titles: { - timetable: 'Stundenplan', - calendar: 'Kalender', - rooms: 'Räume', - lecturers: 'Dozenten', - news: 'THI Neuigkeiten', - library: 'Bibliothek', - events: 'Campus Life', - reimanns: 'Reimanns', - canisius: 'Canisius Konvikt', - mensa: 'Mensa Ingolstadt', - mensaNeuburg: 'Theke Neuburg', - food: 'Essen', - login: 'Anmelden', - }, - announcements: { - readMore: 'Tippe, um mehr zu erfahren.', - }, - events: { - by: 'von {{name}}', - }, - calendar: { - exam: 'Prüfung: {{name}}', - ends: 'endet ', - }, - timetable: { - startingSoon: 'startet in {{count}} min', - startingSoon_plural: 'startet in {{count}} mins', - ongoing: 'endet um {{time}}', - endingSoon: 'endet in {{count}} min', - endingSoon_plural: 'endet in {{count}} mins', - }, - login: { - title: 'Stundenplan, Noten, Raum-Status und mehr', - message: 'Melde dich an, um alle Funktionen freizuschalten.', - }, - }, - contextMenu: { - reset: 'Karten zurücksetzen', - hide: 'Karte ausblenden', - settings: 'Dashboard Einstellungen', - }, - unmatched: { - title: 'Nicht gefunden', - error: { - title: 'Nicht gefunden', - message: 'Die angeforderte Seite ist nicht verfügbar.', - button: 'Zurück', - }, - }, -} diff --git a/src/localization/de/settings.json b/src/localization/de/settings.json new file mode 100644 index 00000000..3bf751f1 --- /dev/null +++ b/src/localization/de/settings.json @@ -0,0 +1,182 @@ +{ + "menu": { + "guest": { + "title": "Anmelden", + "subtitle": "Melde dich an, um alle Funktionen der App freizuschalten." + }, + "employee": { + "subtitle1": "Mitarbeitenden Account", + "subtitle2": "Tippe zum Abmelden" + }, + "error": { + "subtitle2": "Ziehe zum Aktualisieren oder tippe zum Abmelden", + "noData": { + "title": "Keine Daten vorhanden", + "subtitle1": "Es scheint als seist du nicht länger immatrikuliert.", + "subtitle2": "Bitte überprüfe deinen Primuss-Account." + } + }, + "formlist": { + "preferences": { + "title": "Einstellungen", + "food": "Essen", + "language": "Sprache" + }, + "appearance": { + "title": "Darstellung", + "theme": "Design" + }, + "language": { + "title": "Sprache wechseln", + "message": "Bestätige, um die Sprache der App auf Englisch zu ändern.", + "confirm": "Bestätigen" + }, + "legal": { + "title": "Rechtliches", + "about": "Über", + "rate": "Bewerte die App" + } + }, + "copyright": "© {{year}} von Neuland Ingolstadt e.V." + }, + "about": { + "header": { + "developed": "Entwickelt von" + }, + "formlist": { + "legal": { + "title": "Rechtliches", + "privacy": "Datenschutz", + "imprint": "Impressum" + }, + "about": { + "title": "Über uns" + } + }, + "easterEgg": { + "title": "Easter Egg", + "message": "Du hast das exklusive App-Icon \"Paradies Katze\" freigeschaltet! 😻", + "messageAndroid": "Du hast ein Easter Egg gefunden! 🐣", + "confirm": "Nice!" + }, + "analytics": { + "title": "Analytics", + "toggle": "Anonyme Nutzungsdaten Sammeln", + "message": "Hilf uns, die App zu verbessern, indem du anonyme Nutzungsdaten sendest. Ein Rückschluss auf deine Identität ist zu keinem Zeitpunkt möglich." + } + }, + "changelog": { + "footer": "Um alle detaillierten Änderungen zu sehen, öffne die Commits auf " + }, + "licenses": { + "footer": "Diese Bibliotheken wurden zur Erstellung dieser App verwendet. Einige sind plattformspezifisch und sind daher in deinem Build nicht enthalten." + }, + "profile": { + "error": { + "title": "Keine Daten vorhanden", + "message": "Es scheint als seist du nicht länger immatrikuliert. Bitte überprüfe deinen Primuss-Account.", + "button": "Primuss" + }, + "formlist": { + "grades": { + "title": "Noten", + "button": "Noten und Fächer" + }, + "user": { + "title": "Benutzer", + "matrical": "Matrikelnummer", + "library": "Bibliotheksnummer", + "printer": "Druckguthaben" + }, + "study": { + "title": "Studium", + "degree": "Abschluss", + "spo": "Prüfungsordnung", + "group": "Studiengruppe" + }, + "contact": { + "title": "Kontakt", + "phone": "Telefon", + "street": "Straße", + "city": "Stadt" + } + }, + "logout": { + "button": "Abmelden", + "alert": { + "title": "Bestätigen", + "message": "Das wird dich aus der App abmelden und alle deine Daten löschen.", + "cancel": "Abbrechen", + "confirm": "Abmelden" + } + } + }, + "theme": { + "accent": { + "title": "Akzentfarbe" + }, + "colors": { + "teal": "Türkis", + "blue": "Blau", + "contrast": "Kontrast", + "pink": "Pink", + "brown": "Braun", + "purple": "Lila", + "yellow": "Gelb", + "orange": "Orange", + "green": "Neuland" + }, + "footer": "Das ändert die Farbe der Symbole und Schaltflächen in der App.", + "themes": { + "title": "Design", + "default": "Automatisch", + "dark": "Dunkel", + "light": "Hell" + } + }, + "dashboard": { + "shown": "Angezeigte Karten", + "hidden": "Ausgeblendete Karten", + "noShown": "Dashboard ist leer", + "noShownButton": "Konfigurieren", + "noShownDescription": "Füge Karten zu deinem Dashboard hinzu, um loszulegen.", + "reset": "Reihenfolge zurücksetzen", + "footer": "Passe dein Dashboard an, indem du die Karten per Drag & Drop in die gewünschte Reihenfolge ziehst. Verstecke Karten, indem du auf das Entfernen-Symbol drückst.", + "unavailable": { + "title": "Nicht verfügbare Karten", + "message": "Um alle Funktionen der App nutzen zu können, musst du dich anmelden. Tippe um mit deinem THI-Account fortzufahren." + } + }, + "grades": { + "grade": "Note", + "none": "(keine)", + "finished": "Noten", + "open": "Offen", + "average": "Durchschnitt", + "exactAverage": "Basierend auf {{number}} gewichteten Noten.", + "missingAverage": "Der genaue Durchschnitt kann nicht berechnet werden und liegt zwischen {{min}} und {{max}}.", + "averageError": "Noten-Durchschnitt ist derzeit nicht verfügbar.", + "temporarilyUnavailable": "Noten sind vorübergehend nicht verfügbar.", + "footer": "Diese Übersicht dient nur zur allgemeinen Information und ist rechtlich nicht bindend. Für verbindliche Informationen nutze das offizielle Notenblatt auf Primuss." + }, + "appIcon": { + "names": { + "default": "Neuland Next", + "modernDark": "Modern Dunkel", + "retro": "neuland.app", + "modernGreen": "Modern Grün", + "modernPink": "Modern Pink", + "rainbowMoonLight": "Regenbogen Hell", + "rainbowDark": "Regenbogen Dunkel", + "rainbowNeon": "Regenbogen Neon", + "cat": "Paradies Katze" + }, + "categories": { + "default": "Standard", + "neon": "Neon", + "rainbow": "Regenbogen", + "exclusive": "Exklusiv" + }, + "exclusive": "Besuche unsere Veranstaltungen und finde Easter-Eggs, um exklusive App-Icons freizuschalten." + } +} diff --git a/src/localization/de/settings.ts b/src/localization/de/settings.ts deleted file mode 100644 index fbbf70e8..00000000 --- a/src/localization/de/settings.ts +++ /dev/null @@ -1,192 +0,0 @@ -export default { - menu: { - guest: { - title: 'Anmelden', - subtitle: - 'Melde dich an, um alle Funktionen der App freizuschalten.', - }, - employee: { - subtitle1: 'Mitarbeitenden Account', - subtitle2: 'Tippe zum Abmelden', - }, - error: { - subtitle2: 'Ziehe zum Aktualisieren oder tippe zum Abmelden', - noData: { - title: 'Keine Daten vorhanden', - subtitle1: - 'Es scheint als seist du nicht länger immatrikuliert.', - subtitle2: 'Bitte überprüfe deinen Primuss-Account.', - }, - }, - formlist: { - preferences: { - title: 'Einstellungen', - food: 'Essen', - language: 'Sprache', - }, - appearance: { - title: 'Darstellung', - theme: 'Design', - }, - language: { - title: 'Sprache wechseln', - message: - 'Bestätige, um die Sprache der App auf Englisch zu ändern.', - confirm: 'Bestätigen', - }, - legal: { - title: 'Rechtliches', - about: 'Über', - rate: 'Bewerte die App', - }, - }, - copyright: '© {{year}} von Neuland Ingolstadt e.V.', - }, - about: { - header: { - developed: 'Entwickelt von', - }, - formlist: { - legal: { - title: 'Rechtliches', - privacy: 'Datenschutz', - imprint: 'Impressum', - }, - about: { - title: 'Über uns', - }, - }, - easterEgg: { - title: 'Easter Egg', - message: - 'Du hast das exklusive App-Icon "Paradies Katze" freigeschaltet! 😻', - messageAndroid: 'Du hast ein Easter Egg gefunden! 🐣', - confirm: 'Nice!', - }, - analytics: { - title: 'Analytics', - toggle: 'Anonyme Nutzungsdaten Sammeln', - message: - 'Hilf uns, die App zu verbessern, indem du anonyme Nutzungsdaten sendest. Ein Rückschluss auf deine Identität ist zu keinem Zeitpunkt möglich.', - }, - }, - changelog: { - footer: 'Um alle detaillierten Änderungen zu sehen, öffne die Commits auf ', - }, - licenses: { - footer: 'Diese Bibliotheken wurden zur Erstellung dieser App verwendet. Einige sind plattformspezifisch und sind daher in deinem Build nicht enthalten.', - }, - profile: { - error: { - title: 'Keine Daten vorhanden', - message: - 'Es scheint als seist du nicht länger immatrikuliert. Bitte überprüfe deinen Primuss-Account.', - }, - formlist: { - grades: { - title: 'Noten', - button: 'Noten und Fächer', - }, - user: { - title: 'Benutzer', - matrical: 'Matrikelnummer', - library: 'Bibliotheksnummer', - printer: 'Druckguthaben', - }, - study: { - title: 'Studium', - degree: 'Abschluss', - spo: 'Prüfungsordnung', - group: 'Studiengruppe', - }, - contact: { - title: 'Kontakt', - phone: 'Telefon', - street: 'Straße', - city: 'Stadt', - }, - }, - logout: { - button: 'Abmelden', - alert: { - title: 'Bestätigen', - message: - 'Das wird dich aus der App abmelden und alle deine Daten löschen.', - cancel: 'Abbrechen', - confirm: 'Abmelden', - }, - }, - }, - theme: { - accent: { - title: 'Akzentfarbe', - }, - colors: { - teal: 'Türkis', - blue: 'Blau', - contrast: 'Kontrast', - pink: 'Pink', - brown: 'Braun', - purple: 'Lila', - yellow: 'Gelb', - orange: 'Orange', - green: 'Neuland', - }, - footer: 'Das ändert die Farbe der Symbole und Schaltflächen in der App.', - themes: { - title: 'Design', - default: 'Automatisch', - dark: 'Dunkel', - light: 'Hell', - }, - }, - dashboard: { - shown: 'Angezeigte Karten', - hidden: 'Ausgeblendete Karten', - noShown: 'Dashboard ist leer', - noShownButton: 'Konfigurieren', - noShownDescription: - 'Füge Karten zu deinem Dashboard hinzu, um loszulegen.', - reset: 'Reihenfolge zurücksetzen', - footer: 'Passe dein Dashboard an, indem du die Karten per Drag & Drop in die gewünschte Reihenfolge ziehst. Verstecke Karten, indem du auf das Entfernen-Symbol drückst.', - unavailable: { - title: 'Nicht verfügbare Karten', - message: - 'Um alle Funktionen der App nutzen zu können, musst du dich anmelden. Tippe um mit deinem THI-Account fortzufahren.', - }, - }, - grades: { - grade: 'Note', - none: '(keine)', - finished: 'Noten', - open: 'Offen', - average: 'Durchschnitt', - exactAverage: 'Basierend auf {{number}} gewichteten Noten.', - missingAverage: - 'Der genaue Durchschnitt kann nicht berechnet werden und liegt zwischen {{min}} und {{max}}.', - averageError: 'Noten-Durchschnitt ist derzeit nicht verfügbar.', - temporarilyUnavailable: 'Noten sind vorübergehend nicht verfügbar.', - footer: 'Diese Übersicht dient nur zur allgemeinen Information und ist rechtlich nicht bindend. Für verbindliche Informationen nutze das offizielle Notenblatt auf Primuss.', - }, - appIcon: { - names: { - default: 'Neuland Next', - modernDark: 'Modern Dunkel', - retro: 'neuland.app', - modernGreen: 'Modern Grün', - modernPink: 'Modern Pink', - rainbowMoonLight: 'Regenbogen Hell', - rainbowDark: 'Regenbogen Dunkel', - rainbowNeon: 'Regenbogen Neon', - cat: 'Paradies Katze', - }, - categories: { - default: 'Standard', - neon: 'Neon', - rainbow: 'Regenbogen', - exclusive: 'Exklusiv', - }, - exclusive: - 'Besuche unsere Veranstaltungen und finde Easter-Eggs, um exklusive App-Icons freizuschalten.', - }, -} diff --git a/src/localization/de/timetable.json b/src/localization/de/timetable.json new file mode 100644 index 00000000..9901852b --- /dev/null +++ b/src/localization/de/timetable.json @@ -0,0 +1,44 @@ +{ + "details": { + "title": "Kursdetails", + "weeklySemesterHours": "SWS", + "exam": "Prüfung", + "studyGroup": "Lerngruppe", + "courseOfStudies": "Studiengang", + "error": { + "title": "Details nicht verfügbar", + "message": "Die Details zu dieser Vorlesung sind nicht verfügbar." + } + }, + "overview": { + "title": "Übersicht", + "goal": "Ziel", + "content": "Inhalt", + "literature": "Literatur" + }, + "time": { + "minutes": "Minuten" + }, + "error": { + "empty": { + "title": "Stundenplan nicht konfiguriert", + "title2": "Keine Vorlesungen gefunden", + "message": "Um deinen Stundenplan anzuzeigen, musst du ihn über die THI Stundenplan Webseite konfigurieren.", + "button": "Jetzt konfigurieren" + }, + "filtered": { + "title": "Keine Vorlesungen gefunden", + "message": "Es scheint keine weiteren Vorlesungen in diesem Semester zu geben." + } + }, + "notificatons": { + "title": "Benachrichtigungen", + "description": "Erhalte Benachrichtigungen bevor diese Vorlesung beginnt. Diese wiederholen sich bis du sie hier deaktivierst.", + "active": "Benachrichtigungen aktiviert. Du wirst {{mins}} Minuten vor Beginn der Vorlesung benachrichtigt.", + "five": "5 Minuten vorher", + "fifteen": "15 Minuten vorher", + "thirty": "30 Minuten vorher", + "sixty": "60 Minuten vorher", + "body": "beginnt in {{mins}} Minuten in Raum {{room}}." + } +} diff --git a/src/localization/de/timetable.ts b/src/localization/de/timetable.ts deleted file mode 100644 index 34e9a3a9..00000000 --- a/src/localization/de/timetable.ts +++ /dev/null @@ -1,47 +0,0 @@ -export default { - details: { - title: 'Kursdetails', - weeklySemesterHours: 'SWS', - exam: 'Prüfung', - studyGroup: 'Lerngruppe', - courseOfStudies: 'Studiengang', - error: { - title: 'Details nicht verfügbar', - message: 'Die Details zu dieser Vorlesung sind nicht verfügbar.', - }, - }, - overview: { - title: 'Übersicht', - goal: 'Ziel', - content: 'Inhalt', - literature: 'Literatur', - }, - time: { - minutes: 'Minuten', - }, - error: { - empty: { - title: 'Stundenplan nicht konfiguriert', - title2: 'Keine Vorlesungen gefunden', - message: - 'Um deinen Stundenplan anzuzeigen, musst du ihn über die THI Stundenplan Webseite konfigurieren.', - button: 'Jetzt konfigurieren', - }, - filtered: { - title: 'Keine Vorlesungen gefunden', - message: - 'Es scheint keine weiteren Vorlesungen in diesem Semester zu geben.', - }, - }, - notificatons: { - title: 'Benachrichtigungen', - description: - 'Erhalte Benachrichtigungen bevor diese Vorlesung beginnt. Diese wiederholen sich bis du sie hier deaktivierst.', - active: 'Benachrichtigungen aktiviert. Du wirst {{mins}} Minuten vor Beginn der Vorlesung benachrichtigt.', - five: '5 Minuten vorher', - fifteen: '15 Minuten vorher', - thirty: '30 Minuten vorher', - sixty: '60 Minuten vorher', - body: 'beginnt in {{mins}} Minuten in Raum {{room}}.', - }, -} diff --git a/src/localization/en/accessibility.json b/src/localization/en/accessibility.json new file mode 100644 index 00000000..fa8dc42d --- /dev/null +++ b/src/localization/en/accessibility.json @@ -0,0 +1,9 @@ +{ + "button": { + "foodPreferences": "Food Preferences", + "timetableMode": "Switch Timetable View", + "timetableBack": "Jump to today", + "settingsLogo": "Neuland Logo", + "libraryBarcode": "Library Number as Barcode" + } +} diff --git a/src/localization/en/api.json b/src/localization/en/api.json new file mode 100644 index 00000000..8afed83d --- /dev/null +++ b/src/localization/en/api.json @@ -0,0 +1,30 @@ +{ + "lecturerFunctions": { + "Laboringenieur(in)": "Laboratory Engineer", + "Lehrbeauftragte(r)": "Lecturer", + "Lehrkraft für besondere Aufgaben": "Teacher for special tasks", + "Professor(in)": "Professor", + "Wiss. Mitarbeiter(in) mit Deputat": "Scientific staff with Deputat", + "Wissenschaftl. Mitarbeiter(in)": "Research associate", + "unbestimmt": "indeterminate" + }, + "lecturerOrganizations": { + "Fakultät Business School": "Faculty Business School", + "Fakultät Elektro- und Informationstechnik": "Faculty of Electrical Engineering and Information Technology", + "Fakultät Informatik": "Faculty of Computer Science", + "Fakultät Maschinenbau": "Faculty of Mechanical Engineering", + "Fakultät Wirtschaftsingenieurwesen": "Faculty of Industrial Engineering", + "Institut für Akademische Weiterbildung": "Institute for Academic Continuing Education", + "Nachhaltige Infrastruktur": "Sustainable infrastructure", + "Sprachenzentrum": "Language Center", + "Verwaltung": "Management", + "Zentrum für Angewandte Forschung": "Center for Applied Research" + }, + "roomTypes": { + "PC-Pool": "PC-Pool", + "Seminarraum": "Seminar room", + "Labor": "Laboratory", + "Kleiner Hörsaal": "Small lecture hall", + "Großer Hörsaal": "Large Llecture hall" + } +} diff --git a/src/localization/en/api.ts b/src/localization/en/api.ts deleted file mode 100644 index e2e0e1e8..00000000 --- a/src/localization/en/api.ts +++ /dev/null @@ -1,33 +0,0 @@ -export default { - lecturerFunctions: { - 'Laboringenieur(in)': 'Laboratory Engineer', - 'Lehrbeauftragte(r)': 'Lecturer', - 'Lehrkraft für besondere Aufgaben': 'Teacher for special tasks', - 'Professor(in)': 'Professor', - 'Wiss. Mitarbeiter(in) mit Deputat': 'Scientific staff with Deputat', - 'Wissenschaftl. Mitarbeiter(in)': 'Research associate', - unbestimmt: 'indeterminate', - }, - lecturerOrganizations: { - 'Fakultät Business School': 'Faculty Business School', - 'Fakultät Elektro- und Informationstechnik': - 'Faculty of Electrical Engineering and Information Technology', - 'Fakultät Informatik': 'Faculty of Computer Science', - 'Fakultät Maschinenbau': 'Faculty of Mechanical Engineering', - 'Fakultät Wirtschaftsingenieurwesen': - 'Faculty of Industrial Engineering', - 'Institut für Akademische Weiterbildung': - 'Institute for Academic Continuing Education', - 'Nachhaltige Infrastruktur': 'Sustainable infrastructure', - Sprachenzentrum: 'Language Center', - Verwaltung: 'Management', - 'Zentrum für Angewandte Forschung': 'Center for Applied Research', - }, - roomTypes: { - 'PC-Pool': 'PC-Pool', - Seminarraum: 'Seminar room', - Labor: 'Laboratory', - 'Kleiner Hörsaal': 'Small lecture hall', - 'Großer Hörsaal': 'Large Llecture hall', - }, -} diff --git a/src/localization/en/common.json b/src/localization/en/common.json new file mode 100644 index 00000000..2f181611 --- /dev/null +++ b/src/localization/en/common.json @@ -0,0 +1,232 @@ +{ + "toast": { + "clipboard": "copied to clipboard", + "paused": "No internet connection", + "roomNotFound": "Room not found", + "mapOverlay": "Error while loading overlay", + "dashboard": "Drag & drop to reorder" + }, + "error": { + "title": "An error occurred", + "description": "An error occurred while loading the data.", + "refreshPull": "An error occurred while loading the data.\nPull to refresh.", + "button": "Retry", + "noSession": "Not signed in.", + "pull": "Pull down to refresh", + "network": { + "title": "No internet connection", + "description": "Please check your internet connection." + }, + "guest": { + "title": "Sign in required", + "description": "This feature requires you to sign in using your THI account.", + "button": "Sign in" + }, + "permission": { + "title": "Feature not available", + "description": "This feature is not available for your user group." + }, + "map": { + "mapLoadError": "Error while loading map", + "mapOverlay": "Error while loading overlay" + }, + "noMeals": "No meals available", + "crash": { + "title": "Internal Error", + "description": "Neuland Next has encountered an internal error.", + "steps": "Please check the system status page and send us feedback to help us fix the issue.", + "reload": "Reload App", + "feedback": "Send Feedback", + "status": "Check System Status" + } + }, + "dates": { + "until": "until", + "ends": "ends", + "today": "Today", + "notYet": "Date not yet available", + "tomorrow": "Tomorrow", + "thisWeek": "This Week", + "nextWeek": "Next Week" + }, + "misc": { + "share": "Share", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "disable": "Disable", + "more": "more", + "unknown": "Unknown" + }, + "pages": { + "calendar": { + "exams": { + "title": "Exams", + "error": "Not a student", + "errorSubtitle": "Sign in to see your exams.", + "noExams": { + "title": "No exams found", + "subtitle": "After registering for exams, they will appear here." + } + }, + "calendar": { + "link": "https://www.thi.de/en/international/studies/examination/semester-dates/", + "noData": { + "title": "No data found", + "subtitle": "Please try again later." + } + }, + "footer": { + "part1": "All information without guarantee. Binding information is only available directly on the ", + "part2": "university website", + "part3": "." + } + }, + "lecturers": { + "results": "Search results", + "personal": "Personal", + "faculty": "Faculty", + "professors": "Professors", + "error": { + "title": "No lecturers found", + "subtitle": "Setup your timetable to view your personal lecturers." + } + }, + "lecturer": { + "details": { + "title": "Title", + "organization": "Organization", + "function": "Function" + }, + "contact": { + "room": "Room", + "title": "Contact", + "phone": "Phone", + "office": "Office hours", + "exam": "Exam insights" + } + }, + "exam": { + "details": { + "date": "Date", + "room": "Room", + "seat": "Seat", + "tools": "Tools" + }, + "about": { + "title": "About", + "type": "Type", + "examiner": "Examiner", + "registration": "Registered", + "notes": "Notes" + }, + "footer": "All information without guarantee. Binding information is only available directly from the THI." + }, + "event": { + "date": "Date", + "location": "Location", + "organizer": "Organizer", + "description": "Description", + "begin": "Begin", + "end": "End", + "shareMessage": "Check out this event: {{title}} by {{organizer}} on {{date}}\nhttps://neuland.app/events" + }, + "map": { + "search": { + "hint": "Search the campus...", + "placeholder": "Search for rooms, buildings or facilities", + "recent": "Recent searches", + "clear": "Clear history", + "noResults": "No search results", + "results": "Results", + "fuzzy": "Suggestions" + }, + + "easterEgg": { + "title": "Easter Egg", + "message": "You unlocked the exclusive retro app icon \"neuland.app\"!", + "confirm": "Nice!" + }, + "noAvailableRooms": "No free rooms available", + "details": { + "room": { + "details": "Room details", + "title": "Room", + "capacity": "Capacity", + "availability": "Availability", + "timeLeft": "Time left", + "building": "Building", + "timeSpan": "Time span", + "floor": "Floor", + "type": "Type", + "equipment": "Equipment", + "available": "Available", + "notAvailable": "Not available", + "availableRooms": "Available rooms", + "nextLecture": "Next lecture", + "availableRoomsTitle": "Available rooms", + "history": "Recent searches", + "signIn": "Sign in to see available rooms" + }, + "building": { + "free": "Free rooms", + "total": "Total rooms", + "floors": "Floors" + }, + "osm": "Map data from OpenStreetMap" + } + }, + + "rooms": { + "options": { + "title": "Search options", + "building": "Building", + "date": "Date", + "time": "Time", + "duration": "Duration", + "seats": "seats" + }, + "results": "Available rooms", + "modified": { + "title": "Filter adjusted", + "description": "THI is currently closed. The filter has been adjusted to the next valid opening time." + }, + "noRooms": { + "title": "No available rooms found", + "subtitle": "Try changing the filter options." + } + }, + "library": { + "reservations": { + "title": "Reservations", + "seat": "Seat", + "id": "Reservation code", + "alert": { + "title": "Delete reservation", + "message": "Do you really want to delete this reservation?" + } + }, + "available": { + "title": "Available seats", + "reserve": "Reserve", + "seatsAvailable": "{{available}} / {{total}} available", + "ratelimit": "You can not reserve more seats.", + "noSeats": "No more seats available.", + "footer": "Up to 5 active reservations are allowed per seven days. Cancelation is possible before the reservation starts.", + "book": "Book seat", + "room": "Room" + }, + "code": { + "number": "Number", + "footer": "Use this barcode to sign in at the library terminals to borrow books." + } + } + }, + "notification": { + "permission": { + "title": "Notifications", + "description": "Open the system settings to enable notifications.", + "button": "Settings" + } + } +} diff --git a/src/localization/en/common.ts b/src/localization/en/common.ts deleted file mode 100644 index 30ffbd28..00000000 --- a/src/localization/en/common.ts +++ /dev/null @@ -1,224 +0,0 @@ -export default { - toast: { - clipboard: 'copied to clipboard', - paused: 'No internet connection', - roomNotFound: 'Room not found', - mapOverlay: 'Error while loading overlay', - dashboard: 'Drag & drop to reorder', - }, - error: { - title: 'An error occurred', - description: 'An error occurred while loading the data.', - refreshPull: - 'An error occurred while loading the data.\nPull to refresh.', - button: 'Retry', - noSession: 'Not signed in.', - pull: 'Pull down to refresh', - network: { - title: 'No internet connection', - description: 'Please check your internet connection.', - }, - guest: { - title: 'Sign in required', - description: - 'This feature requires you to sign in using your THI account.', - button: 'Sign in', - }, - permission: { - title: 'Feature not available', - description: 'This feature is not available for your user group.', - }, - map: { - mapLoadError: 'Error while loading map', - mapOverlay: 'Error while loading overlay', - }, - noMeals: 'No meals available', - }, - dates: { - until: 'until', - ends: 'ends', - today: 'Today', - notYet: 'Date not yet available', - tomorrow: 'Tomorrow', - thisWeek: 'This Week', - nextWeek: 'Next Week', - }, - misc: { - share: 'Share', - cancel: 'Cancel', - confirm: 'Confirm', - delete: 'Delete', - disable: 'Disable', - more: 'more', - unknown: 'Unknown', - }, - pages: { - calendar: { - exams: { - title: 'Exams', - error: 'Not a student', - errorSubtitle: 'Sign in to see your exams.', - noExams: { - title: 'No exams found', - subtitle: - 'After registering for exams, they will appear here.', - }, - }, - calendar: { - link: 'https://www.thi.de/en/international/studies/examination/semester-dates/', - noData: { - title: 'No data found', - subtitle: 'Please try again later.', - }, - }, - footer: { - part1: 'All information without guarantee. Binding information is only available directly on the ', - part2: 'university website', - part3: '.', // needed for german translation - }, - }, - lecturers: { - results: 'Search results', - personal: 'Personal', - faculty: 'Faculty', - professors: 'Professors', - error: { - title: 'No lecturers found', - subtitle: - 'Setup your timetable to view your personal lecturers.', - }, - }, - lecturer: { - details: { - title: 'Title', - organization: 'Organization', - function: 'Function', - }, - contact: { - room: 'Room', - title: 'Contact', - phone: 'Phone', - office: 'Office hours', - exam: 'Exam insights', - }, - }, - exam: { - details: { - date: 'Date', - room: 'Room', - seat: 'Seat', - tools: 'Tools', - }, - about: { - title: 'About', - type: 'Type', - examiner: 'Examiner', - registration: 'Registered', - notes: 'Notes', - }, - }, - event: { - date: 'Date', - location: 'Location', - organizer: 'Organizer', - description: 'Description', - begin: 'Begin', - end: 'End', - shareMessage: - 'Check out this event: {{title}} by {{organizer}} on {{date}}\nhttps://neuland.app/events', - }, - map: { - search: { - placeholder: 'Search for rooms, buildings, ...', - recent: 'Recent searches', - clear: 'Clear history', - noResults: 'No search results', - results: 'Results', - fuzzy: 'Suggestions', - }, - - easterEgg: { - title: 'Easter Egg', - message: - 'You unlocked the exclusive retro app icon "neuland.app"!', - confirm: 'Nice!', - }, - noAvailableRooms: 'No free rooms available', - details: { - room: { - details: 'Room details', - title: 'Room', - capacity: 'Capacity', - availability: 'Availability', - timeLeft: 'Time left', - building: 'Building', - timeSpan: 'Time span', - floor: 'Floor', - type: 'Type', - equipment: 'Equipment', - available: 'Available', - notAvailable: 'Not available', - availableRooms: 'Available rooms', - nextLecture: 'Next lecture', - availableRoomsTitle: 'Available rooms', - signIn: 'Sign in to see available rooms', - }, - building: { - free: 'Free rooms', - total: 'Total rooms', - floors: 'Floors', - }, - location: { - title: 'Location', - alert: 'To see your current location, please enable location services.', - settings: 'Settings', - }, - osm: 'Map data from OpenStreetMap', - }, - }, - - rooms: { - options: { - title: 'Search options', - building: 'Building', - date: 'Date', - time: 'Time', - duration: 'Duration', - seats: 'seats', - }, - results: 'Available rooms', - }, - library: { - reservations: { - title: 'Reservations', - seat: 'Seat', - id: 'Reservation code', - alert: { - title: 'Delete reservation', - message: 'Do you really want to delete this reservation?', - }, - }, - available: { - title: 'Available seats', - reserve: 'Reserve', - seatsAvailable: '{{available}} / {{total}} available', - ratelimit: 'You can not reserve more seats.', - noSeats: 'No more seats available.', - footer: 'Up to 5 active reservations are allowed per seven days. Cancelation is possible before the reservation starts.', - book: 'Book seat', - room: 'Room', - }, - code: { - number: 'Number', - footer: 'Use this barcode to sign in at the library terminals to borrow books.', - }, - }, - }, - notification: { - permission: { - title: 'Notifications', - description: 'Open the system settings to enable notifications.', - button: 'Settings', - }, - }, -} diff --git a/src/localization/en/flow.json b/src/localization/en/flow.json new file mode 100644 index 00000000..1ebebd6f --- /dev/null +++ b/src/localization/en/flow.json @@ -0,0 +1,55 @@ +{ + "whatsnew": { + "title": "What's new?", + "version": "in version {{version}}", + "continue": "Continue" + }, + "onboarding": { + "links": { + "privacy": "Privacy Policy", + "agree1": "By continuing, you agree to the ", + "agree2": ".", + "safety": "Data Safety" + }, + "page1": { + "title": "Welcome to" + }, + "cards": { + "title1": "Everything in one place", + "description1": "This app combines everything you need for your studies in one place.", + "title2": "By students for students", + "description2": "Neuland Next is an open-source project by Neuland Ingolstadt e.V. and is an alternative to the official THI app.", + "title3": "Security comes first", + "description3": "Your data is strictly protected by using the official and encrypted services of THI and is never accessible to us or third parties." + } + }, + "login": { + "title1": "Sign in", + "title2": "with your THI account", + "toast": "Login successful", + "alert": { + "error": { + "title": "Login failed", + "wrongCredentials": { + "title": "Wrong credentials", + "message": "Your login credentials are incorrect. Please try again." + }, + "missing": "Please fill out all fields.", + "generic": "An error occurred while connecting to the server.", + "backend": "The THI backend is currently unavailable. Please try again later.", + "noConnection": { + "title": "Network request failed", + "message": "Please check your internet connection and try again." + } + }, + "restored": { + "title": "Credentials restored", + "message": "Previous login data was found in the iOS keychain." + } + }, + "username": "Username", + "password": "Password", + "button": "Sign in", + "guest": "or continue as guest" + } +} diff --git a/src/localization/en/flow.ts b/src/localization/en/flow.ts deleted file mode 100644 index 1410c187..00000000 --- a/src/localization/en/flow.ts +++ /dev/null @@ -1,62 +0,0 @@ -export default { - whatsnew: { - title: "What's new?", - version: 'in version {{version}}', - continue: 'Continue', - }, - onboarding: { - links: { - privacy: 'Privacy Policy', - privacypolicy: 'Privacy Policy', - imprint: 'Imprint', - agree1: 'By continuing, you agree to our ', - agree2: '.', - }, - page1: { - title: 'Welcome to\nNeuland Next', - subtitle: ' Swipe to learn more', - }, - page2: { - title: 'Everything in one place', - text: - `Neuland Next combines all important information about your studies in one app.\n\n` + - `Customize your dashboard to your needs and get a quick overview of your schedule, grades, and more.\n\n` + - `The interactive map shows you all important locations on campus.`, - }, - page3: { - title: 'Data Security', - text: - `Neuland Next is an open source project and developed by students for students.\n\n` + - `As an alternative to the official THI app, we strictly protect your data. ` + - `To access your personal data, we use the official and encrypted API provided by the THI.\n\n` + - `Your password and data is therefore never accessible to us or third parties.`, - }, - navigation: { - next: 'Next', - skip: 'Skip', - }, - }, - login: { - title: 'Sign in with your THI account', - toast: 'Login successful', - alert: { - error: { - title: 'Login failed', - wrongCredentials: 'Your login credentials are incorrect.', - missing: 'Please fill out all fields.', - generic: 'An error occurred while connecting to the server.', - backend: - 'The THI backend is currently unavailable. Please try again later.', - noConnection: 'Network request failed', - }, - restored: { - title: 'Credentials restored', - message: 'Previous login data was found in the iOS keychain.', - }, - }, - username: 'THI Username', - password: 'Password', - button: 'Sign In', - guest: 'or continue as guest', - }, -} diff --git a/src/localization/en/food.json b/src/localization/en/food.json new file mode 100644 index 00000000..053a46dc --- /dev/null +++ b/src/localization/en/food.json @@ -0,0 +1,90 @@ +{ + "empty": { + "allergens": "No matching allergens found.", + "noAllergens": "Allergens not available", + "flags": "No matching flags found.", + "config": "No allergens set up yet. Tap to configure your allergies to display not binding information about allergens." + }, + "preferences": { + "formlist": { + "allergens": "Allergens", + "flags": "Flags", + "static": "Static meals", + "language": "Food language" + }, + "footer": "We are not responsible for the correctness and accuracy of the data. Please verify the data at the restaurant before consuming. You can also check the data source of each meal in the detail view.", + "languages": { + "de": "German", + "en": "English", + "auto": "Default" + } + }, + "price": { + "guests": "for guests", + "students": "for students", + "employees": "for employees", + "unknown": "price not available" + }, + "dashboard": { + "oneMore": "and one more meal", + "manyMore": "and {{count}} more meals", + "empty": "Today's menu is empty." + }, + "categories": { + "soup": "Soup", + "salad": "Salad", + "main": "Food", + "special": "Special" + }, + "details": { + "footer": "We are not responsible for the correctness of the data. Please verify the correctness of the data with the respective restaurant before consume anything.", + "translated": "This meal has been automatically translated. ", + "formlist": { + "prices": { + "title": "Prices", + "student": "Student", + "employee": "Employee", + "guest": "Guest" + }, + "nutrition": { + "title": "Nutrition", + "footer": "Values are per meal and may vary.", + "energy": "Energy", + "fat": "Fat", + "saturated": "Saturated Fat", + "carbs": "Carbohydrates", + "sugar": "Sugar", + "fiber": "Fiber", + "protein": "Protein", + "salt": "Salt" + }, + "about": { + "title": "About", + "category": "Category", + "source": "Data source" + }, + "variants": "Variations", + "allergenFooter": "We cannot guarantee the correctness and completeness of the information. ({{allergens}})", + "flagsFooter": "Tap on a flag to quickly update your preferences.", + "alert": { + "allergen": { + "title": "Update allergens", + "message": { + "add": "Do you want to add {{allergen}} to your allergens?", + "remove": "Do you want to remove {{allergen}} from your allergens?" + } + }, + "flag": { + "title": "Update preferences", + "message": { + "add": "Do you want to add {{flag}} to your preferences?", + "remove": "Do you want to remove {{flag}} from your preferences?" + } + } + } + }, + "share": { + "message": "Check out \"{{meal}}\" ({{price}}) at {{location}}.\nhttps://neuland.app/food/{{id}}" + } + } +} diff --git a/src/localization/en/food.ts b/src/localization/en/food.ts deleted file mode 100644 index 4b50f1c8..00000000 --- a/src/localization/en/food.ts +++ /dev/null @@ -1,92 +0,0 @@ -export default { - empty: { - allergens: 'No matching allergens found.', - noAllergens: 'Allergens not available', - flags: 'No matching flags found.', - config: 'No allergens set up yet. Tap to configure your allergies to display not binding information about allergens.', - }, - preferences: { - formlist: { - allergens: 'Allergens', - flags: 'Flags', - static: 'Static meals', - language: 'Food language', - }, - footer: 'We are not responsible for the correctness and accuracy of the data. Please verify the data at the restaurant before consuming. You can also check the data source of each meal in the detail view.', - languages: { - de: 'German', - en: 'English', - auto: 'Default', - }, - }, - price: { - guests: 'for guests', - students: 'for students', - employees: 'for employees', - unknown: 'price not available', - }, - dashboard: { - oneMore: 'and one more meal', - manyMore: 'and {{count}} more meals', - empty: "Today's menu is empty.", - }, - categories: { - soup: 'Soup', - salad: 'Salad', - main: 'Food', - special: 'Special', - }, - details: { - footer: 'We are not responsible for the correctness of the data. Please verify the correctness of the data with the respective restaurant before consume anything.', - translated: 'This meal has been automatically translated. ', - formlist: { - prices: { - title: 'Prices', - student: 'Student', - employee: 'Employee', - guest: 'Guest', - }, - nutrition: { - title: 'Nutrition', - footer: 'Values are per meal and may vary.', - energy: 'Energy', - fat: 'Fat', - saturated: 'Saturated Fat', - carbs: 'Carbohydrates', - sugar: 'Sugar', - fiber: 'Fiber', - protein: 'Protein', - salt: 'Salt', - }, - about: { - title: 'About', - category: 'Category', - source: 'Data source', - }, - variants: 'Variations', - allergenFooter: - 'We cannot guarantee the correctness and completeness of the information. ({{allergens}})', - flagsFooter: 'Tap on a flag to quickly update your preferences.', - alert: { - allergen: { - title: 'Update allergens', - message: { - add: 'Do you want to add {{allergen}} to your allergens?', - remove: 'Do you want to remove {{allergen}} from your allergens?', - }, - }, - flag: { - title: 'Update preferences', - message: { - add: 'Do you want to add {{flag}} to your preferences?', - remove: 'Do you want to remove {{flag}} from your preferences?', - }, - }, - }, - }, - share: { - message: - 'Check out "{{meal}}" ({{price}}) at {{location}}.\nhttps://neuland.app/food/{{id}}', - }, - }, -} diff --git a/src/localization/en/index.ts b/src/localization/en/index.ts deleted file mode 100644 index 0cfb5c0a..00000000 --- a/src/localization/en/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import api from './api' -import common from './common' -import flow from './flow' -import food from './food' -import navigation from './navigation' -import settings from './settings' -import timetable from './timetable' - -export default { - common, - settings, - navigation, - food, - flow, - timetable, - api, -} diff --git a/src/localization/en/navigation.json b/src/localization/en/navigation.json new file mode 100644 index 00000000..91d82b95 --- /dev/null +++ b/src/localization/en/navigation.json @@ -0,0 +1,94 @@ +{ + "navigation": { + "timetable": "Timetable", + "map": "Map", + "campusMap": "Campus Map", + "food": "Food", + "settings": "Settings", + "preferences": "Preferences", + "flags": "Flags", + "flagsSearch": "Search flags", + "allergensSearch": "Search allergens", + "allergens": "Allergens", + "details": "Details", + "theme": "Design", + "profile": "Profile", + "about": "About", + "advancedSearch": "Advanced Search", + "calendar": "Calendar", + "examDetails": "Exam Details", + "lecturers": { + "title": "Lecturers", + "search": "Search all lecturers" + }, + "lecturer": "Lecturer Details", + "grades": "Grades", + "news": "THI News", + "library": "Library", + "libraryCode": "Library Number", + "notifications": "Notifications", + "licenses": { + "title": "Licenses", + "search": "Search licenses" + }, + "license": "License", + "login": "Sign in", + "dashboard": "Neuland Next" + }, + "cards": { + "titles": { + "timetable": "Timetable", + "calendar": "Calendar", + "rooms": "Rooms", + "lecturers": "Lecturers", + "news": "THI News", + "library": "Library", + "events": "Campus Life", + "reimanns": "Reimanns", + "canisius": "Canisius Konvikt", + "mensa": "Mensa Ingolstadt", + "mensaNeuburg": "Theke Neuburg", + "food": "Food", + "login": "Sign in" + }, + "announcements": { + "readMore": "Tap to learn more." + }, + "events": { + "by": "by {{name}}" + }, + "calendar": { + "exam": "Exam: {{name}}", + "ends": "ends " + }, + "timetable": { + "startingSoon": "starts in {{count}} min", + "startingSoon_plural": "starts in {{count}} mins", + "ongoing": "ends at {{time}}", + "endingSoon": "ends in {{count}} min", + "endingSoon_plural": "ends in {{count}} mins" + }, + "login": { + "title": "Timetable, grades, room status and more", + "message": "Sign in to unlock all features." + } + }, + "contextMenu": { + "reset": "Reset cards", + "hide": "Hide card", + "settings": "Dashboard settings" + }, + "unmatched": { + "title": "Not found", + "error": { + "title": "not found", + "message": "The requested page is not available.", + "button": "Back" + } + }, + "profile": { + "error": { + "button": "Primuss" + } + } +} diff --git a/src/localization/en/navigation.ts b/src/localization/en/navigation.ts deleted file mode 100644 index 24ab3820..00000000 --- a/src/localization/en/navigation.ts +++ /dev/null @@ -1,84 +0,0 @@ -export default { - navigation: { - timetable: 'Timetable', - map: 'Map', - campusMap: 'Campus Map', - food: 'Food', - settings: 'Settings', - preferences: 'Preferences', - flags: 'Flags', - flagsSearch: 'Search flags', - allergensSearch: 'Search allergens', - allergens: 'Allergens', - details: 'Details', - theme: 'Design', - profile: 'Profile', - about: 'About', - advancedSearch: 'Advanced Search', - calendar: 'Calendar', - examDetails: 'Exam Details', - lecturers: { - title: 'Lecturers', - search: 'Search all lecturers', - }, - lecturer: 'Lecturer Details', - grades: 'Grades', - news: 'THI News', - library: 'Library', - libraryCode: 'Library Number', - notifications: 'Notifications', - licenses: 'Licenses', - license: 'License', - }, - cards: { - titles: { - timetable: 'Timetable', - calendar: 'Calendar', - rooms: 'Rooms', - lecturers: 'Lecturers', - news: 'THI News', - library: 'Library', - events: 'Campus Life', - reimanns: 'Reimanns', - canisius: 'Canisius Konvikt', - mensa: 'Mensa Ingolstadt', - mensaNeuburg: 'Theke Neuburg', - food: 'Food', - login: 'Sign in', - }, - announcements: { - readMore: 'Tap to learn more.', - }, - events: { - by: 'by {{name}}', - }, - calendar: { - exam: 'Exam: {{name}}', - ends: 'ends ', - }, - timetable: { - startingSoon: 'starts in {{count}} min', - startingSoon_plural: 'starts in {{count}} mins', - ongoing: 'ends at {{time}}', - endingSoon: 'ends in {{count}} min', - endingSoon_plural: 'ends in {{count}} mins', - }, - login: { - title: 'Timetable, grades, room status and more', - message: 'Sign in to unlock all features.', - }, - }, - contextMenu: { - reset: 'Reset cards', - hide: 'Hide card', - settings: 'Dashboard settings', - }, - unmatched: { - title: 'Not found', - error: { - title: 'not found', - message: 'The requested page is not available.', - button: 'Back', - }, - }, -} diff --git a/src/localization/en/settings.json b/src/localization/en/settings.json new file mode 100644 index 00000000..65ff3ea8 --- /dev/null +++ b/src/localization/en/settings.json @@ -0,0 +1,183 @@ +{ + "menu": { + "guest": { + "title": "Sign in", + "subtitle": "Sign in to unlock all features of the app" + }, + "employee": { + "subtitle1": "Employee account", + "subtitle2": "Tap to logout" + }, + "error": { + "subtitle2": "Pull to refresh or tap to logout", + "noData": { + "title": "No data available", + "subtitle1": "Looks like you are no longer enrolled.", + "subtitle2": "Please check your Primuss account." + } + }, + "formlist": { + "preferences": { + "title": "Preferences", + "food": "Food", + "language": "Language" + }, + "appearance": { + "title": "Appearance", + "theme": "Design" + }, + "language": { + "title": "Change language", + "message": "Confirm to change the app language to German.", + "confirm": "Confirm" + }, + "legal": { + "title": "Legal", + "about": "About", + "rate": "Rate the app" + } + }, + "copyright": "© {{year}} by Neuland Ingolstadt e.V." + }, + "about": { + "header": { + "developed": "Developed by" + }, + "formlist": { + "legal": { + "title": "Legal", + "privacy": "Privacy Policy", + "imprint": "Imprint" + }, + "about": { + "title": "About us" + } + }, + "easterEgg": { + "title": "Easter Egg", + "message": "You unlocked the exclusive app icon \"Paradise Cat\"! 😻", + "messageAndroid": "You found an easter egg! 🐣", + "confirm": "Nice!" + }, + "analytics": { + "title": "Analytics", + "toggle": "Collect Anonymous Usage Data", + "message": "Help us improve the app by sending anonymous usage data. It is not possible to deduce your identity at any time." + } + }, + "changelog": { + "footer": "To see the full changelog, check out the commits on " + }, + "licenses": { + "footer": "These libraries were used to create this app. Some are platform-specific and therefore may not be included in your build." + }, + "profile": { + "error": { + "title": "No data available", + "message": "Looks like you are no longer enrolled. Please check your Primuss account.", + "button": "Primuss" + }, + + "formlist": { + "grades": { + "title": "Grades", + "button": "Grades and subjects" + }, + "user": { + "title": "User", + "matrical": "Matriculation number", + "library": "Library number", + "printer": "Printer credits" + }, + "study": { + "title": "Study", + "degree": "Degree", + "spo": "Exam regulations", + "group": "Study group" + }, + "contact": { + "title": "Contact", + "phone": "Phone", + "street": "Street", + "city": "City" + } + }, + "logout": { + "button": "Logout", + "alert": { + "title": "Confirm", + "message": "This will log you out of the app and clear all your data.", + "cancel": "Cancel", + "confirm": "Logout" + } + } + }, + "theme": { + "accent": { + "title": "Accent color" + }, + "colors": { + "teal": "Teal", + "blue": "Blue", + "contrast": "Contrast", + "pink": "Pink", + "brown": "Brown", + "purple": "Purple", + "yellow": "Yellow", + "orange": "Orange", + "green": "Neuland" + }, + "footer": "This changes the color of the icons and buttons in the app.", + "themes": { + "title": "Theme", + "default": "Automatic", + "dark": "Dark", + "light": "Light" + } + }, + "dashboard": { + "shown": "Shown cards", + "hidden": "Hidden cards", + "reset": "Reset order", + "noShown": "Dashboard is empty", + "noShownDescription": "Add some cards to your dashboard to get started.", + "noShownButton": "Configure", + "footer": "Customize your dashboard by dragging and dropping the cards to your preferred order. Hide cards by pressing on the remove icon.", + "unavailable": { + "title": "Unavailable cards", + "message": "To use all features of the app, you need to sign in. Tap to continue with your THI account." + } + }, + "grades": { + "grade": "Grade", + "none": "(none)", + "finished": "Grades", + "open": "Open", + "average": "Average", + "averageError": "Average grade is currently not available.", + "missingAverage": "The exact average cannot be calculated and ranges between {{min}} and {{max}}.", + "exactAverage": "Based on {{number}} weighted grades.", + "temporarilyUnavailable": "Grades are temporarily unavailable.", + "footer": "This is overview is only for general information and is not legally binding. Please refer to the official grades sheet on Primuss for binding information." + }, + "appIcon": { + "names": { + "default": "Neuland Next", + "modernDark": "Modern Dark", + "retro": "neuland.app", + "modernGreen": "Modern Green", + "modernPink": "Modern Pink", + "rainbowMoonLight": "Rainbow Light", + "rainbowDark": "Rainbow Dark", + "rainbowNeon": "Rainbow Neon", + "cat": "Pradise Cat" + }, + "categories": { + "default": "Default", + "neon": "Neon", + "rainbow": "Rainbow", + "exclusive": "Exclusive" + }, + "exclusive": "Attend to our events and find easter eggs to unlock exclusive app icons." + } +} diff --git a/src/localization/en/settings.ts b/src/localization/en/settings.ts deleted file mode 100644 index dab4db01..00000000 --- a/src/localization/en/settings.ts +++ /dev/null @@ -1,189 +0,0 @@ -export default { - menu: { - guest: { - title: 'Sign in', - subtitle: 'Sign in to unlock all features of the app', - }, - employee: { - subtitle1: 'Employee account', - subtitle2: 'Tap to logout', - }, - error: { - subtitle2: 'Pull to refresh or tap to logout', - noData: { - title: 'No data available', - subtitle1: 'Looks like you are no longer enrolled.', - subtitle2: 'Please check your Primuss account.', - }, - }, - formlist: { - preferences: { - title: 'Preferences', - food: 'Food', - language: 'Language', - }, - appearance: { - title: 'Appearance', - theme: 'Design', - }, - language: { - title: 'Change language', - message: 'Confirm to change the app language to German.', - confirm: 'Confirm', - }, - legal: { - title: 'Legal', - about: 'About', - rate: 'Rate the app', - }, - }, - copyright: '© {{year}} by Neuland Ingolstadt e.V.', - }, - about: { - header: { - developed: 'Developed by', - }, - formlist: { - legal: { - title: 'Legal', - privacy: 'Privacy Policy', - imprint: 'Imprint', - }, - about: { - title: 'About us', - }, - }, - easterEgg: { - title: 'Easter Egg', - message: 'You unlocked the exclusive app icon "Paradise Cat"! 😻', - messageAndroid: 'You found an easter egg! 🐣', - confirm: 'Nice!', - }, - analytics: { - title: 'Analytics', - toggle: 'Collect Anonymous Usage Data', - message: - 'Help us improve the app by sending anonymous usage data. It is not possible to deduce your identity at any time.', - }, - }, - changelog: { - footer: 'To see the full changelog, check out the commits on ', - }, - licenses: { - footer: 'These libraries were used to create this app. Some are platform-specific and therefore may not be included in your build.', - }, - profile: { - error: { - title: 'No data available', - message: - 'Looks like you are no longer enrolled. Please check your Primuss account.', - button: 'Primuss', - }, - - formlist: { - grades: { - title: 'Grades', - button: 'Grades and subjects', - }, - user: { - title: 'User', - matrical: 'Matriculation number', - library: 'Library number', - printer: 'Printer credits', - }, - study: { - title: 'Study', - degree: 'Degree', - spo: 'Exam regulations', - group: 'Study group', - }, - contact: { - title: 'Contact', - phone: 'Phone', - street: 'Street', - city: 'City', - }, - }, - logout: { - button: 'Logout', - alert: { - title: 'Confirm', - message: - 'This will log you out of the app and clear all your data.', - cancel: 'Cancel', - confirm: 'Logout', - }, - }, - }, - theme: { - accent: { - title: 'Accent color', - }, - colors: { - teal: 'Teal', - blue: 'Blue', - contrast: 'Contrast', - pink: 'Pink', - brown: 'Brown', - purple: 'Purple', - yellow: 'Yellow', - orange: 'Orange', - green: 'Neuland', - }, - footer: 'This changes the color of the icons and buttons in the app.', - themes: { - title: 'Theme', - default: 'Automatic', - dark: 'Dark', - light: 'Light', - }, - }, - dashboard: { - shown: 'Shown cards', - hidden: 'Hidden cards', - reset: 'Reset order', - noShown: 'Dashboard is empty', - noShownDescription: 'Add some cards to your dashboard to get started.', - noShownButton: 'Configure', - footer: 'Customize your dashboard by dragging and dropping the cards to your preferred order. Hide cards by pressing on the remove icon.', - unavailable: { - title: 'Unavailable cards', - message: - 'To use all features of the app, you need to sign in. Tap to continue with your THI account.', - }, - }, - grades: { - grade: 'Grade', - none: '(none)', - finished: 'Grades', - open: 'Open', - average: 'Average', - averageError: 'Average grade is currently not available.', - missingAverage: - 'The exact average cannot be calculated and ranges between {{min}} and {{max}}.', - exactAverage: 'Based on {{number}} weighted grades.', - temporarilyUnavailable: 'Grades are temporarily unavailable.', - footer: 'This is overview is only for general information and is not legally binding. Please refer to the official grades sheet on Primuss for binding information.', - }, - appIcon: { - names: { - default: 'Neuland Next', - modernDark: 'Modern Dark', - retro: 'neuland.app', - modernGreen: 'Modern Green', - modernPink: 'Modern Pink', - rainbowMoonLight: 'Rainbow Light', - rainbowDark: 'Rainbow Dark', - rainbowNeon: 'Rainbow Neon', - cat: 'Pradise Cat', - }, - categories: { - default: 'Default', - neon: 'Neon', - rainbow: 'Rainbow', - exclusive: 'Exclusive', - }, - exclusive: - 'Attend to our events and find easter eggs to unlock exclusive app icons.', - }, -} diff --git a/src/localization/en/timetable.json b/src/localization/en/timetable.json new file mode 100644 index 00000000..89bfbee7 --- /dev/null +++ b/src/localization/en/timetable.json @@ -0,0 +1,44 @@ +{ + "details": { + "title": "Course details", + "weeklySemesterHours": "SWS", + "exam": "Exam", + "studyGroup": "Study group", + "courseOfStudies": "Course of studies", + "error": { + "title": "Details not available", + "message": "The details for this lecture are not available." + } + }, + "overview": { + "title": "Overview", + "goal": "Goal", + "content": "Content", + "literature": "Literature" + }, + "time": { + "minutes": "minutes" + }, + "error": { + "empty": { + "title": "Timetable not configured", + "title2": "No lectures found", + "message": "To display your timetable, you have to configure it using the THI timetable website.", + "button": "Configure now" + }, + "filtered": { + "title": "No future lectures found", + "message": "Looks like there are no more lectures this semester." + } + }, + "notificatons": { + "title": "Notifications", + "description": "Receive notifications before this lecture starts. These repeat until you deactivate them here.", + "active": "Notifications enabled. You will be notified {{mins}} minutes before the lecture starts.", + "five": "5 minutes before", + "fifteen": "15 minutes before", + "thirty": "30 minutes before", + "sixty": "60 minutes before", + "body": "starts in {{mins}} minutes in room {{room}}." + } +} diff --git a/src/localization/en/timetable.ts b/src/localization/en/timetable.ts deleted file mode 100644 index ecab8396..00000000 --- a/src/localization/en/timetable.ts +++ /dev/null @@ -1,46 +0,0 @@ -export default { - details: { - title: 'Course details', - weeklySemesterHours: 'SWS', - exam: 'Exam', - studyGroup: 'Study group', - courseOfStudies: 'Course of studies', - error: { - title: 'Details not available', - message: 'The details for this lecture are not available.', - }, - }, - overview: { - title: 'Overview', - goal: 'Goal', - content: 'Content', - literature: 'Literature', - }, - time: { - minutes: 'minutes', - }, - error: { - empty: { - title: 'Timetable not configured', - title2: 'No lectures found', - message: - 'To display your timetable, you have to configure it using the THI timetable website.', - button: 'Configure now', - }, - filtered: { - title: 'No future lectures found', - message: 'Looks like there are no more lectures this semester.', - }, - }, - notificatons: { - title: 'Notifications', - description: - 'Receive notifications before this lecture starts. These repeat until you deactivate them here.', - active: 'Notifications enabled. You will be notified {{mins}} minutes before the lecture starts.', - five: '5 minutes before', - fifteen: '15 minutes before', - thirty: '30 minutes before', - sixty: '60 minutes before', - body: 'starts in {{mins}} minutes in room {{room}}.', - }, -} diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 7b696700..8360ff15 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -2,8 +2,44 @@ import { getLocales } from 'expo-localization' import i18n from 'i18next' import { initReactI18next } from 'react-i18next' -import de from './de' -import en from './en' +import accessibilityDE from './de/accessibility.json' +import apiDE from './de/api.json' +import commonDE from './de/common.json' +import flowDE from './de/flow.json' +import foodDE from './de/food.json' +import navigationDE from './de/navigation.json' +import settingsDE from './de/settings.json' +import timetableDE from './de/timetable.json' +import accessiblityEN from './en/accessibility.json' +import apiEN from './en/api.json' +import commonEN from './en/common.json' +import flowEN from './en/flow.json' +import foodEN from './en/food.json' +import navigationEN from './en/navigation.json' +import settingsEN from './en/settings.json' +import timetableEN from './en/timetable.json' + +const en = { + api: apiEN, + common: commonEN, + settings: settingsEN, + navigation: navigationEN, + food: foodEN, + flow: flowEN, + timetable: timetableEN, + accessibility: accessiblityEN, +} + +const de = { + api: apiDE, + common: commonDE, + settings: settingsDE, + navigation: navigationDE, + food: foodDE, + flow: flowDE, + timetable: timetableDE, + accessibility: accessibilityDE, +} export const resources = { en, @@ -12,7 +48,7 @@ export const resources = { export const defaultNS = 'en' export type LanguageKey = keyof typeof resources -const languageCode = getLocales()[0].languageCode +const languageCode = getLocales()[0].languageCode ?? '' const fallbackLanguage = defaultNS const language = Object.keys(resources).includes(languageCode) ? languageCode @@ -22,10 +58,9 @@ void i18n.use(initReactI18next).init({ fallbackLng: fallbackLanguage, lng: language, compatibilityJSON: 'v3', - resources, - debug: false, interpolation: { escapeValue: false, }, + resources, }) export default i18n diff --git a/src/types/components.ts b/src/types/components.ts index 8fbc4616..c0743511 100644 --- a/src/types/components.ts +++ b/src/types/components.ts @@ -1,4 +1,4 @@ -import { type MaterialCommunityIcons } from '@expo/vector-icons' +import { type CommunityIcon } from '@/components/Elements/Universal/Icon' import { type ColorValue } from 'react-native' import { type MaterialIcon } from './material-icons' @@ -8,7 +8,7 @@ export interface SectionGroup { value?: string icon?: { ios: string - android: MaterialIcon | typeof MaterialCommunityIcons.defaultProps.name + android: MaterialIcon | CommunityIcon iosFallback?: boolean } disabled?: boolean @@ -28,6 +28,7 @@ export interface SectionGroup { | '800' | '900' | undefined + selectable?: boolean } export interface FormListSections { diff --git a/src/utils/animation-utils.ts b/src/utils/animation-utils.ts new file mode 100644 index 00000000..a5ab527a --- /dev/null +++ b/src/utils/animation-utils.ts @@ -0,0 +1,80 @@ +import { ImpactFeedbackStyle, impactAsync } from 'expo-haptics' +import { useCallback, useState } from 'react' +import { Platform } from 'react-native' +import { defineAnimation, runOnJS } from 'react-native-reanimated' + +import { getRandomHSLColor } from './ui-utils' + +interface AnimationState { + current: number + lastTimestamp: number + direction: number +} + +export function animatedHapticFeedback(): void { + 'worklet' + const shouldVibrate = Platform.OS === 'ios' + if (shouldVibrate) runOnJS(impactAsync)(ImpactFeedbackStyle.Light) +} + +export function useRandomColor(): { + color: string + randomizeColor: () => void +} { + const [color, setColor] = useState(getRandomHSLColor) + + const randomizeColor = useCallback(() => { + setColor(getRandomHSLColor()) + }, []) + + return { color, randomizeColor } +} + +export function withBouncing( + velocity: number, + bottomBound: number, + topBound: number, + randomizeColor: () => void +): any { + 'worklet' + return defineAnimation( + { + current: bottomBound, + lastTimestamp: Date.now(), + direction: 1, + }, + (): any => { + 'worklet' + const onFrame = (state: AnimationState, now: number): boolean => { + const delta = (now - state.lastTimestamp) / 1000 + state.current += state.direction * delta * velocity + + if (state.current < bottomBound || state.current > topBound) { + state.direction *= -1 + state.current = Math.min( + Math.max(state.current, bottomBound), + topBound + ) + + runOnJS(randomizeColor)() + animatedHapticFeedback() + } + + state.lastTimestamp = now + return false + } + + const onStart = ( + state: AnimationState, + _value: number, + now: number + ): void => { + state.current = bottomBound + 0.8 * (topBound - bottomBound) + state.lastTimestamp = now + state.direction = 1 + } + + return { onFrame, onStart } + } + ) +} diff --git a/src/utils/api-utils.ts b/src/utils/api-utils.ts index c25eb741..63f6b448 100644 --- a/src/utils/api-utils.ts +++ b/src/utils/api-utils.ts @@ -1,13 +1,17 @@ import API from '@/api/authenticated-api' import { createGuestSession } from '@/api/thi-session-handler' -import { queryClient } from '@/components/provider' import courseShortNames from '@/data/course-short-names.json' -import { USER_GUEST } from '@/hooks/contexts/userKind' import { type CourseShortNames } from '@/types/data' import { type PersDataDetails } from '@/types/thi-api' +import { type QueryClient } from '@tanstack/react-query' import { router } from 'expo-router' import { getItemAsync } from 'expo-secure-store' +import { USER_GUEST } from './app-utils' + +export const networkError = 'Network request failed' +export const guestError = 'User is logged in as guest' +export const permissionError = '"Service for user-group not defined" (-120)' /** * Removes the quotation marks and the error code from the error message. * @param str The error message string to be trimmed. @@ -38,25 +42,21 @@ export async function getUsername(): Promise { export const performLogout = async ( toggleUser: (user: undefined) => void, - toggleAccentColor: (color: string) => void, - resetDashboard: (userKind: string) => void + resetDashboard: (userKind: string) => void, + queryClient: QueryClient ): Promise => { try { toggleUser(undefined) - toggleAccentColor('blue') + resetDashboard(USER_GUEST) - queryClient.clear() - router.navigate('(tabs)') await createGuestSession() + queryClient.clear() + router.navigate('/') } catch (e) { console.log(e) } } -export const networkError = 'Network request failed' -export const guestError = 'User is logged in as guest' -export const permissionError = '"Service for user-group not defined" (-120)' - /** * Checks if the error message is a known error. * @param error The error to be checked. @@ -71,7 +71,12 @@ export const isKnownError = (error: Error | string): boolean => { ) } +/** + * Fetches the personal data of the user. + * @returns The personal data of the user. + */ export async function getPersonalData(): Promise { + console.log('Fetching personal data') const response = await API.getPersonalData() const data: PersDataDetails = response.persdata data.pcounter = response.pcounter @@ -99,3 +104,39 @@ export function extractFacultyFromPersonal( ) return faculty } + +/** + * Determines the users SPO version. + * @param {PersonalDataDetails} data Personal data + * @returns {string} + */ +export function extractSpoName(data: PersDataDetails): string | null { + if (data?.po_url == null) { + return null + } + + const split = data.po_url.split('/').filter((x) => x.length > 0) + return split[split.length - 1] +} + +/** + * Determines the users faculty. + * @param {PersonalData} data Personal data + * @returns {string} Faculty name (e.g. `Informatik`) + */ +export function extractFacultyFromPersonalData( + data: PersDataDetails | undefined +): string | null { + if (data?.stg == null) { + return null + } + const shortNames: CourseShortNames = courseShortNames + const shortName = data.stg + const faculty = Object.keys(shortNames).find((faculty) => + (courseShortNames as Record)[faculty].includes( + shortName + ) + ) + + return faculty ?? null +} diff --git a/src/utils/app-utils.ts b/src/utils/app-utils.ts index 841ae481..81263090 100644 --- a/src/utils/app-utils.ts +++ b/src/utils/app-utils.ts @@ -31,5 +31,9 @@ export function arraysEqual(arr1: any[], arr2: any[]): boolean { } export const PRIVACY_URL = - 'https://assets.neuland.app/datenschutzerklaerung-app.htm' + 'https://assets.neuland.app/datenschutzerklaerung-app-v2.htm' export const IMPRINT_URL = 'https://assets.neuland.app/impressum-app.htm' +export const STATUS_URL = 'https://status.neuland.app/status/app' +export const USER_STUDENT = 'student' +export const USER_EMPLOYEE = 'employee' +export const USER_GUEST = 'guest' diff --git a/src/utils/calendar-utils.ts b/src/utils/calendar-utils.ts index 8e08916d..d620bf7f 100644 --- a/src/utils/calendar-utils.ts +++ b/src/utils/calendar-utils.ts @@ -9,11 +9,14 @@ import { ignoreTime } from './date-utils' export const compileTime = new Date() export const calendar: Calendar[] = rawCalendar - .map((x) => { + .map((x: unknown) => { const event: Calendar = { - ...x, - begin: new Date(x.begin), - end: x.end != null ? new Date(x.end) : undefined, + ...(x as Calendar), + begin: new Date((x as Calendar).begin), + end: + (x as Calendar).end != null + ? new Date((x as any).end as string) + : undefined, } return event diff --git a/src/utils/food-utils.ts b/src/utils/food-utils.ts index 145ab9e6..42832f6e 100644 --- a/src/utils/food-utils.ts +++ b/src/utils/food-utils.ts @@ -1,16 +1,13 @@ import NeulandAPI from '@/api/neuland-api' +import { type FoodLanguage } from '@/contexts/foodFilter' import allergenMap from '@/data/allergens.json' import flapMap from '@/data/mensa-flags.json' -import { type FoodLanguage } from '@/hooks/contexts/foodFilter' -import { - USER_EMPLOYEE, - USER_GUEST, - USER_STUDENT, -} from '@/hooks/contexts/userKind' import { type LanguageKey } from '@/localization/i18n' import { type Food, type Meal, type Name } from '@/types/neuland-api' import { type Labels, type Prices } from '@/types/utils' +import { type TFunction } from 'i18next' +import { USER_EMPLOYEE, USER_GUEST, USER_STUDENT } from './app-utils' import { formatISODate } from './date-utils' /** @@ -20,9 +17,11 @@ import { formatISODate } from './date-utils' */ export async function loadFoodEntries( restaurants: string[], - includeStatic: boolean + includeStatic: boolean = false ): Promise { - const data = [(await NeulandAPI.getFoodPlan(restaurants)).food] as Food[][] + const data = [ + (await NeulandAPI.getFoodPlan(restaurants)).food.foodData, + ] as Food[][] // create day entries for next 7 days (current and next week including the weekend) starting from monday let days: Date[] = Array.from({ length: 7 }, (_, i) => { @@ -123,11 +122,14 @@ export function getUserSpecificPrice(meal: Meal, userKind: string): string { * @returns {string} */ -export function getUserSpecificLabel(userKind: string, t: any): string { +export function getUserSpecificLabel( + userKind: string, + t: TFunction<'food'> +): string { const labels: Labels = { - [USER_GUEST]: t('price.guests'), - [USER_EMPLOYEE]: t('price.employees'), - [USER_STUDENT]: t('price.students'), + [USER_GUEST]: t('price.guests', { ns: 'food' }), + [USER_EMPLOYEE]: t('price.employees', { ns: 'food' }), + [USER_STUDENT]: t('price.students', { ns: 'food' }), } return labels[userKind] } diff --git a/src/utils/grades-utils.ts b/src/utils/grades-utils.ts index b5e679e9..aa5482d2 100644 --- a/src/utils/grades-utils.ts +++ b/src/utils/grades-utils.ts @@ -102,10 +102,10 @@ export async function calculateECTS(): Promise { * @throws {Error} - if the grade average is not available */ export async function loadGradeAverage( - courseSPOs: SpoWeights | null = null + courseSPOs: SpoWeights | null = null, + spoName: string | null = null ): Promise { const gradeList = await getGradeList() - const spoName = await API.getSpoName() if (spoName == null || courseSPOs?.[spoName] == null) { throw new Error('Failed to load data') diff --git a/src/utils/map-utils.ts b/src/utils/map-utils.ts index 373461c7..cd447f2d 100644 --- a/src/utils/map-utils.ts +++ b/src/utils/map-utils.ts @@ -4,7 +4,6 @@ import { type Rooms } from '@/types/thi-api' import { type AvailableRoom } from '@/types/utils' import { trackEvent } from '@aptabase/react-native' import { type GeoJsonProperties, type Position } from 'geojson' -import { type TFunction } from 'i18next' import { Platform, Share } from 'react-native' import { formatISODate } from './date-utils' @@ -186,27 +185,31 @@ export function getRoomOpenings(rooms: Rooms[], date: Date): RoomOpenings { * If outside the opening hours, this will skip to the time the university opens. * @returns {Date} */ -export function getNextValidDate(): Date { + +export function getNextValidDate(): { startDate: Date; wasModified: boolean } { const startDate = new Date() + let wasModified = false // Track if startDate is modified if (startDate.getDay() === 6 && startDate.getHours() > 20) { startDate.setDate(startDate.getDate() + 2) startDate.setHours(8) startDate.setMinutes(15) + wasModified = true // Date was modified } else if (startDate.getDay() === 0 || startDate.getHours() > 20) { - // sunday or after 9pm + // Sunday or after 8pm startDate.setDate(startDate.getDate() + 1) startDate.setHours(8) startDate.setMinutes(15) + wasModified = true // Date was modified } else if (startDate.getHours() < 8) { - // before 6am + // Before 8am startDate.setHours(8) startDate.setMinutes(15) + wasModified = true // Date was modified } - return startDate + return { startDate, wasModified } } - /** * Filters suitable room openings. * @param {object[]} data Room data @@ -273,6 +276,11 @@ export async function searchRooms( .sort((a, b) => a.room.localeCompare(b.room)) } +/** + * Filters suitable room openings. + * @param rooms Room data + * @returns {object[]} + */ export function getCenter(rooms: Position[][][]): Position { const getCenterPoint = (points: Position[][]): Position[] => { const x = points[0].map((point: any) => point[0]) @@ -301,6 +309,11 @@ export function getCenter(rooms: Position[][][]): Position { ] } +/** + * Filters suitable room openings. + * @param coordinates oom data + * @returns Position + */ export function getCenterSingle( coordinates: number[][][] | undefined ): number[] { @@ -337,24 +350,6 @@ export const handleShareModal = (room: string): void => { ) } -/** - * Adjusts error message to use it with ErrorView - * @param errorMsg Error message - * @returns - */ -export function adjustErrorTitle(errorMsg: string, t: TFunction): string { - switch (errorMsg) { - case 'noInternetConnection': - return 'Network request failed' - case 'mapLoadError': - return t('error.map.mapLoadError') - case 'mapOverlay': - return t('error.map.mapOverlay') - default: - return 'Error' - } -} - /** * Determines the type of search based on the search string. * @param search Search string diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 00000000..49a36f36 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,21 @@ +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' +import { MMKV } from 'react-native-mmkv' + +export const storage = new MMKV() + +const clientStorage = { + setItem: (key: string, value: string | number | boolean | Uint8Array) => { + storage.set(key, value) + }, + getItem: (key: string) => { + const value = storage.getString(key) + return value ?? null + }, + removeItem: (key: string) => { + storage.delete(key) + }, +} + +export const syncStoragePersister = createSyncStoragePersister({ + storage: clientStorage, +}) diff --git a/src/utils/timetable-utils.ts b/src/utils/timetable-utils.ts index 326833ac..121a70a3 100644 --- a/src/utils/timetable-utils.ts +++ b/src/utils/timetable-utils.ts @@ -1,12 +1,10 @@ import API from '@/api/authenticated-api' -import { type LectureData } from '@/hooks/contexts/notifications' import { type CalendarEvent, type Exam, type FriendlyTimetableEntry, type TimetableSections, } from '@/types/utils' -import { scheduleNotificationAsync } from 'expo-notifications' import { type TFunction } from 'i18next' import { Alert, Linking } from 'react-native' @@ -157,36 +155,6 @@ export function convertTimetableToWeekViewEvents( }) } -/** - * Schedules a notification for a lecture. - * @param lectureTitle Title of the lecture - * @param room Room of the lecture - * @param minsBefore Minutes before the lecture to send the notification - * @param date Date of the lecture - * @param t Translation function - * @returns Promise with the id of the scheduled notification - */ -export async function scheduleLectureNotification( - lectureTitle: string, - room: string, - minsBefore: number, - date: Date, - t: any -): Promise { - const alertDate = new Date(date.getTime() - minsBefore * 60000) - const id = await scheduleNotificationAsync({ - content: { - title: lectureTitle, - body: `${t('notificatons.body', { - mins: minsBefore, - room, - })}`, - }, - trigger: alertDate, - }) - return [{ startDateTime: date, room, id }] -} - /** * Shows an alert to the user that they need to enable notifications. * @param t Translation function diff --git a/src/utils/ui-utils.ts b/src/utils/ui-utils.ts index 10ef7787..64aaaba5 100644 --- a/src/utils/ui-utils.ts +++ b/src/utils/ui-utils.ts @@ -157,3 +157,18 @@ export const inverseColor = (color: ColorValue): string => { } return inverseColor } + +/** + * Generates a random HSL color, which is vibrant and visible. + * @returns A random HSL color. + */ +export function getRandomHSLColor(): string { + function rand(min: number, max: number): number { + return min + Math.random() * (max - min) + } + + const h = rand(1, 360) // hue + const s = rand(60, 100) // saturation + const l = rand(30, 70) // lightness + return `hsl(${h},${s}%,${l}%)` +}