diff --git a/.docs/DATA.md b/.docs/DATA.md
index 63d00da..9cbbdab 100644
--- a/.docs/DATA.md
+++ b/.docs/DATA.md
@@ -1,22 +1,37 @@
# Data guide
-## Data stored in SecureStore
+Everything stays on the device: UniceNotes has no server of its own and never uploads personal data.
+All keys are listed in `src/utils/storage.ts`.
+
+## SecureStore
-This data is stored in the secure enclave (Keychain from Apple / Keystore from Android) of the device, which is a secure storage system that is used to store sensitive data. The data is encrypted and can only be accessed by the user with biometrical authentication or a password (phone PIN).
+Encrypted storage backed by the iOS Keychain / Android Keystore.
-- ```username```: Username of the user
+- `adeid`: ADE timetable code, either a student number or a program id ending with `-VET`
-## Data stored in AsyncStorage
+## AsyncStorage
-This data is stored in the AsyncStorage of the device, which is a storage system that is used to store data. The data is not encrypted and can be accessed by the user.
+Unencrypted key-value storage for non-sensitive preferences.
+
+- `theme`: selected color palette (`azur`, `menthe`, `lavande`, `ambre`)
+- `haptics`: `"false"` when haptic feedback is disabled
+- `liveActivities`: `"false"` when the iOS Live Activity is disabled
+- `oobeCompleted`: `"true"` once the first-launch setup is done
+- `releaseNotesVersion`: last app version whose release notes were shown
+- `adeProjectOverride`: ADE project (school year) picked manually, absent in automatic mode
+- `favoriteRooms`: ids of the pinned rooms (3 max)
+- `pinnedApps`: ids of the ENT services pinned to the home screen's quick access (3 max)
+- `androidWidgetTimeline`: upcoming classes read by the Android widget
+
+## File system
+
+- `calendar.json` (document directory): last downloaded timetable with its ADE code and download date, shown while offline
-- ```haptics```: Boolean value to check if the user has chosen to enable haptics
-- ```calendar```: Array of objects containing the calendar events
\ No newline at end of file
diff --git a/.docs/ERRORS.md b/.docs/ERRORS.md
deleted file mode 100644
index 15792a3..0000000
--- a/.docs/ERRORS.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Errors guide
-
-
-
-
-## Erreurs pouvant apparaître sur l'application
-
-- ```EC=0xL``` -> Erreur au login
-- ```EC=0xG``` -> Erreur à la récupération des notes
-- ```EC=0xC``` -> Erreur à la récupération du calendrier
-- ```EC=0xP``` -> Erreur à la récupération des semestres
-- ```EC=0xS``` -> Erreur à la connexion au serveur
-- ```EC=0xI``` -> Mot de passe ou nom d'utilisateur incorrect
-- ```EC=0xB``` -> Authentification via SecureStore annulée (FaceID ou TouchID)
-- ```EC=0xF``` -> Connexion à des services indisponibles via le serveur de backup
-- ```EC=0xT``` -> Vous n'êtes pas connecté à Internet
-- ```EC=0xR``` -> Erreur à la récupération des ressources locales
\ No newline at end of file
diff --git a/.docs/HAPTICS.md b/.docs/HAPTICS.md
index c0097ba..f2ceed8 100644
--- a/.docs/HAPTICS.md
+++ b/.docs/HAPTICS.md
@@ -2,39 +2,15 @@
-
-## New
-
-### Inputs
-
-Text Input -> ```haptics(selection)```
-
-Refresh Input -> ```haptics(light)```
-
-### Buttons
-
-Normal Button -> ```haptics(medium)```
-
-URL Buttons -> ```haptics(selection)```
-
-Logout Button -> ```haptics(heavy)```
-
-Delete Button -> ```haptics(warning)```
-
-## Deprecated
-
-### Inputs
-
-Text Input -> ```Haptics.selectionAsync()```
-
-Refresh Input -> ```Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)```
-
-### Buttons
-
-Normal Button -> ```Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)```
-
-URL Buttons -> ```Haptics.selectionAsync()```
-
-Logout Button -> ```Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)```
-
-Delete Button -> ```Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)```
\ No newline at end of file
+
+
+Always go through `haptics()` from `src/utils/haptics.ts`: it does nothing when the user disabled haptic feedback in the settings.
+
+| Interaction | Call |
+| --- | --- |
+| Navigation (open a screen, go back, open a menu) | `haptics('light')` |
+| Main action (open the timetable, refresh, retry) | `haptics('medium')` |
+| Choice (chip, switch, list item, theme, link) | `haptics('selection')` |
+| Action completed (timetable saved, data deleted) | `haptics('success')` |
+| Limit reached, destructive confirmation | `haptics('warning')` |
+| Failure | `haptics('error')` |
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f09d59b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+EXPO_PUBLIC_ADE_TOKEN=
+
+# clés Measure (suivi des erreurs), lues par app.config.js
+MEASURE_ANDROID_API_KEY=
+MEASURE_IOS_API_KEY=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bff1ebd
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ check:
+ name: Lint & typecheck
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: npm run lint
+ - run: npm run typecheck
diff --git a/.gitignore b/.gitignore
index c094fb5..cbab9f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,7 @@ yarn-error.*
*.pem
# local env files
-.env/
+.env
.env*.local
# typescript
@@ -44,3 +44,4 @@ app-example
# generated native folders
/ios
/android
+/modules
\ No newline at end of file
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..dc8c710
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "singleQuote": true,
+ "printWidth": 120,
+ "trailingComma": "all"
+}
diff --git a/README.md b/README.md
index d904245..ba78634 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,34 @@
## ⚠️ Documentation
- Données : [Voir la documentation](https://github.com/UniceApps/UniceNotes/tree/main/.docs/DATA.md)
-- Erreurs : [Voir la documentation](https://github.com/UniceApps/UniceNotes/tree/main/.docs/ERRORS.md)
- Haptics : [Voir la documentation](https://github.com/UniceApps/UniceNotes/tree/main/.docs/HAPTICS.md)
+- API ADE : [Voir la documentation](https://github.com/UniceApps/UniceNotes/tree/main/.docs/ADE_API.md)
- Utilisation : [Voir la documentation](https://github.com/UniceApps/UniceNotes/tree/main/.docs/USAGE.md)
- API : [Voir la documentation](https://github.com/UniceApps/UniceAPI)
+## 🗂️ Architecture
+
+```
+modules/
+├── content-scroll/ module natif local (iOS) : la barre d'onglets se réduit au défilement
+└── web-session/ module natif local : garde la connexion aux services de l'ENT (cookies de session)
+src/
+├── app/ écrans (expo-router : un fichier = une route, (tabs)/ = barre d'onglets)
+├── components/
+│ ├── ui/ briques communes : Screen, Card, ListItem, Tile, Sheet, Banner…
+│ └── … composants propres à un écran (home, rooms, timetable, browser, ent, appearance)
+├── context/ réglages et emploi du temps partagés par toute l'app
+├── hooks/ logique réutilisable des écrans
+├── services/ accès réseau : ADE, UniceAPI, cache, widgets
+├── theme/ palettes Material 3 et thème choisi
+├── utils/ fonctions pures : dates, couleurs, parseurs ADE…
+└── widgets/ widgets iOS / Android et Live Activity
+```
+
+L'emploi du temps n'est téléchargé qu'à un seul endroit (`src/context/CalendarContext.tsx`) : l'accueil, l'écran emploi du temps, les widgets et la Live Activity partagent les mêmes données.
+
+Les services de l'ENT (`src/constants/ent.ts`) s'ouvrent dans le navigateur de l'app (`src/app/browser.tsx`), qui n'accepte que les services de ce catalogue : un lien profond ne peut pas lui faire ouvrir une autre adresse.
+
## ⚙️ Contribution
Merci pour ton intérêt pour le projet ! Si tu souhaites contribuer, contacte-nous grâce à l'email suivant : [app at metrixmedia.fr](mailto://app@metrixmedia.fr) ou en créant une issue / pull request sur GitHub.
@@ -47,33 +70,38 @@ L'application UniceNotes utilise :
avec ton consentement (en te connectant sur l'application et en acceptant les conditions d'utilisation) afin de te fournir une expérience utilisateur optimale.
\
-Les données critiques sont stockées dans un format **crypté** dans la Keychain d'Apple / Keystore d'Android et ne peuvent être déchiffrées que par l'utilisateur lorsqu'il s'identifie grâce à un code ou grâce à une option de connxion biométrique. [Voir l'API SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/)
+Les données critiques sont stockées dans un format **chiffré** dans la Keychain d'Apple / Keystore d'Android et ne peuvent être déchiffrées que par l'utilisateur lorsqu'il s'identifie grâce à un code ou grâce à une option de connexion biométrique. [Voir l'API SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/)
\
Les données non-critiques sont stockées dans un stockage persistant nommé AsyncStorage de React Native. [Voir l'API AsyncStorage](https://react-native-async-storage.github.io/async-storage/docs/usage/)
## 🛠️ Build
-Pour construire l'application, vous aurez besoin de Node.js, npm, Expo CLI, EAS CLI et un compte Expo.
+Pour construire l'application, vous aurez besoin de Node.js, npm, EAS CLI et un compte Expo.
> [!IMPORTANT]
> Attention, vous devez posséder un compte payant Apple Developer ou Google Play Console pour pouvoir construire l'application pour iOS ou Android.
```bash
-# Installer Expo CLI et EAS CLI
-npm install -g expo-cli eas-cli
+# Installer EAS CLI
+npm install -g eas-cli
# Cloner le dépôt
git clone https://github.com/UniceApps/UniceNotes.git
-
-# Aller dans le dossier
cd UniceNotes
# Installer les dépendances
npm install
+# Renseigner le jeton ADE et les clés Measure
+cp .env.example .env
+
+# Vérifier le code
+npm run lint
+npm run typecheck
+
# Démarrer l'application en mode développement
-# Vous devez posséder Expo Go sur votre appareil
-npx expo
+# L'app utilise des modules natifs (widgets, icônes…) : Expo Go ne suffit pas, il faut un build de développement
+npx expo run:ios # ou npx expo run:android
# Construire l'application
eas login
@@ -93,7 +121,7 @@ Toute ressemblance avec le nom de l'application, le logo et l'interface ne saura
Toute utilisation de l'application UniceNotes est sous la seule responsabilité de l'utilisateur.
-Cette application agit comme un client internet où l'utilisateur effectue des pseudos-requêtes (GET HTTPS) sur l'intranet de l'Université Côte d'Azur à travers des API exposées. Cette application ne contient aucun code malveillant et ne vise pas à nuire à l'Université Côte d'Azur ou à ses utilisateurs. Les éventuelles suspiscions de "fuites de données" sont infondées dûes à la nature de l'application (les données sont stockées sur l'appareil de l'utilisateur et non sur des serveurs tiers).
+Cette application agit comme un client internet où l'utilisateur effectue des pseudos-requêtes (GET HTTPS) sur l'intranet de l'Université Côte d'Azur à travers des API exposées. Cette application ne contient aucun code malveillant et ne vise pas à nuire à l'Université Côte d'Azur ou à ses utilisateurs. Les éventuelles suspicions de "fuites de données" sont infondées du fait de la nature de l'application (les données sont stockées sur l'appareil de l'utilisateur et non sur des serveurs tiers).
## 🤝 Conditions d'utilisation :
[Consulter les conditions d'utilisation](https://notes.metrixmedia.fr/eula)
diff --git a/app.config.js b/app.config.js
index a5e1fd9..611a9ae 100644
--- a/app.config.js
+++ b/app.config.js
@@ -43,4 +43,4 @@ module.exports = ({ config }) => {
github_hash: process.env.EAS_BUILD_GIT_COMMIT_HASH,
},
};
-};
\ No newline at end of file
+};
diff --git a/app.json b/app.json
index b57865c..4add8df 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "UniceNotes",
"slug": "unicenotes",
- "version": "3.3.0",
+ "version": "4.0.0",
"githubUrl": "https://github.com/UniceApps/UniceNotes",
"orientation": "portrait",
"icon": "./src/assets/icon.png",
@@ -22,11 +22,11 @@
"tinted": "./src/assets/ios/tinteddark.png"
},
"infoPlist": {
- "NSFaceIDUsageDescription": "Connecte-toi sur l'application grâce à l'authentification biométrique.",
- "NSNotificationsUsageDescription": "Reçois des notifications pour être informé de diverses choses.",
"CFBundleLocalizations": ["fr"],
"CFBundleDevelopmentRegion": "fr",
- "CADisableMinimumFrameDurationOnPhone": true
+ "CADisableMinimumFrameDurationOnPhone": true,
+ "NSCameraUsageDescription": "Les services de l'ENT ouverts dans l'application peuvent utiliser l'appareil photo, par exemple pour scanner un QR code d'émargement.",
+ "NSMicrophoneUsageDescription": "Les services de l'ENT ouverts dans l'application peuvent utiliser le micro, par exemple pour une visioconférence."
},
"config": {
"usesNonExemptEncryption": false
@@ -40,17 +40,10 @@
"backgroundColor": "#FFFFFF"
}
},
- "extra": {
- "eas": {
- "projectId": "ec0ebd57-7227-4cd2-a71e-344e0a5fffe5"
- },
- "github_hash": "$(EAS_BUILD_GIT_COMMIT_HASH)"
- },
"owner": "hugofnm",
"plugins": [
"expo-router",
"expo-image",
- "expo-sharing",
"expo-status-bar",
"expo-font",
"expo-secure-store",
diff --git a/index.ts b/index.ts
index ca6edc9..e4c67ea 100644
--- a/index.ts
+++ b/index.ts
@@ -1,2 +1,4 @@
import 'expo-router/entry';
-import './src/widgets/NextClassWidget';
\ No newline at end of file
+
+// enregistre la tâche de fond du widget Android dès le démarrage
+import './src/widgets/NextClassWidget';
diff --git a/modules/content-scroll/expo-module.config.json b/modules/content-scroll/expo-module.config.json
new file mode 100644
index 0000000..0c28b2c
--- /dev/null
+++ b/modules/content-scroll/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["ContentScrollModule"]
+ }
+}
diff --git a/modules/content-scroll/index.ts b/modules/content-scroll/index.ts
new file mode 100644
index 0000000..00bd186
--- /dev/null
+++ b/modules/content-scroll/index.ts
@@ -0,0 +1 @@
+export { ContentScrollMarker } from './src/ContentScrollMarker';
diff --git a/modules/content-scroll/ios/ContentScroll.podspec b/modules/content-scroll/ios/ContentScroll.podspec
new file mode 100644
index 0000000..61ab514
--- /dev/null
+++ b/modules/content-scroll/ios/ContentScroll.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+ s.name = 'ContentScroll'
+ s.version = '1.0.0'
+ s.summary = 'Liste principale des onglets'
+ s.description = 'Declare la ScrollView d un onglet a UIKit pour que iOS 26 reduise la barre d onglets au defilement'
+ s.author = ''
+ s.homepage = 'https://docs.expo.dev/modules/'
+ s.platforms = {
+ :ios => '16.4'
+ }
+ s.source = { git: '' }
+ s.static_framework = true
+ s.swift_version = '5.9'
+
+ s.dependency 'ExpoModulesCore'
+
+ # Swift/Objective-C compatibility
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/content-scroll/ios/ContentScrollModule.swift b/modules/content-scroll/ios/ContentScrollModule.swift
new file mode 100644
index 0000000..05c0dba
--- /dev/null
+++ b/modules/content-scroll/ios/ContentScrollModule.swift
@@ -0,0 +1,57 @@
+import ExpoModulesCore
+import UIKit
+
+public class ContentScrollModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("ContentScroll")
+
+ View(ContentScrollMarkerView.self) {}
+ }
+}
+
+// repère qui signale la ScrollView au contrôleur d'onglet, pour réduire la barre au défilement (iOS 26)
+final class ContentScrollMarkerView: ExpoView {
+ private weak var registeredScrollView: UIScrollView?
+ private weak var registeredController: UIViewController?
+
+ // l'onglet n'entre dans la fenêtre qu'à sa première ouverture : contrôleur et liste sont alors en place
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ guard window != nil,
+ let scrollView = enclosingScrollView(),
+ let controller = owningController(of: scrollView)
+ else {
+ return
+ }
+ if scrollView === registeredScrollView && controller === registeredController {
+ return
+ }
+ // la barre d'onglets suit le bord bas
+ controller.setContentScrollView(scrollView, for: .bottom)
+ registeredScrollView = scrollView
+ registeredController = controller
+ }
+
+ private func enclosingScrollView() -> UIScrollView? {
+ var view = superview
+ while let current = view {
+ if let scrollView = current as? UIScrollView {
+ return scrollView
+ }
+ view = current.superview
+ }
+ return nil
+ }
+
+ // le contrôleur le plus proche : celui de l'onglet
+ private func owningController(of view: UIView) -> UIViewController? {
+ var responder: UIResponder? = view
+ while let current = responder {
+ if let controller = current as? UIViewController {
+ return controller
+ }
+ responder = current.next
+ }
+ return nil
+ }
+}
diff --git a/modules/content-scroll/src/ContentScrollMarker.tsx b/modules/content-scroll/src/ContentScrollMarker.tsx
new file mode 100644
index 0000000..b480207
--- /dev/null
+++ b/modules/content-scroll/src/ContentScrollMarker.tsx
@@ -0,0 +1,16 @@
+import type { ComponentType } from 'react';
+import { Platform, type ViewProps } from 'react-native';
+
+import { requireNativeView, requireOptionalNativeModule } from 'expo';
+
+// iOS seulement, et si l'app a été recompilée avec le module : sinon rien n'est rendu
+const NativeMarker: ComponentType | null =
+ Platform.OS === 'ios' && requireOptionalNativeModule('ContentScroll') !== null
+ ? requireNativeView('ContentScroll')
+ : null;
+
+// à poser dans la ScrollView d'un onglet : iOS 26 la suit pour réduire la barre d'onglets au défilement
+export function ContentScrollMarker() {
+ if (!NativeMarker) return null;
+ return ;
+}
diff --git a/modules/web-session/android/build.gradle b/modules/web-session/android/build.gradle
new file mode 100644
index 0000000..f082ae3
--- /dev/null
+++ b/modules/web-session/android/build.gradle
@@ -0,0 +1,15 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.websession'
+version = '1.0.0'
+
+android {
+ namespace "expo.modules.websession"
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
diff --git a/modules/web-session/android/src/main/AndroidManifest.xml b/modules/web-session/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bdae66c
--- /dev/null
+++ b/modules/web-session/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/web-session/android/src/main/java/expo/modules/websession/WebSessionModule.kt b/modules/web-session/android/src/main/java/expo/modules/websession/WebSessionModule.kt
new file mode 100644
index 0000000..f509304
--- /dev/null
+++ b/modules/web-session/android/src/main/java/expo/modules/websession/WebSessionModule.kt
@@ -0,0 +1,33 @@
+package expo.modules.websession
+
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.functions.Queues
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+// la WebView Android garde ses cookies : il suffit de forcer leur écriture sur le disque
+class WebSessionModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("WebSession")
+
+ AsyncFunction("restoreAsync") {
+ 0
+ }
+
+ AsyncFunction("persistAsync") {
+ CookieManager.getInstance().flush()
+ 0
+ }.runOnQueue(Queues.MAIN)
+
+ AsyncFunction("clearAsync") { promise: Promise ->
+ WebStorage.getInstance().deleteAllData()
+ val cookies = CookieManager.getInstance()
+ cookies.removeAllCookies {
+ cookies.flush()
+ promise.resolve()
+ }
+ }.runOnQueue(Queues.MAIN)
+ }
+}
diff --git a/modules/web-session/expo-module.config.json b/modules/web-session/expo-module.config.json
new file mode 100644
index 0000000..9caac78
--- /dev/null
+++ b/modules/web-session/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["WebSessionModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.websession.WebSessionModule"]
+ }
+}
diff --git a/modules/web-session/index.ts b/modules/web-session/index.ts
new file mode 100644
index 0000000..e0412f8
--- /dev/null
+++ b/modules/web-session/index.ts
@@ -0,0 +1 @@
+export { default } from './src/WebSessionModule';
diff --git a/modules/web-session/ios/WebSession.podspec b/modules/web-session/ios/WebSession.podspec
new file mode 100644
index 0000000..2feb26e
--- /dev/null
+++ b/modules/web-session/ios/WebSession.podspec
@@ -0,0 +1,24 @@
+Pod::Spec.new do |s|
+ s.name = 'WebSession'
+ s.version = '1.0.0'
+ s.summary = 'Persistance des cookies du navigateur intégré'
+ s.description = 'Garde les cookies de session de WKWebView entre deux lancements de UniceNotes'
+ s.author = ''
+ s.homepage = 'https://docs.expo.dev/modules/'
+ s.platforms = {
+ :ios => '16.4'
+ }
+ s.source = { git: '' }
+ s.static_framework = true
+ s.swift_version = '5.9'
+
+ s.dependency 'ExpoModulesCore'
+ s.frameworks = 'WebKit'
+
+ # Swift/Objective-C compatibility
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/web-session/ios/WebSessionModule.swift b/modules/web-session/ios/WebSessionModule.swift
new file mode 100644
index 0000000..1e1f150
--- /dev/null
+++ b/modules/web-session/ios/WebSessionModule.swift
@@ -0,0 +1,175 @@
+import ExpoModulesCore
+import Foundation
+import WebKit
+
+// sauvegarde les cookies de session, que WKWebView perd quand l'app est tuée
+public class WebSessionModule: Module {
+ // tant que la sauvegarde n'a pas été relue, persistAsync l'écraserait avec une session vide
+ private var restored = false
+ // certaines versions de WebKit ne répondent sur le magasin de cookies qu'une fois une WebView créée
+ private var warmUpView: WKWebView?
+
+ private func dataStore() -> WKWebsiteDataStore {
+ if warmUpView == nil {
+ warmUpView = WKWebView(frame: .zero)
+ }
+ return WKWebsiteDataStore.default()
+ }
+
+ public func definition() -> ModuleDefinition {
+ Name("WebSession")
+
+ // ne remplace jamais un cookie déjà présent, plus récent que la sauvegarde
+ AsyncFunction("restoreAsync") { (promise: Promise) in
+ let saved = CookieVault.load()
+ let store = self.dataStore().httpCookieStore
+ store.getAllCookies { current in
+ let present = Set(current.map(CookieVault.key))
+ let missing = saved.filter { !present.contains(CookieVault.key($0)) }
+ let group = DispatchGroup()
+ for cookie in missing {
+ group.enter()
+ store.setCookie(cookie) { group.leave() }
+ }
+ group.notify(queue: .main) {
+ self.restored = true
+ promise.resolve(missing.count)
+ }
+ }
+ }
+ .runOnQueue(.main)
+
+ // les cookies persistants sont déjà gardés par WebKit : seuls ceux de session sont sauvegardés
+ AsyncFunction("persistAsync") { (promise: Promise) in
+ guard self.restored else {
+ promise.resolve(0)
+ return
+ }
+ self.dataStore().httpCookieStore.getAllCookies { cookies in
+ let session = cookies.filter { $0.isSessionOnly }
+ do {
+ try CookieVault.save(session)
+ promise.resolve(session.count)
+ } catch {
+ promise.reject(error)
+ }
+ }
+ }
+ .runOnQueue(.main)
+
+ AsyncFunction("clearAsync") { (promise: Promise) in
+ CookieVault.delete()
+ self.dataStore().removeData(
+ ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
+ modifiedSince: .distantPast
+ ) {
+ promise.resolve()
+ }
+ }
+ .runOnQueue(.main)
+ }
+}
+
+// cookie tel qu'écrit dans la sauvegarde
+private struct StoredCookie: Codable {
+ let name: String
+ let value: String
+ let domain: String
+ let path: String
+ let expires: Date?
+ let secure: Bool
+ let httpOnly: Bool
+ let sameSite: String?
+
+ init(_ cookie: HTTPCookie) {
+ name = cookie.name
+ value = cookie.value
+ domain = cookie.domain
+ path = cookie.path
+ expires = cookie.expiresDate
+ secure = cookie.isSecure
+ httpOnly = cookie.isHTTPOnly
+ sameSite = cookie.sameSitePolicy?.rawValue
+ }
+
+ var cookie: HTTPCookie? {
+ var properties: [HTTPCookiePropertyKey: Any] = [
+ .name: name,
+ .value: value,
+ .domain: domain,
+ .path: path,
+ ]
+ if let expires {
+ properties[.expires] = expires
+ }
+ if secure {
+ properties[.secure] = "TRUE"
+ }
+ if httpOnly {
+ // pas de constante publique, mais la clé est reconnue par CFNetwork
+ properties[HTTPCookiePropertyKey("HttpOnly")] = "TRUE"
+ }
+ if let sameSite {
+ properties[.sameSitePolicy] = sameSite
+ }
+ return HTTPCookie(properties: properties)
+ }
+}
+
+private enum CookieVault {
+ // une session restée inutilisée plus longtemps n'est plus réinjectée
+ private static let maxAge: TimeInterval = 30 * 24 * 60 * 60
+
+ private struct Snapshot: Codable {
+ let savedAt: Date
+ let cookies: [StoredCookie]
+ }
+
+ private static var directory: URL? {
+ FileManager.default
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask)
+ .first?
+ .appendingPathComponent("WebSession", isDirectory: true)
+ }
+
+ private static var file: URL? {
+ directory?.appendingPathComponent("cookies.json")
+ }
+
+ static func key(_ cookie: HTTPCookie) -> String {
+ "\(cookie.domain)|\(cookie.path)|\(cookie.name)"
+ }
+
+ static func save(_ cookies: [HTTPCookie]) throws {
+ guard var folder = directory, let target = file else { return }
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ // jamais dans les sauvegardes iCloud ou de l'ordinateur
+ var values = URLResourceValues()
+ values.isExcludedFromBackup = true
+ try folder.setResourceValues(values)
+
+ let snapshot = Snapshot(savedAt: Date(), cookies: cookies.map { StoredCookie($0) })
+ let data = try JSONEncoder().encode(snapshot)
+ try data.write(to: target, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
+ }
+
+ static func load() -> [HTTPCookie] {
+ guard let target = file,
+ let data = try? Data(contentsOf: target),
+ let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data),
+ Date().timeIntervalSince(snapshot.savedAt) < maxAge
+ else {
+ return []
+ }
+ let now = Date()
+ return snapshot.cookies.compactMap { $0.cookie }.filter { cookie in
+ guard let expires = cookie.expiresDate else { return true }
+ return expires > now
+ }
+ }
+
+ static func delete() {
+ guard let target = file else { return }
+ try? FileManager.default.removeItem(at: target)
+ }
+}
diff --git a/modules/web-session/src/WebSessionModule.ts b/modules/web-session/src/WebSessionModule.ts
new file mode 100644
index 0000000..3e3df4b
--- /dev/null
+++ b/modules/web-session/src/WebSessionModule.ts
@@ -0,0 +1,13 @@
+import { NativeModule, requireOptionalNativeModule } from 'expo';
+
+declare class WebSessionModule extends NativeModule {
+ // réinjecte les cookies de session sauvegardés qui manquent, renvoie leur nombre
+ restoreAsync(): Promise;
+ // sauvegarde les cookies de session (iOS) ou force leur écriture sur le disque (Android)
+ persistAsync(): Promise;
+ // efface cookies, stockage web et sauvegarde
+ clearAsync(): Promise;
+}
+
+// null sans build natif (Expo Go, web)
+export default requireOptionalNativeModule('WebSession');
diff --git a/package-lock.json b/package-lock.json
index b2b55b3..6e488fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "unicenotes",
- "version": "3.3.0",
+ "version": "4.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "unicenotes",
- "version": "3.3.0",
+ "version": "4.0.0",
"dependencies": {
"@expo/ui": "~57.0.21",
"@expo/vector-icons": "^15.0.3",
@@ -14,34 +14,23 @@
"@howljs/calendar-kit": "^2.0.4",
"@measuresh/react-native": "^0.3.1",
"@react-native-async-storage/async-storage": "2.2.0",
- "@react-native-community/netinfo": "12.0.1",
- "@shopify/flash-list": "2.0.2",
"expo": "~57.0.26",
"expo-alternate-app-icons": "^8.0.0",
- "expo-application": "~57.0.3",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.8",
- "expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
- "expo-device": "~57.0.2",
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
- "expo-image-picker": "~57.0.20",
"expo-insights": "~57.0.20",
"expo-linking": "~57.0.11",
- "expo-local-authentication": "~57.0.3",
- "expo-network": "~57.0.2",
- "expo-notifications": "~57.0.21",
"expo-observe": "~57.0.24",
"expo-quick-actions": "^6.0.2",
"expo-router": "~57.0.24",
"expo-secure-store": "~57.0.4",
- "expo-sharing": "~57.0.22",
"expo-splash-screen": "~57.0.5",
"expo-status-bar": "~57.0.1",
- "expo-store-review": "~57.0.3",
"expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"expo-widgets": "~57.0.22",
@@ -56,8 +45,8 @@
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
- "react-native-view-shot": "5.1.0",
"react-native-web": "~0.21.0",
+ "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
@@ -1458,9 +1447,9 @@
}
},
"node_modules/@expo/devcert": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz",
- "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz",
+ "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==",
"license": "MIT",
"dependencies": {
"@expo/sudo-prompt": "^9.3.1",
@@ -1896,9 +1885,9 @@
"license": "MIT"
},
"node_modules/@expo/sdk-runtime-versions": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz",
- "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.1.tgz",
+ "integrity": "sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==",
"license": "MIT"
},
"node_modules/@expo/spawn-async": {
@@ -1960,10 +1949,10 @@
}
},
"node_modules/@expo/xcpretty": {
- "version": "4.4.5",
- "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
- "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==",
- "license": "BSD-3-Clause",
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.6.tgz",
+ "integrity": "sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==",
+ "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"chalk": "^4.1.0",
@@ -2444,16 +2433,6 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
- "node_modules/@react-native-community/netinfo": {
- "version": "12.0.1",
- "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz",
- "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==",
- "license": "MIT",
- "peerDependencies": {
- "react": "*",
- "react-native": ">=0.59"
- }
- },
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
@@ -2791,20 +2770,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@shopify/flash-list": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz",
- "integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
- "license": "MIT",
- "dependencies": {
- "tslib": "2.8.1"
- },
- "peerDependencies": {
- "@babel/runtime": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/@sinclair/typebox": {
"version": "0.27.12",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
@@ -2873,9 +2838,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.6.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
- "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
@@ -2944,9 +2909,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
- "version": "7.0.11",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
- "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
+ "version": "7.0.12",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
+ "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4103,12 +4068,6 @@
}
}
},
- "node_modules/badgin": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
- "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
- "license": "MIT"
- },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4116,15 +4075,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/base64-arraybuffer": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
- "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -4146,9 +4096,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.11.26",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz",
- "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==",
+ "version": "2.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -4678,15 +4628,6 @@
"hyphenate-style-name": "^1.0.3"
}
},
- "node_modules/css-line-break": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
- "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4912,9 +4853,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.443",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.443.tgz",
- "integrity": "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==",
+ "version": "1.5.444",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz",
+ "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -5727,15 +5668,6 @@
"react-native": "*"
}
},
- "node_modules/expo-application": {
- "version": "57.0.3",
- "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz",
- "integrity": "sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-asset": {
"version": "57.0.18",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz",
@@ -5790,15 +5722,6 @@
"react-native": "*"
}
},
- "node_modules/expo-crypto": {
- "version": "57.0.3",
- "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
- "integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-dev-client": {
"version": "57.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz",
@@ -5852,18 +5775,6 @@
"expo": "*"
}
},
- "node_modules/expo-device": {
- "version": "57.0.2",
- "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.2.tgz",
- "integrity": "sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow==",
- "license": "MIT",
- "dependencies": {
- "ua-parser-js": "^0.7.33"
- },
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-eas-client": {
"version": "57.0.4",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.4.tgz",
@@ -5934,27 +5845,6 @@
}
}
},
- "node_modules/expo-image-loader": {
- "version": "57.0.1",
- "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz",
- "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*"
- }
- },
- "node_modules/expo-image-picker": {
- "version": "57.0.20",
- "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
- "integrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==",
- "license": "MIT",
- "dependencies": {
- "expo-image-loader": "~57.0.1"
- },
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-insights": {
"version": "57.0.20",
"resolved": "https://registry.npmjs.org/expo-insights/-/expo-insights-57.0.20.tgz",
@@ -5997,18 +5887,6 @@
"react-native": "*"
}
},
- "node_modules/expo-local-authentication": {
- "version": "57.0.3",
- "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.3.tgz",
- "integrity": "sha512-1oQCBKz8JOpai+Zp+Z2RkBFIi2CHHWLSJk/pjvT9VEyf8nPFslo4kt1ZlQvtJ4rupbKNCmxqKfYr8Mlo8nJEug==",
- "license": "MIT",
- "dependencies": {
- "invariant": "^2.2.4"
- },
- "peerDependencies": {
- "expo": "*"
- }
- },
"node_modules/expo-manifests": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.2.tgz",
@@ -6066,34 +5944,6 @@
"react-native": "*"
}
},
- "node_modules/expo-network": {
- "version": "57.0.2",
- "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz",
- "integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*",
- "react": "*"
- }
- },
- "node_modules/expo-notifications": {
- "version": "57.0.21",
- "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.21.tgz",
- "integrity": "sha512-CWBW6DxqRJ2MaYZW5+/WfEWwIwbiVk5UAPqx0kE676B4R1+PlAXARk/kuNeQBcVrcDNV6i9ihE9B0db8Cd2RXQ==",
- "license": "MIT",
- "dependencies": {
- "@expo/image-utils": "^0.11.5",
- "abort-controller": "^3.0.0",
- "badgin": "^1.1.5",
- "expo-application": "~57.0.3",
- "expo-constants": "~57.0.19"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/expo-observe": {
"version": "57.0.24",
"resolved": "https://registry.npmjs.org/expo-observe/-/expo-observe-57.0.24.tgz",
@@ -6418,22 +6268,6 @@
"node": ">=20.16.0"
}
},
- "node_modules/expo-sharing": {
- "version": "57.0.22",
- "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz",
- "integrity": "sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==",
- "license": "MIT",
- "dependencies": {
- "@expo/config-plugins": "^57.0.9",
- "@expo/config-types": "^57.0.2",
- "@expo/plist": "^0.8.1"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/expo-splash-screen": {
"version": "57.0.9",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz",
@@ -6459,16 +6293,6 @@
"react-native": "*"
}
},
- "node_modules/expo-store-review": {
- "version": "57.0.3",
- "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-57.0.3.tgz",
- "integrity": "sha512-++aTnzw0Dsudxx026aZPgrRUc96dxdJVCoi+LZWYbmyoXf76KNKtNWsv7vYWEDjuDU7Z67Z9mGpP+cxQRcBLpw==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*",
- "react-native": "*"
- }
- },
"node_modules/expo-symbols": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.3.tgz",
@@ -6657,9 +6481,9 @@
}
},
"node_modules/expo/node_modules/@expo/ws-tunnel": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz",
- "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz",
+ "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==",
"license": "MIT",
"peerDependencies": {
"ws": "^8.0.0"
@@ -6858,32 +6682,6 @@
"asap": "~2.0.3"
}
},
- "node_modules/fbjs/node_modules/ua-parser-js": {
- "version": "1.0.41",
- "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
- "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/ua-parser-js"
- },
- {
- "type": "paypal",
- "url": "https://paypal.me/faisalman"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/faisalman"
- }
- ],
- "license": "MIT",
- "bin": {
- "ua-parser-js": "script/cli.js"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -7469,19 +7267,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
- "node_modules/html2canvas": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
- "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
- "license": "MIT",
- "dependencies": {
- "css-line-break": "^2.1.0",
- "text-segmentation": "^1.0.3"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -10709,23 +10494,6 @@
"react-native": "*"
}
},
- "node_modules/react-native-view-shot": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-5.1.0.tgz",
- "integrity": "sha512-JZgElCD82aO+hejIF/leUzI7JufL9mgJ6ChzGWIcdZ2ajpaEvvSnvIcw0qD32XWkrbId8wfSbyz/4u/ulTQzQA==",
- "license": "MIT",
- "dependencies": {
- "html2canvas": "^1.4.1"
- },
- "engines": {
- "node": ">=20",
- "npm": ">=10"
- },
- "peerDependencies": {
- "react": ">=18.0.0",
- "react-native": ">=0.76.0"
- }
- },
"node_modules/react-native-web": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.3.tgz",
@@ -10758,6 +10526,20 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
+ "node_modules/react-native-webview": {
+ "version": "13.16.1",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
+ "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
@@ -11478,9 +11260,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.11.0.tgz",
- "integrity": "sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
+ "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -11961,15 +11743,6 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
- "node_modules/text-segmentation": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
- "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/throat": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
@@ -12190,9 +11963,9 @@
}
},
"node_modules/ua-parser-js": {
- "version": "0.7.41",
- "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz",
- "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==",
+ "version": "1.0.41",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
+ "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
@@ -12428,15 +12201,6 @@
"node": ">= 0.4.0"
}
},
- "node_modules/utrie": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
- "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
- "license": "MIT",
- "dependencies": {
- "base64-arraybuffer": "^1.0.2"
- }
- },
"node_modules/uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
diff --git a/package.json b/package.json
index a2b6698..d5850a7 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,14 @@
{
"name": "unicenotes",
"main": "index.ts",
- "version": "3.3.0",
+ "version": "4.0.0",
"scripts": {
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
- "lint": "expo lint"
+ "lint": "expo lint",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/ui": "~57.0.21",
@@ -16,34 +17,23 @@
"@howljs/calendar-kit": "^2.0.4",
"@measuresh/react-native": "^0.3.1",
"@react-native-async-storage/async-storage": "2.2.0",
- "@react-native-community/netinfo": "12.0.1",
- "@shopify/flash-list": "2.0.2",
"expo": "~57.0.26",
"expo-alternate-app-icons": "^8.0.0",
- "expo-application": "~57.0.3",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.8",
- "expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
- "expo-device": "~57.0.2",
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
- "expo-image-picker": "~57.0.20",
"expo-insights": "~57.0.20",
"expo-linking": "~57.0.11",
- "expo-local-authentication": "~57.0.3",
- "expo-network": "~57.0.2",
- "expo-notifications": "~57.0.21",
"expo-observe": "~57.0.24",
"expo-quick-actions": "^6.0.2",
"expo-router": "~57.0.24",
"expo-secure-store": "~57.0.4",
- "expo-sharing": "~57.0.22",
"expo-splash-screen": "~57.0.5",
"expo-status-bar": "~57.0.1",
- "expo-store-review": "~57.0.3",
"expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"expo-widgets": "~57.0.22",
@@ -58,8 +48,8 @@
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
- "react-native-view-shot": "5.1.0",
"react-native-web": "~0.21.0",
+ "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..caade3d
--- /dev/null
+++ b/src/app/(tabs)/_layout.tsx
@@ -0,0 +1,62 @@
+import { Platform } from 'react-native';
+
+import { NativeTabs } from 'expo-router/unstable-native-tabs';
+
+import { useAppTheme } from '@/src/theme';
+import { withAlpha } from '@/src/utils/color';
+
+// barre d'onglets du système : Liquid Glass sur iOS, Material 3 aux couleurs du thème sur Android
+export default function TabsLayout() {
+ const theme = useAppTheme();
+ const c = theme.colors;
+ const android = Platform.OS === 'android';
+
+ return (
+
+
+
+ Accueil
+
+
+
+
+ EDT
+
+
+
+
+ Salles
+
+
+
+
+ ENT
+
+
+
+
+ Paramètres
+
+
+ );
+}
diff --git a/src/app/(tabs)/ent/_layout.tsx b/src/app/(tabs)/ent/_layout.tsx
new file mode 100644
index 0000000..803fa25
--- /dev/null
+++ b/src/app/(tabs)/ent/_layout.tsx
@@ -0,0 +1,14 @@
+import { Stack } from 'expo-router';
+
+// la liste reste sous le navigateur, même ouvert depuis un autre onglet
+export const unstable_settings = { initialRouteName: 'index' };
+
+export default function EntLayout() {
+ return (
+
+
+ {/* un seul navigateur dans la pile */}
+ 'browser'} />
+
+ );
+}
diff --git a/src/app/(tabs)/ent/browser.tsx b/src/app/(tabs)/ent/browser.tsx
new file mode 100644
index 0000000..cb76f30
--- /dev/null
+++ b/src/app/(tabs)/ent/browser.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useRef, useState } from 'react';
+import { ActivityIndicator, AppState, BackHandler, Linking, Platform, View } from 'react-native';
+
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { IconButton, ProgressBar, Text } from 'react-native-paper';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { WebView, type WebViewNavigation } from 'react-native-webview';
+import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes';
+
+import { BrowserError } from '@/src/components/browser/BrowserError';
+import { EntAppIcon } from '@/src/components/ent/EntAppIcon';
+import { HeaderButton } from '@/src/components/ui/ScreenHeader';
+import { getEntApp, type EntApp } from '@/src/constants/ent';
+import { useTabBarInset } from '@/src/hooks/useTabBarInset';
+import { persistWebSession, restoreWebSession } from '@/src/services/webSession';
+import { useAppTheme } from '@/src/theme';
+import { isWebViewUrl } from '@/src/utils/browser';
+import { haptics } from '@/src/utils/haptics';
+
+// navigateur de l'onglet ENT : les services y restent connectés d'un lancement à l'autre
+export default function BrowserScreen() {
+ const router = useRouter();
+ const { app: appId } = useLocalSearchParams<{ app?: string }>();
+ const app = getEntApp(appId);
+
+ // lien vers un service inconnu
+ useEffect(() => {
+ if (app) return;
+ if (router.canGoBack()) router.back();
+ else router.replace('/ent');
+ }, [app, router]);
+
+ function close() {
+ haptics('light');
+ if (router.canGoBack()) router.back();
+ else router.replace('/ent');
+ }
+
+ if (!app) return null;
+ // un autre service repart d'une page et d'un historique vierges
+ return ;
+}
+
+function Browser({ app, onClose }: { app: EntApp; onClose: () => void }) {
+ const theme = useAppTheme();
+ const insets = useSafeAreaInsets();
+ const tabBarInset = useTabBarInset();
+ const c = theme.colors;
+ const webRef = useRef(null);
+
+ // cookies de session réinjectés avant la première requête
+ const [ready, setReady] = useState(false);
+ const [nav, setNav] = useState();
+ const [loadCount, setLoadCount] = useState(0);
+ // le processus de rendu Android a planté : nouvelle WebView
+ const [webKey, setWebKey] = useState(0);
+
+ const loading = nav?.loading ?? true;
+ const canGoBack = nav?.canGoBack ?? false;
+
+ useEffect(() => {
+ let mounted = true;
+ restoreWebSession().then(() => {
+ if (mounted) setReady(true);
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ // sauvegarde un peu après chaque page : une connexion CAS enchaîne plusieurs redirections
+ useEffect(() => {
+ if (loadCount === 0) return;
+ const timer = setTimeout(persistWebSession, 1000);
+ return () => clearTimeout(timer);
+ }, [loadCount]);
+
+ // iOS peut tuer l'app dès qu'elle passe en arrière-plan
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state !== 'active') persistWebSession();
+ });
+ return () => {
+ subscription.remove();
+ persistWebSession();
+ };
+ }, []);
+
+ // le bouton retour d'Android remonte l'historique de la page avant de fermer
+ useEffect(() => {
+ if (Platform.OS !== 'android' || !canGoBack) return;
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
+ webRef.current?.goBack();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [canGoBack]);
+
+ // mailto:, tel:, Teams… confiés au système, jamais depuis une iframe
+ function shouldStartLoad({ url, isTopFrame }: ShouldStartLoadRequest): boolean {
+ if (isWebViewUrl(url)) return true;
+ if (isTopFrame !== false) Linking.openURL(url).catch(() => {});
+ return false;
+ }
+
+ function tap(action: () => void) {
+ haptics('light');
+ action();
+ }
+
+ // Android renvoie l'URL comme titre pendant le chargement
+ const title = nav?.title && !isWebViewUrl(nav.title) ? nav.title : app.label;
+
+ return (
+
+ {/* sans historique, le glissement depuis le bord revient à la liste */}
+
+
+
+
+
+
+
+ {title}
+
+
+ {nav?.url ?? app.url}
+
+
+
+ tap(() => webRef.current?.goBack())}
+ />
+ tap(() => webRef.current?.goForward())}
+ />
+ tap(() => webRef.current?.reload())}
+ />
+
+
+
+
+
+ {ready ? (
+ {
+ // une erreur ne met pas à jour nav
+ setNav((current) => current && { ...current, loading: false });
+ setLoadCount((count) => count + 1);
+ }}
+ onContentProcessDidTerminate={() => webRef.current?.reload()}
+ onRenderProcessGone={() => setWebKey((key) => key + 1)}
+ renderError={(_domain, code, description) => (
+ webRef.current?.reload()} />
+ )}
+ // pas de voile pendant un rechargement
+ renderLoading={() => }
+ allowsBackForwardNavigationGestures
+ pullToRefreshEnabled
+ refreshControlLightMode={theme.dark}
+ // inertie de Safari ; la chaîne fait planter Android
+ decelerationRate={Platform.OS === 'ios' ? 'normal' : undefined}
+ // liens target="_blank" ouverts dans la même page (Android)
+ setSupportMultipleWindows={false}
+ webviewDebuggingEnabled={__DEV__}
+ style={{ flex: 1, backgroundColor: c.background }}
+ containerStyle={{ backgroundColor: c.background }}
+ />
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(tabs)/ent/index.tsx b/src/app/(tabs)/ent/index.tsx
new file mode 100644
index 0000000..00b5373
--- /dev/null
+++ b/src/app/(tabs)/ent/index.tsx
@@ -0,0 +1,84 @@
+import { useState } from 'react';
+import { View } from 'react-native';
+
+import { IconButton, Snackbar, Text } from 'react-native-paper';
+
+import { EntAppIcon } from '@/src/components/ent/EntAppIcon';
+import { Screen } from '@/src/components/ui/Screen';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { Tile, TileGrid } from '@/src/components/ui/Tile';
+import { ENT_APPS, type EntApp } from '@/src/constants/ent';
+import { MAX_PINNED_APPS, togglePinnedApp, usePinnedApps } from '@/src/hooks/usePinnedApps';
+import { useAppTheme } from '@/src/theme';
+import { openEntApp } from '@/src/utils/browser';
+import { haptics } from '@/src/utils/haptics';
+
+export default function EntScreen() {
+ const theme = useAppTheme();
+ const c = theme.colors;
+ const pinned = usePinnedApps();
+ const [notice, setNotice] = useState(null);
+
+ function togglePin(app: EntApp) {
+ const wasPinned = pinned.includes(app.id);
+ if (!togglePinnedApp(app.id)) {
+ haptics('warning');
+ setNotice(`Accès rapide complet : ${MAX_PINNED_APPS} apps maximum`);
+ return;
+ }
+ haptics('selection');
+ setNotice(wasPinned ? `${app.label} retiré de l'accès rapide` : `${app.label} épinglé à l'accès rapide`);
+ }
+
+ return (
+ setNotice(null)} duration={2500}>
+ {notice ?? ''}
+
+ }
+ >
+
+
+
+ {ENT_APPS.map((app) => {
+ const isPinned = pinned.includes(app.id);
+ return (
+ }
+ aside={
+ togglePin(app)}
+ />
+ }
+ accessibilityHint="Appui long pour l'épingler à l'accès rapide ou l'en retirer"
+ // la bordure ne décale pas le contenu : 14 + 2 = le padding de 16 d'une tuile
+ style={{ padding: 14, borderWidth: 2, borderColor: isPinned ? c.primary : 'transparent' }}
+ onPress={() => openEntApp(app.id)}
+ onLongPress={() => togglePin(app)}
+ />
+ );
+ })}
+
+
+
+
+ Épingle jusqu'à {MAX_PINNED_APPS} services pour les retrouver sur l'accueil et sur l'icône de
+ l'app. Tes connexions restent enregistrées sur cet appareil.
+
+
+ );
+}
diff --git a/src/app/(tabs)/free-rooms.tsx b/src/app/(tabs)/free-rooms.tsx
new file mode 100644
index 0000000..3a0ce43
--- /dev/null
+++ b/src/app/(tabs)/free-rooms.tsx
@@ -0,0 +1,228 @@
+import { useMemo, useRef, useState } from 'react';
+import { RefreshControl, ScrollView, View } from 'react-native';
+
+import { useIsFocused } from 'expo-router';
+import { Snackbar } from 'react-native-paper';
+
+import { FavoriteRooms } from '@/src/components/rooms/FavoriteRooms';
+import { RoomFilters } from '@/src/components/rooms/RoomFilters';
+import type { RoomsViewContext } from '@/src/components/rooms/RoomRow';
+import { RoomTree } from '@/src/components/rooms/RoomTree';
+import { Banner } from '@/src/components/ui/Banner';
+import { Card } from '@/src/components/ui/Card';
+import { EmptyState } from '@/src/components/ui/EmptyState';
+import { Screen } from '@/src/components/ui/Screen';
+import { HeaderButton } from '@/src/components/ui/ScreenHeader';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { Bone, Skeleton } from '@/src/components/ui/Skeleton';
+import { MAX_FAVORITE_ROOMS, useFavoriteRooms } from '@/src/hooks/useFavoriteRooms';
+import { useRoomsSnapshot } from '@/src/hooks/useRoomsSnapshot';
+import { getStatusColors, useAppTheme } from '@/src/theme';
+import type { RoomBooking } from '@/src/types';
+import { formatDuration, formatTime } from '@/src/utils/date';
+import { haptics } from '@/src/utils/haptics';
+import {
+ buildRoomTree,
+ buildRoomView,
+ getParisClock,
+ getRoomAvailability,
+ groupBookingsByRoom,
+ keepFree,
+} from '@/src/utils/rooms';
+
+const DEFAULT_DURATION = 60;
+const NO_BOOKINGS: RoomBooking[] = [];
+
+export default function FreeRoomsScreen() {
+ const theme = useAppTheme();
+ const colors = getStatusColors(theme);
+ const focused = useIsFocused();
+
+ const { snapshot, clock, loading, failed, reload } = useRoomsSnapshot(focused);
+ const [duration, setDuration] = useState(DEFAULT_DURATION);
+ const [onlyFree, setOnlyFree] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const favorites = useFavoriteRooms(snapshot?.rooms);
+
+ const scrollRef = useRef(null);
+ const scrollY = useRef(0);
+ const treeRef = useRef(null);
+
+ const tree = useMemo(() => buildRoomTree(snapshot?.rooms ?? []), [snapshot]);
+ const bookingsByRoom = useMemo(() => groupBookingsByRoom(snapshot?.bookings ?? []), [snapshot]);
+ const view = useMemo(() => {
+ const full = buildRoomView(tree, bookingsByRoom, clock.minutes, duration);
+ return onlyFree ? keepFree(full) : full;
+ }, [tree, bookingsByRoom, clock.minutes, duration, onlyFree]);
+
+ const favoriteEntries = useMemo(() => {
+ const rooms = snapshot?.rooms ?? [];
+ return favorites.ids.flatMap((id) => {
+ const room = rooms.find((r) => r.id === id);
+ if (!room) return [];
+ const availability = getRoomAvailability(bookingsByRoom.get(id) ?? NO_BOOKINGS, clock.minutes, duration);
+ return [{ room, availability }];
+ });
+ }, [snapshot, favorites.ids, bookingsByRoom, clock.minutes, duration]);
+
+ function refresh() {
+ if (loading) return;
+ haptics('medium');
+ reload();
+ }
+
+ function selectDuration(minutes: number) {
+ haptics('selection');
+ setDuration(minutes);
+ }
+
+ function toggleOnlyFree() {
+ haptics('selection');
+ setOnlyFree((value) => !value);
+ }
+
+ function toggleFavorite(roomId: string) {
+ if (favorites.toggle(roomId)) {
+ haptics('selection');
+ } else {
+ haptics('warning');
+ setNotice(`${MAX_FAVORITE_ROOMS} salles favorites maximum`);
+ }
+ }
+
+ // remonte au début de l'arbre quand ouvrir ou fermer un niveau change sa hauteur
+ function keepTreeInView() {
+ const scrollView = scrollRef.current?.getNativeScrollRef();
+ scrollView?.measureInWindow((_x, top) => {
+ treeRef.current?.measureInWindow((_x2, y) => {
+ if (y < top) scrollRef.current?.scrollTo({ y: scrollY.current + y - top, animated: true });
+ });
+ });
+ }
+
+ const ctx: RoomsViewContext = {
+ now: clock.minutes,
+ duration,
+ colors,
+ secondary: theme.colors.onSurfaceVariant,
+ accent: theme.colors.primary,
+ highlight: theme.colors.secondaryContainer,
+ onTreeChange: keepTreeInView,
+ isFavorite: (roomId) => favorites.ids.includes(roomId),
+ onToggleFavorite: toggleFavorite,
+ };
+
+ const updatedAt = snapshot ? formatTime(getParisClock(new Date(snapshot.fetchedAt)).minutes) : null;
+
+ return (
+ }
+ scrollRef={scrollRef}
+ onScroll={(event) => {
+ scrollY.current = event.nativeEvent.contentOffset.y;
+ }}
+ refreshControl={
+ snapshot ? (
+
+ ) : undefined
+ }
+ overlay={
+ setNotice(null)} duration={2500}>
+ {notice ?? ''}
+
+ }
+ >
+ {failed && snapshot && (
+
+ )}
+
+ {snapshot && snapshot.bookings.length === 0 && (
+
+ )}
+
+ {!snapshot &&
+ (failed && !loading ? (
+
+
+
+ ) : (
+
+ ))}
+
+ {snapshot && favoriteEntries.length > 0 && }
+
+ {snapshot && (
+
+ )}
+
+ {snapshot && (
+
+
+ {view.length === 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
+
+// squelette pendant le premier chargement (ADE met quelques secondes à répondre)
+function LoadingTree() {
+ return (
+
+
+ {[0, 1, 2, 3].map((index) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(tabs)/home.tsx b/src/app/(tabs)/home.tsx
new file mode 100644
index 0000000..66475e6
--- /dev/null
+++ b/src/app/(tabs)/home.tsx
@@ -0,0 +1,106 @@
+import { RefreshControl } from 'react-native';
+
+import { useRouter } from 'expo-router';
+import { Text } from 'react-native-paper';
+
+import { AnnouncementSheet } from '@/src/components/home/AnnouncementSheet';
+import { HomeHeader } from '@/src/components/home/HomeHeader';
+import { NextClassCard } from '@/src/components/home/NextClassCard';
+import { QuickAccess } from '@/src/components/home/QuickAccess';
+import { UpNextList } from '@/src/components/home/UpNextList';
+import { Banner } from '@/src/components/ui/Banner';
+import { Screen } from '@/src/components/ui/Screen';
+import { useSettings } from '@/src/context/SettingsContext';
+import { useAgenda } from '@/src/hooks/useAgenda';
+import { useAppTheme } from '@/src/theme';
+import { formatSyncTime } from '@/src/utils/date';
+import { haptics } from '@/src/utils/haptics';
+
+export default function HomeScreen() {
+ const router = useRouter();
+ const theme = useAppTheme();
+ const { adeid } = useSettings();
+ const agenda = useAgenda();
+
+ function editEdt() {
+ haptics('light');
+ router.push('/edt-config');
+ }
+
+ // l'emploi du temps et l'ENT sont des onglets : on bascule dessus sans empiler d'écran
+ function openTimetable() {
+ if (!agenda.configured) {
+ editEdt();
+ return;
+ }
+ haptics('medium');
+ router.navigate('/timetable');
+ }
+
+ function openEnt() {
+ haptics('light');
+ router.navigate('/ent');
+ }
+
+ // les paramètres sont aussi un onglet : la roue dentée reste là où on a l'habitude de la trouver
+ function openSettings() {
+ haptics('light');
+ router.navigate('/settings');
+ }
+
+ function refresh() {
+ haptics('medium');
+ agenda.refresh();
+ }
+
+ function retry() {
+ haptics('medium');
+ agenda.reload();
+ }
+
+ const showOffline = agenda.configured && agenda.offline && agenda.classes.length > 0;
+ const footer = agenda.configured
+ ? `Connecté à ${adeid}${agenda.syncedAt ? ` · synchronisé ${formatSyncTime(agenda.syncedAt, agenda.now)}` : ''}`
+ : 'Emploi du temps non configuré';
+
+ return (
+ }
+ refreshControl={
+ agenda.configured ? (
+
+ ) : undefined
+ }
+ overlay={ }
+ >
+ {showOffline && (
+
+ )}
+
+
+
+ {agenda.later.length > 0 && agenda.dayEnd && (
+
+ )}
+
+
+
+
+ {footer}
+
+
+ );
+}
diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..d6861cc
--- /dev/null
+++ b/src/app/(tabs)/settings.tsx
@@ -0,0 +1,5 @@
+import { SettingsScreen } from '@/src/components/settings/SettingsScreen';
+
+export default function SettingsTab() {
+ return ;
+}
diff --git a/src/app/(tabs)/timetable.tsx b/src/app/(tabs)/timetable.tsx
new file mode 100644
index 0000000..c7a579e
--- /dev/null
+++ b/src/app/(tabs)/timetable.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useState } from 'react';
+import { View } from 'react-native';
+
+import { useFocusEffect, useRouter } from 'expo-router';
+
+import { TimetableScreen } from '@/src/components/timetable/TimetableScreen';
+import { Card } from '@/src/components/ui/Card';
+import { EmptyState } from '@/src/components/ui/EmptyState';
+import { Screen } from '@/src/components/ui/Screen';
+import { useSettings } from '@/src/context/SettingsContext';
+import { useAppTheme } from '@/src/theme';
+import { haptics } from '@/src/utils/haptics';
+
+// onglet EDT : l'emploi du temps enregistré
+export default function TimetableTab() {
+ const router = useRouter();
+ const theme = useAppTheme();
+ const { adeid } = useSettings();
+ // l'onglet est monté avec l'app : le calendrier, lourd, attend la première visite
+ const [visited, setVisited] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ setVisited(true);
+ }, []),
+ );
+
+ if (!adeid) {
+ return (
+
+
+ {
+ haptics('light');
+ router.push('/edt-config');
+ },
+ }}
+ />
+
+
+ );
+ }
+
+ if (!visited) return ;
+ return ;
+}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 1e18109..6702e85 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -1,92 +1,85 @@
import { useEffect, useMemo, useState } from 'react';
+import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
+import { Measure, MeasureConfig } from '@measuresh/react-native';
import * as Font from 'expo-font';
import { Stack, ThemeProvider } from 'expo-router';
+import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import * as SystemUI from 'expo-system-ui';
-
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Provider as PaperProvider } from 'react-native-paper';
-import 'react-native-reanimated';
import { SafeAreaProvider } from 'react-native-safe-area-context';
-import { GestureHandlerRootView } from 'react-native-gesture-handler';
-import { getNavigationTheme, loadThemePreference, updateFontConfig, useChoosenTheme } from '@/src/constants/theme';
-import { AppProvider } from '@/src/context/AppContext';
-import { DeepLinkHandler } from '@/src/context/DeepLinkHandler';
-import { watchClassActivity } from '@/src/services/widgets';
-import { Measure, MeasureConfig } from '@measuresh/react-native';
+import { DeepLinkHandler } from '@/src/components/DeepLinkHandler';
+import { CalendarProvider } from '@/src/context/CalendarContext';
+import { loadSettings, SettingsProvider, type Settings } from '@/src/context/SettingsContext';
+import { loadPinnedApps } from '@/src/hooks/usePinnedApps';
+import { getNavigationTheme, loadThemePreference, useAppTheme } from '@/src/theme';
+
+// l'écran de lancement natif reste affiché pendant le chargement
+SplashScreen.preventAutoHideAsync();
+SplashScreen.setOptions({ fade: true });
+
+async function prepare(): Promise {
+ const [settings] = await Promise.all([
+ loadSettings(),
+ loadThemePreference(),
+ loadPinnedApps(),
+ Font.loadAsync({ Bahnschrift: require('../assets/bahnschrift.ttf') }),
+ // une erreur de Measure ne doit pas bloquer le démarrage
+ Measure.init({ config: new MeasureConfig({}) }).catch(console.warn),
+ ]);
+ return settings;
+}
export default function RootLayout() {
- const [isReady, setIsReady] = useState(false);
- const theme = useChoosenTheme();
+ const [settings, setSettings] = useState(null);
+ const theme = useAppTheme();
const navigationTheme = useMemo(() => getNavigationTheme(theme), [theme]);
useEffect(() => {
- async function prepare() {
- // a Measure failure must not block startup
- await Measure.init({ config: new MeasureConfig({}) }).catch(console.warn);
- await Promise.all([
- Font.loadAsync({
- Bahnschrift: require('../assets/bahnschrift.ttf'),
- }),
- loadThemePreference(),
- ]);
- updateFontConfig();
- setIsReady(true);
- }
-
- prepare();
+ prepare().then(setSettings);
}, []);
- useEffect(() => watchClassActivity(), []);
+ useEffect(() => {
+ if (settings) SplashScreen.hide();
+ }, [settings]);
// fond de la fenêtre native, derrière le Stack
useEffect(() => {
SystemUI.setBackgroundColorAsync(theme.colors.background).catch(console.warn);
}, [theme.colors.background]);
- if (!isReady) return null;
+ if (!settings) return null;
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* les feuilles s'affichent au-dessus de la barre d'onglets */}
+
+
+
+
+
+ {/* paramètres ouverts pendant la configuration initiale, avant les onglets */}
+
+ {/* accueil, emploi du temps, salles libres, ENT et paramètres */}
+
+ {/* le calendrier se fait défiler à l'horizontale : pas de retour par glissement */}
+
+
+
+
+
+
+
+
+
diff --git a/src/app/appearance.tsx b/src/app/appearance.tsx
index 8fb1167..a789569 100644
--- a/src/app/appearance.tsx
+++ b/src/app/appearance.tsx
@@ -1,64 +1,53 @@
-import React from 'react';
-import { View, ScrollView, Platform } from 'react-native';
-import { Text, Appbar, Card, Avatar, Tooltip } from 'react-native-paper';
-import * as Linking from 'expo-linking';
-import { useRouter } from 'expo-router';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { IconPicker, ThemePicker } from '@/src/components/AppearancePickers';
-import { themeOptions, useChoosenTheme, useThemeId } from '@/src/constants/theme';
+import { Linking, View } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { IconPicker, ThemePicker } from '@/src/components/appearance/AppearancePickers';
+import { Card } from '@/src/components/ui/Card';
+import { IconBadge } from '@/src/components/ui/IconBadge';
+import { Screen } from '@/src/components/ui/Screen';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { LINKS } from '@/src/constants/config';
+import { themeOptions, useAppTheme, useThemeId } from '@/src/theme';
export default function AppearanceScreen() {
- const router = useRouter();
- const theme = useChoosenTheme();
+ const theme = useAppTheme();
const themeId = useThemeId();
- const insets = useSafeAreaInsets();
- const selectedTheme = themeOptions.find((t) => t.id === themeId);
+ const selected = themeOptions.find((option) => option.id === themeId);
return (
-
-
-
- router.back()} />
-
-
-
-
-
-
- Thème
-
-
- {selectedTheme?.description ?? 'Les couleurs de l’application'}
-
-
+
+
+
+
+
+
+ {selected?.description}
+
+
+
-
- Icône
-
-
- L'icône affichée sur ton écran d'accueil.
-
-
+
+
+
+
+
+
-
- }
- />
-
-
- Envoie-nous tes œuvres d'art à{' '}
- Linking.openURL('mailto://app+icons@metrixmedia.fr')}
- >
- app+icons@metrixmedia.fr
-
+ Linking.openURL(LINKS.iconsMail)} accessibilityHint="Écrire un e-mail">
+
+
+
+ Tu es un·e artiste ?
+
+ Propose ton icône à la communauté : app+icons@metrixmedia.fr
-
-
-
-
+
+
+
+
);
}
diff --git a/src/app/edt-config.tsx b/src/app/edt-config.tsx
index 3f99eee..3138fa3 100644
--- a/src/app/edt-config.tsx
+++ b/src/app/edt-config.tsx
@@ -1,272 +1,255 @@
-import React, { useEffect, useState } from 'react';
-import { View, ScrollView, Alert, Platform } from 'react-native';
+import { useEffect, useState } from 'react';
+import { View } from 'react-native';
import { useRouter } from 'expo-router';
+import { Button, HelperText, Icon, Searchbar, SegmentedButtons, Switch, Text, TextInput } from 'react-native-paper';
+
+import { Card } from '@/src/components/ui/Card';
+import { EmptyState } from '@/src/components/ui/EmptyState';
+import { IconBadge } from '@/src/components/ui/IconBadge';
+import { ListGroup, ListItem } from '@/src/components/ui/ListGroup';
+import { Screen } from '@/src/components/ui/Screen';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { useCalendar } from '@/src/context/CalendarContext';
+import { useSettings } from '@/src/context/SettingsContext';
+import { getProjectSelection, setProjectOverride, type ProjectSelection } from '@/src/services/ade';
+import { PROGRAM_SUFFIX, searchPrograms } from '@/src/services/edt';
+import type { AdeProgram } from '@/src/types';
+import { useAppTheme } from '@/src/theme';
+import { haptics } from '@/src/utils/haptics';
-import {
- Text,
- Appbar,
- Card,
- Avatar,
- TextInput,
- SegmentedButtons,
- Searchbar,
- Tooltip,
- Button,
- RadioButton,
-} from 'react-native-paper';
-
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+type Mode = 'student' | 'program';
-import { useApp } from '@/src/context/AppContext';
-import { useChoosenTheme } from '@/src/constants/theme';
-import { haptics } from '@/src/utils/haptics';
-import { saveSecure } from '@/src/utils/storage';
-import { stringToColour } from '@/src/utils/color';
-import { edtService } from '@/src/services/edt';
-import type { AdeProject, SearchResult } from '@/src/types';
+const SEARCH_DELAY_MS = 300;
+const STUDENT_ID = /^\d{5,12}$/;
-export default function EDTConfigScreen() {
+export default function EdtConfigScreen() {
const router = useRouter();
- const theme = useChoosenTheme();
- const insets = useSafeAreaInsets();
+ const { adeid, setAdeid } = useSettings();
+ const [mode, setMode] = useState(adeid?.endsWith(PROGRAM_SUFFIX) ? 'program' : 'student');
- const { adeid, setAdeid, onboarding } = useApp();
- const [tempAde, setTempAde] = useState('');
+ function save(code: string) {
+ haptics('success');
+ setAdeid(code);
+ router.back();
+ }
- const [mode, setMode] = useState(adeid?.includes('-VET') ? '1' : '0');
+ return (
+
+ {adeid && }
+
+ {
+ haptics('selection');
+ setMode(value as Mode);
+ }}
+ buttons={[
+ { value: 'student', label: 'Individuel', icon: 'account' },
+ { value: 'program', label: 'Cursus', icon: 'account-group' },
+ ]}
+ />
+
+ {mode === 'student' ? (
+
+ ) : (
+ save(id + PROGRAM_SUFFIX)} />
+ )}
+
+
+
+ );
+}
- const [searchValue, setSearchValue] = useState('');
- const [searchResults, setSearchResults] = useState([]);
- const [loading, setLoading] = useState(false);
+function CurrentEdt({ adeid }: { adeid: string }) {
+ const theme = useAppTheme();
+ const program = adeid.endsWith(PROGRAM_SUFFIX);
- const [projectMode, setProjectMode] = useState('0');
- const [adeProjects, setAdeProjects] = useState([]);
- const [selectedProjectId, setSelectedProjectId] = useState(null);
+ return (
+
+
+
+
+
+ EDT actuel · {program ? 'cursus' : 'individuel'}
+
+
+ {program ? adeid.slice(0, -PROGRAM_SUFFIX.length) : adeid}
+
+
+
+
+ );
+}
- useEffect(() => {
- edtService.getProjectSelection().then(({ projects, selectedId, isOverridden }) => {
- setAdeProjects(projects);
- setSelectedProjectId(selectedId);
- setProjectMode(isOverridden ? '1' : '0');
- });
- }, []);
+function StudentForm({ onSave }: { onSave: (code: string) => void }) {
+ const theme = useAppTheme();
+ const [value, setValue] = useState('');
+ const studentId = value.trim();
+ const invalid = studentId.length > 0 && !STUDENT_ID.test(studentId);
- function selectProjectMode(value: string) {
- setProjectMode(value);
- if (value === '0') {
- edtService.setProjectOverride(null).then(() => {
- edtService.getProjectSelection().then(({ selectedId }) => setSelectedProjectId(selectedId));
- });
- }
- }
+ return (
+
+ !invalid && studentId && onSave(studentId)}
+ />
+
+ {invalid ? 'Uniquement des chiffres, comme sur ta carte étudiant.' : 'Celui indiqué sur ta carte étudiant.'}
+
+ onSave(studentId)}>
+ Enregistrer
+
+
+
+ );
+}
- function selectAdeProject(project: AdeProject) {
- setSelectedProjectId(project.id);
- edtService.setProjectOverride(project.id);
- haptics('light');
- }
+function ProgramSearch({ onSelect }: { onSelect: (id: string) => void }) {
+ const theme = useAppTheme();
+ const [query, setQuery] = useState('');
+ // résultats de la dernière recherche terminée
+ const [search, setSearch] = useState<{ term: string; results: AdeProgram[] | null } | null>(null);
+ const term = query.trim();
+ const active = term.length >= 2;
- async function searchCursus(value: string) {
- setLoading(true);
- setSearchValue(value);
- if (value.length >= 2) {
- try {
- const res = await fetch(
- 'https://ade-consult.univ-cotedazur.fr/?action=search-vet&term=' + value,
- );
- if (res.status === 200) {
- const data = (await res.json()) as { results: SearchResult[] };
- setSearchResults(data.results);
- } else {
- setSearchResults([]);
- }
- } catch {
- setSearchResults([{ id: 'demo', text: 'Erreur, vérifie ta connexion.' }]);
- }
- }
- setLoading(false);
- }
+ useEffect(() => {
+ if (term.length < 2) return;
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ searchPrograms(term).then((results) => {
+ if (!cancelled) setSearch({ term, results });
+ });
+ }, SEARCH_DELAY_MS);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [term]);
- function selectCursus(value: string, individual = false) {
- if (!value || value === 'demo') {
- Alert.alert('Erreur', 'Entrée invalide.');
- return;
- }
- const newAdeid = individual ? value : value + '-VET';
- setAdeid(newAdeid);
- saveSecure('adeid', newAdeid);
- haptics('heavy');
- if (onboarding) router.back();
- else router.replace('/home');
- }
+ const searching = active && search?.term !== term;
+ const results = active ? search?.results : undefined;
return (
-
-
-
- router.back()} />
-
-
-
+
+
+
+ {results === null && !searching && (
+
+
+
+ )}
+ {results?.length === 0 && !searching && (
+
+
+
+ )}
+ {results && results.length > 0 && (
+
+ {results.map((program) => (
+ onSelect(program.id)}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
-
-
- EDT affiché : {adeid ?? 'Non configuré'} / {adeProjects.find((p) => p.id === selectedProjectId)?.name ?? 'Non configuré'}
-
+// année scolaire (projet ADE) : automatique, ou choisie à la main
+function ProjectPicker() {
+ const theme = useAppTheme();
+ const { reload } = useCalendar();
+ const [selection, setSelection] = useState(null);
-
- }
- title="Identifiant ADE"
- />
+ useEffect(() => {
+ getProjectSelection().then(setSelection);
+ }, []);
-
-
+ async function choose(id: string | null) {
+ haptics('selection');
+ await setProjectOverride(id);
+ setSelection(await getProjectSelection());
+ reload();
+ }
- {mode === '0' ? (
- <>
-
- Entre ton numéro étudiant pour configurer l'emploi du temps affiché :
-
-
- selectCursus(tempAde, true)}
- style={{ marginTop: 16 }}>
- Sauvegarder
-
-
- } title />
-
-
- Ton numéro étudiant est celui indiqué sur ta carte étudiant.
-
-
- L'emploi du temps individuel comprend les cours de ton cursus ainsi que
- les cours de groupes dont tu fais partie.
-
-
-
- >
- ) : (
- <>
- {searchResults.length > 1 ? (
-
- Sélectionne un cursus pour changer l'emploi du temps affiché :
-
- ) :
-
- Tape au moins 2 caractères pour rechercher un cursus.
-
- }
-
- {searchResults.map((item, index) => (
-
- selectCursus(item.id, false)}>
-
-
- {item.text}
-
-
-
- ))}
-
- } title />
-
-
- L'emploi du temps par cursus comprend les cours du cursus sélectionné ainsi
- que tous les cours de groupes, y compris ceux dont tu ne fais pas partie.
-
-
-
- >
- )}
-
-
+ // passer en manuel garde l'année affichée
+ function setAuto(auto: boolean) {
+ if (!selection) return;
+ choose(auto ? null : (selection.selected ?? selection.projects[0]).id);
+ }
-
- }
- title="Année scolaire (projet ADE)"
- />
+ if (!selection) {
+ return ;
+ }
+ if (selection.projects.length === 0) {
+ return ;
+ }
-
-
+
+
+ setAuto(selection.manual)}
+ right={ }
+ />
+ {selection.manual &&
+ selection.projects.map((project) => (
+ choose(project.id)}
+ right={
+ project.id === selection.selected?.id ? (
+
+ ) : null
+ }
/>
-
- {projectMode === '1' && (
- adeProjects.length === 0 ? (
-
- Chargement des projets...
-
- ) : (
- {
- const project = adeProjects.find((p) => p.id === id);
- if (project) selectAdeProject(project);
- }}
- >
- {adeProjects.map((project) => (
-
- ))}
-
- )
- )}
-
-
-
+ ))}
+
);
}
+
+function Hint({ text, color }: { text: string; color: string }) {
+ return (
+
+ {text}
+
+ );
+}
diff --git a/src/app/edt/[code].tsx b/src/app/edt/[code].tsx
new file mode 100644
index 0000000..c7f41e1
--- /dev/null
+++ b/src/app/edt/[code].tsx
@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+
+import { useLocalSearchParams, useRouter } from 'expo-router';
+
+import { TimetableScreen } from '@/src/components/timetable/TimetableScreen';
+import { isValidEdtCode } from '@/src/utils/deeplink';
+
+// edt d'un autre code ADE, ouvert par un lien unicenotes://edt/{code} : lecture seule
+export default function SharedTimetableScreen() {
+ const router = useRouter();
+ const { code } = useLocalSearchParams<{ code?: string }>();
+ const tempCode = isValidEdtCode(code) ? code : null;
+
+ // code invalide : rien n'est chargé ni affiché
+ useEffect(() => {
+ if (tempCode) return;
+ if (router.canGoBack()) router.back();
+ else router.replace('/home');
+ }, [tempCode, router]);
+
+ if (!tempCode) return null;
+ return ;
+}
diff --git a/src/app/ent.tsx b/src/app/ent.tsx
deleted file mode 100644
index ddd5ec7..0000000
--- a/src/app/ent.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { useChoosenTheme } from '@/src/constants/theme';
-import { handleURL } from '@/src/utils/api';
-import { useRouter } from 'expo-router';
-import React from 'react';
-import { Platform, ScrollView, View } from 'react-native';
-import { Appbar, Avatar, Card, Text, Tooltip } from 'react-native-paper';
-
-const apps = [
- {
- label: 'Outlook',
- subtitle: 'Emails',
- icon: 'email-outline',
- image: require('../assets/images/ent/outlook.png'),
- url: 'https://outlook.office.com/owa/?realm=etu.univ-cotedazur.fr&exsvurl=1&ll-cc=1036&modurl=0',
- },
- {
- label: 'Moodle',
- subtitle: 'Portail LMS',
- icon: 'book-open-variant',
- image: require('../assets/images/ent/moodle.png'),
- url: 'https://portail-lms.univ-cotedazur.fr',
- },
- {
- label: 'PronoteCampus',
- subtitle: 'Notes',
- icon: 'calculator-variant-outline',
- image: require('../assets/images/ent/pronotecampus.png'),
- url: 'https://sco.polytech.unice.fr/1',
- },
- {
- label: 'IUT Notes',
- subtitle: 'Notes',
- icon: 'calculator-variant',
- url: 'https://iut-notes.unice.fr/',
- },
- {
- label: 'SoWeSign',
- subtitle: 'Émargement',
- icon: 'check-circle-outline',
- url: 'https://app.sowesign.com/login',
- },
- {
- label: 'Mon dossier Web',
- subtitle: 'Scolarité',
- icon: 'school-outline',
- url: 'https://mondossierweb.univ-cotedazur.fr/',
- },
- {
- label: 'Annuaire UniCA',
- subtitle: 'Contacts',
- icon: 'account-search-outline',
- url: 'https://annuaire.univ-cotedazur.fr',
- },
- {
- label: 'BU',
- subtitle: 'Bibliothèques',
- icon: 'book-open-page-variant-outline',
- url: 'https://bu.univ-cotedazur.fr/',
- },
- {
- label: 'Imprimerie',
- subtitle: 'Impressions',
- icon: 'printer-outline',
- url: 'https://impression.univ-cotedazur.fr/',
- },
- {
- label: 'Izly',
- subtitle: 'Mon espace',
- icon: 'cash-multiple',
- image: require('../assets/images/ent/izly.png'),
- url: 'https://mon-espace.izly.fr',
- },
-];
-
-export default function ShowENTScreen() {
- const router = useRouter();
- const theme = useChoosenTheme();
-
- return (
-
-
-
- router.back()} />
-
-
-
-
-
-
- Vos applications
-
-
-
- {apps.map((app) => (
- handleURL(app.url)}
- style={{ width: '46%', marginBottom: 12, backgroundColor: theme.colors.surface }}
- >
-
- {app.image ? (
-
- ) : (
-
- )}
-
- {app.label}
-
-
- {app.subtitle}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/src/app/free-rooms.tsx b/src/app/free-rooms.tsx
deleted file mode 100644
index df59de0..0000000
--- a/src/app/free-rooms.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import React, { useMemo, useRef, useState } from 'react';
-import { RefreshControl, ScrollView, View } from 'react-native';
-
-import { Appbar, Divider, ProgressBar, Snackbar, Text, Tooltip } from 'react-native-paper';
-
-import { useRouter } from 'expo-router';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { Banner } from '@/src/components/Banner';
-import { FavoriteRooms } from '@/src/components/rooms/FavoriteRooms';
-import { RoomFilters } from '@/src/components/rooms/RoomFilters';
-import type { RoomsViewContext } from '@/src/components/rooms/RoomRow';
-import { RoomTree } from '@/src/components/rooms/RoomTree';
-import { getRoomStatusColors, useChoosenTheme } from '@/src/constants/theme';
-import { MAX_FAVORITE_ROOMS, useFavoriteRooms } from '@/src/hooks/useFavoriteRooms';
-import { useRoomsSnapshot } from '@/src/hooks/useRoomsSnapshot';
-import type { RoomBooking, RoomEntry } from '@/src/types';
-import { haptics } from '@/src/utils/haptics';
-import {
- buildRoomTree,
- buildRoomView,
- formatDuration,
- formatTime,
- getParisClock,
- getRoomAvailability,
- groupBookingsByRoom,
- keepFree,
-} from '@/src/utils/rooms';
-
-const DEFAULT_DURATION = 60;
-const NO_ROOMS: RoomEntry[] = [];
-const NO_BOOKINGS: RoomBooking[] = [];
-
-export default function FreeRoomsScreen() {
- const router = useRouter();
- const theme = useChoosenTheme();
- const insets = useSafeAreaInsets();
- const colors = getRoomStatusColors(theme);
-
- const { snapshot, clock, loading, failed, reload } = useRoomsSnapshot();
- const [duration, setDuration] = useState(DEFAULT_DURATION);
- const [onlyFree, setOnlyFree] = useState(false);
- const [notice, setNotice] = useState(null);
- const favorites = useFavoriteRooms(snapshot?.rooms);
-
- const scrollRef = useRef(null);
- const scrollY = useRef(0);
- // position du début de l'arbre dans la page
- const listY = useRef(0);
-
- const tree = useMemo(() => buildRoomTree(snapshot?.rooms ?? []), [snapshot]);
- const bookingsByRoom = useMemo(() => groupBookingsByRoom(snapshot?.bookings ?? []), [snapshot]);
- const view = useMemo(() => {
- const full = buildRoomView(tree, bookingsByRoom, clock.minutes, duration);
- return onlyFree ? keepFree(full) : full;
- }, [tree, bookingsByRoom, clock.minutes, duration, onlyFree]);
-
- const favoriteEntries = useMemo(() => {
- const rooms = snapshot?.rooms ?? [];
- return favorites.ids.flatMap((id) => {
- const room = rooms.find((r) => r.id === id);
- if (!room) return [];
- const availability = getRoomAvailability(bookingsByRoom.get(id) ?? NO_BOOKINGS, clock.minutes, duration);
- return [{ room, availability }];
- });
- }, [snapshot, favorites.ids, bookingsByRoom, clock.minutes, duration]);
-
- function goBack() {
- haptics('medium');
- router.back();
- }
-
- function refresh() {
- if (loading) return;
- haptics('medium');
- reload();
- }
-
- function selectDuration(minutes: number) {
- haptics('selection');
- setDuration(minutes);
- }
-
- function toggleFavorite(roomId: string) {
- if (favorites.toggle(roomId)) {
- haptics('selection');
- } else {
- haptics('warning');
- setNotice(`${MAX_FAVORITE_ROOMS} salles favorites maximum`);
- }
- }
-
- function toggleOnlyFree() {
- haptics('selection');
- setOnlyFree((value) => !value);
- }
-
- // ouvrir ou fermer un niveau change la hauteur de la liste : si l'utilisateur avait dépassé le
- // début de l'arbre, on y remonte pour qu'il ne perde pas le fil
- function keepTreeInView() {
- if (scrollY.current > listY.current) {
- scrollRef.current?.scrollTo({ y: listY.current, animated: true });
- }
- }
-
- const ctx: RoomsViewContext = {
- now: clock.minutes,
- duration,
- colors,
- secondary: theme.colors.onSurfaceVariant,
- accent: theme.colors.primary,
- highlight: theme.colors.secondaryContainer,
- onTreeChange: keepTreeInView,
- isFavorite: (roomId) => favorites.ids.includes(roomId),
- onToggleFavorite: toggleFavorite,
- };
-
- const errorBackground = theme.dark ? theme.colors.errorContainer : theme.colors.error;
- const errorForeground = theme.dark ? theme.colors.onErrorContainer : theme.colors.onError;
- const updatedAt = snapshot ? formatTime(getParisClock(new Date(snapshot.fetchedAt)).minutes) : null;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- {loading && }
-
- {failed && (
-
- )}
-
- {snapshot && snapshot.bookings.length === 0 && (
-
- )}
-
- {
- scrollY.current = event.nativeEvent.contentOffset.y;
- }}
- scrollEventThrottle={64}
- contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
- refreshControl={ }
- >
- {!snapshot && !failed && (
-
- Chargement des salles...
-
- )}
-
- {snapshot && (
- <>
-
-
-
-
- {
- listY.current = event.nativeEvent.layout.y;
- }}
- >
- {view.length === 0 ? (
-
- Aucune salle libre pour {formatDuration(duration)}.
-
- ) : (
-
- )}
-
- >
- )}
-
-
- setNotice(null)} duration={2500}>
- {notice ?? ''}
-
-
- );
-}
diff --git a/src/app/home.tsx b/src/app/home.tsx
deleted file mode 100644
index ad3943a..0000000
--- a/src/app/home.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { StyleSheet, View } from 'react-native';
-
-import {
- ActivityIndicator,
- Avatar,
- Button,
- Card,
- Chip,
- Divider,
- Text,
- TouchableRipple
-} from 'react-native-paper';
-
-import { useRouter } from 'expo-router';
-
-import BottomSheet, {
- BottomSheetBackdrop,
- BottomSheetBackdropProps,
- BottomSheetView,
-} from '@gorhom/bottom-sheet';
-import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { APP_VERSION, PRONOTE_URL, RELEASE_NOTES } from '@/src/constants/config';
-import { useChoosenTheme } from '@/src/constants/theme';
-import { useApp } from '@/src/context/AppContext';
-import { edtService } from '@/src/services/edt';
-import { updateWidgets, WIDGETS_SUPPORTED } from '@/src/services/widgets';
-import type { NextEvent } from '@/src/types';
-import { handleURL } from '@/src/utils/api';
-import { haptics } from '@/src/utils/haptics';
-import { saveAsync } from '../utils/storage';
-
-const WELCOME_MESSAGES = [
- 'Passe une excellente journée !',
- 'Quoi de prévu aujourd\'hui ? :)',
- 'Voyons ce qui t\'attend aujourd\'hui.',
- 'Un œil sur l\'emploi du temps ?',
- 'On fait le point sur la journée ?',
- 'C\'est quoi le plan pour aujourd\'hui ?',
- 'Petit check rapide de ta journée ?',
- 'Ravi de te revoir ! ^^',
-];
-
-function getRandomWelcomeMessage() {
- return WELCOME_MESSAGES[Math.floor(Math.random() * WELCOME_MESSAGES.length)];
-}
-
-export default function HomeScreen() {
- const router = useRouter();
- const {
- adeid, setCalendar, setCalendarOffline,
- updateModalShown, setUpdateModalShown,
- setOnboarding
- } = useApp();
- const theme = useChoosenTheme();
- const insets = useSafeAreaInsets();
-
- const [loading, setLoading] = useState(false);
- const [selectable, setSelectable] = useState(true);
-
- const [nextEvent, setNextEvent] = useState({
- summary: 'Chargement...',
- location: 'Chargement...',
- });
- const [nextEventLoaded, setNextEventLoaded] = useState(false);
-
- const [infoTitle, setInfoTitle] = useState('Informations');
- const [infoSubtitle, setInfoSubtitle] = useState('');
-
- const isDemo = !adeid || adeid === 'demo';
-
- const [welcomeMessage] = useState(getRandomWelcomeMessage);
-
- const bottomSheetInfoRef = useRef(null);
-
- const renderBackdrop = useCallback(
- (props: BottomSheetBackdropProps) => (
-
- ),
- [],
- );
-
- useEffect(() => {
- setOnboarding(false);
- if (!isDemo) {
- getNextEvent('normal');
- if (WIDGETS_SUPPORTED) pushWidgetTimeline();
- }
-
- // check in app context if update modal has been shown, if not show it and set it to true
- if (!updateModalShown) {
- showUpdateModal();
- }
- }, []);
-
- async function getNextEvent(mode: 'normal' | 'force') {
- if (isDemo) return;
- if (mode === 'force') {
- setNextEvent({ summary: 'Chargement...', location: 'Chargement...' });
- }
- if (mode === 'force' || (mode === 'normal' && !nextEventLoaded)) {
- const result = await edtService.getNextEvent(adeid ?? 'demo');
- setNextEvent(result);
- setNextEventLoaded(true);
- }
- }
-
- async function getMyCal() {
- haptics('medium');
- setSelectable(false);
- setLoading(true);
-
- const { events, offline } = await edtService.getEDT(adeid ?? 'demo');
- setCalendar(events);
- setCalendarOffline(offline);
- updateWidgets(events);
-
- setSelectable(true);
- setLoading(false);
- router.push('/timetable');
- }
-
- async function pushWidgetTimeline() {
- const { events } = await edtService.getEDT(adeid ?? 'demo');
- updateWidgets(events);
- }
-
- async function showUpdateModal() {
- // wait 1 second before showing the update modal
- await new Promise(resolve => setTimeout(resolve, 1500));
- setInfoTitle(RELEASE_NOTES.info);
- setInfoSubtitle(RELEASE_NOTES.subtitle);
- saveAsync('releaseNotesVersion', APP_VERSION);
- setUpdateModalShown(true);
- bottomSheetInfoRef.current?.expand();
- }
-
- return (
-
-
-
-
- router.push('/edt-config')}
- rippleColor="rgba(0, 0, 0, 0)"
- style={{ marginLeft: 'auto', marginTop: insets.top, marginBottom: 32 }}
- >
-
-
- router.push('/settings')}
- rippleColor="rgba(0, 0, 0, 0)"
- style={{ marginLeft: 8, marginTop: insets.top, marginBottom: 32 }}
- >
-
-
-
-
-
- Salut ! 👋
-
-
- {welcomeMessage}
-
-
- router.push('/edt-config') : getMyCal}
- >
-
-
-
- {isDemo ? 'Non configuré' : nextEvent.summary}
-
-
- {isDemo ? "Appuies pour choisir ton emploi du temps" : (nextEvent.location || "Salle non précisée")}
-
-
-
- {isDemo && (
- router.push('/edt-config')} icon="calendar-edit">
- Configurer l'EDT
-
- )}
- {!isDemo && (
- getNextEvent('force')}
- icon="refresh"
- >
- Rafraîchir
-
- )}
- {!isDemo && (
- nextEvent.summary !== 'ADE Indisponible' ? (
-
- Emploi du temps
-
- ) : (
-
- EDT (Hors-ligne)
-
- )
- )}
-
-
-
-
- router.push('/free-rooms')}
- icon="door-open"
- >
- Salles libres
-
- handleURL(PRONOTE_URL)}
- >
- PronoteCampus
-
- router.push('/ent')}
- icon="briefcase-variant"
- >
- Intranet Étudiant (ENT)
-
-
-
-
-
- {adeid ? `Connecté en tant que ${adeid}` : "Non connecté"}
-
-
-
-
-
-
-
-
- {infoTitle}
-
-
- {infoSubtitle}
-
- bottomSheetInfoRef.current?.close()}
- >
- Fermer
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/index.tsx b/src/app/index.tsx
index c644d4f..f30fcb8 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -1,5 +1,8 @@
import { Redirect } from 'expo-router';
+import { useSettings } from '@/src/context/SettingsContext';
+
export default function Index() {
- return ;
+ const { oobeCompleted } = useSettings();
+ return ;
}
diff --git a/src/app/oobe.tsx b/src/app/oobe.tsx
index ec1d016..0cfad20 100644
--- a/src/app/oobe.tsx
+++ b/src/app/oobe.tsx
@@ -1,11 +1,15 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useEffect, useState, type ReactNode } from 'react';
import { View } from 'react-native';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
-
-import { Button, IconButton, Text, Tooltip } from 'react-native-paper';
+import LottieView from 'lottie-react-native';
+import { Button, Icon, IconButton, Text } from 'react-native-paper';
import Animated, {
+ cancelAnimation,
+ FadeInRight,
+ FadeOutLeft,
+ LinearTransition,
useAnimatedStyle,
useSharedValue,
withRepeat,
@@ -13,211 +17,252 @@ import Animated, {
withSpring,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
-import LottieView from 'lottie-react-native';
-import { IconPicker, ThemePicker } from '@/src/components/AppearancePickers';
-import { useChoosenTheme } from '@/src/constants/theme';
-import { useApp } from '@/src/context/AppContext';
-import { handleURL } from '@/src/utils/api';
+import darkBackground from '@/src/assets/lottie/background_login_dark.json';
+import lightBackground from '@/src/assets/lottie/background_login_light.json';
+import { IconPicker, ThemePicker } from '@/src/components/appearance/AppearancePickers';
+import { APP_VERSION, LINKS } from '@/src/constants/config';
+import { useSettings } from '@/src/context/SettingsContext';
+import { useAppTheme } from '@/src/theme';
+import { openURL } from '@/src/utils/browser';
import { haptics } from '@/src/utils/haptics';
-import { saveSecure } from '@/src/utils/storage';
+import { storage } from '@/src/utils/storage';
+
+const STEPS = ['welcome', 'edt', 'appearance'] as const;
+type Step = (typeof STEPS)[number];
-type Step = 'welcome' | 'edt' | 'appearance';
+// marge intérieure du panneau, que le sélecteur d'icônes déborde
+const PANEL_PADDING = 24;
-export default function OOBEScreen() {
+export default function OobeScreen() {
const router = useRouter();
- const { adeid, setAdeid, setOnboarding, setOobeCompleted } = useApp();
- const theme = useChoosenTheme();
+ const theme = useAppTheme();
const insets = useSafeAreaInsets();
+ const { adeid, setOobeCompleted } = useSettings();
const [step, setStep] = useState('welcome');
- const edtDone = !!adeid && adeid !== 'demo';
-
- useEffect(() => {
- setOnboarding(true);
- }, [setOnboarding]);
-
- const rotation = useSharedValue(0);
- const rotateConfig = { damping: 2, stiffness: 15 };
- rotation.value = withRepeat(
- withSequence(withSpring(0, rotateConfig), withSpring(360, rotateConfig)),
- -1,
- false,
- );
- const animatedStyleLogo = useAnimatedStyle(() => ({
- transform: [{ rotate: `${rotation.value}deg` }],
- }));
-
- const renderNoBackdrop = useCallback(() => null, []);
-
- async function handleWelcomeNext() {
- haptics('medium');
- // second passage (bouton "Relancer" des paramètres) : on n'écrase pas un EDT déjà configuré
- if (!edtDone) {
- await saveSecure('adeid', 'demo');
- setAdeid('demo');
- }
- setStep('edt');
- }
-
- function configureEdt() {
- haptics('medium');
- router.push('/edt-config');
- }
- function goToAppearance() {
+ function goTo(next: Step) {
haptics('light');
- setStep('appearance');
+ setStep(next);
}
- function finishOobe() {
+ async function finish() {
+ // un nouvel utilisateur n'a pas besoin des nouveautés de cette version
+ await storage.set('releaseNotesVersion', APP_VERSION);
haptics('success');
setOobeCompleted(true);
- setOnboarding(false);
router.replace('/home');
}
- const GREEN = '#2E7D32';
-
- const bgStyle = { backgroundColor: theme.colors.background };
- const handleStyle = { backgroundColor: theme.colors.onBackground };
-
return (
-
-
-
-
+
+
-
-
+
+
{step === 'welcome' && (
- <>
-
- UniceNotes
-
-
- Délaisse les vieux intranets et retrouve tes notes et ton emploi du temps directement dans l'application.
-
-
- En continuant, tu acceptes les conditions{'\n'}
- d'utilisation ainsi que la politique de confidentialité.
-
-
- Suivant
-
-
-
- handleURL('https://notes.metrixmedia.fr/credits')}
- />
-
-
- handleURL('https://github.com/UniceApps/UniceNotes')}
- />
-
-
- router.push('/settings')}
- />
-
-
- >
+ goTo('edt')} onSettings={() => router.push('/oobe/settings')} />
)}
-
{step === 'edt' && (
- <>
-
- Configuration
-
-
- (1/2) — Emploi du temps
-
-
- Configure ton emploi du temps (via ADE) pour le retrouver directement à l'accueil.
-
- {edtDone ? (
-
- Suivant
-
- ) : (
- <>
-
- Configurer
-
-
- Plus tard
-
- >
- )}
- >
+ router.push('/edt-config')} onNext={() => goTo('appearance')} />
)}
+ {step === 'appearance' && }
+
+
+
+ );
+}
- {step === 'appearance' && (
- <>
-
- Personnalisation
-
-
- (2/2) — UniceNotes de ton style
-
-
- Thème
-
-
-
- Icône
-
-
-
- Tu pourras changer d'avis à tout moment dans Paramètres › Apparence.
-
-
- C'est parti !
-
- >
- )}
-
-
+function Welcome({ onNext, onSettings }: { onNext: () => void; onSettings: () => void }) {
+ const theme = useAppTheme();
+ return (
+
+
+ Commencer
+
+
+ En continuant, tu acceptes les conditions d'utilisation et la politique de confidentialité.
+
+
+ openURL(LINKS.credits)}
+ />
+ openURL(LINKS.source)}
+ />
+
+
+
+ );
+}
+
+function EdtStep({
+ adeid,
+ onConfigure,
+ onNext,
+}: {
+ adeid: string | null;
+ onConfigure: () => void;
+ onNext: () => void;
+}) {
+ const theme = useAppTheme();
+ return (
+
+ {adeid ? (
+ <>
+
+
+
+ EDT {adeid} configuré
+
+
+
+ Suivant
+
+ >
+ ) : (
+ <>
+
+ Configurer
+
+
+ Plus tard
+
+ >
+ )}
+
+ );
+}
+
+function AppearanceStep({ onFinish }: { onFinish: () => void }) {
+ const theme = useAppTheme();
+ return (
+
+
+
+
+ Tu pourras changer d'avis à tout moment dans Paramètres › Apparence.
+
+
+ C'est parti !
+
+
+ );
+}
+
+function StepContent({
+ label,
+ title,
+ text,
+ children,
+}: {
+ label?: string;
+ title: string;
+ text?: string;
+ children: ReactNode;
+}) {
+ const theme = useAppTheme();
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+ {title}
+ {text && (
+
+ {text}
+
+ )}
+
+ {children}
+
+ );
+}
+
+function StepDots({ step }: { step: Step }) {
+ const theme = useAppTheme();
+ const index = STEPS.indexOf(step);
+ return (
+
+ {STEPS.map((item, i) => (
+
+ ))}
);
}
+
+// tour complet avec rebond, en boucle
+function SpinningLogo({ dark }: { dark: boolean }) {
+ const rotation = useSharedValue(0);
+
+ useEffect(() => {
+ const spring = { damping: 2, stiffness: 15 };
+ rotation.set(withRepeat(withSequence(withSpring(0, spring), withSpring(360, spring)), -1, false));
+ return () => cancelAnimation(rotation);
+ }, [rotation]);
+
+ const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }] }));
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/oobe/settings.tsx b/src/app/oobe/settings.tsx
new file mode 100644
index 0000000..1cda058
--- /dev/null
+++ b/src/app/oobe/settings.tsx
@@ -0,0 +1,6 @@
+import { SettingsScreen } from '@/src/components/settings/SettingsScreen';
+
+// ouvert depuis la configuration initiale : les onglets n'existent pas encore, l'écran revient à la configuration
+export default function OobeSettingsScreen() {
+ return ;
+}
diff --git a/src/app/servers.tsx b/src/app/servers.tsx
index 1fd296d..2a6a7e8 100644
--- a/src/app/servers.tsx
+++ b/src/app/servers.tsx
@@ -1,106 +1,85 @@
-import { useChoosenTheme } from '@/src/constants/theme';
-import { useRouter } from 'expo-router';
-import { useState } from 'react';
-import { Platform, ScrollView, View } from 'react-native';
-import {
- Appbar,
- Button,
- Chip,
- Text,
- Tooltip,
-} from 'react-native-paper';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useEffect, useState } from 'react';
+import { View } from 'react-native';
-export default function ServerConfigScreen() {
- const router = useRouter();
- const theme = useChoosenTheme();
- const insets = useSafeAreaInsets();
+import { ActivityIndicator, Button, Icon, Text } from 'react-native-paper';
- const [loading, setLoading] = useState(false);
- const [statusPronote, setStatusPronote] = useState('timer-sand');
- const [statusDW, setStatusDW] = useState('timer-sand');
- const [statusADE, setStatusADE] = useState('timer-sand');
- const [statusLoginUniCA, setStatusLoginUniCA] = useState('timer-sand');
+import { ListGroup, ListItem } from '@/src/components/ui/ListGroup';
+import { Screen } from '@/src/components/ui/Screen';
+import { checkServer, SERVERS } from '@/src/services/servers';
+import { getStatusColors, useAppTheme } from '@/src/theme';
+import { haptics } from '@/src/utils/haptics';
- async function startTest() {
- setLoading(true);
+// temps de réponse par URL : null hors ligne, absent pendant le test
+type Results = Record;
- const check = async (url: string, setter: (v: string) => void, okStatuses: number[] = [200]) => {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 5000);
+export default function ServersScreen() {
+ const theme = useAppTheme();
+ const [results, setResults] = useState({});
+ const [run, setRun] = useState(0);
+ const testing = SERVERS.some((server) => !(server.url in results));
- try {
- const res = await fetch(url, { signal: controller.signal });
- setter(okStatuses.includes(res.status) ? 'check' : 'close');
- } catch {
- setter('close');
- } finally {
- clearTimeout(timeout);
- }
+ useEffect(() => {
+ let cancelled = false;
+ for (const server of SERVERS) {
+ checkServer(server.url).then((latency) => {
+ if (!cancelled) setResults((current) => ({ ...current, [server.url]: latency }));
+ });
+ }
+ return () => {
+ cancelled = true;
};
+ }, [run]);
- await check('https://sco.polytech.unice.fr/1', setStatusPronote, [200, 301, 302]);
- await check('https://mondossierweb.univ-cotedazur.fr', setStatusDW, [200, 301, 302]);
- await check('https://edtweb.univ-cotedazur.fr', setStatusADE, [200, 301, 302]);
- await check('https://login.univ-cotedazur.fr', setStatusLoginUniCA);
-
- setLoading(false);
+ function retry() {
+ haptics('medium');
+ setResults({});
+ setRun((n) => n + 1);
}
return (
-
-
-
- router.back()} />
-
-
-
+
+
+ {SERVERS.map((server) => (
+ }
+ />
+ ))}
+
-
-
- Status des serveurs Université :
+
+
+ {testing ? 'Test en cours…' : 'Relancer le test'}
+
+
+ Si un serveur ne répond pas, le problème vient sûrement de l'université : réessaie plus tard.
+
+
+ );
+}
-
- Démarrer test serveurs
-
+function ServerStatus({ latency }: { latency: number | null | undefined }) {
+ const theme = useAppTheme();
+ const colors = getStatusColors(theme);
-
- Si ces serveurs ne répondent pas, c'est sûrement de la faute de l'Université
-
-
- Serveur PronoteCampus
-
-
- Serveur Mon Dossier Web (Notes)
-
-
- Serveur ADE (Emploi du temps)
-
-
- Serveur Login UniCA (Connexion)
-
-
+ if (latency === undefined) return ;
+
+ const online = latency !== null;
+ const color = online ? colors.free : colors.busy;
+ return (
+
+
+
+ {online ? `${latency} ms` : 'Hors ligne'}
+
);
}
diff --git a/src/app/settings.tsx b/src/app/settings.tsx
deleted file mode 100644
index cf08a85..0000000
--- a/src/app/settings.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-import { Alert, ScrollView, View } from 'react-native';
-
-import Constants from 'expo-constants';
-import { useRouter } from 'expo-router';
-
-import {
- Appbar,
- Avatar,
- Button,
- Card,
- Divider,
- Text,
- Tooltip,
-} from 'react-native-paper';
-
-import { APP_VERSION, IS_BETA } from '@/src/constants/config';
-import { useChoosenTheme } from '@/src/constants/theme';
-import { useApp } from '@/src/context/AppContext';
-import { handleURL } from '@/src/utils/api';
-import { haptics } from '@/src/utils/haptics';
-import { saveAsync } from '@/src/utils/storage';
-import { LIVE_ACTIVITIES_SUPPORTED } from '@/src/services/widgets';
-
-export default function ShowSettingsScreen() {
- const router = useRouter();
- const {
- hapticsOn, setHapticsOn, liveActivitiesOn,
- setLiveActivitiesOn, clearAllData, setOobeCompleted
- } = useApp();
- const theme = useChoosenTheme();
-
- const hash = Constants.expoConfig?.extra?.github_hash as string | undefined;
-
- function goBack() {
- haptics('medium');
- router.back();
- }
-
- function whatHapticMode(value: 'ON' | 'OFF'): 'contained' | 'contained-tonal' {
- if (hapticsOn && value === 'ON') return 'contained';
- if (!hapticsOn && value === 'OFF') return 'contained';
- return 'contained-tonal';
- }
-
- function setHapticsBool(value: boolean) {
- setHapticsOn(value);
- saveAsync('haptics', value.toString());
- haptics('error');
- }
-
- function whatLiveActivitiesMode(value: 'ON' | 'OFF'): 'contained' | 'contained-tonal' {
- return liveActivitiesOn === (value === 'ON') ? 'contained' : 'contained-tonal';
- }
-
- function setLiveActivitiesBool(value: boolean) {
- setLiveActivitiesOn(value);
- saveAsync('liveActivities', value.toString());
- haptics('error');
- }
-
- function restartOobe() {
- haptics('medium');
- setOobeCompleted(false);
- router.dismissTo('/oobe');
- }
-
- function deleteAllData() {
- Alert.alert(
- 'Supprimer mes données',
- 'Es-tu sûr de vouloir supprimer toutes tes données de l\'application ? Cette action est irréversible.',
- [
- {
- text: 'Annuler',
- style: 'cancel',
- },
- {
- text: 'Supprimer',
- style: 'destructive',
- onPress: () => {
- clearAllData().then(() => {
- haptics('success');
- });
- },
- },
- ],
- )
- }
-
- return (
-
-
-
-
-
-
-
-
-
- handleURL('https://notes.metrixmedia.fr/support')}
- >
- F.A.Q. / Signaler un bug
-
-
-
- Relancer la configuration initiale
-
-
-
-
-
- }
- />
-
- router.push('/appearance')}>
- Choisir
-
-
-
-
-
- }
- />
-
- setHapticsBool(true)}>
- Activer
-
- setHapticsBool(false)}>
- Désactiver
-
-
-
-
- {LIVE_ACTIVITIES_SUPPORTED && (
-
- }
- />
-
- setLiveActivitiesBool(true)}>
- Activer
-
- setLiveActivitiesBool(false)}>
- Désactiver
-
-
-
- )}
-
-
- }
- />
-
- router.push('/servers')}>
- Accéder
-
-
-
-
-
-
-
- UniceNotes
-
-
- Ton ENT. Dans ta poche.
-
-
- © {new Date().getFullYear()} - MetrixMedia / hugofnm
-
-
- Merci d'avoir téléchargé UniceNotes :)
-
-
- ⚡ Version : {APP_VERSION}
-
-
- ❤️ Fièrement développé par un SI :{' '}
- handleURL('https://github.com/hugofnm')}
- >
- @hugofnm
-
-
-
- 🛠️ Hash local du commit Git : {hash ?? 'N/A'}
-
-
-
- } title />
-
-
- UniceNotes n'est lié d'aucune forme à l'Université Côte d'Azur, Polytech Nice Sophia Antipolis ou à l'I.U.T. de Nice
- Côte d'Azur.
-
-
- Tout usage de cette application implique la seule responsabilité de l'utilisateur comme
- prévue dans les conditions d'utilisation.
-
-
-
-
- handleURL('https://notes.metrixmedia.fr/credits')}
- >
- Mentions légales
-
- handleURL('https://notes.metrixmedia.fr/privacy')}
- >
- Politique de confidentialité
-
- handleURL('https://github.com/UniceApps/UniceNotes')}
- >
- Code source
-
- deleteAllData()}
- >
- Supprimer mes données de l'application
-
-
- {IS_BETA && (
- { throw new Error('This is a crash'); }}>
- crash_app
-
- )}
-
-
-
-
- );
-}
diff --git a/src/app/splash.tsx b/src/app/splash.tsx
deleted file mode 100644
index e5b83f6..0000000
--- a/src/app/splash.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import { View, StyleSheet } from 'react-native';
-
-import { Image } from 'expo-image';
-import * as Network from 'expo-network';
-import { useRouter } from 'expo-router';
-
-import {
- Text,
- Button,
- ActivityIndicator,
- IconButton,
- Tooltip,
-} from 'react-native-paper'
-
-import BottomSheet, {
- BottomSheetView,
- BottomSheetBackdrop,
- BottomSheetBackdropProps,
-} from '@gorhom/bottom-sheet';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { useApp } from '@/src/context/AppContext';
-import { useChoosenTheme } from '@/src/constants/theme';
-import { APP_VERSION, DOWNLOAD_URL, IS_BETA } from '@/src/constants/config';
-import { fetchApiStatus, isUpdateAvailable, type ApiAlert } from '@/src/services/uniceapi';
-import { handleURL } from '@/src/utils/api';
-import { getCalendarFromCache } from '@/src/utils/calendar';
-
-// type pour parser les données de l'API
-type InfoPrompt =
- | { kind: 'update'; latestVersion: string }
- | { kind: 'alert'; alert: ApiAlert };
-
-export default function SplashScreen() {
- const router = useRouter();
- const { setCalendar, setCalendarOffline, isInitialized, oobeCompleted } = useApp();
-
- const [loading, setLoading] = useState(true);
- const [attempt, setAttempt] = useState(0);
- const [titleError, setTitleError] = useState('Erreur');
- const [subtitleError, setSubtitleError] = useState('');
- const [infoPrompt, setInfoPrompt] = useState(null);
-
- const insets = useSafeAreaInsets();
- const theme = useChoosenTheme();
-
- const lastAttemptRef = useRef(-1);
- const bottomSheetErrorRef = useRef(null);
- const bottomSheetInfoRef = useRef(null);
- const pendingPromptsRef = useRef([]);
- const infoSheetOpenRef = useRef(false);
-
- const renderBackdrop = useCallback(
- (props: BottomSheetBackdropProps) => (
-
- ),
- [],
- );
-
- async function access() {
- const netState = await Network.getNetworkStateAsync();
- if (!netState.isInternetReachable) {
- setLoading(false);
- setTitleError('Internet indisponible');
- setSubtitleError("Tu n'es pas connecté à Internet ! EC=0xT");
- setTimeout(() => bottomSheetErrorRef.current?.expand(), 500);
- return;
- }
-
- // on check l'api pour versionning et alerts
- const status = await fetchApiStatus();
- const prompts: InfoPrompt[] = [];
- if (status) {
- if (!IS_BETA && status.version && isUpdateAvailable(APP_VERSION, status.version)) {
- prompts.push({ kind: 'update', latestVersion: status.version });
- }
- if (status.alert) {
- prompts.push({ kind: 'alert', alert: status.alert });
- }
- }
-
- setLoading(false);
- pendingPromptsRef.current = prompts;
- showNextPrompt();
- }
-
- function showNextPrompt() {
- const next = pendingPromptsRef.current.shift();
- if (!next) {
- enterApp();
- return;
- }
-
- setInfoPrompt(next);
- infoSheetOpenRef.current = true;
- setTimeout(() => bottomSheetInfoRef.current?.expand(), 500);
- }
-
- function onInfoSheetClosed() {
- if (!infoSheetOpenRef.current) return;
- infoSheetOpenRef.current = false;
- showNextPrompt();
- }
-
- function enterApp() {
- if (oobeCompleted) {
- router.replace('/home');
- } else {
- router.replace('/oobe');
- }
- }
-
- async function getMyCal() {
- bottomSheetErrorRef.current?.close();
- const cal = await getCalendarFromCache();
- setCalendar(cal);
- setCalendarOffline(true);
- router.replace('/home');
- }
-
- function refresh() {
- bottomSheetErrorRef.current?.close();
- setLoading(true);
- setAttempt((n) => n + 1);
- }
-
- useEffect(() => {
- if (isInitialized && lastAttemptRef.current !== attempt) {
- lastAttemptRef.current = attempt;
- access();
- }
- }, [isInitialized, attempt]);
-
- const infoTitle =
- infoPrompt?.kind === 'update' ? 'Mise à jour disponible' : (infoPrompt?.alert.title ?? '');
-
- const infoSubtitle =
- infoPrompt?.kind === 'update'
- ? `Une nouvelle version de UniceNotes (${infoPrompt.latestVersion}) est disponible. Mets à jour l'application pour profiter des dernières nouveautés et corrections.`
- : (infoPrompt?.alert.message ?? '');
-
- return (
-
-
-
- UniceNotes
-
-
-
-
- router.push('/settings')}
- />
-
-
-
-
-
-
-
-
-
- {infoTitle}
-
-
- {infoSubtitle}
-
- {infoPrompt?.kind === 'update' ? (
- <>
- handleURL(DOWNLOAD_URL)}
- >
- Mettre à jour
-
- bottomSheetInfoRef.current?.close()}
- >
- Plus tard
-
- >
- ) : (
- bottomSheetInfoRef.current?.close()}
- >
- Continuer
-
- )}
-
-
-
-
-
-
- {titleError}
-
-
- {subtitleError}
-
-
- Emploi du temps (hors-ligne)
-
-
- Rafraîchir
-
-
-
-
-
-
- );
-}
diff --git a/src/app/timetable.tsx b/src/app/timetable.tsx
deleted file mode 100644
index 28e2320..0000000
--- a/src/app/timetable.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { View } from 'react-native';
-
-import { Appbar, Divider, ProgressBar, Text, Tooltip } from 'react-native-paper';
-
-import BottomSheet from '@gorhom/bottom-sheet';
-import { CalendarBody, CalendarContainer, CalendarHeader } from '@howljs/calendar-kit';
-import { Redirect, useLocalSearchParams, useRouter } from 'expo-router';
-
-import { Banner } from '@/src/components/Banner';
-import { EventCard } from '@/src/components/timetable/EventCard';
-import {
- describeEvent,
- EventSheet,
- type EventDetails,
- type PressedEvent,
-} from '@/src/components/timetable/EventSheet';
-import { TimetableMenu } from '@/src/components/timetable/TimetableMenu';
-import { getCalendarTheme, useChoosenTheme } from '@/src/constants/theme';
-import { useApp } from '@/src/context/AppContext';
-import { edtService } from '@/src/services/edt';
-import type { CalendarEvent } from '@/src/types';
-import { getCalendarFromCache } from '@/src/utils/calendar';
-import { isValidEdtCode } from '@/src/utils/deeplink';
-import { haptics } from '@/src/utils/haptics';
-
-const MONTHS = [
- 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
- 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
-];
-
-export default function ShowEDTScreen() {
- const router = useRouter();
- const { code, fresh } = useLocalSearchParams<{ code?: string; fresh?: string }>();
- const { calendar, setCalendar, calendarOffline, setCalendarOffline, adeid } = useApp();
- const theme = useChoosenTheme();
-
- const tempCode = isValidEdtCode(code) ? code : null;
- const invalidCode = code !== undefined && tempCode === null;
-
- const [view, setView] = useState(3);
- const [viewIcon, setViewIcon] = useState('magnify-minus');
- const [menuVisible, setMenuVisible] = useState(false);
-
- const [details, setDetails] = useState({ title: 'Infos', description: '', room: '', time: '' });
-
- const [selectedMonth, setSelectedMonth] = useState(MONTHS[new Date().getMonth()]);
- const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
-
- const [tempEvents, setTempEvents] = useState([]);
-
- const [loading, setLoading] = useState(tempCode !== null);
- const [loadFailed, setLoadFailed] = useState(false);
-
- const calendarRef = useRef>(null);
- const bottomSheetInfoRef = useRef(null);
-
- useEffect(() => {
- async function loadCache() {
- const cal = await getCalendarFromCache();
- setCalendar(cal);
- }
- if (invalidCode) return;
- if (tempCode) {
- loadTemp(tempCode);
- } else if (fresh === '1') {
- refreshOwn();
- } else if (!calendar || calendar.length === 0) {
- loadCache();
- }
- setTimeout(() => goToToday(), 500);
- }, []);
-
- async function loadTemp(target: string) {
- setLoading(true);
- setLoadFailed(false);
- const events = await edtService.getTemporaryEDT(target);
- if (events) setTempEvents(events);
- else setLoadFailed(true);
- setLoading(false);
- }
-
- // ouvert par un raccourci : le calendrier en mémoire peut dater
- async function refreshOwn() {
- if (!adeid || adeid === 'demo') return;
- setLoading(true);
- const { events, offline } = await edtService.getEDT(adeid);
- setCalendar(events);
- setCalendarOffline(offline);
- setLoading(false);
- }
-
- // premier écran de la pile si l'app a été ouverte directement ici
- function goBack() {
- if (router.canGoBack()) router.back();
- else router.replace('/home');
- }
-
- function toggleMenu() {
- haptics('medium');
- setMenuVisible(!menuVisible);
- }
-
- function goToToday(toggle = false) {
- haptics('medium');
- if (toggle) toggleMenu();
- calendarRef.current?.goToDate({
- date: new Date(),
- hourScroll: true,
- animatedDate: true,
- animatedHour: true,
- });
- }
-
- function changeView() {
- haptics('medium');
- toggleMenu();
- if (view === 5) {
- setView(3);
- setViewIcon('magnify-minus');
- } else {
- setView(5);
- setViewIcon('magnify-plus');
- }
- }
-
- function changeDate(date: Date | string) {
- const resDate = new Date(date.toString());
- setSelectedMonth(MONTHS[resDate.getMonth()]);
- setSelectedYear(resDate.getFullYear());
- }
-
- function showInfos(event: PressedEvent) {
- haptics('selection');
- setDetails(describeEvent(event));
- bottomSheetInfoRef.current?.expand();
- }
-
- // code invalide : on ne charge ni n'affiche rien
- if (invalidCode) return ;
-
- const calTheme = getCalendarTheme(theme);
-
- const offlineBannerBg = theme.dark ? theme.colors.errorContainer : theme.colors.error;
- const offlineBannerFg = theme.dark ? theme.colors.onErrorContainer : theme.colors.onError;
-
- const tempBannerBg = loadFailed ? offlineBannerBg : theme.colors.tertiaryContainer;
- const tempBannerFg = loadFailed ? offlineBannerFg : theme.colors.onTertiaryContainer;
- const tempLabel = loadFailed
- ? 'ADE indisponible'
- : !loading && tempEvents.length === 0
- ? 'Aucun cours trouvé'
- : 'EDT temporaire';
-
- return (
-
-
-
-
-
-
- { toggleMenu(); router.push('/edt-config'); }}
- onToday={() => goToToday(true)}
- onChangeView={changeView}
- onSettings={() => { toggleMenu(); router.push('/settings'); }}
- />
-
-
- {loading && }
-
- {tempCode && (
- loadTemp(tempCode) } : undefined}
- />
- )}
-
- {!tempCode && calendarOffline && (
-
- )}
-
-
-
- {selectedMonth} {selectedYear}
-
-
- showInfos(eventItem as PressedEvent)}
- onChange={(date: Date | string) => changeDate(date)}
- scrollToNow
- numberOfDays={view}
- allowPinchToZoom
- start={420}
- end={1200}
- useHaptic
- showWeekNumber
- unavailableHours={{ 6: [{ start: 0, end: 24 * 60 }], 7: [{ start: 0, end: 24 * 60 }] }}
- timeZone="Europe/Paris"
- >
-
- } />
-
-
-
-
- );
-}
diff --git a/src/assets/background.png b/src/assets/background.png
deleted file mode 100644
index 94a269d..0000000
Binary files a/src/assets/background.png and /dev/null differ
diff --git a/src/assets/favicon.png b/src/assets/favicon.png
deleted file mode 100644
index 1bf13bb..0000000
Binary files a/src/assets/favicon.png and /dev/null differ
diff --git a/src/assets/images/profile.png b/src/assets/images/profile.png
deleted file mode 100644
index 03599ed..0000000
Binary files a/src/assets/images/profile.png and /dev/null differ
diff --git a/src/assets/themes/lottie/background_login_dark.json b/src/assets/lottie/background_login_dark.json
similarity index 100%
rename from src/assets/themes/lottie/background_login_dark.json
rename to src/assets/lottie/background_login_dark.json
diff --git a/src/assets/themes/lottie/background_login_light.json b/src/assets/lottie/background_login_light.json
similarity index 100%
rename from src/assets/themes/lottie/background_login_light.json
rename to src/assets/lottie/background_login_light.json
diff --git a/src/assets/themes/christmas/AllIWantForChristmas.mp3 b/src/assets/themes/christmas/AllIWantForChristmas.mp3
deleted file mode 100644
index 196d590..0000000
Binary files a/src/assets/themes/christmas/AllIWantForChristmas.mp3 and /dev/null differ
diff --git a/src/assets/themes/christmas/christmas.json b/src/assets/themes/christmas/christmas.json
deleted file mode 100644
index fe800b6..0000000
--- a/src/assets/themes/christmas/christmas.json
+++ /dev/null
@@ -1 +0,0 @@
-{"v":"5.5.8","fr":29.9700012207031,"ip":0,"op":300.00001221925,"w":1080,"h":2340,"nm":"snow","ddd":0,"assets":[{"id":"image_0","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_1","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_2","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_3","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_4","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_5","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_6","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_7","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_8","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_9","w":64,"h":64,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_10","w":350,"h":117,"u":"","p":"data:image/png;base64,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","e":1}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"10","parent":81,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-752,2540,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[200,200,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":2,"nm":"10","parent":85,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-752,2540,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[200,200,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":2,"nm":"10","parent":82,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-15.368,-244.316,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":2,"nm":"10","parent":86,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-15.368,-244.316,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":2,"nm":"10","parent":83,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[342.667,-486.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":2,"nm":"10","parent":87,"refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[342.667,-486.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":2,"nm":"9","parent":82,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-120.632,1408.316,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":2,"nm":"9","parent":86,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-120.632,1408.316,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":2,"nm":"9","parent":82,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[584.632,-223.263,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[81.579,81.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":2,"nm":"9","parent":86,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[584.632,-223.263,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[81.579,81.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":2,"nm":"9","parent":84,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[344,-762,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[62,62,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":2,"nm":"9","parent":88,"refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[344,-762,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[62,62,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":2,"nm":"8","parent":83,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[100,-676,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[83.333,83.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":2,"nm":"8","parent":87,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[100,-676,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[83.333,83.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":2,"nm":"8","parent":82,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[984.632,-239.053,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[71.053,71.053,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":2,"nm":"8","parent":86,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[984.632,-239.053,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[71.053,71.053,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":2,"nm":"8","parent":81,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[768,1064,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[108,108,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":2,"nm":"8","parent":85,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[768,1064,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[108,108,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":2,"nm":"8","parent":84,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[632,-1230,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":2,"nm":"8","parent":88,"refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[632,-1230,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":21,"ty":2,"nm":"7","parent":81,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-272,2840,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[280,280,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":22,"ty":2,"nm":"7","parent":85,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-272,2840,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[280,280,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":23,"ty":2,"nm":"7","parent":83,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[294.667,-308,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[93.333,93.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":24,"ty":2,"nm":"7","parent":87,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[294.667,-308,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[93.333,93.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":25,"ty":2,"nm":"7","parent":82,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[737.263,3071.474,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76.316,76.316,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":26,"ty":2,"nm":"7","parent":86,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[737.263,3071.474,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76.316,76.316,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":27,"ty":2,"nm":"7","parent":82,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[737.263,650.421,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76.316,76.316,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":28,"ty":2,"nm":"7","parent":86,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[737.263,650.421,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76.316,76.316,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":29,"ty":2,"nm":"last 2","parent":84,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[240,-2406,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":30,"ty":2,"nm":"last","parent":88,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[240,-2406,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":31,"ty":2,"nm":"7","parent":84,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[240,-566,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":32,"ty":2,"nm":"7","parent":88,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[240,-566,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":33,"ty":2,"nm":"6","parent":82,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[105.684,2874.105,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":34,"ty":2,"nm":"6","parent":86,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[105.684,2874.105,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":35,"ty":2,"nm":"6","parent":84,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[708,-236,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":36,"ty":2,"nm":"6","parent":88,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[708,-236,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":37,"ty":2,"nm":"6","parent":81,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[1024,3500,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[274,274,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":38,"ty":2,"nm":"6","parent":85,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[1024,3500,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[274,274,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":39,"ty":2,"nm":"6","parent":81,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[716,600,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[274,274,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":40,"ty":2,"nm":"6","parent":85,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[716,600,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[274,274,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":41,"ty":2,"nm":"papa16","parent":83,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[238.667,-973.333,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[50.667,50.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":42,"ty":2,"nm":"papa8","parent":87,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[238.667,-973.333,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[50.667,50.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":43,"ty":2,"nm":"5","parent":82,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[371.474,-352.211,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[71.053,71.053,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":44,"ty":2,"nm":"5","parent":86,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[371.474,-352.211,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[71.053,71.053,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":45,"ty":2,"nm":"5","parent":84,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[284,-1136,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":46,"ty":2,"nm":"5","parent":88,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[284,-1136,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":47,"ty":2,"nm":"5","parent":83,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30.667,-842.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[83.333,83.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":48,"ty":2,"nm":"5","parent":87,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30.667,-842.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[83.333,83.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":49,"ty":2,"nm":"5","parent":81,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[760,2996,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[200,200,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":50,"ty":2,"nm":"5","parent":85,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[760,2996,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[200,200,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":51,"ty":2,"nm":"4","parent":84,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[542,-2300,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[150,150,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":52,"ty":2,"nm":"4","parent":88,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[542,-2300,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[150,150,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":53,"ty":2,"nm":"4","parent":81,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-132,580,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[300,300,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":54,"ty":2,"nm":"4","parent":85,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-132,580,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[300,300,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":55,"ty":2,"nm":"4","parent":82,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-65.368,1784.632,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[65.789,65.789,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":56,"ty":2,"nm":"4","parent":86,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-65.368,1784.632,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[65.789,65.789,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":57,"ty":2,"nm":"4","parent":83,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-172,-1521.333,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":58,"ty":2,"nm":"4","parent":87,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-172,-1521.333,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":59,"ty":2,"nm":"4","parent":83,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-172,-410.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":60,"ty":2,"nm":"4","parent":87,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-172,-410.667,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":61,"ty":2,"nm":"3","parent":81,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-796,3500,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[300,300,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":62,"ty":2,"nm":"3","parent":85,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-796,3500,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[300,300,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":63,"ty":2,"nm":"3","parent":82,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-44.316,732,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":64,"ty":2,"nm":"3","parent":86,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-44.316,732,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":65,"ty":2,"nm":"papa15","parent":83,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[81.333,-1372,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[33.333,33.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":66,"ty":2,"nm":"papa7","parent":87,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[81.333,-1372,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[33.333,33.333,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":67,"ty":2,"nm":"papa14","parent":83,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-178,-1308,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":68,"ty":2,"nm":"papa6","parent":87,"refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-178,-1308,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[66.667,66.667,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":69,"ty":2,"nm":"papa13","parent":82,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[597.789,2521.474,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":70,"ty":2,"nm":"papa5","parent":86,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[597.789,2521.474,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":71,"ty":2,"nm":"2","parent":84,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[406,-1476,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":72,"ty":2,"nm":"2","parent":88,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[406,-1476,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":73,"ty":2,"nm":"2","parent":81,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-184,1684,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":74,"ty":2,"nm":"2","parent":85,"refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[-184,1684,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":75,"ty":2,"nm":"1","parent":84,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[60,-1196,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[75,75,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":76,"ty":2,"nm":"1","parent":88,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[60,-1196,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[75,75,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":77,"ty":2,"nm":"1","parent":82,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[421.474,813.579,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":78,"ty":2,"nm":"1","parent":86,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[421.474,813.579,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[197.368,197.368,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":79,"ty":2,"nm":"1","parent":82,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[921.474,1424.105,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":80,"ty":2,"nm":"1","parent":86,"refId":"image_9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[921.474,1424.105,0],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[131.579,131.579,100],"ix":6}},"ao":0,"ip":-10.0000004073083,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":81,"ty":2,"nm":"papa12","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":900,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[532,1933,0],"to":[0,165.571,0],"ti":[0,-388.565,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":91,"s":[431,2570.697,0],"to":[0,202.457,0],"ti":[-7.707,-215.514,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":156,"s":[501.977,3205.786,0],"to":[6.562,183.493,0],"ti":[-0.55,-171.129,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":211,"s":[441.744,3742.738,0],"to":[1.208,375.978,0],"ti":[0,-161.375,0]},{"t":299.00001217852,"s":[532,4273,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[50,50,100],"ix":6}},"ao":0,"ip":-6.00000024438501,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":82,"ty":2,"nm":"papa11","refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":1260,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[860,2083,0],"to":[0,293.943,0],"ti":[0,-732.882,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":162,"s":[696,3791.201,0],"to":[0,640.914,0],"ti":[0,-257.057,0]},{"t":299.00001217852,"s":[860,4423,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76,76,100],"ix":6}},"ao":0,"ip":-15.0000006109625,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":83,"ty":2,"nm":"papa10","refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":720,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[384,2040,0],"to":[0,136.492,0],"ti":[0,-303.252,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[319,2395.795,0],"to":[0,349.05,0],"ti":[-25.241,-380.875,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":191,"s":[408.527,3541.278,0],"to":[29.868,450.691,0],"ti":[0,-185.903,0]},{"t":299.00001217852,"s":[384,4380,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[150,150,100],"ix":6}},"ao":0,"ip":-17.0000006924242,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":84,"ty":2,"nm":"papa9","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[128,2093,0],"to":[0,128.162,0],"ti":[0,-282.715,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[242,2594.162,0],"to":[0,389.786,0],"ti":[30.224,-413.67,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":209,"s":[75.452,3877.565,0],"to":[-26.536,363.193,0],"ti":[0,-155.138,0]},{"t":299.00001217852,"s":[128,4433,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-15.0000006109625,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":85,"ty":2,"nm":"papa4","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":900,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[532,-407,0],"to":[0,165.571,0],"ti":[0,-388.565,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":91,"s":[431,230.697,0],"to":[0,202.457,0],"ti":[-7.707,-215.514,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":156,"s":[501.977,865.786,0],"to":[6.562,183.493,0],"ti":[-0.55,-171.129,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":211,"s":[441.744,1402.738,0],"to":[1.208,375.978,0],"ti":[0,-161.375,0]},{"t":299.00001217852,"s":[532,1933,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[50,50,100],"ix":6}},"ao":0,"ip":-6.00000024438501,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":86,"ty":2,"nm":"papa3","refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":1260,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[860,-257,0],"to":[0,293.943,0],"ti":[0,-732.882,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":162,"s":[696,1451.201,0],"to":[0,640.914,0],"ti":[0,-257.057,0]},{"t":299.00001217852,"s":[860,2083,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[76,76,100],"ix":6}},"ao":0,"ip":-15.0000006109625,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":87,"ty":2,"nm":"papa2","refId":"image_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":720,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[384,-300,0],"to":[0,136.492,0],"ti":[0,-303.252,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[319,55.795,0],"to":[0,349.05,0],"ti":[-25.241,-380.875,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":191,"s":[408.527,1201.278,0],"to":[29.868,450.691,0],"ti":[0,-185.903,0]},{"t":299.00001217852,"s":[384,2040,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[150,150,100],"ix":6}},"ao":0,"ip":-17.0000006924242,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":88,"ty":2,"nm":"papa1","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[128,-247,0],"to":[0,128.162,0],"ti":[0,-282.715,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[242,254.162,0],"to":[0,389.786,0],"ti":[30.224,-413.67,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":209,"s":[75.452,1537.565,0],"to":[-26.536,363.193,0],"ti":[0,-155.138,0]},{"t":299.00001217852,"s":[128,2093,0]}],"ix":2},"a":{"a":0,"k":[32,32,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":-15.0000006109625,"op":300.00001221925,"st":0,"bm":0},{"ddd":0,"ind":89,"ty":2,"nm":"santa.png","cl":"png","refId":"image_10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-17.175,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.597,"y":0.777},"o":{"x":0.087,"y":0.121},"t":0,"s":[-288,1520,0],"to":[246.254,-115.595,0],"ti":[-352.137,165.298,0]},{"i":{"x":0.679,"y":0.782},"o":{"x":0.344,"y":0.441},"t":107,"s":[1154.006,825.672,0],"to":[29.666,-13.926,0],"ti":[-20.746,9.738,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[1230,790,0],"to":[267,-125.333,0],"ti":[0,1.333,0]},{"t":299.00001217852,"s":[1230,590,0]}],"ix":2},"a":{"a":0,"k":[175,58.5,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[150,150,100]},{"t":120.0000048877,"s":[50.195,50.195,100]}],"ix":6}},"ao":0,"ip":-15.0000006109625,"op":300.00001221925,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/src/assets/themes/christmas/color.png b/src/assets/themes/christmas/color.png
deleted file mode 100644
index 52aaccd..0000000
Binary files a/src/assets/themes/christmas/color.png and /dev/null differ
diff --git a/src/assets/themes/christmas/icon.png b/src/assets/themes/christmas/icon.png
deleted file mode 100644
index 32dba57..0000000
Binary files a/src/assets/themes/christmas/icon.png and /dev/null differ
diff --git a/src/assets/themes/lottie/confettis.json b/src/assets/themes/lottie/confettis.json
deleted file mode 100644
index f84de5d..0000000
--- a/src/assets/themes/lottie/confettis.json
+++ /dev/null
@@ -1 +0,0 @@
-{"v":"5.5.6","fr":60,"ip":0,"op":300,"w":609,"h":812,"nm":"lottie (mobile)","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"_small-side","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[218,320,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":15,"op":234,"st":15,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"streamer b","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":166,"ix":10},"p":{"a":0,"k":[554,664,0],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.929411768913,0.745098054409,0.196078434587,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[4]},{"t":57,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":14,"s":[0]},{"t":57,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":14,"s":[35]},{"t":57,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":9,"op":58,"st":9,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"streamer a","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":167,"ix":10},"p":{"a":0,"k":[532,582,0],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.196078434587,0.380392163992,0.929411768913,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[4]},{"t":48,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"t":48,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[35]},{"t":48,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":5,"op":49,"st":5,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"circle a","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":29,"s":[321.019]},{"t":158,"s":[1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":1,"s":[599.5,838,0],"to":[-30,-106.667,0],"ti":[46.667,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":29,"s":[419.5,198,0],"to":[-46.667,0,0],"ti":[0,0,0]},{"t":158,"s":[319.5,838,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":1,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"t":128,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784316063,0.572549045086,0.180392161012,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":159,"st":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"circle b","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[0]},{"t":128,"s":[1440]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":3,"s":[599.5,838,0],"to":[-26.667,-93.333,0],"ti":[66.667,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":21,"s":[439.5,278,0],"to":[-66.667,0,0],"ti":[0,0,0]},{"t":128,"s":[199.5,838,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":3,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[100,100,100]},{"t":98,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196078434587,0.588235318661,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":129,"st":3,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"star a","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[343.949]},{"t":158,"s":[1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":1,"s":[596.087,836.292,0],"to":[-36.098,-100,0],"ti":[52.765,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":31,"s":[379.5,236.292,0],"to":[-52.765,0,0],"ti":[0,0,0]},{"t":158,"s":[279.5,836.292,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":1,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":128,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.745098054409,0.196078434587,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":159,"st":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"star b","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":23,"s":[288]},{"t":128,"s":[1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":3,"s":[596.087,836.292,0],"to":[-39.431,-113.333,0],"ti":[66.098,1.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":23,"s":[359.5,156.292,0],"to":[-66.098,-1.667,0],"ti":[0,0,0]},{"t":128,"s":[199.5,826.292,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":3,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[100,100,100]},{"t":98,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.537254929543,0.196078434587,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":129,"st":3,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"rec a","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[381.468]},{"t":218,"s":[2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[599.5,842,0],"to":[-23.333,-100,0],"ti":[41.333,1.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":33,"s":[459.5,242,0],"to":[-41.333,-1.333,0],"ti":[0,0,0]},{"t":218,"s":[351.5,834,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":0,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":33,"s":[100,100,100]},{"t":188,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.411764711142,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":219,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"rec b","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[262.857]},{"t":191,"s":[2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":2,"s":[599.5,842,0],"to":[-23.333,-113.333,0],"ti":[74,5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":25,"s":[459.5,162,0],"to":[-74,-5,0],"ti":[0,0,0]},{"t":191,"s":[155.5,812,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":2,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":161,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.784313738346,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":192,"st":2,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"square a","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[462.385]},{"t":218,"s":[2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[599.5,838,0],"to":[-43.333,-123.333,0],"ti":[60,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":35,"s":[339.5,98,0],"to":[-60,0,0],"ti":[0,0,0]},{"t":218,"s":[239.5,838,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":0,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":35,"s":[100,100,100]},{"t":188,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588235318661,0.831372559071,0.305882364511,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":219,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"square b","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":27,"s":[285.714]},{"t":191,"s":[2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":2,"s":[599.5,838,0],"to":[-30,-120,0],"ti":[75,3.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":27,"s":[419.5,118,0],"to":[-75,-3.333,0],"ti":[0,0,0]},{"t":191,"s":[149.5,818,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0,0]},"t":2,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":27,"s":[100,100,100]},{"t":161,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196078434587,0.380392163992,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":192,"st":2,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"streamer b 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":171,"ix":10},"p":{"a":0,"k":[543,427,0],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.929411768913,0.196078434587,0.317647069693,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[4]},{"t":61,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[0]},{"t":61,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[35]},{"t":61,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":13,"op":62,"st":13,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"streamer a 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":151,"ix":10},"p":{"a":0,"k":[454,444,0],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.305882364511,0.831372559071,0.803921580315,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[4]},{"t":53,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[0]},{"t":53,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[35]},{"t":53,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":10,"op":54,"st":10,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"circle a 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":29,"s":[-371.368]},{"t":191,"s":[-2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":1,"s":[599.5,838,0],"to":[-53.333,-113.333,0],"ti":[56.667,-3.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":29,"s":[279.5,158,0],"to":[-56.667,3.333,0],"ti":[0,0,0]},{"t":191,"s":[259.5,858,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":1,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"t":161,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.411764711142,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":192,"st":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"circle b 2","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":21,"s":[-250.839]},{"t":158,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":3,"s":[599.5,838,0],"to":[-53.333,-100,0],"ti":[63,-7,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":21,"s":[279.5,238,0],"to":[-63,7,0],"ti":[0,0,0]},{"t":158,"s":[221.5,880,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":3,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[100,100,100]},{"t":128,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.537254929543,0.196078434587,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":159,"st":3,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"star a 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[-397.895]},{"t":191,"s":[-2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":1,"s":[596.087,836.292,0],"to":[-9.431,-113.333,0],"ti":[42.765,-3.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":31,"s":[539.5,156.292,0],"to":[-42.765,3.333,0],"ti":[0,0,0]},{"t":191,"s":[339.5,856.292,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":1,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":161,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.803921580315,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":192,"st":1,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"star b 2","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":23,"s":[-278.71]},{"t":158,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":3,"s":[596.087,836.292,0],"to":[-12.765,-96.667,0],"ti":[52.765,-3.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":23,"s":[519.5,256.292,0],"to":[-52.765,3.333,0],"ti":[0,0,0]},{"t":158,"s":[279.5,856.292,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":3,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[100,100,100]},{"t":128,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.784313738346,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":159,"st":3,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"rec a 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-556.875]},{"t":128,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[599.5,842,0],"to":[-16.667,-120,0],"ti":[66.667,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":33,"s":[499.5,122,0],"to":[-66.667,0,0],"ti":[0,0,0]},{"t":128,"s":[199.5,842,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":0,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":33,"s":[100,100,100]},{"t":98,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588235318661,0.831372559071,0.305882364511,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":129,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"rec b 2","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[-306.667]},{"t":218,"s":[-2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":2,"s":[599.5,842,0],"to":[-22.62,-109.87,0],"ti":[2.877,-2.055,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":25,"s":[339.5,262,0],"to":[-51.333,36.667,0],"ti":[0,0,0]},{"t":218,"s":[219.5,862,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":2,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":188,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.317647069693,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":219,"st":2,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"square a 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[-590.625]},{"t":128,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[599.5,838,0],"to":[-20,-90,0],"ti":[36.667,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":35,"s":[479.5,298,0],"to":[-36.667,0,0],"ti":[0,0,0]},{"t":128,"s":[379.5,838,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":0,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":35,"s":[100,100,100]},{"t":98,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784316063,0.572549045086,0.180392161012,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":129,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"square b 2","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":27,"s":[-333.333]},{"t":218,"s":[-2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":2,"s":[599.5,838,0],"to":[-10,-103.333,0],"ti":[50,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":27,"s":[539.5,218,0],"to":[-50,0,0],"ti":[0,0,0]},{"t":218,"s":[299.5,838,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.167,0]},"t":2,"s":[100,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":27,"s":[100,100,100]},{"t":188,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196078434587,0.588235318661,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":219,"st":2,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"_small-side","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,320,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":0,"op":219,"st":0,"bm":0}]},{"id":"comp_3","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"left","refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":313,"st":13,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"right","refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":30,"op":330,"st":30,"bm":0}]},{"id":"comp_4","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"streamer a 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":14,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[219.178,-190.096,0],"to":[-95.333,426.667,0],"ti":[167.333,-560.667,0]},{"t":173,"s":[179.178,989.904,0]}],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.196078434587,0.380392163992,0.929411768913,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[4]},{"t":176,"s":[0.5]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[0]},{"t":176,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[40]},{"t":176,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":13,"op":174,"st":13,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"streamer b 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-1.458,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[269.863,-175.455,0],"to":[-110,415.333,0],"ti":[216,-599.333,0]},{"t":173,"s":[69.863,984.545,0]}],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.929411768913,0.745098054409,0.196078434587,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[4]},{"t":173,"s":[0.5]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"t":173,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[40]},{"t":173,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":5,"op":174,"st":5,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"circle a 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[0]},{"t":155,"s":[1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[325.643,-26.292,0],"to":[-101.333,75.667,0],"ti":[15.333,-507.667,0]},{"t":155,"s":[125.643,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":8,"s":[50,100,100]},{"t":155,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784316063,0.572549045086,0.180392161012,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":156,"st":-7,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"circle b 4","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"t":215,"s":[2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[619.5,-26.292,0],"to":[-138,77.667,0],"ti":[-2,-497.667,0]},{"t":215,"s":[259.5,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":2,"s":[50,100,100]},{"t":215,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196078434587,0.588235318661,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":216,"st":-13,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"star a 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[0]},{"t":245,"s":[2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[376.929,-28,0],"to":[-213.333,157.667,0],"ti":[173.333,-127.667,0]},{"t":245,"s":[116.929,834,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[50,100,100]},{"t":245,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.745098054409,0.196078434587,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":246,"st":-9,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"star b 4","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"t":125,"s":[1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[171.786,-28,0],"to":[0,0,0],"ti":[-161.333,-275.667,0]},{"t":125,"s":[251.786,834,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":2,"s":[50,100,100]},{"t":125,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.537254929543,0.196078434587,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":126,"st":-13,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"rec a 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[0]},{"t":185,"s":[2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":4,"s":[428.214,-22.292,0],"to":[-167.333,119.667,0],"ti":[-130.667,-315.667,0]},{"t":185,"s":[228.214,839.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":4,"s":[50,100,100]},{"t":185,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.411764711142,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":186,"st":-11,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"rec b 4","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[0]},{"t":245,"s":[2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[223.071,-22.292,0],"to":[0,0,0],"ti":[-92.571,-383.708,0]},{"t":245,"s":[223.071,839.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[50,100,100]},{"t":245,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.784313738346,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":246,"st":-9,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"square a 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[0]},{"t":217,"s":[2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":4,"s":[120.5,-26.292,0],"to":[13,430.305,0],"ti":[52.221,-418.892,0]},{"t":217,"s":[198.5,833.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":4,"s":[50,100,100]},{"t":217,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588235318661,0.831372559071,0.305882364511,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":218,"st":-11,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"square b 4","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[0]},{"t":215,"s":[2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[274.357,-26.292,0],"to":[157.333,415.667,0],"ti":[22.667,-253.667,0]},{"t":215,"s":[114.357,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":8,"s":[50,100,100]},{"t":215,"s":[100,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588235318661,0.831372559071,0.305882364511,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":216,"st":-7,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"streamer a 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":3,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[275.178,-173.096,0],"to":[-87.333,413.333,0],"ti":[177.333,-643.333,0]},{"t":185,"s":[219.178,974.904,0]}],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.305882364511,0.831372559071,0.803921580315,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[4]},{"t":177,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"t":177,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[40]},{"t":177,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":186,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"streamer b 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":9,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":11,"s":[199.863,74.545,0],"to":[-13.363,405.455,0],"ti":[179.333,-430.667,0]},{"t":217,"s":[139.863,834.545,0]}],"ix":2},"a":{"a":0,"k":[-157,-245,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.685,-13.314],[0,-14.907],[0,-15.206],[0,-14.907],[0,-14.907],[0,-15.206],[1.754,-14.206],[-3.934,-9.465]],"o":[[-3.895,8.562],[1.872,14.789],[0,15.206],[0,14.907],[0,14.907],[0,15.206],[0,14.314],[-1.803,14.605],[0,0]],"v":[[-156.5,-406],[-166.5,-367],[-146.5,-327],[-166.5,-286],[-146.5,-246],[-166.5,-206],[-146.5,-165],[-166.5,-127],[-156.5,-84]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.929411768913,0.196078434587,0.317647069693,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11,"s":[4]},{"t":199,"s":[0.5]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[0]},{"t":199,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[40]},{"t":199,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":11,"op":218,"st":11,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"circle a 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"t":125,"s":[-1800]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[365.643,-26.292,0],"to":[-101.333,75.667,0],"ti":[15.333,-507.667,0]},{"t":125,"s":[165.643,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":2,"s":[100,50,100]},{"t":125,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.411764711142,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":126,"st":-13,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"circle b 3","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[0]},{"t":245,"s":[-2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[519.5,-26.292,0],"to":[-138,77.667,0],"ti":[-2,-497.667,0]},{"t":245,"s":[159.5,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":8,"s":[100,50,100]},{"t":245,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.537254929543,0.196078434587,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":246,"st":-7,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"star a 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[0]},{"t":245,"s":[-2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[416.929,-28,0],"to":[-213.333,157.667,0],"ti":[173.333,-127.667,0]},{"t":245,"s":[156.929,834,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[100,50,100]},{"t":245,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.305882364511,0.831372559071,0.803921580315,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":246,"st":-9,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"star b 3","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[0]},{"t":155,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[211.786,-28,0],"to":[0,0,0],"ti":[-161.333,-275.667,0]},{"t":155,"s":[291.786,834,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":8,"s":[100,50,100]},{"t":155,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":5,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":12,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.784313738346,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":156,"st":-7,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"rec a 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[0]},{"t":185,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":4,"s":[468.214,-22.292,0],"to":[-167.333,119.667,0],"ti":[-130.667,-315.667,0]},{"t":185,"s":[268.214,839.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":4,"s":[100,50,100]},{"t":185,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588235318661,0.831372559071,0.305882364511,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":186,"st":-11,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"rec b 3","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[0]},{"t":245,"s":[-2880]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[263.071,-22.292,0],"to":[0,143.667,0],"ti":[-114.571,-267.708,0]},{"t":245,"s":[263.071,839.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[100,50,100]},{"t":245,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411768913,0.196078434587,0.317647069693,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":246,"st":-9,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"square a 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[0]},{"t":217,"s":[-2520]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[160.5,-26.292,0],"to":[13,430.305,0],"ti":[52.221,-418.892,0]},{"t":217,"s":[238.5,833.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":2,"s":[100,50,100]},{"t":217,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.960784316063,0.572549045086,0.180392161012,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":218,"st":-13,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"square b 3","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[0]},{"t":185,"s":[-2160]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":4,"s":[314.357,-26.292,0],"to":[157.333,415.667,0],"ti":[22.667,-253.667,0]},{"t":185,"s":[154.357,835.708,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":4,"s":[100,50,100]},{"t":185,"s":[50,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196078434587,0.588235318661,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":186,"st":-11,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"cannon (small - left)","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[76,452,0],"ix":2},"a":{"a":0,"k":[0,360,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":480,"h":720,"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"cannon (small - right)","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[533,452,0],"ix":2},"a":{"a":0,"k":[479.994,360,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":480,"h":720,"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"cannon (small - top)","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[304,408,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[102,102,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":0,"op":300,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx
deleted file mode 100644
index a03a456..0000000
--- a/src/components/Banner.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { View } from 'react-native';
-
-import { Button, Icon, Text } from 'react-native-paper';
-
-interface BannerProps {
- icon: string;
- text: string;
- background: string;
- foreground: string;
- alert?: boolean;
- action?: { label: string; onPress: () => void };
-}
-
-// bandeau d'information pleine largeur sous l'en-tête
-export function Banner({ icon, text, background, foreground, alert, action }: BannerProps) {
- return (
-
-
-
- {text}
-
- {action && (
-
- {action.label}
-
- )}
-
- );
-}
diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx
new file mode 100644
index 0000000..a2a3621
--- /dev/null
+++ b/src/components/DeepLinkHandler.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useRef, useSyncExternalStore } from 'react';
+import { Platform } from 'react-native';
+
+import * as QuickActions from 'expo-quick-actions';
+import { useQuickActionCallback } from 'expo-quick-actions/hooks';
+import { useGlobalSearchParams, usePathname, useRouter } from 'expo-router';
+
+import { getEntApp } from '@/src/constants/ent';
+import { useSettings } from '@/src/context/SettingsContext';
+import { usePinnedApps } from '@/src/hooks/usePinnedApps';
+import { emitDeepLink, parseDeepLink, peekDeepLink, subscribeDeepLink, takeDeepLink } from '@/src/utils/deeplink';
+
+const icon = (symbol: string) => (Platform.OS === 'ios' ? `symbol:${symbol}` : null);
+
+// l'écran d'accueil d'iOS n'affiche que les 4 premiers raccourcis
+const MAX_QUICK_ACTIONS = 4;
+
+const EDT_ACTION: QuickActions.Action = {
+ id: 'edt',
+ title: 'Emploi du temps',
+ icon: icon('calendar'),
+ params: { href: 'unicenotes://edt' },
+};
+
+const ENT_ACTION: QuickActions.Action = {
+ id: 'ent',
+ title: 'ENT',
+ subtitle: 'Toutes les apps',
+ icon: icon('square.grid.2x2'),
+ params: { href: 'unicenotes://ent' },
+};
+
+// raccourcis de l'icône : l'EDT, les apps épinglées à l'accès rapide puis l'ENT
+function buildQuickActions(pinned: string[]): QuickActions.Action[] {
+ const apps = pinned.flatMap((id): QuickActions.Action[] => {
+ const app = getEntApp(id);
+ if (!app) return [];
+ return [
+ {
+ id: `app-${app.id}`,
+ title: app.label,
+ subtitle: app.subtitle,
+ icon: icon(app.symbol),
+ params: { href: `unicenotes://app/${app.id}` },
+ },
+ ];
+ });
+ return [EDT_ACTION, ...apps, ENT_ACTION].slice(0, MAX_QUICK_ACTIONS);
+}
+
+function onQuickAction(action: QuickActions.Action) {
+ const link = parseDeepLink(action.params?.href);
+ if (link) emitDeepLink(link);
+}
+
+// liens profonds, widgets et raccourcis de l'icône : traités une fois l'accueil affiché
+export function DeepLinkHandler() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { code: currentCode, app: currentApp } = useGlobalSearchParams<{ code?: string; app?: string }>();
+ const { adeid, oobeCompleted } = useSettings();
+ const pinned = usePinnedApps();
+
+ const link = useSyncExternalStore(subscribeDeepLink, peekDeepLink);
+ const landed = useRef(false);
+
+ useQuickActionCallback(onQuickAction);
+
+ useEffect(() => {
+ QuickActions.setItems(oobeCompleted ? buildQuickActions(pinned) : []);
+ }, [oobeCompleted, pinned]);
+
+ useEffect(() => {
+ if (pathname === '/home') landed.current = true;
+ if (!link || !landed.current || !oobeCompleted) return;
+
+ const target = takeDeepLink();
+ if (!target) return;
+
+ // repart toujours des onglets
+ const reset = () => {
+ if (router.canDismiss()) router.dismissAll();
+ };
+
+ // seulement un service du catalogue, jamais une adresse venue du lien
+ const openApp = (id: string) => {
+ if (!getEntApp(id) || (pathname === '/ent/browser' && currentApp === id)) return;
+ reset();
+ router.push({ pathname: '/ent/browser', params: { app: id } });
+ };
+
+ switch (target.kind) {
+ case 'notes':
+ openApp('pronote');
+ break;
+ case 'app':
+ openApp(target.id);
+ break;
+ case 'ent':
+ if (pathname !== '/ent') {
+ reset();
+ router.navigate('/ent');
+ }
+ break;
+ case 'edt':
+ if (target.code) {
+ // edt temporaire : lecture seule, l'edt enregistré n'est jamais modifié
+ if (!pathname.startsWith('/edt/') || currentCode !== target.code) {
+ reset();
+ router.push({ pathname: '/edt/[code]', params: { code: target.code } });
+ }
+ } else if (!adeid) {
+ if (pathname !== '/edt-config') {
+ reset();
+ router.push('/edt-config');
+ }
+ } else if (pathname !== '/timetable') {
+ reset();
+ router.navigate('/timetable');
+ }
+ break;
+ }
+ }, [link, pathname, currentCode, currentApp, oobeCompleted, adeid, router]);
+
+ return null;
+}
diff --git a/src/components/AppearancePickers.tsx b/src/components/appearance/AppearancePickers.tsx
similarity index 67%
rename from src/components/AppearancePickers.tsx
rename to src/components/appearance/AppearancePickers.tsx
index 726de65..c1548bc 100644
--- a/src/components/AppearancePickers.tsx
+++ b/src/components/appearance/AppearancePickers.tsx
@@ -1,17 +1,16 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
-import { Image } from 'expo-image';
import { getAppIconName, resetAppIcon, setAlternateAppIcon, supportsAlternateIcons } from 'expo-alternate-app-icons';
-
+import { Image } from 'expo-image';
import { Icon, Text } from 'react-native-paper';
import { allAppIcons, appIconGroups, type AppIconOption } from '@/src/constants/icons';
-import { getTheme, setThemeId, themeOptions, useChoosenTheme, useThemeId, type AppTheme } from '@/src/constants/theme';
+import { setThemeId, themeOptions, useAppTheme, useThemeId, type AppTheme } from '@/src/theme';
import { haptics } from '@/src/utils/haptics';
function CheckBadge() {
- const theme = useChoosenTheme();
+ const theme = useAppTheme();
return (
@@ -19,38 +18,35 @@ function CheckBadge() {
);
}
-// ---------------------------------------------------------------------------
-// Thèmes
-// ---------------------------------------------------------------------------
-
-// aperçu miniature d'un écran de l'app avec les couleurs du thème
+// aperçu miniature de l'accueil avec les couleurs du thème
function ThemePreview({ preview }: { preview: AppTheme }) {
const c = preview.colors;
return (
-
-
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
);
}
export function ThemePicker() {
- const theme = useChoosenTheme();
+ const theme = useAppTheme();
const themeId = useThemeId();
function select(id: string) {
if (id === themeId) return;
- haptics('medium');
+ haptics('selection');
setThemeId(id);
}
@@ -68,7 +64,7 @@ export function ThemePicker() {
accessibilityLabel={`${option.label}, ${option.description}`}
>
-
+
{selected && }
void;
-}) {
- const theme = useChoosenTheme();
+}
+
+function IconTile({ icon, selected, width, onPress }: IconTileProps) {
+ const theme = useAppTheme();
return (
(() => (supportsAlternateIcons ? currentIconName() : null));
if (!supportsAlternateIcons) {
@@ -147,14 +137,14 @@ export function IconPicker({ compact = false }: { compact?: boolean }) {
async function select(name: string | null) {
if (name === current) return;
- haptics('medium');
+ haptics('selection');
const previous = current;
setCurrent(name);
try {
if (name === null) await resetAppIcon();
else await setAlternateAppIcon(name);
} catch (e) {
- console.error('Error changing app icon:', e);
+ console.warn("[icons] impossible de changer l'icône", e);
setCurrent(previous);
haptics('error');
}
@@ -175,8 +165,8 @@ export function IconPicker({ compact = false }: { compact?: boolean }) {
{allAppIcons.map(tile)}
@@ -184,29 +174,35 @@ export function IconPicker({ compact = false }: { compact?: boolean }) {
}
return (
- <>
+
{appIconGroups.map((group) => (
-
+
{group.title}
{group.icons.map(tile)}
))}
- >
+
);
}
const styles = StyleSheet.create({
themeRow: { flexDirection: 'row', gap: 8 },
themeItem: { flex: 1, alignItems: 'center' },
- ring: { width: '100%', borderWidth: 2.5, borderRadius: 18, padding: 3 },
- preview: { height: 112, borderRadius: 13, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },
- previewBar: { height: 20, justifyContent: 'center', paddingHorizontal: 8 },
- previewBody: { padding: 6, gap: 5 },
- previewCard: { height: 22, borderRadius: 6, justifyContent: 'center', paddingHorizontal: 6 },
- previewLine: { height: 4, borderRadius: 2, opacity: 0.8 },
- previewFab: { position: 'absolute', right: 7, bottom: 7, width: 18, height: 18, borderRadius: 6 },
+ ring: { width: '100%', borderWidth: 2.5, borderRadius: 20, padding: 3 },
+ preview: {
+ height: 120,
+ borderRadius: 15,
+ borderWidth: StyleSheet.hairlineWidth,
+ overflow: 'hidden',
+ paddingHorizontal: 7,
+ },
+ previewLine: { height: 4, borderRadius: 2, opacity: 0.85 },
+ previewCard: { marginTop: 8, borderRadius: 8, justifyContent: 'center', paddingHorizontal: 6 },
+ previewTiles: { flexDirection: 'row', gap: 5, marginTop: 5 },
+ previewTile: { flex: 1, height: 26, borderRadius: 7, padding: 5 },
+ previewDot: { width: 10, height: 10, borderRadius: 3 },
iconGrid: { flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -4 },
iconTile: { alignItems: 'center', paddingHorizontal: 4, paddingVertical: 6 },
diff --git a/src/components/browser/BrowserError.tsx b/src/components/browser/BrowserError.tsx
new file mode 100644
index 0000000..59995e1
--- /dev/null
+++ b/src/components/browser/BrowserError.tsx
@@ -0,0 +1,38 @@
+import { StyleSheet, View } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { EmptyState } from '@/src/components/ui/EmptyState';
+import { useAppTheme } from '@/src/theme';
+
+interface BrowserErrorProps {
+ code: number;
+ description: string;
+ onRetry: () => void;
+}
+
+// page qui n'a pas pu être chargée (réseau coupé, serveur de l'université en panne…)
+export function BrowserError({ code, description, onRetry }: BrowserErrorProps) {
+ const theme = useAppTheme();
+
+ return (
+
+
+
+ {description ? `${description} (${code})` : `Erreur ${code}`}
+
+
+ );
+}
diff --git a/src/components/ent/EntAppIcon.tsx b/src/components/ent/EntAppIcon.tsx
new file mode 100644
index 0000000..8ea3d29
--- /dev/null
+++ b/src/components/ent/EntAppIcon.tsx
@@ -0,0 +1,27 @@
+import { View } from 'react-native';
+
+import { Image } from 'expo-image';
+
+import { IconBadge } from '@/src/components/ui/IconBadge';
+import type { EntApp } from '@/src/constants/ent';
+
+// logo du service sur fond blanc, comme une icône d'app (lisible en thème sombre), sinon pictogramme tonal
+export function EntAppIcon({ app, size = 44 }: { app: EntApp; size?: number }) {
+ if (!app.image) return ;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/home/AnnouncementSheet.tsx b/src/components/home/AnnouncementSheet.tsx
new file mode 100644
index 0000000..4d8edcf
--- /dev/null
+++ b/src/components/home/AnnouncementSheet.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { View } from 'react-native';
+
+import type { BottomSheetModal } from '@gorhom/bottom-sheet';
+import { Button, Icon, Text } from 'react-native-paper';
+
+import { IconBadge } from '@/src/components/ui/IconBadge';
+import { Sheet } from '@/src/components/ui/Sheet';
+import { APP_VERSION, IS_BETA, LINKS, RELEASE_NOTES } from '@/src/constants/config';
+import { fetchApiStatus, isUpdateAvailable } from '@/src/services/uniceapi';
+import { useAppTheme } from '@/src/theme';
+import { openURL } from '@/src/utils/browser';
+import { storage } from '@/src/utils/storage';
+
+type Announcement =
+ | { kind: 'alert'; title: string; message: string }
+ | { kind: 'update'; version: string }
+ | { kind: 'release' };
+
+// laisse l'accueil apparaître avant la première annonce
+const OPEN_DELAY_MS = 1200;
+
+async function loadAnnouncements(): Promise {
+ const [seenVersion, status] = await Promise.all([storage.get('releaseNotesVersion'), fetchApiStatus()]);
+ const queue: Announcement[] = [];
+ if (status?.alert) queue.push({ kind: 'alert', ...status.alert });
+ if (!IS_BETA && status?.version && isUpdateAvailable(APP_VERSION, status.version)) {
+ queue.push({ kind: 'update', version: status.version });
+ }
+ if (seenVersion !== APP_VERSION) queue.push({ kind: 'release' });
+ return queue;
+}
+
+// alerte de l'API, mise à jour disponible puis nouveautés : une feuille à la fois
+export function AnnouncementSheet() {
+ const sheetRef = useRef(null);
+ const [queue, setQueue] = useState([]);
+ const current = queue[0];
+
+ useEffect(() => {
+ let cancelled = false;
+ loadAnnouncements().then((announcements) => {
+ if (!cancelled) setQueue(announcements);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!current) return;
+ const timer = setTimeout(() => {
+ if (current.kind === 'release') storage.set('releaseNotesVersion', APP_VERSION);
+ sheetRef.current?.present();
+ }, OPEN_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, [current]);
+
+ const close = () => sheetRef.current?.dismiss();
+
+ return (
+ setQueue((items) => items.slice(1))}>
+ {current?.kind === 'alert' && (
+
+
+ Compris
+
+
+ )}
+
+ {current?.kind === 'update' && (
+
+ openURL(LINKS.download)}>
+ Mettre à jour
+
+
+ Plus tard
+
+
+ )}
+
+ {current?.kind === 'release' && (
+
+
+ {RELEASE_NOTES.items.map((item) => (
+
+ ))}
+
+
+ Merci !
+
+
+ )}
+
+ );
+}
+
+function Content({ icon, title, text, children }: { icon: string; title: string; text?: string; children: ReactNode }) {
+ const theme = useAppTheme();
+ return (
+
+
+
+ {title}
+
+ {text && (
+
+ {text}
+
+ )}
+ {children}
+
+ );
+}
+
+function ReleaseItem({ icon, text }: { icon: string; text: string }) {
+ const theme = useAppTheme();
+ return (
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx
new file mode 100644
index 0000000..a5a3411
--- /dev/null
+++ b/src/components/home/HomeHeader.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import { View } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { HeaderButton } from '@/src/components/ui/ScreenHeader';
+import { useAppTheme } from '@/src/theme';
+import { formatLongDate } from '@/src/utils/date';
+
+// salutation selon l'heure, avant 5 h on reste sur le soir
+const GREETINGS = [
+ {
+ from: 18,
+ title: 'Bonsoir ! 🌙',
+ messages: [
+ 'On jette un œil à demain ?',
+ 'Petit check avant demain ?',
+ 'Ravi de te revoir ! ^^',
+ "Un œil sur l'emploi du temps ?",
+ ],
+ },
+ {
+ from: 12,
+ title: 'Salut ! 👋',
+ messages: [
+ 'On fait le point sur la journée ?',
+ 'Petit check rapide de ta journée ?',
+ "Un œil sur l'emploi du temps ?",
+ 'Ravi de te revoir ! ^^',
+ ],
+ },
+ {
+ from: 5,
+ title: 'Bonjour ! ☀️',
+ messages: [
+ 'Passe une excellente journée !',
+ "Quoi de prévu aujourd'hui ? :)",
+ "Voyons ce qui t'attend aujourd'hui.",
+ "C'est quoi le plan pour aujourd'hui ?",
+ ],
+ },
+];
+
+function getGreeting(date: Date) {
+ const hour = date.getHours();
+ const { title, messages } = GREETINGS.find((g) => hour >= g.from) ?? GREETINGS[0];
+ return { title, message: messages[Math.floor(Math.random() * messages.length)] };
+}
+
+interface HomeHeaderProps {
+ now: Date;
+ onEditEdt: () => void;
+ onSettings: () => void;
+}
+
+export function HomeHeader({ now, onEditEdt, onSettings }: HomeHeaderProps) {
+ const theme = useAppTheme();
+ const [greeting] = useState(() => getGreeting(new Date()));
+
+ return (
+
+
+
+ {formatLongDate(now)}
+
+
+
+
+
+
+ {greeting.title}
+
+
+ {greeting.message}
+
+
+ );
+}
diff --git a/src/components/home/NextClassCard.tsx b/src/components/home/NextClassCard.tsx
new file mode 100644
index 0000000..9a97d22
--- /dev/null
+++ b/src/components/home/NextClassCard.tsx
@@ -0,0 +1,221 @@
+import { useEffect, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { Button, Icon, Text } from 'react-native-paper';
+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
+
+import { IconBadge } from '@/src/components/ui/IconBadge';
+import { InfoLine } from '@/src/components/ui/InfoLine';
+import { PressableScale } from '@/src/components/ui/PressableScale';
+import { Bone, Skeleton } from '@/src/components/ui/Skeleton';
+import { Watermark } from '@/src/components/ui/Watermark';
+import type { AgendaState } from '@/src/hooks/useAgenda';
+import { usePulse } from '@/src/hooks/usePulse';
+import { getToneColors, useAppTheme } from '@/src/theme';
+import { formatSchedule, getClassStatus, type AgendaClass } from '@/src/utils/agenda';
+import { withAlpha } from '@/src/utils/color';
+
+interface NextClassCardProps {
+ agenda: AgendaState;
+ onOpen: () => void;
+ onSetup: () => void;
+ onRetry: () => void;
+}
+
+// carte principale : cours en cours, sinon le prochain
+export function NextClassCard({ agenda, onOpen, onSetup, onRetry }: NextClassCardProps) {
+ if (!agenda.configured) {
+ return (
+
+ );
+ }
+
+ if (agenda.classes.length === 0) {
+ if (agenda.loading) return ;
+ if (agenda.offline) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ if (!agenda.next) {
+ return ;
+ }
+
+ return ;
+}
+
+function ClassCard({ item, now, onPress }: { item: AgendaClass; now: Date; onPress: () => void }) {
+ const theme = useAppTheme();
+ const c = theme.colors;
+ const status = getClassStatus(item, now);
+ const room = item.room || 'Salle non précisée';
+ const schedule = formatSchedule(item);
+
+ return (
+
+
+
+
+
+ {status.ongoing && }
+
+ {status.label}
+
+
+
+
+
+
+ {item.title}
+
+
+
+
+
+
+
+ {status.ongoing && (
+
+
+
+ {status.remaining}
+
+
+ )}
+
+ );
+}
+
+interface MessageCardProps {
+ icon: string;
+ title: string;
+ text: string;
+ error?: boolean;
+ action?: { label: string; icon: string; onPress: () => void };
+ onPress?: () => void;
+}
+
+function MessageCard({ icon, title, text, error, action, onPress }: MessageCardProps) {
+ const theme = useAppTheme();
+ const { container, onContainer, accent, onAccent } = getToneColors(theme, error ? 'error' : 'primary');
+
+ return (
+
+
+
+
+ {title}
+
+
+ {text}
+
+ {action && (
+
+ {action.label}
+
+ )}
+
+ );
+}
+
+// squelette pendant le premier téléchargement
+function LoadingCard() {
+ const theme = useAppTheme();
+ const color = theme.colors.onPrimaryContainer;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ProgressTrack({ progress, color, track }: { progress: number; color: string; track: string }) {
+ const [width, setWidth] = useState(0);
+ const fill = useSharedValue(0);
+
+ useEffect(() => {
+ fill.set(withTiming(progress * width, { duration: 700 }));
+ }, [fill, progress, width]);
+
+ const fillStyle = useAnimatedStyle(() => ({ width: fill.value }));
+
+ return (
+ setWidth(event.nativeEvent.layout.width)}
+ >
+
+
+ );
+}
+
+function LiveDot({ color }: { color: string }) {
+ const pulse = usePulse(0.25, 900);
+ return ;
+}
+
+const styles = StyleSheet.create({
+ card: { borderRadius: 28, padding: 20, overflow: 'hidden' },
+ row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ pill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ borderRadius: 999,
+ paddingHorizontal: 14,
+ paddingVertical: 6,
+ },
+ dot: { width: 8, height: 8, borderRadius: 4 },
+ track: { flex: 1, height: 8, borderRadius: 4, overflow: 'hidden' },
+ fill: { height: '100%', borderRadius: 4 },
+});
diff --git a/src/components/home/QuickAccess.tsx b/src/components/home/QuickAccess.tsx
new file mode 100644
index 0000000..01a5d62
--- /dev/null
+++ b/src/components/home/QuickAccess.tsx
@@ -0,0 +1,102 @@
+import { Alert, View } from 'react-native';
+
+import { Icon } from 'react-native-paper';
+
+import { EntAppIcon } from '@/src/components/ent/EntAppIcon';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { Tile, TileGrid } from '@/src/components/ui/Tile';
+import { ENT_APPS, getEntApp, type EntApp } from '@/src/constants/ent';
+import { MAX_PINNED_APPS, togglePinnedApp, usePinnedApps } from '@/src/hooks/usePinnedApps';
+import { useAppTheme } from '@/src/theme';
+import { openEntApp } from '@/src/utils/browser';
+import { haptics } from '@/src/utils/haptics';
+
+const BADGE = { width: 44, height: 44, borderRadius: 14 } as const;
+
+// les 3 apps épinglées depuis l'onglet ENT, puis l'ENT et toutes les autres
+export function QuickAccess({ onOpenEnt }: { onOpenEnt: () => void }) {
+ const theme = useAppTheme();
+ const c = theme.colors;
+ const pinnedIds = usePinnedApps();
+ const pinned = pinnedIds.map((id) => getEntApp(id)).filter((app): app is EntApp => app !== undefined);
+ const others = ENT_APPS.filter((app) => !pinnedIds.includes(app.id));
+ const emptySlots = Math.max(0, MAX_PINNED_APPS - pinned.length);
+ const othersLabel = `${others.length} ${pinned.length === 0 ? 'apps' : 'autres apps'}`;
+
+ function confirmUnpin(app: EntApp) {
+ haptics('warning');
+ Alert.alert(app.label, "Retirer ce service de l'accès rapide ?", [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Retirer',
+ style: 'destructive',
+ onPress: () => {
+ togglePinnedApp(app.id);
+ haptics('selection');
+ },
+ },
+ ]);
+ }
+
+ return (
+
+
+
+ {pinned.map((app) => (
+ }
+ accessibilityHint="Appui long pour le retirer de l'accès rapide"
+ onPress={() => openEntApp(app.id)}
+ onLongPress={() => confirmUnpin(app)}
+ />
+ ))}
+
+ {/* place libre : mène à l'onglet ENT pour épingler une app */}
+ {Array.from({ length: emptySlots }, (_, index) => (
+
+
+
+ }
+ style={{
+ padding: 14.5,
+ borderWidth: 1.5,
+ borderStyle: 'dashed',
+ borderColor: c.outlineVariant,
+ backgroundColor: 'transparent',
+ }}
+ onPress={onOpenEnt}
+ />
+ ))}
+
+ {/* les autres apps en miniature, comme un dossier de l'écran d'accueil */}
+
+ {others.slice(0, 4).map((app) => (
+
+ ))}
+
+ }
+ onPress={onOpenEnt}
+ />
+
+
+ );
+}
diff --git a/src/components/home/UpNextList.tsx b/src/components/home/UpNextList.tsx
new file mode 100644
index 0000000..a51446a
--- /dev/null
+++ b/src/components/home/UpNextList.tsx
@@ -0,0 +1,61 @@
+import { View } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { PressableScale } from '@/src/components/ui/PressableScale';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { useAppTheme } from '@/src/theme';
+import type { AgendaClass } from '@/src/utils/agenda';
+import { formatClock } from '@/src/utils/date';
+
+const MAX_ROWS = 4;
+
+interface UpNextListProps {
+ items: AgendaClass[];
+ dayEnd: Date;
+ onPress: () => void;
+}
+
+// cours suivants, même jour que la carte principale
+export function UpNextList({ items, dayEnd, onPress }: UpNextListProps) {
+ const theme = useAppTheme();
+ const c = theme.colors;
+ const hidden = items.length - MAX_ROWS;
+
+ return (
+
+
+
+ {items.slice(0, MAX_ROWS).map((item, index) => (
+
+
+ {formatClock(item.start)}
+
+ {formatClock(item.end)}
+
+
+
+
+
+ {item.title}
+
+
+ {item.room || 'Salle non précisée'}
+
+
+
+ ))}
+ {hidden > 0 && (
+
+ + {hidden} {hidden > 1 ? 'autres cours' : 'autre cours'}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/rooms/FavoriteRooms.tsx b/src/components/rooms/FavoriteRooms.tsx
index f65f2da..4a1eb85 100644
--- a/src/components/rooms/FavoriteRooms.tsx
+++ b/src/components/rooms/FavoriteRooms.tsx
@@ -1,25 +1,21 @@
-import React from 'react';
import { View } from 'react-native';
-import { Divider, Text } from 'react-native-paper';
-
+import { Card } from '@/src/components/ui/Card';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
import type { RoomEntry } from '@/src/types';
import { RoomRow, type RoomsViewContext } from './RoomRow';
-// salles épinglées, affichées au-dessus des filtres
+// salles épinglées, au-dessus des filtres
export function FavoriteRooms({ entries, ctx }: { entries: RoomEntry[]; ctx: RoomsViewContext }) {
- if (entries.length === 0) return null;
-
return (
-
- Salles favorites
-
- {entries.map((entry) => (
-
- ))}
-
+
+
+ {entries.map((entry) => (
+
+ ))}
+
);
}
diff --git a/src/components/rooms/RoomFilters.tsx b/src/components/rooms/RoomFilters.tsx
index 0e33392..e949e40 100644
--- a/src/components/rooms/RoomFilters.tsx
+++ b/src/components/rooms/RoomFilters.tsx
@@ -1,16 +1,13 @@
-import React from 'react';
import { ScrollView, View } from 'react-native';
import { Chip, Text } from 'react-native-paper';
-import { DURATION_OPTIONS, formatDuration, ROOM_MARGIN_MIN } from '@/src/utils/rooms';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { capitalize, formatDuration } from '@/src/utils/date';
+import { DURATION_OPTIONS, ROOM_MARGIN_MIN } from '@/src/utils/rooms';
import { ROOM_STATUSES, STATUS_LABELS, StatusDot, type StatusColors } from './status';
-function capitalize(text: string): string {
- return text.charAt(0).toUpperCase() + text.slice(1);
-}
-
interface RoomFiltersProps {
duration: number;
onSelectDuration: (minutes: number) => void;
@@ -18,7 +15,6 @@ interface RoomFiltersProps {
onToggleOnlyFree: () => void;
colors: StatusColors;
secondary: string;
- updatedAt: string | null;
}
// durée voulue, légende des statuts et filtre « libres uniquement »
@@ -29,15 +25,16 @@ export function RoomFilters({
onToggleOnlyFree,
colors,
secondary,
- updatedAt,
}: RoomFiltersProps) {
return (
-
- Pour combien de temps veux-tu la salle ?
+
+
+ {/* déborde de la marge de la page pour défiler jusqu'au bord de l'écran */}
{DURATION_OPTIONS.map((minutes) => (
-
+
{ROOM_STATUSES.map((status) => (
{capitalize(STATUS_LABELS[status][0])}
))}
-
+
Libres uniquement
-
- Libre : disponible pendant {formatDuration(duration)}, avec {ROOM_MARGIN_MIN} min de
- marge avant le cours suivant. Juste : libre, mais moins longtemps ou avec moins de
- marge. Mis à jour à {updatedAt}.
+
+ Libre : disponible pendant {formatDuration(duration)}, avec {ROOM_MARGIN_MIN} min de marge avant le cours
+ suivant. Juste : libre, mais moins longtemps ou avec moins de marge.
);
diff --git a/src/components/rooms/RoomRow.tsx b/src/components/rooms/RoomRow.tsx
index 2362192..51abb0c 100644
--- a/src/components/rooms/RoomRow.tsx
+++ b/src/components/rooms/RoomRow.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { View } from 'react-native';
import { IconButton, Text } from 'react-native-paper';
@@ -25,17 +24,15 @@ export interface RoomsViewContext {
onToggleFavorite: (roomId: string) => void;
}
-export function RoomRow({
- entry,
- depth,
- ctx,
- showPath = false,
-}: {
+interface RoomRowProps {
entry: RoomEntry;
depth: number;
ctx: RoomsViewContext;
+ // affiche le campus et le bâtiment (favoris)
showPath?: boolean;
-}) {
+}
+
+export function RoomRow({ entry, depth, ctx, showPath = false }: RoomRowProps) {
const { headline, detail } = describeAvailability(entry.availability, ctx.now, ctx.duration);
const favorite = ctx.isFavorite(entry.room.id);
const path = showPath ? entry.room.path.join(' · ') : '';
@@ -46,8 +43,8 @@ export function RoomRow({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
- paddingLeft: 25 + depth * ROOM_INDENT,
- paddingRight: 13,
+ paddingLeft: 16 + depth * ROOM_INDENT,
+ paddingRight: 4,
}}
>
- {entry.room.name}
+ {entry.room.name}
{path ? (
{path}
@@ -79,8 +76,9 @@ export function RoomRow({
icon={favorite ? 'star' : 'star-outline'}
iconColor={favorite ? ctx.accent : ctx.secondary}
size={22}
- style={{ margin: 0 }}
- accessibilityLabel={favorite ? `Retirer ${entry.room.name} des favoris` : `Ajouter ${entry.room.name} aux favoris`}
+ accessibilityLabel={
+ favorite ? `Retirer ${entry.room.name} des favoris` : `Ajouter ${entry.room.name} aux favoris`
+ }
onPress={() => ctx.onToggleFavorite(entry.room.id)}
/>
diff --git a/src/components/rooms/RoomTree.tsx b/src/components/rooms/RoomTree.tsx
index d30371d..6cccc1a 100644
--- a/src/components/rooms/RoomTree.tsx
+++ b/src/components/rooms/RoomTree.tsx
@@ -1,8 +1,10 @@
-import React, { useState } from 'react';
+import { useState, type ReactNode } from 'react';
import { View } from 'react-native';
-import { Divider, Icon, Text, TouchableRipple } from 'react-native-paper';
+import { Icon, Text, TouchableRipple } from 'react-native-paper';
+import { Card } from '@/src/components/ui/Card';
+import { IconBadge } from '@/src/components/ui/IconBadge';
import type { RoomEntry, RoomNodeView, RoomStatusCounts } from '@/src/types';
import { haptics } from '@/src/utils/haptics';
@@ -12,9 +14,10 @@ import { ROOM_STATUSES, STATUS_LABELS, StatusDot } from './status';
// une icône par niveau de l'arbre : campus, bâtiment, sous-bâtiment, puis le niveau le plus fin
const DEPTH_ICONS = ['city-variant-outline', 'office-building-outline', 'floor-plan', 'layers-outline'];
+const NO_ROOMS: RoomEntry[] = [];
+
function summarize(counts: RoomStatusCounts): string {
- return ROOM_STATUSES
- .filter((status) => counts[status] > 0)
+ return ROOM_STATUSES.filter((status) => counts[status] > 0)
.map((status) => `${counts[status]} ${STATUS_LABELS[status][counts[status] > 1 ? 1 : 0]}`)
.join(', ');
}
@@ -22,32 +25,28 @@ function summarize(counts: RoomStatusCounts): string {
function StatusCounts({ counts, ctx }: { counts: RoomStatusCounts; ctx: RoomsViewContext }) {
return (
- {ROOM_STATUSES
- .filter((status) => counts[status] > 0)
- .map((status) => (
-
-
-
- {counts[status]}
-
-
- ))}
+ {ROOM_STATUSES.filter((status) => counts[status] > 0).map((status) => (
+
+
+
+ {counts[status]}
+
+
+ ))}
);
}
-// Une seule branche ouverte à la fois
-export function RoomTree({
- nodes,
- rooms,
- depth,
- ctx,
-}: {
+interface RoomTreeProps {
nodes: RoomNodeView[];
- rooms: RoomEntry[];
- depth: number;
+ // salles rattachées directement à ce niveau
+ rooms?: RoomEntry[];
+ depth?: number;
ctx: RoomsViewContext;
-}) {
+}
+
+// une branche ouverte à la fois ; campus en cartes, niveaux suivants emboîtés
+export function RoomTree({ nodes, rooms = NO_ROOMS, depth = 0, ctx }: RoomTreeProps) {
const [openKey, setOpenKey] = useState(null);
const open = nodes.find((node) => node.key === openKey);
@@ -58,64 +57,60 @@ export function RoomTree({
}
return (
- <>
- {(open ? [open] : nodes).map((node) => (
- toggle(node.key)}
- ctx={ctx}
- />
- ))}
- {!open &&
- rooms.map((entry) => )}
- >
+
+ {(open ? [open] : nodes).map((node) => {
+ const expanded = node === open;
+ const Wrapper = depth === 0 ? CampusCard : View;
+ return (
+
+ toggle(node.key)} ctx={ctx} />
+ {expanded && }
+
+ );
+ })}
+ {!open && rooms.map((entry) => )}
+
);
}
-function NodeSection({
- node,
- depth,
- expanded,
- onPress,
- ctx,
-}: {
+function CampusCard({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+interface NodeHeaderProps {
node: RoomNodeView;
depth: number;
expanded: boolean;
onPress: () => void;
ctx: RoomsViewContext;
-}) {
+}
+
+function NodeHeader({ node, depth, expanded, onPress, ctx }: NodeHeaderProps) {
+ const icon = DEPTH_ICONS[Math.min(depth, DEPTH_ICONS.length - 1)];
+
return (
-
-
-
-
-
-
- {node.name}
-
-
-
-
+ 0 ? ctx.highlight : undefined,
+ }}
+ >
+
+ {depth === 0 ? : }
+
+
+ {node.name}
+
+
-
-
- {expanded && }
- {depth === 0 && }
-
+
+
+
);
}
diff --git a/src/components/rooms/status.tsx b/src/components/rooms/status.tsx
index 314530e..d771ff7 100644
--- a/src/components/rooms/status.tsx
+++ b/src/components/rooms/status.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { View } from 'react-native';
import type { RoomStatus } from '@/src/types';
diff --git a/src/components/settings/SettingsScreen.tsx b/src/components/settings/SettingsScreen.tsx
new file mode 100644
index 0000000..1ff713b
--- /dev/null
+++ b/src/components/settings/SettingsScreen.tsx
@@ -0,0 +1,229 @@
+import { useState } from 'react';
+import { Alert, View } from 'react-native';
+
+import { Image } from 'expo-image';
+import { useRouter } from 'expo-router';
+import { Button, Snackbar, Switch, Text } from 'react-native-paper';
+
+import { ListGroup, ListItem } from '@/src/components/ui/ListGroup';
+import { Screen } from '@/src/components/ui/Screen';
+import { SectionTitle } from '@/src/components/ui/SectionTitle';
+import { Watermark } from '@/src/components/ui/Watermark';
+import { APP_VERSION, BUILD_COMMIT, IS_BETA, LINKS } from '@/src/constants/config';
+import { useSettings } from '@/src/context/SettingsContext';
+import { clearWebSession } from '@/src/services/webSession';
+import { LIVE_ACTIVITIES_SUPPORTED } from '@/src/services/widgets';
+import { useAppTheme } from '@/src/theme';
+import { openURL } from '@/src/utils/browser';
+import { haptics } from '@/src/utils/haptics';
+
+// tab : onglet Paramètres ; sinon écran ouvert depuis la configuration initiale, avec un retour
+export function SettingsScreen({ tab = false }: { tab?: boolean }) {
+ const router = useRouter();
+ const theme = useAppTheme();
+ const settings = useSettings();
+ const [notice, setNotice] = useState(null);
+
+ function navigate(href: '/appearance' | '/servers' | '/edt-config') {
+ haptics('light');
+ router.push(href);
+ }
+
+ function toggleHaptics(on: boolean) {
+ settings.setHaptics(on);
+ haptics('selection');
+ }
+
+ function toggleLiveActivities(on: boolean) {
+ haptics('selection');
+ settings.setLiveActivities(on);
+ }
+
+ // repart de zéro : la configuration initiale devient le seul écran de la pile
+ function restartFromOobe() {
+ if (router.canDismiss()) router.dismissAll();
+ router.replace('/oobe');
+ }
+
+ function restartOobe() {
+ haptics('medium');
+ settings.setOobeCompleted(false);
+ restartFromOobe();
+ }
+
+ function deleteAllData() {
+ haptics('warning');
+ Alert.alert(
+ 'Supprimer mes données',
+ "Ton emploi du temps, tes favoris, tes connexions aux services de l'ENT et tes réglages seront effacés de l'appareil. Cette action est irréversible.",
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Supprimer',
+ style: 'destructive',
+ onPress: async () => {
+ await settings.clearAllData();
+ haptics('success');
+ restartFromOobe();
+ },
+ },
+ ],
+ );
+ }
+
+ return (
+ setNotice(null)} duration={2500}>
+ {notice ?? ''}
+
+ }
+ >
+
+
+
+
+
+ navigate('/edt-config')}
+ />
+ navigate('/appearance')}
+ />
+ toggleHaptics(!settings.haptics)}
+ right={ }
+ />
+ {LIVE_ACTIVITIES_SUPPORTED && (
+ toggleLiveActivities(!settings.liveActivities)}
+ right={ }
+ />
+ )}
+
+
+
+
+
+
+ openURL(LINKS.support)}
+ />
+ navigate('/servers')}
+ />
+
+
+
+
+
+
+
+ openURL(LINKS.credits)} />
+ openURL(LINKS.privacy)}
+ />
+ openURL(LINKS.source)}
+ />
+ openURL(LINKS.author)}
+ />
+
+
+
+
+
+
+
+
+
+ UniceNotes n'est lié d'aucune façon à l'Université Côte d'Azur, Polytech Nice Sophia ou à
+ l'IUT Nice Côte d'Azur. Tout usage de l'application relève de la seule responsabilité de
+ l'utilisateur, comme prévu dans les conditions d'utilisation.
+
+
+ © {new Date().getFullYear()} MetrixMedia / hugofnm{BUILD_COMMIT ? ` · ${BUILD_COMMIT.slice(0, 7)}` : ''}
+
+ {IS_BETA && (
+ {
+ throw new Error('This is a crash');
+ }}
+ >
+ crash_app
+
+ )}
+
+
+ );
+}
+
+function AboutCard() {
+ const theme = useAppTheme();
+ const c = theme.colors;
+
+ return (
+
+
+
+
+
+
+ UniceNotes
+
+
+ Ton ENT. Dans ta poche.
+
+
+
+
+
+
+ Version {APP_VERSION}
+
+
+
+ Merci de l'avoir installée :)
+
+
+
+ );
+}
diff --git a/src/components/timetable/EventCard.tsx b/src/components/timetable/EventCard.tsx
index f74bab5..ddc4b22 100644
--- a/src/components/timetable/EventCard.tsx
+++ b/src/components/timetable/EventCard.tsx
@@ -1,17 +1,23 @@
-import React from 'react';
import { View } from 'react-native';
+import type { PackedEvent } from '@howljs/calendar-kit';
import { Text } from 'react-native-paper';
-// case d'un cours dans la grille
-export function EventCard({ event }: { event: any }) {
- const e = event && event.title !== undefined ? event : event?.event ?? {};
+import { readableOn } from '@/src/utils/color';
+
+// case d'un cours dans la grille, posée sur la couleur du cours
+export function EventCard({ event }: { event: PackedEvent }) {
+ const color = readableOn(event.color ?? '#9E9E9E');
return (
-
-
- {e.title ?? ''}
+
+
+ {event.title}
- {e.description ?? ''}
+ {event.room ? (
+
+ {event.room}
+
+ ) : null}
);
}
diff --git a/src/components/timetable/EventSheet.tsx b/src/components/timetable/EventSheet.tsx
index 8eff234..8f5fd55 100644
--- a/src/components/timetable/EventSheet.tsx
+++ b/src/components/timetable/EventSheet.tsx
@@ -1,125 +1,74 @@
-import React, { useCallback, type RefObject } from 'react';
-import { StyleSheet } from 'react-native';
+import type { RefObject } from 'react';
+import { View } from 'react-native';
+import type { BottomSheetModal } from '@gorhom/bottom-sheet';
import { Button, Text } from 'react-native-paper';
-import BottomSheet, {
- BottomSheetBackdrop,
- BottomSheetBackdropProps,
- BottomSheetView,
-} from '@gorhom/bottom-sheet';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { InfoLine } from '@/src/components/ui/InfoLine';
+import { Sheet } from '@/src/components/ui/Sheet';
+import { useAppTheme } from '@/src/theme';
+import type { CalendarEvent } from '@/src/types';
+import { formatSchedule } from '@/src/utils/agenda';
+import { formatLongDate } from '@/src/utils/date';
-import { useChoosenTheme } from '@/src/constants/theme';
-
-const MAX_DESCRIPTION_LENGTH = 384;
-
-export interface EventDetails {
- title: string;
- description: string;
- room: string;
- time: string;
-}
-
-// événement tel que renvoyé par onPressEvent de calendar-kit
-export interface PressedEvent {
- title: string;
- subtitle: string;
- description: string;
- _internal: { startUnix: number; endUnix: number; duration: number };
-}
-
-function cleanText(input: string): string {
- return input
- .replace(/[ \t]+/g, ' ')
- .replace(/^[ \t]+|[ \t]+$/gm, '')
- .replace(/\n\s*\n+/g, '\n')
- .trim();
-}
-
-function formatClock(date: Date): string {
- return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
-}
-
-// crée la string 8:00 → 10:00 (2h00)
-export function describeEvent(event: PressedEvent): EventDetails {
- const { startUnix, endUnix, duration } = event._internal;
- const description =
- event.subtitle.length > MAX_DESCRIPTION_LENGTH
- ? event.subtitle.slice(0, MAX_DESCRIPTION_LENGTH - 4) + '...'
- : event.subtitle;
-
- return {
- title: event.title,
- description: cleanText(description),
- room: event.description ?? 'N/A',
- time:
- `${formatClock(new Date(startUnix))} → ${formatClock(new Date(endUnix))}` +
- ` (${Math.floor(duration / 60)}h${String(duration % 60).padStart(2, '0')})`,
- };
+// la description ADE contient des lignes vides et finit par « (Exporté le : …) »
+function cleanNotes(notes: string): string {
+ return notes
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && !/^\(export/i.test(line))
+ .join('\n');
}
export function EventSheet({
sheetRef,
- details,
+ event,
}: {
- sheetRef: RefObject;
- details: EventDetails;
+ sheetRef: RefObject;
+ event: CalendarEvent | null;
}) {
- const theme = useChoosenTheme();
- const insets = useSafeAreaInsets();
-
- const renderBackdrop = useCallback(
- (props: BottomSheetBackdropProps) => (
-
- ),
- [],
+ return (
+
+ {event && sheetRef.current?.dismiss()} />}
+
);
+}
+
+function EventDetails({ event, onClose }: { event: CalendarEvent; onClose: () => void }) {
+ const theme = useAppTheme();
+ const start = new Date(event.start.dateTime);
+ const end = new Date(event.end.dateTime);
+ const notes = cleanNotes(event.notes);
return (
-
-
-
- {details.title}
-
-
- {details.description}
-
- {details.room ? (
-
- {details.room}
+
+
+
+
+
+ {formatLongDate(start)}
+ {event.title || 'Cours sans titre'}
+
+
+
+
+
+
+ {notes ? (
+
) : null}
-
- {details.time}
-
- sheetRef.current?.close()}
- >
- Fermer
-
-
-
+
+
+
+ Fermer
+
+
);
}
diff --git a/src/components/timetable/TimetableMenu.tsx b/src/components/timetable/TimetableMenu.tsx
deleted file mode 100644
index 095038c..0000000
--- a/src/components/timetable/TimetableMenu.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-
-import { Appbar, Divider, Menu } from 'react-native-paper';
-
-interface TimetableMenuProps {
- visible: boolean;
- onToggle: () => void;
- code: string;
- zoomIcon: string;
- onOtherEdt: () => void;
- onToday: () => void;
- onChangeView: () => void;
- onSettings: () => void;
-}
-
-export function TimetableMenu({
- visible,
- onToggle,
- code,
- zoomIcon,
- onOtherEdt,
- onToday,
- onChangeView,
- onSettings,
-}: TimetableMenuProps) {
- return (
- }
- >
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/timetable/TimetableScreen.tsx b/src/components/timetable/TimetableScreen.tsx
new file mode 100644
index 0000000..7cd0c9c
--- /dev/null
+++ b/src/components/timetable/TimetableScreen.tsx
@@ -0,0 +1,190 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { View } from 'react-native';
+
+import type { BottomSheetModal } from '@gorhom/bottom-sheet';
+import {
+ CalendarBody,
+ CalendarContainer,
+ CalendarHeader,
+ type CalendarKitHandle,
+ type OnEventResponse,
+ type PackedEvent,
+} from '@howljs/calendar-kit';
+import { useRouter } from 'expo-router';
+import { Divider, Menu } from 'react-native-paper';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { CALENDAR_LOCALES, getCalendarTheme } from '@/src/components/timetable/calendarTheme';
+import { EventCard } from '@/src/components/timetable/EventCard';
+import { EventSheet } from '@/src/components/timetable/EventSheet';
+import { Banner } from '@/src/components/ui/Banner';
+import { HeaderButton, ScreenHeader } from '@/src/components/ui/ScreenHeader';
+import { useCalendar } from '@/src/context/CalendarContext';
+import { useSettings } from '@/src/context/SettingsContext';
+import { useTabBarInset } from '@/src/hooks/useTabBarInset';
+import { useTemporaryCalendar } from '@/src/hooks/useTemporaryCalendar';
+import { useAppTheme, type Tone } from '@/src/theme';
+import type { CalendarEvent } from '@/src/types';
+import { formatMonth, getIsoWeek } from '@/src/utils/date';
+import { haptics } from '@/src/utils/haptics';
+
+const VIEWS = [
+ { days: 1, label: 'Jour', icon: 'view-day-outline' },
+ { days: 3, label: '3 jours', icon: 'view-column-outline' },
+ { days: 5, label: 'Semaine', icon: 'view-week-outline' },
+];
+
+// samedi et dimanche grisés (jours luxon : 1 = lundi)
+const WEEKEND = { 6: [{ start: 0, end: 24 * 60 }], 7: [{ start: 0, end: 24 * 60 }] };
+
+interface BannerState {
+ tone: Tone;
+ icon: string;
+ text: string;
+ action?: { label: string; onPress: () => void };
+}
+
+// tempCode : edt d'un autre code ADE, en lecture seule ; null : l'edt enregistré
+export function TimetableScreen({ tempCode }: { tempCode: string | null }) {
+ const router = useRouter();
+ const theme = useAppTheme();
+ const insets = useSafeAreaInsets();
+ const tabBarInset = useTabBarInset();
+ const { adeid, haptics: hapticsOn } = useSettings();
+ const calendar = useCalendar();
+ const temporary = useTemporaryCalendar(tempCode);
+ const tab = tempCode === null;
+
+ const [days, setDays] = useState(3);
+ const [visibleDate, setVisibleDate] = useState(() => new Date());
+ const [menuVisible, setMenuVisible] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const calendarRef = useRef(null);
+ const sheetRef = useRef(null);
+
+ const calendarTheme = useMemo(() => getCalendarTheme(theme), [theme]);
+ const renderEvent = useCallback((event: PackedEvent) => , []);
+
+ const events = tempCode ? temporary.events : calendar.events;
+ const loading = tempCode ? temporary.loading : calendar.loading;
+ const reload = tempCode ? temporary.retry : calendar.reload;
+
+ function openMenu() {
+ haptics('light');
+ setMenuVisible(true);
+ }
+
+ function menuAction(action: () => void) {
+ setMenuVisible(false);
+ action();
+ }
+
+ function goToToday() {
+ haptics('light');
+ calendarRef.current?.goToDate({ date: new Date(), hourScroll: true, animatedDate: true, animatedHour: true });
+ }
+
+ function showEvent(pressed: OnEventResponse) {
+ const event = events.find((item) => item.id === pressed.id);
+ if (!event) return;
+ haptics('selection');
+ setSelected(event);
+ sheetRef.current?.present();
+ }
+
+ let banner: BannerState | null = null;
+ if (tempCode) {
+ banner = temporary.failed
+ ? {
+ tone: 'error',
+ icon: 'alert-circle-outline',
+ text: `ADE indisponible · ${tempCode}`,
+ action: { label: 'Réessayer', onPress: reload },
+ }
+ : {
+ tone: 'tertiary',
+ icon: 'eye-outline',
+ text: `${!loading && events.length === 0 ? 'Aucun cours trouvé' : 'EDT temporaire'} · ${tempCode}`,
+ };
+ } else if (calendar.offline) {
+ banner = {
+ tone: 'error',
+ icon: 'wifi-off',
+ text: loading ? 'Nouvelle tentative…' : 'Hors ligne · EDT en cache',
+ action: loading ? undefined : { label: 'Réessayer', onPress: reload },
+ };
+ }
+
+ return (
+
+
+
+
+ setMenuVisible(false)}
+ anchor={ }
+ >
+ {VIEWS.map((view) => (
+ menuAction(() => setDays(view.days))}
+ />
+ ))}
+
+ menuAction(reload)}
+ />
+ menuAction(() => router.push('/edt-config'))}
+ />
+
+ >
+ }
+ />
+ {banner && }
+
+
+ setVisibleDate(new Date(date))}
+ onPressEvent={showEvent}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/timetable/calendarTheme.ts b/src/components/timetable/calendarTheme.ts
new file mode 100644
index 0000000..65a504e
--- /dev/null
+++ b/src/components/timetable/calendarTheme.ts
@@ -0,0 +1,40 @@
+import type { DeepPartial, LocaleConfigsProps, ThemeConfigs } from '@howljs/calendar-kit';
+
+import type { AppTheme } from '@/src/theme';
+
+export const CALENDAR_LOCALES: Record> = {
+ fr: {
+ weekDayShort: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'],
+ meridiem: { ante: 'am', post: 'pm' },
+ more: 'de plus',
+ },
+};
+
+export function getCalendarTheme(theme: AppTheme): DeepPartial {
+ const c = theme.colors;
+ return {
+ colors: {
+ primary: c.primary,
+ onPrimary: c.onPrimary,
+ background: c.background,
+ onBackground: c.onBackground,
+ border: c.outlineVariant,
+ text: c.onBackground,
+ surface: c.elevation.level2,
+ onSurface: c.onSurface,
+ },
+ textStyle: { fontFamily: 'Bahnschrift' },
+ hourTextStyle: { color: c.onSurfaceVariant, fontSize: 11 },
+ dayName: { color: c.onSurfaceVariant },
+ dayNumber: { color: c.onSurface },
+ todayName: { color: c.primary },
+ todayNumber: { color: c.onPrimary },
+ todayNumberContainer: { backgroundColor: c.primary },
+ weekNumber: { color: c.onPrimaryContainer },
+ weekNumberContainer: { backgroundColor: c.primaryContainer },
+ nowIndicatorColor: c.error,
+ // week-end grisé
+ unavailableHourBackgroundColor: c.elevation.level1,
+ eventContainerStyle: { borderRadius: 2 },
+ };
+}
diff --git a/src/components/ui/Banner.tsx b/src/components/ui/Banner.tsx
new file mode 100644
index 0000000..0c82b61
--- /dev/null
+++ b/src/components/ui/Banner.tsx
@@ -0,0 +1,49 @@
+import { View, type StyleProp, type ViewStyle } from 'react-native';
+
+import { Button, Icon, Text } from 'react-native-paper';
+
+import { getToneColors, useAppTheme, type Tone } from '@/src/theme';
+
+interface BannerProps {
+ icon: string;
+ text: string;
+ tone?: Tone;
+ action?: { label: string; onPress: () => void };
+ style?: StyleProp;
+}
+
+// bandeau d'information (hors ligne, edt temporaire…)
+export function Banner({ icon, text, tone = 'primary', action, style }: BannerProps) {
+ const theme = useAppTheme();
+ const { container, onContainer } = getToneColors(theme, tone);
+
+ return (
+
+
+
+ {text}
+
+ {action && (
+
+ {action.label}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx
new file mode 100644
index 0000000..79aa7e0
--- /dev/null
+++ b/src/components/ui/Card.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from 'react';
+import { View, type StyleProp, type ViewStyle } from 'react-native';
+
+import { useAppTheme } from '@/src/theme';
+
+import { PressableScale } from './PressableScale';
+
+interface CardProps {
+ children: ReactNode;
+ style?: StyleProp;
+ // false : contenu collé aux bords (listes)
+ padded?: boolean;
+ onPress?: () => void;
+ accessibilityLabel?: string;
+ accessibilityHint?: string;
+}
+
+export function Card({ children, style, padded = true, onPress, accessibilityLabel, accessibilityHint }: CardProps) {
+ const theme = useAppTheme();
+ const cardStyle = [
+ {
+ borderRadius: 24,
+ overflow: 'hidden',
+ padding: padded ? 16 : 0,
+ backgroundColor: theme.colors.elevation.level2,
+ } as const,
+ style,
+ ];
+
+ if (!onPress) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..dc3a579
--- /dev/null
+++ b/src/components/ui/EmptyState.tsx
@@ -0,0 +1,38 @@
+import { View } from 'react-native';
+
+import { Button, Text } from 'react-native-paper';
+
+import { useAppTheme, type Tone } from '@/src/theme';
+
+import { IconBadge } from './IconBadge';
+
+interface EmptyStateProps {
+ icon: string;
+ title: string;
+ text?: string;
+ tone?: Tone;
+ action?: { label: string; icon: string; onPress: () => void };
+}
+
+export function EmptyState({ icon, title, text, tone, action }: EmptyStateProps) {
+ const theme = useAppTheme();
+
+ return (
+
+
+
+ {title}
+
+ {text && (
+
+ {text}
+
+ )}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/IconBadge.tsx b/src/components/ui/IconBadge.tsx
new file mode 100644
index 0000000..ed299dd
--- /dev/null
+++ b/src/components/ui/IconBadge.tsx
@@ -0,0 +1,33 @@
+import { View } from 'react-native';
+
+import { Icon } from 'react-native-paper';
+
+import { getToneColors, useAppTheme, type Tone } from '@/src/theme';
+
+interface IconBadgeProps {
+ icon: string;
+ tone?: Tone;
+ size?: number;
+ // couleur pleine au lieu du conteneur tonal
+ filled?: boolean;
+}
+
+export function IconBadge({ icon, tone = 'primary', size = 44, filled = false }: IconBadgeProps) {
+ const theme = useAppTheme();
+ const colors = getToneColors(theme, tone);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/InfoLine.tsx b/src/components/ui/InfoLine.tsx
new file mode 100644
index 0000000..d1d5081
--- /dev/null
+++ b/src/components/ui/InfoLine.tsx
@@ -0,0 +1,25 @@
+import { View } from 'react-native';
+
+import { Icon, Text } from 'react-native-paper';
+
+interface InfoLineProps {
+ icon: string;
+ text: string;
+ color: string;
+ variant?: 'titleMedium' | 'bodyLarge';
+ numberOfLines?: number;
+}
+
+// icône + texte aligné sur la première ligne
+export function InfoLine({ icon, text, color, variant = 'titleMedium', numberOfLines = 2 }: InfoLineProps) {
+ return (
+
+
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/ui/ListGroup.tsx b/src/components/ui/ListGroup.tsx
new file mode 100644
index 0000000..4100c79
--- /dev/null
+++ b/src/components/ui/ListGroup.tsx
@@ -0,0 +1,89 @@
+import { Children, Fragment, type ReactNode } from 'react';
+import { View } from 'react-native';
+
+import { Icon, Text, TouchableRipple } from 'react-native-paper';
+
+import { useAppTheme, type Tone } from '@/src/theme';
+
+import { Card } from './Card';
+import { IconBadge } from './IconBadge';
+
+export function ListGroup({ children }: { children: ReactNode }) {
+ const theme = useAppTheme();
+ const items = Children.toArray(children);
+
+ return (
+
+ {items.map((item, index) => (
+
+ {index > 0 && (
+
+ )}
+ {item}
+
+ ))}
+
+ );
+}
+
+interface ListItemProps {
+ title: string;
+ subtitle?: string;
+ icon?: string;
+ tone?: Tone;
+ // élément à droite (interrupteur, statut…), par défaut un chevron si la ligne est pressable
+ right?: ReactNode;
+ // lien vers l'extérieur : icône dédiée au lieu du chevron
+ external?: boolean;
+ onPress?: () => void;
+ disabled?: boolean;
+}
+
+export function ListItem({ title, subtitle, icon, tone, right, external, onPress, disabled }: ListItemProps) {
+ const theme = useAppTheme();
+ const color = tone === 'error' ? theme.colors.error : theme.colors.onSurface;
+ const trailing =
+ right !== undefined
+ ? right
+ : onPress && (
+
+ );
+
+ return (
+
+
+ {icon && }
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {trailing}
+
+
+ );
+}
diff --git a/src/components/ui/PressableScale.tsx b/src/components/ui/PressableScale.tsx
new file mode 100644
index 0000000..ad8396d
--- /dev/null
+++ b/src/components/ui/PressableScale.tsx
@@ -0,0 +1,29 @@
+import { Pressable, type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
+
+import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
+const SPRING = { damping: 18, stiffness: 320, mass: 0.6 };
+
+type PressableScaleProps = Omit & { style?: StyleProp };
+
+// s'enfonce légèrement sous le doigt
+export function PressableScale({ style, onPressIn, onPressOut, ...props }: PressableScaleProps) {
+ const scale = useSharedValue(1);
+ const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
+
+ return (
+ {
+ scale.set(withSpring(0.97, SPRING));
+ onPressIn?.(event);
+ }}
+ onPressOut={(event) => {
+ scale.set(withSpring(1, SPRING));
+ onPressOut?.(event);
+ }}
+ style={[style, animatedStyle]}
+ />
+ );
+}
diff --git a/src/components/ui/Screen.tsx b/src/components/ui/Screen.tsx
new file mode 100644
index 0000000..4238753
--- /dev/null
+++ b/src/components/ui/Screen.tsx
@@ -0,0 +1,87 @@
+import { Children, isValidElement, type ReactElement, type ReactNode, type Ref } from 'react';
+import { Platform, ScrollView, View, type RefreshControlProps, type ScrollViewProps } from 'react-native';
+
+import Animated, { FadeOut } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ContentScrollMarker } from '@/modules/content-scroll';
+import { useAppTheme } from '@/src/theme';
+
+import { enter, layout } from './motion';
+import { ScreenHeader, type ScreenHeaderProps } from './ScreenHeader';
+
+interface ScreenProps extends Partial {
+ // remplace l'en-tête standard (accueil)
+ header?: ReactNode;
+ refreshControl?: ReactElement;
+ scrollRef?: Ref;
+ onScroll?: ScrollViewProps['onScroll'];
+ // posé par-dessus la page : feuilles, snackbar…
+ overlay?: ReactNode;
+ children: ReactNode;
+}
+
+// page défilante : en-tête puis blocs espacés, qui apparaissent en cascade
+export function Screen(props: ScreenProps) {
+ const {
+ title = '',
+ subtitle,
+ modal,
+ tab,
+ actions,
+ header,
+ refreshControl,
+ scrollRef,
+ onScroll,
+ overlay,
+ children,
+ } = props;
+ const theme = useAppTheme();
+ const insets = useSafeAreaInsets();
+ // une modale iOS s'ouvre déjà sous la barre d'état
+ const top = modal && Platform.OS === 'ios' ? 8 : insets.top;
+ // onglet : iOS ajoute la hauteur de la barre d'onglets, Android s'arrête au-dessus
+ const bottom = tab ? 24 : insets.bottom + 24;
+ // demandé à iOS : react-native-screens ne trouve pas cette ScrollView imbriquée
+ const insetBehavior = tab && Platform.OS === 'ios' ? 'automatic' : undefined;
+
+ const blocks = [
+
+ {header ?? }
+ ,
+ ...Children.toArray(children),
+ ];
+
+ return (
+
+
+ {/* iOS 26 : la barre d'onglets se réduit quand on fait défiler cette liste */}
+ {tab && }
+
+ {blocks.map((block, index) => (
+
+ {block}
+
+ ))}
+
+
+ {overlay}
+
+ );
+}
diff --git a/src/components/ui/ScreenHeader.tsx b/src/components/ui/ScreenHeader.tsx
new file mode 100644
index 0000000..caf4a43
--- /dev/null
+++ b/src/components/ui/ScreenHeader.tsx
@@ -0,0 +1,86 @@
+import type { ReactNode } from 'react';
+import { View } from 'react-native';
+
+import { useRouter } from 'expo-router';
+import { IconButton, Text, Tooltip } from 'react-native-paper';
+
+import { useAppTheme } from '@/src/theme';
+import { haptics } from '@/src/utils/haptics';
+
+export interface ScreenHeaderProps {
+ title: string;
+ subtitle?: string;
+ // modale : une croix au lieu de la flèche de retour
+ modal?: boolean;
+ // racine d'un onglet : pas de retour, le titre partage sa ligne avec les actions
+ tab?: boolean;
+ // HeaderButton à droite
+ actions?: ReactNode;
+}
+
+export function HeaderButton({
+ icon,
+ label,
+ onPress,
+ disabled,
+}: {
+ icon: string;
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function ScreenHeader({ title, subtitle, modal, tab, actions }: ScreenHeaderProps) {
+ const router = useRouter();
+ const theme = useAppTheme();
+
+ function goBack() {
+ haptics('light');
+ // premier écran de la pile si l'app a été ouverte directement ici
+ if (router.canGoBack()) router.back();
+ else router.replace('/home');
+ }
+
+ return (
+
+ {tab ? (
+
+
+ {title}
+
+ {actions}
+
+ ) : (
+ <>
+
+
+
+ {actions}
+
+
+ {title}
+
+ >
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/SectionTitle.tsx b/src/components/ui/SectionTitle.tsx
new file mode 100644
index 0000000..621c5de
--- /dev/null
+++ b/src/components/ui/SectionTitle.tsx
@@ -0,0 +1,35 @@
+import { Pressable, View } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { useAppTheme } from '@/src/theme';
+
+interface SectionTitleProps {
+ title: string;
+ aside?: string;
+ // aside devient un lien
+ onPressAside?: () => void;
+}
+
+export function SectionTitle({ title, aside, onPressAside }: SectionTitleProps) {
+ const theme = useAppTheme();
+ return (
+
+
+ {title}
+
+ {aside && onPressAside && (
+
+
+ {aside}
+
+
+ )}
+ {aside && !onPressAside && (
+
+ {aside}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/Sheet.tsx b/src/components/ui/Sheet.tsx
new file mode 100644
index 0000000..b0f97ca
--- /dev/null
+++ b/src/components/ui/Sheet.tsx
@@ -0,0 +1,50 @@
+import { useCallback, type ReactNode, type RefObject } from 'react';
+import { Platform } from 'react-native';
+
+import {
+ BottomSheetBackdrop,
+ BottomSheetModal,
+ BottomSheetView,
+ type BottomSheetBackdropProps,
+} from '@gorhom/bottom-sheet';
+import { initialWindowMetrics, useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { useAppTheme } from '@/src/theme';
+
+interface SheetProps {
+ sheetRef: RefObject;
+ onClose?: () => void;
+ children: ReactNode;
+}
+
+// feuille flottante modale, ouverte avec sheetRef.current.present()
+export function Sheet({ sheetRef, onClose, children }: SheetProps) {
+ const theme = useAppTheme();
+ const insets = useSafeAreaInsets();
+ // iOS : l'inset d'un onglet inclut la barre, que la feuille recouvre
+ const bottom = Platform.OS === 'ios' ? (initialWindowMetrics?.insets.bottom ?? insets.bottom) : insets.bottom;
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..c98a131
--- /dev/null
+++ b/src/components/ui/Skeleton.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from 'react';
+import { View, type DimensionValue } from 'react-native';
+
+import Animated from 'react-native-reanimated';
+
+import { usePulse } from '@/src/hooks/usePulse';
+import { useAppTheme } from '@/src/theme';
+import { withAlpha } from '@/src/utils/color';
+
+// squelette pendant un chargement : les Bone qu'il contient pulsent ensemble
+export function Skeleton({ label, children }: { label: string; children: ReactNode }) {
+ const pulse = usePulse(0.4, 800);
+ return (
+
+ {children}
+
+ );
+}
+
+interface BoneProps {
+ width: DimensionValue;
+ height?: number;
+ radius?: number;
+ // couleur du texte qu'il remplace
+ color?: string;
+ marginTop?: number;
+}
+
+export function Bone({ width, height = 18, radius = 8, color, marginTop }: BoneProps) {
+ const theme = useAppTheme();
+ return (
+
+ );
+}
diff --git a/src/components/ui/Tile.tsx b/src/components/ui/Tile.tsx
new file mode 100644
index 0000000..67728d8
--- /dev/null
+++ b/src/components/ui/Tile.tsx
@@ -0,0 +1,80 @@
+import type { ReactNode } from 'react';
+import { View, type StyleProp, type ViewStyle } from 'react-native';
+
+import { Text } from 'react-native-paper';
+
+import { useAppTheme, type Tone } from '@/src/theme';
+
+import { IconBadge } from './IconBadge';
+import { PressableScale } from './PressableScale';
+
+// tuiles deux par ligne
+export function TileGrid({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+interface TileProps {
+ label: string;
+ subtitle: string;
+ // icône tonale, ou badge sur mesure (logo d'un service…)
+ icon?: string;
+ tone?: Tone;
+ badge?: ReactNode;
+ // à droite du badge (bouton épingle…)
+ aside?: ReactNode;
+ onPress: () => void;
+ onLongPress?: () => void;
+ accessibilityHint?: string;
+ style?: StyleProp;
+}
+
+export function Tile({
+ label,
+ subtitle,
+ icon,
+ tone,
+ badge,
+ aside,
+ onPress,
+ onLongPress,
+ accessibilityHint,
+ style,
+}: TileProps) {
+ const theme = useAppTheme();
+ const leading = badge ?? (icon ? : null);
+
+ return (
+
+ {aside ? (
+
+ {leading}
+ {aside}
+
+ ) : (
+ leading
+ )}
+
+ {label}
+
+
+ {subtitle}
+
+
+ );
+}
diff --git a/src/components/ui/Watermark.tsx b/src/components/ui/Watermark.tsx
new file mode 100644
index 0000000..75231bb
--- /dev/null
+++ b/src/components/ui/Watermark.tsx
@@ -0,0 +1,12 @@
+import { Image } from 'expo-image';
+
+// logo en filigrane dans le coin d'une carte (overflow: 'hidden')
+export function Watermark({ color, size = 170 }: { color: string; size?: number }) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/motion.ts b/src/components/ui/motion.ts
new file mode 100644
index 0000000..d9d0325
--- /dev/null
+++ b/src/components/ui/motion.ts
@@ -0,0 +1,5 @@
+import { FadeInDown, LinearTransition } from 'react-native-reanimated';
+
+// apparition en cascade, puis glissement quand un bloc voisin change de taille
+export const enter = (index: number) => FadeInDown.duration(450).delay(index * 70);
+export const layout = LinearTransition.duration(250);
diff --git a/src/constants/config.ts b/src/constants/config.ts
index 081aaf3..e7a98cc 100644
--- a/src/constants/config.ts
+++ b/src/constants/config.ts
@@ -1,14 +1,37 @@
+import Constants from 'expo-constants';
+
import { version } from '../../package.json';
-// flag beta (debug)
+// outils de debug (bouton de crash…)
export const IS_BETA = false;
export const APP_VERSION = version;
+// commit du build EAS, absent en développement
+export const BUILD_COMMIT = Constants.expoConfig?.extra?.github_hash as string | undefined;
+
export const API_URL = 'https://uniceapi.metrixmedia.fr/';
-export const DOWNLOAD_URL = 'https://notes.metrixmedia.fr/get';
-export const PRONOTE_URL = 'https://sco.polytech.unice.fr/1/mobile.etudiant';
+export const LINKS = {
+ download: 'https://notes.metrixmedia.fr/get',
+ support: 'https://notes.metrixmedia.fr/support',
+ credits: 'https://notes.metrixmedia.fr/credits',
+ privacy: 'https://notes.metrixmedia.fr/privacy',
+ source: 'https://github.com/UniceApps/UniceNotes',
+ author: 'https://github.com/hugofnm',
+ pronote: 'https://sco.polytech.unice.fr/1/mobile.etudiant',
+ iconsMail: 'mailto:app+icons@metrixmedia.fr',
+};
+
+// affichées une fois par version, à l'accueil
export const RELEASE_NOTES = {
- "info": "Tu as mis à jour l\'application ! 🎉",
- "subtitle": "Nouveautés :\n- Les widgets sont maintenant disponibles pour Android !\n- Ajoute tes salles favorites pour vérifier leur disponibilité plus rapidement\n- Correction de bugs\n\nMerci d'utiliser UniceNotes !"
-}
\ No newline at end of file
+ title: "Tu as mis à jour l'application ! 🎉",
+ items: [
+ { icon: 'palette-outline', text: "Toute l'application fait peau neuve !" },
+ { icon: 'widgets-outline', text: 'Les widgets sont maintenant disponibles pour Android' },
+ { icon: 'star-outline', text: 'Ajoute tes salles favorites pour vérifier leur disponibilité plus rapidement' },
+ { icon: 'dock-bottom', text: "Une barre d'onglets pour tout retrouver : accueil, EDT, salles, ENT et paramètres" },
+ { icon: 'web', text: "Moodle, Outlook, PronoteCampus… s'ouvrent dans l'app et tu restes connecté" },
+ { icon: 'pin-outline', text: "Épingle 3 services à l'accès rapide, retrouve-les aussi sur l'icône de l'app" },
+ { icon: 'bug-outline', text: 'Correction de bugs' },
+ ],
+};
diff --git a/src/constants/ent.ts b/src/constants/ent.ts
new file mode 100644
index 0000000..b1d3bb7
--- /dev/null
+++ b/src/constants/ent.ts
@@ -0,0 +1,126 @@
+import type { ImageSourcePropType } from 'react-native';
+
+import type { Tone } from '@/src/theme';
+
+import { LINKS } from './config';
+
+export interface EntApp {
+ // identifiant stable : enregistré dans les apps épinglées et les liens unicenotes://app/{id}
+ id: string;
+ label: string;
+ subtitle: string;
+ url: string;
+ // logo du service, sinon une icône
+ image?: ImageSourcePropType;
+ icon: string;
+ tone: Tone;
+ // SF Symbol des raccourcis de l'icône de l'app (iOS)
+ symbol: string;
+}
+
+// services ouverts dans le navigateur intégré, qui garde la connexion d'un lancement à l'autre
+export const ENT_APPS: EntApp[] = [
+ {
+ id: 'outlook',
+ label: 'Outlook',
+ subtitle: 'Emails',
+ icon: 'email-outline',
+ tone: 'primary',
+ symbol: 'envelope',
+ image: require('../assets/images/ent/outlook.png'),
+ url: 'https://outlook.office.com/owa/?realm=etu.univ-cotedazur.fr&exsvurl=1&ll-cc=1036&modurl=0',
+ },
+ {
+ id: 'moodle',
+ label: 'Moodle',
+ subtitle: 'Cours en ligne',
+ icon: 'book-open-variant',
+ tone: 'tertiary',
+ symbol: 'book',
+ image: require('../assets/images/ent/moodle.png'),
+ url: 'https://portail-lms.univ-cotedazur.fr',
+ },
+ {
+ id: 'pronote',
+ label: 'PronoteCampus',
+ subtitle: 'Notes',
+ icon: 'calculator-variant-outline',
+ tone: 'secondary',
+ symbol: 'graduationcap',
+ image: require('../assets/images/ent/pronotecampus.png'),
+ // version mobile, faite pour l'écran d'un téléphone
+ url: LINKS.pronote,
+ },
+ {
+ id: 'iut-notes',
+ label: 'IUT Notes',
+ subtitle: 'Notes',
+ icon: 'calculator-variant',
+ tone: 'primary',
+ symbol: 'checkmark.seal',
+ url: 'https://iut-notes.unice.fr/',
+ },
+ {
+ id: 'sowesign',
+ label: 'SoWeSign',
+ subtitle: 'Émargement',
+ icon: 'check-circle-outline',
+ tone: 'tertiary',
+ symbol: 'signature',
+ url: 'https://app.sowesign.com/login',
+ },
+ {
+ id: 'dossier-web',
+ label: 'Mon Dossier Web',
+ subtitle: 'Scolarité',
+ icon: 'school-outline',
+ tone: 'secondary',
+ symbol: 'folder',
+ image: require('../assets/images/ent/univ.png'),
+ url: 'https://mondossierweb.univ-cotedazur.fr/',
+ },
+ {
+ id: 'annuaire',
+ label: 'Annuaire',
+ subtitle: 'Contacts UniCA',
+ icon: 'account-search-outline',
+ tone: 'primary',
+ symbol: 'person.2',
+ url: 'https://annuaire.univ-cotedazur.fr',
+ },
+ {
+ id: 'bu',
+ label: 'BU',
+ subtitle: 'Bibliothèques',
+ icon: 'book-open-page-variant-outline',
+ tone: 'tertiary',
+ symbol: 'books.vertical',
+ url: 'https://bu.univ-cotedazur.fr/',
+ },
+ {
+ id: 'impression',
+ label: 'Imprimerie',
+ subtitle: 'Impressions',
+ icon: 'printer-outline',
+ tone: 'secondary',
+ symbol: 'printer',
+ url: 'https://impression.univ-cotedazur.fr/',
+ },
+ {
+ id: 'izly',
+ label: 'Izly',
+ subtitle: 'Paiement CROUS',
+ icon: 'cash-multiple',
+ tone: 'primary',
+ symbol: 'creditcard',
+ image: require('../assets/images/ent/izly.png'),
+ url: 'https://mon-espace.izly.fr',
+ },
+];
+
+// accès rapide d'origine : les notes, l'émargement et les mails
+export const DEFAULT_PINNED_APPS = ['pronote', 'sowesign', 'outlook'];
+
+export function getEntApp(id: unknown): EntApp | undefined {
+ return typeof id === 'string' ? ENT_APPS.find((app) => app.id === id) : undefined;
+}
diff --git a/src/constants/icons.ts b/src/constants/icons.ts
index 994d725..73e68a6 100644
--- a/src/constants/icons.ts
+++ b/src/constants/icons.ts
@@ -1,8 +1,7 @@
import type { ImageSourcePropType } from 'react-native';
-// name doit correspondre exactement au nom déclaré dans app.json
-// null = icone principale de l'application.
export interface AppIconOption {
+ // nom exact déclaré dans app.json (expo-alternate-app-icons), null pour l'icône principale
name: string | null;
label: string;
author?: string;
@@ -31,8 +30,18 @@ export const appIconGroups: AppIconGroup[] = [
{
title: 'Communautaires',
icons: [
- { name: 'Glitched', label: 'Glitched', author: '@f.eli0tt', source: require('../assets/icons/icon_glitched.png') },
- { name: 'Vaporwave', label: 'Vaporwave', author: '@nathan_jaffres', source: require('../assets/icons/icon_vaporwave.png') },
+ {
+ name: 'Glitched',
+ label: 'Glitched',
+ author: '@f.eli0tt',
+ source: require('../assets/icons/icon_glitched.png'),
+ },
+ {
+ name: 'Vaporwave',
+ label: 'Vaporwave',
+ author: '@nathan_jaffres',
+ source: require('../assets/icons/icon_vaporwave.png'),
+ },
{ name: 'Ios6', label: 'iOS 6', author: '@ds.marius', source: require('../assets/icons/icon_ios6.png') },
],
},
@@ -40,7 +49,12 @@ export const appIconGroups: AppIconGroup[] = [
title: 'Événements',
icons: [
{ name: 'France', label: 'France', author: 'Euro & JO 2024', source: require('../assets/icons/icon_france.png') },
- { name: 'Christmas2023', label: 'Noël', author: '2023', source: require('../assets/icons/icon_christmas2023.png') },
+ {
+ name: 'Christmas2023',
+ label: 'Noël',
+ author: '2023',
+ source: require('../assets/icons/icon_christmas2023.png'),
+ },
],
},
];
diff --git a/src/constants/theme.ts b/src/constants/theme.ts
deleted file mode 100644
index d020356..0000000
--- a/src/constants/theme.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { useSyncExternalStore } from 'react';
-import { useColorScheme } from 'react-native';
-import { DarkTheme, DefaultTheme, type Theme } from 'expo-router';
-import { configureFonts, MD3Theme } from 'react-native-paper';
-
-import { getAsync, saveAsync } from '../utils/storage';
-
-import azurJson from '../assets/themes/palettes/azur.json';
-import mentheJson from '../assets/themes/palettes/menthe.json';
-import lavandeJson from '../assets/themes/palettes/lavande.json';
-import ambreJson from '../assets/themes/palettes/ambre.json';
-
-// Chaque palette est un fichier JSON dans assets/themes/palettes
-// suivant MD3 voir doc https://oss.callstack.com/react-native-paper/docs/guides/theming :)
-const PALETTE_FILES: unknown[] = [azurJson, mentheJson, lavandeJson, ambreJson];
-
-export type ThemeColors = typeof azurJson.light;
-
-export type AppTheme = {
- dark: boolean;
- version: 3;
- mode?: 'adaptive';
- colors: ThemeColors;
- fonts?: MD3Theme['fonts'];
-};
-
-export interface ThemeDefinition {
- id: string;
- label: string;
- description: string;
- light: AppTheme;
- dark: AppTheme;
-}
-
-// ---------------------------------------------------------------------------
-// Chargement des palettes JSON
-// ---------------------------------------------------------------------------
-
-// azur sert de référence : une palette doit définir exactement les mêmes clés
-function isValidColors(value: unknown, reference: Record = azurJson.light): boolean {
- if (typeof value !== 'object' || value === null) return false;
- const obj = value as Record;
- return Object.entries(reference).every(([key, ref]) =>
- typeof ref === 'object' ? isValidColors(obj[key], ref as Record) : typeof obj[key] === 'string',
- );
-}
-
-/** Transforme un JSON de palette en thème Paper ; renvoie null si le JSON est invalide. */
-export function loadTheme(json: unknown): ThemeDefinition | null {
- if (typeof json !== 'object' || json === null) return null;
- const { id, label, description, light, dark } = json as Record;
- if (typeof id !== 'string' || typeof label !== 'string') return null;
- if (!isValidColors(light) || !isValidColors(dark)) return null;
-
- return {
- id,
- label,
- description: typeof description === 'string' ? description : '',
- light: { dark: false, version: 3, colors: light as ThemeColors },
- dark: { dark: true, version: 3, mode: 'adaptive', colors: dark as ThemeColors },
- };
-}
-
-export const themes: Record = {};
-for (const file of PALETTE_FILES) {
- const theme = loadTheme(file);
- if (theme) themes[theme.id] = theme;
- else console.warn('[theme] palette JSON invalide ignorée', (file as { id?: unknown })?.id);
-}
-
-export const DEFAULT_THEME_ID = 'azur';
-
-export const themeOptions = Object.values(themes).map((t) => ({
- id: t.id,
- label: t.label,
- description: t.description,
- swatchLight: t.light.colors.primary,
- swatchDark: t.dark.colors.primary,
-}));
-
-export function isThemeId(value: unknown): value is string {
- return typeof value === 'string' && value in themes;
-}
-
-// return la variante clair/sombre d'un thème
-export function getTheme(id: string | null | undefined, isDark: boolean): AppTheme {
- const entry = isThemeId(id) ? themes[id] : themes[DEFAULT_THEME_ID];
- return isDark ? entry.dark : entry.light;
-}
-
-export const lightTheme = themes[DEFAULT_THEME_ID].light;
-export const darkTheme = themes[DEFAULT_THEME_ID].dark;
-
-// ---------------------------------------------------------------------------
-// Thème sélectionné
-// ---------------------------------------------------------------------------
-
-let _themeId: string = DEFAULT_THEME_ID;
-const _listeners = new Set<() => void>();
-
-function emitThemeChange(id: string): void {
- _themeId = id;
- _listeners.forEach((l) => l());
-}
-
-function subscribeTheme(listener: () => void): () => void {
- _listeners.add(listener);
- return () => _listeners.delete(listener);
-}
-
-export async function loadThemePreference(): Promise {
- const stored = await getAsync('theme');
- emitThemeChange(isThemeId(stored) ? stored : DEFAULT_THEME_ID);
-}
-
-export async function setThemeId(id: string): Promise {
- if (!isThemeId(id)) return;
- emitThemeChange(id);
- await saveAsync('theme', id);
-}
-
-// Remet le thème par défaut en mémoire
-export function resetTheme(): void {
- emitThemeChange(DEFAULT_THEME_ID);
-}
-
-export function useThemeId(): string {
- return useSyncExternalStore(subscribeTheme, () => _themeId);
-}
-
-let _fonts: MD3Theme['fonts'] | null = null;
-
-export function updateFontConfig(): void {
- _fonts = configureFonts({ config: { fontFamily: 'Bahnschrift' } });
-}
-
-// hook: re-renders the component when the system switches between light and dark or the theme changes
-export function useChoosenTheme(): AppTheme {
- const scheme = useColorScheme();
- const themeId = useThemeId();
- const base = getTheme(themeId, scheme === 'dark');
- return _fonts ? { ...base, fonts: _fonts } : base;
-}
-
-// pastilles des salles libres (vert / orange / rouge) : le thème Material n'a ni vert ni orange
-export function getRoomStatusColors(theme: AppTheme): Record<'free' | 'tight' | 'busy', string> {
- return theme.dark
- ? { free: '#5BC98A', tight: '#F5B041', busy: '#FF7B72' }
- : { free: '#1E8E4E', tight: '#D97706', busy: '#D93025' };
-}
-
-// fond du Stack (visible pendant les gestes de retour) : sinon React Navigation reste en clair
-export function getNavigationTheme(theme: AppTheme): Theme {
- const base = theme.dark ? DarkTheme : DefaultTheme;
- return {
- ...base,
- colors: {
- ...base.colors,
- primary: theme.colors.primary,
- background: theme.colors.background,
- card: theme.colors.surface,
- text: theme.colors.onBackground,
- border: theme.colors.outline,
- notification: theme.colors.error,
- },
- };
-}
-
-export function getCalendarTheme(theme: AppTheme) {
- return {
- colors: {
- primary: theme.colors.primary,
- onPrimary: theme.colors.onPrimary,
- background: theme.colors.background,
- onBackground: theme.colors.onBackground,
- border: theme.colors.outline,
- text: theme.colors.onBackground,
- },
- hourTextStyle: { color: theme.colors.onBackground },
- weekNumber: { color: theme.colors.onPrimary },
- weekNumberContainer: { backgroundColor: theme.colors.primary },
- unavailableHourBackgroundColor: theme.colors.surfaceVariant,
- };
-}
diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx
deleted file mode 100644
index 60363f9..0000000
--- a/src/context/AppContext.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
-
-import { File, Paths } from 'expo-file-system';
-import * as SecureStore from 'expo-secure-store';
-import { router } from 'expo-router';
-
-import { APP_VERSION } from '../constants/config';
-import { resetTheme } from '../constants/theme';
-import { refreshClassActivity, setLiveActivitiesEnabled } from '../services/widgets';
-import type { CalendarEvent } from '../types';
-import { setHapticsEnabled } from '../utils/haptics';
-import { clearAsync, deleteSecure, getAsync, getSecure, removeAsync, saveAsync } from '../utils/storage';
-
-interface AppContextValue {
- adeid: string | null;
- setAdeid: (v: string | null) => void;
- hapticsOn: boolean;
- setHapticsOn: (v: boolean) => void;
- liveActivitiesOn: boolean;
- setLiveActivitiesOn: (v: boolean) => void;
- calendar: CalendarEvent[];
- setCalendar: (v: CalendarEvent[]) => void;
- calendarOffline: boolean; // true si le calendrier affiché provient du cache local
- setCalendarOffline: (v: boolean) => void;
- clearAllData: () => Promise;
- isInitialized: boolean;
- updateModalShown: boolean; // true if release notes were already shown for the current APP_VERSION
- setUpdateModalShown: (v: boolean) => void;
- onboarding: boolean; // true tant que l'OOBE est actif
- setOnboarding: (v: boolean) => void;
- oobeCompleted: boolean; // true si l'utilisateur a terminé l'OOBE
- setOobeCompleted: (v: boolean) => void;
-}
-
-const AppContext = createContext(null);
-
-export function AppProvider({ children }: { children: React.ReactNode }) {
- const [adeid, setAdeid] = useState(null);
- const [hapticsOn, setHapticsOnState] = useState(true);
- const [liveActivitiesOn, setLiveActivitiesOnState] = useState(true);
- const [calendar, setCalendar] = useState([]);
- const [calendarOffline, setCalendarOffline] = useState(false);
- const [isInitialized, setIsInitialized] = useState(false);
- const [updateModalShown, setUpdateModalShown] = useState(false);
- const [onboarding, setOnboarding] = useState(false);
- const [oobeCompleted, setOobeCompletedState] = useState(false);
-
- useEffect(() => {
- loadPersistedData();
- }, []);
-
- async function loadPersistedData() {
- const [storedAdeid, storedHaptics, storedLiveActivities, releaseNotesVersion, storedOobeCompleted] = await Promise.all([
- getSecure('adeid'),
- getAsync('haptics'),
- getAsync('liveActivities'),
- getAsync('releaseNotesVersion'),
- getAsync('oobeCompleted'),
- ]);
-
- if (storedAdeid) setAdeid(storedAdeid);
- if (storedHaptics !== null) {
- const h = storedHaptics === 'true';
- setHapticsOnState(h);
- setHapticsEnabled(h);
- }
- if (storedLiveActivities !== null) {
- const l = storedLiveActivities === 'true';
- setLiveActivitiesOnState(l);
- setLiveActivitiesEnabled(l);
- }
-
- const edtConfigured = !!storedAdeid && storedAdeid !== 'demo';
- const oobeDone = storedOobeCompleted === 'true' || edtConfigured;
- if (oobeDone && storedOobeCompleted !== 'true') saveAsync('oobeCompleted', 'true');
- setOobeCompletedState(oobeDone);
-
- // release notes already shown only if they were shown for this exact version
- setUpdateModalShown(releaseNotesVersion === APP_VERSION);
-
- setIsInitialized(true);
- }
-
- function setHapticsOn(v: boolean) {
- setHapticsOnState(v);
- setHapticsEnabled(v);
- }
-
- function setLiveActivitiesOn(v: boolean) {
- setLiveActivitiesOnState(v);
- setLiveActivitiesEnabled(v);
- refreshClassActivity();
- }
-
- function setOobeCompleted(v: boolean) {
- setOobeCompletedState(v);
- if (v) saveAsync('oobeCompleted', 'true');
- else removeAsync('oobeCompleted');
- }
-
- async function clearAllData() {
- await Promise.all([
- deleteSecure('adeid'),
- removeAsync('haptics'),
- removeAsync('liveActivities'),
- removeAsync('releaseNotesVersion'),
- removeAsync('oobeCompleted'),
- ]);
-
- const calFile = new File(Paths.document, 'calendar.json');
- if (calFile.exists) {
- calFile.delete();
- }
-
- await clearAsync();
-
- setAdeid(null);
- setHapticsOn(true);
- setLiveActivitiesOn(true);
- setCalendar([]);
- setCalendarOffline(false);
- setUpdateModalShown(false);
- setOobeCompletedState(false);
- resetTheme();
- router.replace('/oobe'); // redirect to onboarding after data wipe
- }
-
- return (
-
- {children}
-
- );
-}
-
-export function useApp(): AppContextValue {
- const ctx = useContext(AppContext);
- if (!ctx) throw new Error('useApp must be used within AppProvider');
- return ctx;
-}
diff --git a/src/context/CalendarContext.tsx b/src/context/CalendarContext.tsx
new file mode 100644
index 0000000..88752a3
--- /dev/null
+++ b/src/context/CalendarContext.tsx
@@ -0,0 +1,128 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
+import { AppState } from 'react-native';
+
+import { readCalendarCache, writeCalendarCache } from '@/src/services/calendar-cache';
+import { fetchCalendar } from '@/src/services/edt';
+import { updateClassActivity, updateWidgets } from '@/src/services/widgets';
+import type { CalendarEvent } from '@/src/types';
+import { MINUTE_MS } from '@/src/utils/date';
+
+import { useSettings } from './SettingsContext';
+
+// au retour dans l'app, l'edt est retéléchargé au-delà de ce délai
+const STALE_AFTER_MS = 15 * MINUTE_MS;
+
+interface CalendarData {
+ // identifiant ADE auquel appartiennent ces cours
+ code: string;
+ events: CalendarEvent[];
+ // true si ADE est injoignable : events vient du cache
+ offline: boolean;
+ syncedAt: number | null;
+}
+
+interface CalendarContextValue {
+ events: CalendarEvent[];
+ offline: boolean;
+ syncedAt: number | null;
+ // téléchargement en cours
+ loading: boolean;
+ // « tirer pour actualiser » en cours
+ refreshing: boolean;
+ reload: () => void;
+ refresh: () => void;
+}
+
+const CalendarContext = createContext(null);
+
+// seul endroit qui télécharge l'edt : l'accueil, l'emploi du temps et les widgets partagent ces données
+export function CalendarProvider({ children }: { children: ReactNode }) {
+ const { adeid, liveActivities } = useSettings();
+ const [data, setData] = useState(null);
+ const [fetchKey, setFetchKey] = useState(0);
+ // dernière synchro terminée
+ const [doneKey, setDoneKey] = useState(null);
+ const [refreshing, setRefreshing] = useState(false);
+ const lastSyncAt = useRef(0);
+ const loadedCode = useRef(null);
+
+ const requestKey = `${adeid}:${fetchKey}`;
+ const current = data && data.code === adeid ? data : null;
+
+ useEffect(() => {
+ if (!adeid) {
+ updateWidgets(null);
+ return;
+ }
+ let cancelled = false;
+ lastSyncAt.current = Date.now();
+
+ async function sync(code: string) {
+ // premier affichage : le cache, le temps qu'ADE réponde
+ if (loadedCode.current !== code) {
+ const cached = await readCalendarCache(code);
+ if (cancelled) return;
+ if (cached) setData({ code, events: cached.events, offline: false, syncedAt: cached.savedAt });
+ loadedCode.current = code;
+ }
+
+ const events = await fetchCalendar(code);
+ if (cancelled) return;
+ if (events) {
+ const syncedAt = Date.now();
+ writeCalendarCache({ code, savedAt: syncedAt, events });
+ updateWidgets(events);
+ setData({ code, events, offline: false, syncedAt });
+ } else {
+ setData((previous) =>
+ previous?.code === code
+ ? { ...previous, offline: true }
+ : { code, events: [], offline: true, syncedAt: null },
+ );
+ }
+ setDoneKey(`${code}:${fetchKey}`);
+ setRefreshing(false);
+ }
+
+ sync(adeid);
+ return () => {
+ cancelled = true;
+ };
+ }, [adeid, fetchKey]);
+
+ // la Live Activity suit l'edt affiché, une fois le cache lu
+ useEffect(() => {
+ if (adeid && !current) return;
+ updateClassActivity(liveActivities && current ? current.events : null);
+ }, [adeid, current, liveActivities]);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state !== 'active') return;
+ if (adeid && Date.now() - lastSyncAt.current > STALE_AFTER_MS) setFetchKey((key) => key + 1);
+ else if (current) updateClassActivity(liveActivities ? current.events : null);
+ });
+ return () => subscription.remove();
+ }, [adeid, current, liveActivities]);
+
+ const value: CalendarContextValue = {
+ events: current?.events ?? [],
+ offline: current?.offline ?? false,
+ syncedAt: current?.syncedAt ?? null,
+ loading: adeid !== null && doneKey !== requestKey,
+ refreshing,
+ reload: () => setFetchKey((key) => key + 1),
+ refresh: () => {
+ setRefreshing(true);
+ setFetchKey((key) => key + 1);
+ },
+ };
+
+ return {children} ;
+}
+
+export function useCalendar(): CalendarContextValue {
+ const context = useContext(CalendarContext);
+ if (!context) throw new Error('useCalendar must be used within CalendarProvider');
+ return context;
+}
diff --git a/src/context/DeepLinkHandler.tsx b/src/context/DeepLinkHandler.tsx
deleted file mode 100644
index bfb7caf..0000000
--- a/src/context/DeepLinkHandler.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useEffect, useRef, useSyncExternalStore } from 'react';
-import { Platform } from 'react-native';
-
-import * as QuickActions from 'expo-quick-actions';
-import { useQuickActionCallback } from 'expo-quick-actions/hooks';
-import { useGlobalSearchParams, usePathname, useRouter, type Href } from 'expo-router';
-
-import { PRONOTE_URL } from '@/src/constants/config';
-import { useApp } from '@/src/context/AppContext';
-import { handleURL } from '@/src/utils/api';
-import {
- emitDeepLink,
- parseDeepLink,
- peekDeepLink,
- subscribeDeepLink,
- takeDeepLink,
-} from '@/src/utils/deeplink';
-
-const icon = (symbol: string) => (Platform.OS === 'ios' ? `symbol:${symbol}` : null);
-
-const QUICK_ACTIONS: QuickActions.Action[] = [
- {
- id: 'edt',
- title: 'Emploi du temps',
- icon: icon('calendar'),
- params: { href: 'unicenotes://edt' },
- },
- {
- id: 'notes',
- title: 'Notes',
- subtitle: 'PronoteCampus',
- icon: icon('graduationcap'),
- params: { href: 'unicenotes://notes' },
- },
- {
- id: 'ent',
- title: 'ENT',
- subtitle: 'Intranet étudiant',
- icon: icon('briefcase'),
- params: { href: 'unicenotes://ent' },
- },
-];
-
-// rejoue l'action à chaque changement de callback
-function onQuickAction(action: QuickActions.Action) {
- const link = parseDeepLink(action.params?.href);
- if (link) emitDeepLink(link);
-}
-
-export function DeepLinkHandler() {
- const router = useRouter();
- const pathname = usePathname();
- const { code: currentCode } = useGlobalSearchParams<{ code?: string }>();
- const { adeid, isInitialized, oobeCompleted } = useApp();
-
- const link = useSyncExternalStore(subscribeDeepLink, peekDeepLink);
- const landed = useRef(false);
-
- useQuickActionCallback(onQuickAction);
-
- useEffect(() => {
- if (!isInitialized) return;
- QuickActions.setItems(oobeCompleted ? QUICK_ACTIONS : []);
- }, [isInitialized, oobeCompleted]);
-
- useEffect(() => {
- if (pathname === '/home') landed.current = true;
- if (!link || !landed.current || !isInitialized || !oobeCompleted) return;
-
- const target = takeDeepLink();
- if (!target) return;
-
- // repart toujours de l'accueil
- const open = (href: Href) => {
- if (router.canDismiss()) router.dismissAll();
- router.push(href);
- };
-
- switch (target.kind) {
- case 'notes':
- handleURL(PRONOTE_URL);
- break;
- case 'ent':
- if (pathname !== '/ent') open('/ent');
- break;
- case 'edt':
- if (target.code) {
- // EDT temporaire : lecture seule, l'EDT enregistré n'est jamais modifié
- if (pathname !== '/timetable' || currentCode !== target.code) {
- open({ pathname: '/timetable', params: { code: target.code } });
- }
- } else if (!adeid || adeid === 'demo') {
- if (pathname !== '/edt-config') open('/edt-config');
- } else if (pathname !== '/timetable' || currentCode) {
- open({ pathname: '/timetable', params: { fresh: '1' } });
- }
- break;
- }
- }, [link, pathname, currentCode, isInitialized, oobeCompleted, adeid, router]);
-
- return null;
-}
diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx
new file mode 100644
index 0000000..65a9b0e
--- /dev/null
+++ b/src/context/SettingsContext.tsx
@@ -0,0 +1,101 @@
+import { createContext, useContext, useState, type ReactNode } from 'react';
+
+import { resetPinnedApps } from '@/src/hooks/usePinnedApps';
+import { clearCalendarCache } from '@/src/services/calendar-cache';
+import { clearWebSession } from '@/src/services/webSession';
+import { resetTheme } from '@/src/theme';
+import { setHapticsEnabled } from '@/src/utils/haptics';
+import { secureStorage, storage } from '@/src/utils/storage';
+
+export interface Settings {
+ // numéro étudiant ou cursus (suffixe -VET), null tant que l'edt n'est pas configuré
+ adeid: string | null;
+ haptics: boolean;
+ liveActivities: boolean;
+ oobeCompleted: boolean;
+}
+
+interface SettingsContextValue extends Settings {
+ setAdeid: (adeid: string) => void;
+ setHaptics: (on: boolean) => void;
+ setLiveActivities: (on: boolean) => void;
+ setOobeCompleted: (done: boolean) => void;
+ clearAllData: () => Promise;
+}
+
+const DEFAULT_SETTINGS: Settings = { adeid: null, haptics: true, liveActivities: true, oobeCompleted: false };
+
+const SettingsContext = createContext(null);
+
+// lu une seule fois, avant le premier rendu (voir app/_layout.tsx)
+export async function loadSettings(): Promise {
+ const [adeid, haptics, liveActivities, oobeCompleted] = await Promise.all([
+ secureStorage.get('adeid'),
+ storage.get('haptics'),
+ storage.get('liveActivities'),
+ storage.get('oobeCompleted'),
+ ]);
+
+ // "demo" : ancien marqueur « non configuré » des versions < 3.4
+ const code = adeid && adeid !== 'demo' ? adeid : null;
+ setHapticsEnabled(haptics !== 'false');
+
+ return {
+ adeid: code,
+ haptics: haptics !== 'false',
+ liveActivities: liveActivities !== 'false',
+ // un edt déjà configuré vient d'une version sans configuration initiale
+ oobeCompleted: oobeCompleted === 'true' || code !== null,
+ };
+}
+
+// chaque réglage est enregistré dès qu'il change
+export function SettingsProvider({ initial, children }: { initial: Settings; children: ReactNode }) {
+ const [settings, setSettings] = useState(initial);
+
+ const update = (patch: Partial) => setSettings((current) => ({ ...current, ...patch }));
+
+ const value: SettingsContextValue = {
+ ...settings,
+
+ setAdeid(adeid) {
+ update({ adeid });
+ secureStorage.set('adeid', adeid);
+ },
+
+ setHaptics(on) {
+ update({ haptics: on });
+ setHapticsEnabled(on);
+ storage.set('haptics', String(on));
+ },
+
+ setLiveActivities(on) {
+ update({ liveActivities: on });
+ storage.set('liveActivities', String(on));
+ },
+
+ setOobeCompleted(done) {
+ update({ oobeCompleted: done });
+ if (done) storage.set('oobeCompleted', 'true');
+ else storage.remove('oobeCompleted');
+ },
+
+ async clearAllData() {
+ // connexions aux services de l'ENT comprises
+ await Promise.all([secureStorage.remove('adeid'), storage.clear(), clearWebSession()]);
+ clearCalendarCache();
+ resetTheme();
+ resetPinnedApps();
+ setHapticsEnabled(true);
+ setSettings(DEFAULT_SETTINGS);
+ },
+ };
+
+ return {children} ;
+}
+
+export function useSettings(): SettingsContextValue {
+ const context = useContext(SettingsContext);
+ if (!context) throw new Error('useSettings must be used within SettingsProvider');
+ return context;
+}
diff --git a/src/hooks/useAgenda.ts b/src/hooks/useAgenda.ts
new file mode 100644
index 0000000..cfa696a
--- /dev/null
+++ b/src/hooks/useAgenda.ts
@@ -0,0 +1,21 @@
+import { useMemo } from 'react';
+
+import { useCalendar } from '@/src/context/CalendarContext';
+import { useSettings } from '@/src/context/SettingsContext';
+import { buildAgenda, toAgendaClasses } from '@/src/utils/agenda';
+
+import { useNow } from './useNow';
+
+// « dans 12 min » et la progression du cours se recalculent sans requête
+export function useAgenda() {
+ const { adeid } = useSettings();
+ const calendar = useCalendar();
+ const now = useNow();
+
+ const classes = useMemo(() => toAgendaClasses(calendar.events), [calendar.events]);
+ const agenda = useMemo(() => buildAgenda(classes, now), [classes, now]);
+
+ return { ...calendar, ...agenda, configured: adeid !== null, classes, now };
+}
+
+export type AgendaState = ReturnType;
diff --git a/src/hooks/useFavoriteRooms.ts b/src/hooks/useFavoriteRooms.ts
index c4e551e..cd572f7 100644
--- a/src/hooks/useFavoriteRooms.ts
+++ b/src/hooks/useFavoriteRooms.ts
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import type { AdeRoom } from '@/src/types';
-import { getAsync, saveAsync } from '@/src/utils/storage';
+import { storage } from '@/src/utils/storage';
-const FAVORITE_ROOMS_KEY = 'favoriteRooms';
export const MAX_FAVORITE_ROOMS = 3;
function parseIds(raw: string | null): string[] {
@@ -16,12 +15,12 @@ function parseIds(raw: string | null): string[] {
}
}
-// rooms : salles connues
+// rooms : salles connues, les favorites disparues d'ADE sont ignorées
export function useFavoriteRooms(rooms: AdeRoom[] | undefined) {
const [storedIds, setStoredIds] = useState([]);
useEffect(() => {
- getAsync(FAVORITE_ROOMS_KEY).then((raw) => setStoredIds(parseIds(raw)));
+ storage.get('favoriteRooms').then((raw) => setStoredIds(parseIds(raw)));
}, []);
const ids = useMemo(() => {
@@ -32,7 +31,7 @@ export function useFavoriteRooms(rooms: AdeRoom[] | undefined) {
function save(next: string[]) {
setStoredIds(next);
- saveAsync(FAVORITE_ROOMS_KEY, JSON.stringify(next));
+ storage.set('favoriteRooms', JSON.stringify(next));
}
// false si la limite est atteinte
diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts
new file mode 100644
index 0000000..be3e7fd
--- /dev/null
+++ b/src/hooks/useNow.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react';
+import { AppState } from 'react-native';
+
+// heure courante, rafraîchie régulièrement et au retour dans l'app
+export function useNow(intervalMs = 30 * 1000): Date {
+ const [now, setNow] = useState(() => new Date());
+
+ useEffect(() => {
+ const timer = setInterval(() => setNow(new Date()), intervalMs);
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state === 'active') setNow(new Date());
+ });
+ return () => {
+ clearInterval(timer);
+ subscription.remove();
+ };
+ }, [intervalMs]);
+
+ return now;
+}
diff --git a/src/hooks/usePinnedApps.ts b/src/hooks/usePinnedApps.ts
new file mode 100644
index 0000000..9a5b837
--- /dev/null
+++ b/src/hooks/usePinnedApps.ts
@@ -0,0 +1,64 @@
+import { useSyncExternalStore } from 'react';
+
+import { DEFAULT_PINNED_APPS, getEntApp } from '@/src/constants/ent';
+import { storage } from '@/src/utils/storage';
+
+// la 4e place de l'accès rapide ouvre l'ENT
+export const MAX_PINNED_APPS = 3;
+
+// partagé hors de React : l'accueil, l'onglet ENT, le navigateur et les raccourcis de l'icône le lisent
+let pinned: string[] = DEFAULT_PINNED_APPS;
+const listeners = new Set<() => void>();
+
+function emit(next: string[]): void {
+ pinned = next;
+ listeners.forEach((listener) => listener());
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+// null si rien n'est enregistré ; une liste vide reste un choix de l'utilisateur
+function parse(raw: string | null): string[] | null {
+ if (raw === null) return null;
+ try {
+ const value: unknown = JSON.parse(raw);
+ if (!Array.isArray(value)) return null;
+ // ignore les services retirés du catalogue et les doublons
+ const ids = value.filter((id): id is string => getEntApp(id) !== undefined);
+ return [...new Set(ids)].slice(0, MAX_PINNED_APPS);
+ } catch {
+ return null;
+ }
+}
+
+// lu une seule fois, avant le premier rendu (voir app/_layout.tsx)
+export async function loadPinnedApps(): Promise {
+ emit(parse(await storage.get('pinnedApps')) ?? DEFAULT_PINNED_APPS);
+}
+
+export function resetPinnedApps(): void {
+ emit(DEFAULT_PINNED_APPS);
+}
+
+function save(next: string[]): void {
+ emit(next);
+ storage.set('pinnedApps', JSON.stringify(next));
+}
+
+// false si l'accès rapide est déjà complet
+export function togglePinnedApp(id: string): boolean {
+ if (pinned.includes(id)) {
+ save(pinned.filter((pinnedId) => pinnedId !== id));
+ return true;
+ }
+ if (pinned.length >= MAX_PINNED_APPS) return false;
+ save([...pinned, id]);
+ return true;
+}
+
+export function usePinnedApps(): string[] {
+ return useSyncExternalStore(subscribe, () => pinned);
+}
diff --git a/src/hooks/usePulse.ts b/src/hooks/usePulse.ts
new file mode 100644
index 0000000..84d5bce
--- /dev/null
+++ b/src/hooks/usePulse.ts
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+
+import { cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
+
+// opacité qui pulse en boucle, de 1 à min
+export function usePulse(min: number, duration: number) {
+ const opacity = useSharedValue(1);
+
+ useEffect(() => {
+ opacity.set(withRepeat(withTiming(min, { duration }), -1, true));
+ return () => cancelAnimation(opacity);
+ }, [opacity, min, duration]);
+
+ return useAnimatedStyle(() => ({ opacity: opacity.value }));
+}
diff --git a/src/hooks/useRoomsSnapshot.ts b/src/hooks/useRoomsSnapshot.ts
index c9076af..504c83d 100644
--- a/src/hooks/useRoomsSnapshot.ts
+++ b/src/hooks/useRoomsSnapshot.ts
@@ -1,13 +1,14 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { loadRoomsSnapshot } from '@/src/services/rooms';
import type { RoomsSnapshot } from '@/src/types';
+import { MINUTE_MS } from '@/src/utils/date';
import { getParisClock, type ParisClock } from '@/src/utils/rooms';
-// les statuts sont recalculés sans nouvelle requête
-const TICK_MS = 30 * 1000;
+import { useNow } from './useNow';
+
// au-delà, les réservations sont retéléchargées
-const STALE_AFTER_MS = 15 * 60 * 1000;
+const STALE_AFTER_MS = 15 * MINUTE_MS;
// garde-fou : jamais deux actualisations automatiques à moins de 5 s d'écart
const MIN_REFRESH_DELAY_MS = 5 * 1000;
@@ -20,41 +21,47 @@ interface RoomsSnapshotState {
reload: () => void;
}
-export function useRoomsSnapshot(): RoomsSnapshotState {
+// statuts recalculés avec l'heure ; active : ne charge et ne rafraîchit qu'à l'écran
+export function useRoomsSnapshot(active = true): RoomsSnapshotState {
const [snapshot, setSnapshot] = useState(null);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [fetchKey, setFetchKey] = useState(0);
- const [now, setNow] = useState(() => new Date());
+ const now = useNow();
+ // dernière requête lancée : quitter l'onglet n'annule pas celle en cours
+ const requestedKey = useRef(null);
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
useEffect(() => {
- let cancelled = false;
+ if (!active || requestedKey.current === fetchKey) return;
+ const key = fetchKey;
+ requestedKey.current = key;
loadRoomsSnapshot().then((result) => {
- if (cancelled) return;
+ // démonté, ou une requête plus récente a été lancée entre-temps
+ if (!mounted.current || requestedKey.current !== key) return;
if (result) setSnapshot(result);
setFailed(result === null);
setLoading(false);
});
- return () => {
- cancelled = true;
- };
- }, [fetchKey]);
-
- useEffect(() => {
- const timer = setInterval(() => setNow(new Date()), TICK_MS);
- return () => clearInterval(timer);
- }, []);
+ }, [active, fetchKey]);
const clock = getParisClock(now);
const usable = snapshot && snapshot.date === clock.eventDate ? snapshot : null;
- // actualisation automatique
+ // actualisation automatique, seulement quand l'écran est affiché
useEffect(() => {
- if (!snapshot || failed) return;
+ if (!active || !snapshot || failed) return;
const delay = usable ? snapshot.fetchedAt + STALE_AFTER_MS - Date.now() : 0;
const timer = setTimeout(() => setFetchKey((key) => key + 1), Math.max(delay, MIN_REFRESH_DELAY_MS));
return () => clearTimeout(timer);
- }, [snapshot, usable, failed]);
+ }, [active, snapshot, usable, failed]);
function reload() {
setLoading(true);
diff --git a/src/hooks/useTabBarInset.ts b/src/hooks/useTabBarInset.ts
new file mode 100644
index 0000000..e5b6afe
--- /dev/null
+++ b/src/hooks/useTabBarInset.ts
@@ -0,0 +1,9 @@
+import { Platform } from 'react-native';
+
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// marge sous un onglet, que la barre recouvre sur iOS (inutile pour Screen)
+export function useTabBarInset(): number {
+ const insets = useSafeAreaInsets();
+ return Platform.OS === 'ios' ? insets.bottom : 0;
+}
diff --git a/src/hooks/useTemporaryCalendar.ts b/src/hooks/useTemporaryCalendar.ts
new file mode 100644
index 0000000..beaf834
--- /dev/null
+++ b/src/hooks/useTemporaryCalendar.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+import { fetchCalendar } from '@/src/services/edt';
+import type { CalendarEvent } from '@/src/types';
+
+// edt d'un autre code ADE (lien unicenotes://edt/{code}), en lecture seule et jamais mis en cache
+export function useTemporaryCalendar(code: string | null) {
+ const [attempt, setAttempt] = useState(0);
+ const [result, setResult] = useState<{ key: string; events: CalendarEvent[] | null } | null>(null);
+ const key = `${code}:${attempt}`;
+
+ useEffect(() => {
+ if (!code) return;
+ let cancelled = false;
+ fetchCalendar(code).then((events) => {
+ if (!cancelled) setResult({ key: `${code}:${attempt}`, events });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [code, attempt]);
+
+ const loading = code !== null && result?.key !== key;
+ return {
+ events: result?.events ?? [],
+ loading,
+ failed: !loading && result?.events === null,
+ retry: () => setAttempt((n) => n + 1),
+ };
+}
diff --git a/src/services/ade.ts b/src/services/ade.ts
new file mode 100644
index 0000000..4a57418
--- /dev/null
+++ b/src/services/ade.ts
@@ -0,0 +1,106 @@
+import type { AdeProject } from '@/src/types';
+import { withTimeout } from '@/src/utils/network';
+import { storage } from '@/src/utils/storage';
+
+// API ADE : voir .docs/ADE_API.md
+export const ADE_URL = 'https://edtweb.univ-cotedazur.fr';
+
+const PROJECTS_TIMEOUT_MS = 5000;
+
+export interface ProjectSelection {
+ projects: AdeProject[];
+ selected: AdeProject | null;
+ // true si l'année a été choisie à la main dans la config de l'EDT
+ manual: boolean;
+}
+
+// session anonyme : jeton Basic fourni au build (EXPO_PUBLIC_ADE_TOKEN)
+export async function openSession(signal?: AbortSignal): Promise {
+ const res = await fetch(`${ADE_URL}/jsp/webapi?function=connect`, {
+ headers: { Authorization: `Basic ${process.env.EXPO_PUBLIC_ADE_TOKEN ?? ''}` },
+ signal,
+ });
+ if (!res.ok) return null;
+ return (await res.text()).match(/ /)?.[1] ?? null;
+}
+
+// sans réponse, la session expire d'elle-même côté serveur
+export function closeSession(sessionId: string): void {
+ fetch(`${ADE_URL}/jsp/webapi?function=disconnect&sessionId=${sessionId}`).catch(() => {});
+}
+
+export async function adeRequest(
+ sessionId: string,
+ params: Record,
+ signal?: AbortSignal,
+): Promise {
+ const query = Object.entries({ ...params, sessionId })
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
+ .join('&');
+ const res = await fetch(`${ADE_URL}/jsp/webapi?${query}`, { signal });
+ if (!res.ok) throw new Error(`ADE ${res.status}`);
+ return res.text();
+}
+
+async function fetchProjects(signal: AbortSignal): Promise {
+ const sessionId = await openSession(signal);
+ if (!sessionId) return null;
+ try {
+ const xml = await adeRequest(sessionId, { function: 'getProjects', detail: '2' }, signal);
+ const projects = Array.from(xml.matchAll(/ ({
+ id,
+ name,
+ }));
+ return projects.length > 0 ? projects : null;
+ } finally {
+ closeSession(sessionId);
+ }
+}
+
+let projectsRequest: Promise | null = null;
+
+// gardés le temps de la session de l'app, redemandés après un échec
+function getProjects(): Promise {
+ projectsRequest ??= withTimeout(PROJECTS_TIMEOUT_MS, fetchProjects).then((projects) => {
+ if (!projects) projectsRequest = null;
+ return projects;
+ });
+ return projectsRequest;
+}
+
+// année scolaire de septembre à août : 2025 pour octobre 2025 comme pour mars 2026
+function getAcademicYear(date = new Date()): number {
+ return date.getMonth() >= 8 ? date.getFullYear() : date.getFullYear() - 1;
+}
+
+// "2025-2026 PROD" -> 2025
+export function getProjectYear(project: AdeProject): number {
+ const match = project.name.match(/(\d{4})-\d{4}/);
+ return match ? Number(match[1]) : getAcademicYear();
+}
+
+function pickAutoProject(projects: AdeProject[]): AdeProject | null {
+ const year = getAcademicYear();
+ return projects.find((p) => p.name.includes(`${year}-${year + 1}`) && p.name.toLowerCase().includes('prod')) ?? null;
+}
+
+export async function getProjectSelection(): Promise {
+ const [projects, overrideId] = await Promise.all([getProjects(), storage.get('adeProjectOverride')]);
+ if (!projects) return { projects: [], selected: null, manual: false };
+
+ const override = projects.find((p) => p.id === overrideId);
+ if (override) return { projects, selected: override, manual: true };
+ // l'année choisie à la main n'existe plus dans ADE : retour en automatique
+ if (overrideId) storage.remove('adeProjectOverride');
+ return { projects, selected: pickAutoProject(projects), manual: false };
+}
+
+export async function getCurrentProject(): Promise {
+ return (await getProjectSelection()).selected;
+}
+
+// null : choix automatique
+export async function setProjectOverride(id: string | null): Promise {
+ if (id) await storage.set('adeProjectOverride', id);
+ else await storage.remove('adeProjectOverride');
+}
diff --git a/src/services/calendar-cache.ts b/src/services/calendar-cache.ts
new file mode 100644
index 0000000..43fbe13
--- /dev/null
+++ b/src/services/calendar-cache.ts
@@ -0,0 +1,37 @@
+import { File, Paths } from 'expo-file-system';
+
+import type { CalendarEvent } from '@/src/types';
+
+const CACHE_FILE = new File(Paths.document, 'calendar.json');
+
+export interface CachedCalendar {
+ // identifiant ADE auquel appartient l'edt
+ code: string;
+ savedAt: number;
+ events: CalendarEvent[];
+}
+
+// null si le cache appartient à un autre identifiant (ou date d'une version < 3.4)
+export async function readCalendarCache(code: string): Promise {
+ try {
+ const cache = JSON.parse(await CACHE_FILE.text()) as Partial;
+ const valid = cache.code === code && typeof cache.savedAt === 'number' && Array.isArray(cache.events);
+ return valid ? (cache as CachedCalendar) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeCalendarCache(cache: CachedCalendar): void {
+ try {
+ CACHE_FILE.write(JSON.stringify(cache));
+ } catch {
+ // stockage plein : l'edt reste affiché, il ne sera juste pas dispo hors ligne
+ }
+}
+
+export function clearCalendarCache(): void {
+ try {
+ if (CACHE_FILE.exists) CACHE_FILE.delete();
+ } catch {}
+}
diff --git a/src/services/edt.ts b/src/services/edt.ts
index 089bfc4..c00626b 100644
--- a/src/services/edt.ts
+++ b/src/services/edt.ts
@@ -1,273 +1,64 @@
import ICAL from 'ical.js';
-import type { AdeProject, CalendarEvent, EDTResult, NextEvent } from '../types';
+import type { AdeProgram, CalendarEvent } from '@/src/types';
+import { colorFromString } from '@/src/utils/color';
+import { isValidEdtCode } from '@/src/utils/deeplink';
+import { withTimeout } from '@/src/utils/network';
-import { getCalendarFromCache, saveCalendarToFile } from '../utils/calendar';
-import { stringToColour } from '../utils/color';
-import { isValidEdtCode } from '../utils/deeplink';
-import { getAsync, removeAsync, saveAsync } from '../utils/storage';
+import { ADE_URL, getCurrentProject, getProjectYear } from './ade';
-interface ICALComponent {
- getAllSubcomponents(name: string): unknown[];
-}
-
-interface ICALEvent {
- summary: string;
- location: string;
- description: string;
- uid: string;
- startDate: { toJSDate: () => Date };
- endDate: { toJSDate: () => Date };
-}
-
-export const ADE_BASE = 'https://edtweb.univ-cotedazur.fr';
-const ONGOING_THRESHOLD_MS = 15 * 60 * 1000;
-const ADE_PROJECT_OVERRIDE_KEY = 'adeProjectOverride';
+const CALENDAR_TIMEOUT_MS = 10000;
+const SEARCH_TIMEOUT_MS = 5000;
+const SEARCH_URL = 'https://ade-consult.univ-cotedazur.fr/?action=search-vet&term=';
-export const EDT_FETCH_TIMEOUT_MS = 5000;
+// suffixe ADE d'un emploi du temps de cursus (VET)
+export const PROGRAM_SUFFIX = '-VET';
-function getAcademicYearDateRange(projectName?: string): string {
- const match = projectName?.match(/(\d{4})-(\d{4})/);
- let startYear: number;
- if (match) {
- startYear = Number(match[1]);
- } else {
- const now = new Date();
- const month = now.getMonth() + 1;
- const year = now.getFullYear();
- startYear = month >= 9 ? year : year - 1;
- }
- const endYear = startYear + 1;
- return `&firstDate=${startYear}-09-01&lastDate=${endYear}-08-31`;
-}
-
-function getCurrentAcademicYearString(): string {
- const now = new Date();
- const startYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
- return `${startYear}-${startYear + 1}`;
-}
-
-export async function fetchSessionId(): Promise {
+function parseCalendar(ics: string): CalendarEvent[] | null {
try {
- // nouveau mode d'authentification de l'API ADE
- // header Basic avec un token codé en base64
- const authHeader = `Basic ${process.env.EXPO_PUBLIC_ADE_TOKEN ?? ''}`;
-
- const res = await fetch(`${ADE_BASE}/jsp/webapi?function=connect`, {
- method: "GET",
- headers: {
- Authorization: authHeader,
- },
+ const calendar = new ICAL.Component(ICAL.parse(ics));
+ return calendar.getAllSubcomponents('vevent').map((vevent, index) => {
+ const event = new ICAL.Event(vevent);
+ const title = event.summary?.trim() ?? '';
+ return {
+ id: event.uid ?? String(index),
+ title,
+ room: event.location?.trim() ?? '',
+ notes: event.description?.trim() ?? '',
+ color: colorFromString(title),
+ start: { dateTime: event.startDate.toJSDate().toISOString() },
+ end: { dateTime: event.endDate.toJSDate().toISOString() },
+ };
});
-
- if (!res.ok) return null;
- const text = await res.text();
- const match = text.match(/ /);
- return match ? match[1] : null;
} catch {
return null;
}
}
-async function fetchProjects(sessionId: string): Promise {
- try {
- const res = await fetch(
- `${ADE_BASE}/jsp/webapi?function=getProjects&sessionId=${sessionId}&detail=2`,
- );
- if (!res.ok) return [];
- const text = await res.text();
- const projectRegex = / ({ id, name }));
- } catch {
- return [];
- }
+// null si ADE est injoignable ou renvoie autre chose qu'un calendrier
+export async function fetchCalendar(code: string): Promise {
+ if (!isValidEdtCode(code)) return null;
+ const project = await getCurrentProject();
+ if (!project) return null;
+
+ const year = getProjectYear(project);
+ const url =
+ `${ADE_URL}/jsp/custom/modules/plannings/anonymous_cal.jsp?calType=ical` +
+ `&code=${encodeURIComponent(code)}&projectId=${project.id}` +
+ `&firstDate=${year}-09-01&lastDate=${year + 1}-08-31`;
+
+ return withTimeout(CALENDAR_TIMEOUT_MS, async (signal) => {
+ const res = await fetch(url, { signal });
+ return res.ok ? parseCalendar(await res.text()) : null;
+ });
}
-function pickAutoProjectId(projects: AdeProject[]): string | null {
- const yearStr = getCurrentAcademicYearString();
- const match = projects.find(
- (p) => p.name.includes(yearStr) && p.name.toLowerCase().includes('prod'),
- );
- return match?.id ?? null;
-}
-
-export async function disconnectSession(sessionId: string): Promise {
- try {
- await fetch(`${ADE_BASE}/jsp/webapi?function=disconnect&sessionId=${sessionId}`);
- } catch {
- // la session expirera d'elle-même côté serveur
- }
-}
-
-export class EDT {
- private ADE_PROJECT: string | null = null;
- private isOverridden = false;
- private projects: AdeProject[] = [];
- private _ready: Promise;
-
- constructor() {
- this._ready = this.resolveProject();
- }
-
- private async resolveProject(): Promise {
- const sessionId = await fetchSessionId();
- if (!sessionId) return;
- this.projects = await fetchProjects(sessionId);
- await disconnectSession(sessionId);
-
- const override = await getAsync(ADE_PROJECT_OVERRIDE_KEY);
- if (override) {
- const stillExists = this.projects.some((p) => p.id === override);
- if (stillExists) {
- this.ADE_PROJECT = override;
- this.isOverridden = true;
- return;
- }
- // le projet choisi manuellement n'existe plus (nouvelle année ADE), retour en mode auto
- await removeAsync(ADE_PROJECT_OVERRIDE_KEY);
- }
-
- this.ADE_PROJECT = pickAutoProjectId(this.projects);
- this.isOverridden = false;
- }
-
- private waitUntilReady(): Promise {
- return this._ready;
- }
-
- // projet ADE en cours d'utilisation
- async ensureProject(): Promise {
- await this.waitUntilReady();
- if (!this.ADE_PROJECT) {
- this._ready = this.resolveProject();
- await this._ready;
- }
- return this.ADE_PROJECT;
- }
-
- async getProjectSelection(): Promise<{
- projects: AdeProject[];
- selectedId: string | null;
- isOverridden: boolean;
- }> {
- await this.waitUntilReady();
- return { projects: this.projects, selectedId: this.ADE_PROJECT, isOverridden: this.isOverridden };
- }
-
- async setProjectOverride(id: string | null): Promise {
- await this.waitUntilReady();
- if (id === null) {
- await removeAsync(ADE_PROJECT_OVERRIDE_KEY);
- this.ADE_PROJECT = pickAutoProjectId(this.projects);
- this.isOverridden = false;
- return;
- }
- if (!this.projects.some((p) => p.id === id)) return;
- await saveAsync(ADE_PROJECT_OVERRIDE_KEY, id);
- this.ADE_PROJECT = id;
- this.isOverridden = true;
- }
-
- async fetchEDT(adeid: string): Promise {
- const controller = new AbortController();
- let timer: ReturnType | undefined;
- const timeout = new Promise((resolve) => {
- timer = setTimeout(() => {
- controller.abort();
- resolve(null);
- }, EDT_FETCH_TIMEOUT_MS); // ajout d'un timeout parce que ade c'est de la merde
- });
- try {
- return await Promise.race([this.fetchEDTRequest(adeid, controller.signal), timeout]);
- } finally {
- clearTimeout(timer);
- }
- }
-
- private async fetchEDTRequest(adeid: string, signal: AbortSignal): Promise {
- await this.waitUntilReady();
- if (signal.aborted || !this.ADE_PROJECT) return null;
- try {
- const projectName = this.projects.find((p) => p.id === this.ADE_PROJECT)?.name;
- const dateRange = getAcademicYearDateRange(projectName);
- const url =
- `${ADE_BASE}/jsp/custom/modules/plannings/anonymous_cal.jsp` +
- `?code=${encodeURIComponent(adeid)}&projectId=${this.ADE_PROJECT}&calType=ical${dateRange}`;
- const res = await fetch(url, { signal });
- if (!res.ok) return null;
- return await res.text();
- } catch {
- return null;
- }
- }
-
- parseICal(icalData: string): ICALEvent[] {
- try {
- const parsed = ICAL.parse(icalData);
- const comp = new ICAL.Component(parsed) as ICALComponent;
- return comp.getAllSubcomponents('vevent').map((v) => new ICAL.Event(v as ICAL.Component) as ICALEvent);
- } catch {
- return [];
- }
- }
-
- findNextEvent(events: ICALEvent[]): NextEvent {
- const now = new Date();
- const windowStart = new Date(now.getTime() - ONGOING_THRESHOLD_MS);
-
- const sorted = events
- .map((e) => ({
- start: e.startDate.toJSDate(),
- summary: e.summary ?? 'Cours inconnu',
- location: e.location ?? '',
- }))
- .sort((a, b) => a.start.getTime() - b.start.getTime());
-
- const next = sorted.find((e) => e.start >= windowStart);
- if (!next) return { summary: 'Aucun cours', location: 'La chance...' };
- return { summary: next.summary, location: next.location };
- }
-
- async getNextEvent(adeid: string): Promise {
- const icalData = await this.fetchEDT(adeid);
- if (!icalData) {
- return { summary: 'ADE Indisponible', location: 'Impossible de récupérer le cours.' };
- }
- const events = this.parseICal(icalData);
- return this.findNextEvent(events);
- }
-
- private convertToCalendarEvents(events: ICALEvent[]): CalendarEvent[] {
- return events.map((e, index) => ({
- id: e.uid ?? String(index),
- start: { dateTime: e.startDate.toJSDate().toISOString() },
- end: { dateTime: e.endDate.toJSDate().toISOString() },
- title: e.summary ?? '',
- subtitle: e.description ?? '',
- description: e.location ?? '',
- color: stringToColour(e.summary ?? ''),
- }));
- }
-
- // récupère l'edt si possible, sinon retourne l'edt en cache
- async getEDT(adeid: string): Promise {
- if (!adeid || adeid === 'demo') return { events: [], offline: false };
-
- const icalData = await this.fetchEDT(adeid);
- if (!icalData) return { events: await getCalendarFromCache(), offline: true };
- const events = this.parseICal(icalData);
- const calEvents = this.convertToCalendarEvents(events);
- await saveCalendarToFile(calEvents);
- return { events: calEvents, offline: false };
- }
-
- // edt d'un autre code ADE pour un affichage temporaire
- async getTemporaryEDT(code: string): Promise {
- if (!isValidEdtCode(code)) return null;
- const icalData = await this.fetchEDT(code);
- if (!icalData) return null;
- return this.convertToCalendarEvents(this.parseICal(icalData));
- }
+// null en cas d'erreur réseau, [] si rien ne correspond
+export function searchPrograms(term: string): Promise {
+ return withTimeout(SEARCH_TIMEOUT_MS, async (signal) => {
+ const res = await fetch(SEARCH_URL + encodeURIComponent(term), { signal });
+ if (!res.ok) return null;
+ const data = (await res.json()) as { results?: { id: string; text: string }[] };
+ return (data.results ?? []).map(({ id, text }) => ({ id, name: text }));
+ });
}
-
-export const edtService = new EDT();
\ No newline at end of file
diff --git a/src/services/rooms.ts b/src/services/rooms.ts
index 390ffb8..c30165d 100644
--- a/src/services/rooms.ts
+++ b/src/services/rooms.ts
@@ -1,33 +1,18 @@
-import type { AdeRoom, RoomsSnapshot } from '../types';
+import type { AdeRoom, RoomsSnapshot } from '@/src/types';
+import { parseBookings, parseRooms } from '@/src/utils/ade-xml';
+import { withTimeout } from '@/src/utils/network';
+import { getParisClock } from '@/src/utils/rooms';
-import { parseBookings, parseRooms } from '../utils/ade-xml';
-import { getParisClock } from '../utils/rooms';
-import { ADE_BASE, disconnectSession, edtService, fetchSessionId } from './edt';
+import { adeRequest, closeSession, getCurrentProject, openSession } from './ade';
// ADE ne compresse pas ses réponses : les événements d'une journée pèsent environ 2 Mo
-export const ROOMS_FETCH_TIMEOUT_MS = 20000;
+const ROOMS_TIMEOUT_MS = 20000;
-// l'arbre des salles change rarement : gardé en mémoire pour ne pas le retélécharger à chaque rafraîchissement
+// l'arbre des salles change rarement : gardé en mémoire pour ne pas le retélécharger à chaque actualisation
let roomsCache: { projectId: string; rooms: AdeRoom[] } | null = null;
-async function adeRequest(
- sessionId: string,
- params: Record,
- signal: AbortSignal,
-): Promise {
- const query = Object.entries({ ...params, sessionId })
- .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
- .join('&');
- const res = await fetch(`${ADE_BASE}/jsp/webapi?${query}`, { signal });
- if (!res.ok) throw new Error(`ADE ${res.status}`);
- return res.text();
-}
-
-async function fetchSnapshot(signal: AbortSignal): Promise {
- const projectId = await edtService.ensureProject();
- if (!projectId) return null;
-
- const sessionId = await fetchSessionId();
+async function fetchSnapshot(projectId: string, signal: AbortSignal): Promise {
+ const sessionId = await openSession(signal);
if (!sessionId) return null;
try {
@@ -51,35 +36,17 @@ async function fetchSnapshot(signal: AbortSignal): Promise
}
// toutes les réservations du jour en une requête
- const events = await adeRequest(
- sessionId,
- { function: 'getEvents', date: clock.apiDate, detail: '8' },
- signal,
- );
+ const events = await adeRequest(sessionId, { function: 'getEvents', date: clock.apiDate, detail: '8' }, signal);
const bookings = parseBookings(events, clock.eventDate);
return { rooms, bookings, date: clock.eventDate, fetchedAt: Date.now() };
- } catch {
- return null;
} finally {
- void disconnectSession(sessionId);
+ closeSession(sessionId);
}
}
// null si ADE est indisponible ou renvoie des données inexploitables
export async function loadRoomsSnapshot(): Promise {
- const controller = new AbortController();
- let timer: ReturnType | undefined;
- const timeout = new Promise((resolve) => {
- timer = setTimeout(() => {
- controller.abort();
- resolve(null);
- }, ROOMS_FETCH_TIMEOUT_MS);
- });
- try {
- return await Promise.race([fetchSnapshot(controller.signal), timeout]);
- } catch {
- return null;
- } finally {
- clearTimeout(timer);
- }
+ const project = await getCurrentProject();
+ if (!project) return null;
+ return withTimeout(ROOMS_TIMEOUT_MS, (signal) => fetchSnapshot(project.id, signal));
}
diff --git a/src/services/servers.ts b/src/services/servers.ts
new file mode 100644
index 0000000..8eaef52
--- /dev/null
+++ b/src/services/servers.ts
@@ -0,0 +1,47 @@
+import { withTimeout } from '@/src/utils/network';
+
+const CHECK_TIMEOUT_MS = 5000;
+
+export interface Server {
+ name: string;
+ description: string;
+ icon: string;
+ url: string;
+}
+
+export const SERVERS: Server[] = [
+ {
+ name: 'ADE',
+ description: 'Emploi du temps, salles libres',
+ icon: 'calendar-month-outline',
+ url: 'https://edtweb.univ-cotedazur.fr',
+ },
+ {
+ name: 'PronoteCampus',
+ description: 'Notes',
+ icon: 'calculator-variant-outline',
+ url: 'https://sco.polytech.unice.fr/1',
+ },
+ {
+ name: 'Mon Dossier Web',
+ description: 'Scolarité',
+ icon: 'school-outline',
+ url: 'https://mondossierweb.univ-cotedazur.fr',
+ },
+ {
+ name: 'Login UniCA',
+ description: 'Connexion aux services',
+ icon: 'account-key-outline',
+ url: 'https://login.univ-cotedazur.fr',
+ },
+];
+
+// temps de réponse en ms, null si le serveur ne répond pas
+export async function checkServer(url: string): Promise {
+ const start = Date.now();
+ const ok = await withTimeout(CHECK_TIMEOUT_MS, async (signal) => {
+ const res = await fetch(url, { signal });
+ return res.status < 400;
+ });
+ return ok ? Date.now() - start : null;
+}
diff --git a/src/services/uniceapi.ts b/src/services/uniceapi.ts
index 17f153c..9875cd1 100644
--- a/src/services/uniceapi.ts
+++ b/src/services/uniceapi.ts
@@ -1,4 +1,5 @@
-import { API_URL } from '../constants/config';
+import { API_URL } from '@/src/constants/config';
+import { withTimeout } from '@/src/utils/network';
export interface ApiAlert {
title: string;
@@ -6,76 +7,53 @@ export interface ApiAlert {
}
export interface ApiStatus {
+ // dernière version publiée sur les stores
version: string | null;
alert: ApiAlert | null;
}
-const FETCH_TIMEOUT_MS = 2000;
+const STATUS_TIMEOUT_MS = 3000;
-export async function fetchApiStatus(): Promise {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
-
- try {
- const res = await fetch(API_URL, {
- signal: controller.signal,
- headers: { Accept: 'application/json' },
- });
- if (!res.ok) return null;
- return parseApiStatus(await res.json());
- } catch {
- return null;
- } finally {
- clearTimeout(timeout);
- }
+function readString(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : '';
}
-export function parseApiStatus(json: unknown): ApiStatus | null {
+function parseStatus(json: unknown): ApiStatus | null {
if (typeof json !== 'object' || json === null) return null;
const data = json as Record;
+ const alert = (typeof data.alert === 'object' && data.alert !== null ? data.alert : {}) as Record;
+ const title = readString(alert.title);
+ const message = readString(alert.message);
return {
- version: typeof data.version === 'string' && data.version.trim() !== '' ? data.version.trim() : null,
- alert: parseAlert(data.alert),
+ version: readString(data.version) || null,
+ alert: title || message ? { title: title || 'Information', message } : null,
};
}
-function parseAlert(raw: unknown): ApiAlert | null {
- if (typeof raw !== 'object' || raw === null) return null;
- const data = raw as Record;
-
- const title = typeof data.title === 'string' ? data.title.trim() : '';
- const message = typeof data.message === 'string' ? data.message.trim() : '';
- if (title === '' && message === '') return null;
-
- return { title: title || 'Information', message };
+export function fetchApiStatus(): Promise {
+ return withTimeout(STATUS_TIMEOUT_MS, async (signal) => {
+ const res = await fetch(API_URL, { signal, headers: { Accept: 'application/json' } });
+ return res.ok ? parseStatus(await res.json()) : null;
+ });
}
-export function compareVersions(a: string, b: string): number {
- const pa = toNumericParts(a);
- const pb = toNumericParts(b);
- const len = Math.max(pa.length, pb.length);
-
- for (let i = 0; i < len; i++) {
- const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
- if (diff !== 0) return diff;
- }
- return 0;
-}
-
-function toNumericParts(version: string): number[] {
+// "v3.10.0" -> [3, 10, 0]
+function toNumbers(version: string): number[] {
return version
- .trim()
.replace(/^v/i, '')
.split('.')
- .map((part) => {
- const n = parseInt(part, 10);
- return Number.isNaN(n) ? 0 : n;
- });
+ .map((part) => parseInt(part, 10) || 0);
}
-/** true si la version installée est < à la version actuelle */
+// true si la version installée est plus ancienne que la dernière publiée
export function isUpdateAvailable(installed: string, latest: string | null): boolean {
if (!latest) return false;
- return compareVersions(installed, latest) < 0;
+ const a = toNumbers(installed);
+ const b = toNumbers(latest);
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
+ if (diff !== 0) return diff < 0;
+ }
+ return false;
}
diff --git a/src/services/webSession.ts b/src/services/webSession.ts
new file mode 100644
index 0000000..550479d
--- /dev/null
+++ b/src/services/webSession.ts
@@ -0,0 +1,25 @@
+import WebSession from '@/modules/web-session';
+import { withTimeout } from '@/src/utils/network';
+
+// session du navigateur intégré, avec délais : WebKit peut ne jamais répondre
+
+let restoring: Promise | null = null;
+
+// une fois par lancement, avant le premier chargement d'une page
+export function restoreWebSession(): Promise {
+ restoring ??= withTimeout(2000, async () => (WebSession ? WebSession.restoreAsync() : 0)).then(() => undefined);
+ return restoring;
+}
+
+export function persistWebSession(): void {
+ WebSession?.persistAsync().catch((error) => console.warn('[webSession] sauvegarde impossible', error));
+}
+
+// déconnecte tous les services et efface la sauvegarde ; false si le système n'a pas répondu
+export async function clearWebSession(): Promise {
+ const cleared = await withTimeout(3000, async () => {
+ await WebSession?.clearAsync();
+ return true;
+ });
+ return cleared === true;
+}
diff --git a/src/services/widgets.ts b/src/services/widgets.ts
index 5557f5d..1b1a5d2 100644
--- a/src/services/widgets.ts
+++ b/src/services/widgets.ts
@@ -1,118 +1,92 @@
-import { AppState, Platform } from 'react-native';
+import { Platform } from 'react-native';
-import type { CalendarEvent, ClassActivityProps, NextClassWidgetProps, WidgetClass } from '../types';
-import { getCalendarFromCache } from '../utils/calendar';
-import { ClassActivity } from '../widgets/ClassActivity';
-import { NextClassWidgetInstance } from '../widgets/NextClassWidget';
+import type { CalendarEvent, ClassActivityProps, WidgetClass, WidgetTimelineEntry } from '@/src/types';
+import { DAY_MS, MINUTE_MS, formatClock } from '@/src/utils/date';
+import { ClassActivity } from '@/src/widgets/ClassActivity';
+import { NextClassWidgetInstance } from '@/src/widgets/NextClassWidget';
-// plateformes dont le widget est alimenté par l'app
-export const WIDGETS_SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android';
+export const LIVE_ACTIVITIES_SUPPORTED = Platform.OS === 'ios';
+const WIDGETS_SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android';
-// la timeline couvre les 3 prochains jours
-const TIMELINE_SPAN_MS = 3 * 24 * 60 * 60 * 1000;
+// la timeline des widgets couvre les 3 prochains jours
+const TIMELINE_SPAN_MS = 3 * DAY_MS;
// la Live Activity apparaît 15 min avant le cours
-const ACTIVITY_LEAD_MS = 15 * 60 * 1000;
+const ACTIVITY_LEAD_MS = 15 * MINUTE_MS;
-export interface WidgetTimelineEntry {
- date: Date;
- props: NextClassWidgetProps;
-}
+const startOf = (event: CalendarEvent) => new Date(event.start.dateTime);
+const endOf = (event: CalendarEvent) => new Date(event.end.dateTime);
-function formatHour(date: Date): string {
- const pad = (n: number) => String(n).padStart(2, '0');
- return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
+function upcoming(events: CalendarEvent[], now: Date): CalendarEvent[] {
+ return events.filter((e) => endOf(e) > now).sort((a, b) => startOf(a).getTime() - startOf(b).getTime());
}
function toWidgetClass(event: CalendarEvent): WidgetClass {
return {
title: event.title,
- room: event.description, // description = location dans CalendarEvent
- startTime: formatHour(new Date(event.start.dateTime)),
- endTime: formatHour(new Date(event.end.dateTime)),
+ room: event.room,
+ startTime: formatClock(startOf(event)),
+ endTime: formatClock(endOf(event)),
color: event.color,
- endsAt: new Date(event.end.dateTime).getTime(),
+ endsAt: endOf(event).getTime(),
};
}
-export function buildWidgetTimeline(events: CalendarEvent[]): WidgetTimelineEntry[] {
- if (events.length === 0) {
- return [{ date: new Date(), props: { courses: [], configured: false } }];
- }
-
- const now = new Date();
- const cutoff = new Date(now.getTime() + TIMELINE_SPAN_MS);
-
- // Tous les cours futurs triés par heure de début
- const allFuture = events
- .filter((e) => new Date(e.end.dateTime) > now)
- .sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime());
+// une entrée maintenant puis à chaque fin de cours ; events null : edt non configuré
+function buildWidgetTimeline(events: CalendarEvent[] | null, now = new Date()): WidgetTimelineEntry[] {
+ if (!events) return [{ date: now, props: { courses: [], configured: false } }];
- const getCoursesAt = (from: Date): WidgetClass[] =>
- allFuture
- .filter((e) => new Date(e.end.dateTime) > from)
+ const future = upcoming(events, now);
+ const coursesAt = (date: Date) =>
+ future
+ .filter((e) => endOf(e) > date)
.slice(0, 3)
.map(toWidgetClass);
- const seen = new Set([now.getTime()]);
- const timeline: WidgetTimelineEntry[] = [
- { date: now, props: { courses: getCoursesAt(now), configured: true } },
- ];
-
- for (const event of allFuture) {
- const endDate = new Date(event.end.dateTime);
- const ms = endDate.getTime();
- if (endDate <= cutoff && !seen.has(ms)) {
- seen.add(ms);
- timeline.push({ date: endDate, props: { courses: getCoursesAt(endDate), configured: true } });
- }
- }
-
- return timeline;
-}
-
-// ---
-// Live Activity (iOS)
-// ---
-
-export const LIVE_ACTIVITIES_SUPPORTED = Platform.OS === 'ios';
-
-let liveActivitiesEnabled = true;
+ const cutoff = now.getTime() + TIMELINE_SPAN_MS;
+ const ends = future.map((e) => endOf(e).getTime()).filter((ms) => ms > now.getTime() && ms <= cutoff);
-export function setLiveActivitiesEnabled(value: boolean): void {
- liveActivitiesEnabled = value;
+ return [now.getTime(), ...new Set(ends)]
+ .sort((a, b) => a - b)
+ .map((ms) => {
+ const date = new Date(ms);
+ return { date, props: { courses: coursesAt(date), configured: true } };
+ });
}
// cours en cours ou qui commence bientôt, null sinon
function buildClassActivity(events: CalendarEvent[], now = new Date()): ClassActivityProps | null {
- const [current, next] = events
- .filter((e) => new Date(e.end.dateTime) > now)
- .sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime());
- if (!current) return null;
+ const [current, next] = upcoming(events, now);
+ if (!current || startOf(current).getTime() - now.getTime() > ACTIVITY_LEAD_MS) return null;
- const start = new Date(current.start.dateTime);
- const end = new Date(current.end.dateTime);
- if (start.getTime() - now.getTime() > ACTIVITY_LEAD_MS) return null;
-
- const nextStart = next ? new Date(next.start.dateTime) : null;
+ const end = endOf(current);
+ const nextStart = next ? startOf(next) : null;
return {
title: current.title,
- room: current.description,
- start: start.getTime(),
+ room: current.room,
+ start: startOf(current).getTime(),
end: end.getTime(),
color: current.color,
next:
next && nextStart && nextStart.toDateString() === end.toDateString()
- ? { title: next.title, room: next.description, startTime: formatHour(nextStart) }
+ ? { title: next.title, room: next.room, startTime: formatClock(nextStart) }
: undefined,
};
}
-// démarre, met à jour ou arrête la Live Activity selon l'edt
-async function updateClassActivity(events: CalendarEvent[]): Promise {
+export function updateWidgets(events: CalendarEvent[] | null): void {
+ if (!WIDGETS_SUPPORTED) return;
+ try {
+ NextClassWidgetInstance.updateTimeline(buildWidgetTimeline(events));
+ } catch {
+ // widget non installé ou extension indisponible
+ }
+}
+
+// démarre, met à jour ou arrête la Live Activity (events null : l'arrête)
+export async function updateClassActivity(events: CalendarEvent[] | null): Promise {
if (!LIVE_ACTIVITIES_SUPPORTED) return;
try {
- // désactivée dans les paramètres : on arrête celle en cours
- const props = liveActivitiesEnabled ? buildClassActivity(events) : null;
+ const props = events ? buildClassActivity(events) : null;
const [running, ...extra] = ClassActivity.getInstances();
await Promise.all(extra.map((activity) => activity.end('immediate')));
@@ -125,32 +99,6 @@ async function updateClassActivity(events: CalendarEvent[]): Promise {
if (running) await running.update(props, staleDate);
else ClassActivity.start(props, 'unicenotes://edt', staleDate);
} catch {
- // Live Activities désactivées dans les réglages
- }
-}
-
-// aligne la Live Activity sur l'edt en cache
-export function refreshClassActivity(): void {
- if (!LIVE_ACTIVITIES_SUPPORTED) return;
- getCalendarFromCache().then(updateClassActivity);
-}
-
-// au retour dans l'app, la Live Activity suit l'edt en cache
-export function watchClassActivity(): () => void {
- if (!LIVE_ACTIVITIES_SUPPORTED) return () => {};
- const subscription = AppState.addEventListener('change', (state) => {
- if (state === 'active') refreshClassActivity();
- });
- return () => subscription.remove();
-}
-
-// met à jour les widgets avec l'edt de l'utilisateur
-export function updateWidgets(events: CalendarEvent[]): void {
- if (!WIDGETS_SUPPORTED) return;
- try {
- NextClassWidgetInstance.updateTimeline(buildWidgetTimeline(events));
- } catch {
- // widget non installé ou extension indisponible
+ // Live Activities désactivées dans les réglages iOS
}
- updateClassActivity(events);
}
diff --git a/src/theme/index.ts b/src/theme/index.ts
new file mode 100644
index 0000000..f93a050
--- /dev/null
+++ b/src/theme/index.ts
@@ -0,0 +1,177 @@
+import { useSyncExternalStore } from 'react';
+import { useColorScheme } from 'react-native';
+
+import { DarkTheme, DefaultTheme, type Theme } from 'expo-router';
+import { configureFonts, type MD3Theme } from 'react-native-paper';
+
+import type { RoomStatus } from '@/src/types';
+import { storage } from '@/src/utils/storage';
+
+import ambre from './palettes/ambre.json';
+import azur from './palettes/azur.json';
+import lavande from './palettes/lavande.json';
+import menthe from './palettes/menthe.json';
+
+// une palette = un JSON MD3, voir https://oss.callstack.com/react-native-paper/docs/guides/theming
+const PALETTES: unknown[] = [azur, menthe, lavande, ambre];
+
+const DEFAULT_THEME_ID = 'azur';
+
+export type ThemeColors = typeof azur.light;
+
+export interface AppTheme {
+ dark: boolean;
+ version: 3;
+ mode?: 'adaptive';
+ colors: ThemeColors;
+ fonts: MD3Theme['fonts'];
+}
+
+export interface ThemeOption {
+ id: string;
+ label: string;
+ description: string;
+ light: AppTheme;
+ dark: AppTheme;
+}
+
+// la police est chargée avant le premier rendu (voir app/_layout.tsx)
+const fonts = configureFonts({ config: { fontFamily: 'Bahnschrift' } });
+
+// azur sert de référence : une palette doit définir exactement les mêmes clés
+function isValidColors(value: unknown, reference: Record = azur.light): boolean {
+ if (typeof value !== 'object' || value === null) return false;
+ const colors = value as Record;
+ return Object.entries(reference).every(([key, ref]) =>
+ typeof ref === 'object'
+ ? isValidColors(colors[key], ref as Record)
+ : typeof colors[key] === 'string',
+ );
+}
+
+function loadPalette(json: unknown): ThemeOption | null {
+ if (typeof json !== 'object' || json === null) return null;
+ const { id, label, description, light, dark } = json as Record;
+ if (typeof id !== 'string' || typeof label !== 'string') return null;
+ if (!isValidColors(light) || !isValidColors(dark)) return null;
+
+ return {
+ id,
+ label,
+ description: typeof description === 'string' ? description : '',
+ light: { dark: false, version: 3, colors: light as ThemeColors, fonts },
+ dark: { dark: true, version: 3, mode: 'adaptive', colors: dark as ThemeColors, fonts },
+ };
+}
+
+export const themeOptions: ThemeOption[] = PALETTES.flatMap((json) => {
+ const option = loadPalette(json);
+ if (!option) console.warn('[theme] palette invalide ignorée', (json as { id?: unknown })?.id);
+ return option ? [option] : [];
+});
+
+function isThemeId(value: unknown): value is string {
+ return themeOptions.some((option) => option.id === value);
+}
+
+function getTheme(id: string, dark: boolean): AppTheme {
+ const option = themeOptions.find((t) => t.id === id) ?? themeOptions.find((t) => t.id === DEFAULT_THEME_ID)!;
+ return dark ? option.dark : option.light;
+}
+
+// thème choisi, partagé hors de React pour être lu avant le premier rendu
+
+let currentId = DEFAULT_THEME_ID;
+const listeners = new Set<() => void>();
+
+function emit(id: string): void {
+ currentId = id;
+ listeners.forEach((listener) => listener());
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export async function loadThemePreference(): Promise {
+ const stored = await storage.get('theme');
+ emit(isThemeId(stored) ? stored : DEFAULT_THEME_ID);
+}
+
+export function setThemeId(id: string): void {
+ if (!isThemeId(id)) return;
+ emit(id);
+ storage.set('theme', id);
+}
+
+export function resetTheme(): void {
+ emit(DEFAULT_THEME_ID);
+}
+
+export function useThemeId(): string {
+ return useSyncExternalStore(subscribe, () => currentId);
+}
+
+// suit le thème choisi et le mode clair / sombre du système
+export function useAppTheme(): AppTheme {
+ const scheme = useColorScheme();
+ return getTheme(useThemeId(), scheme === 'dark');
+}
+
+// couleurs dérivées
+
+export type Tone = 'primary' | 'secondary' | 'tertiary' | 'error';
+
+export function getToneColors(theme: AppTheme, tone: Tone) {
+ const c = theme.colors;
+ switch (tone) {
+ case 'secondary':
+ return {
+ container: c.secondaryContainer,
+ onContainer: c.onSecondaryContainer,
+ accent: c.secondary,
+ onAccent: c.onSecondary,
+ };
+ case 'tertiary':
+ return {
+ container: c.tertiaryContainer,
+ onContainer: c.onTertiaryContainer,
+ accent: c.tertiary,
+ onAccent: c.onTertiary,
+ };
+ case 'error':
+ return { container: c.errorContainer, onContainer: c.onErrorContainer, accent: c.error, onAccent: c.onError };
+ default:
+ return {
+ container: c.primaryContainer,
+ onContainer: c.onPrimaryContainer,
+ accent: c.primary,
+ onAccent: c.onPrimary,
+ };
+ }
+}
+
+// pastilles vert / orange / rouge (salles, serveurs) : le thème Material n'en a pas
+export function getStatusColors(theme: AppTheme): Record {
+ return theme.dark
+ ? { free: '#5BC98A', tight: '#F5B041', busy: '#FF7B72' }
+ : { free: '#1E8E4E', tight: '#D97706', busy: '#D93025' };
+}
+
+// fond du Stack (visible pendant les gestes de retour) : sinon React Navigation reste en clair
+export function getNavigationTheme(theme: AppTheme): Theme {
+ const base = theme.dark ? DarkTheme : DefaultTheme;
+ return {
+ ...base,
+ colors: {
+ ...base.colors,
+ primary: theme.colors.primary,
+ background: theme.colors.background,
+ card: theme.colors.surface,
+ text: theme.colors.onBackground,
+ border: theme.colors.outlineVariant,
+ notification: theme.colors.error,
+ },
+ };
+}
diff --git a/src/assets/themes/palettes/ambre.json b/src/theme/palettes/ambre.json
similarity index 100%
rename from src/assets/themes/palettes/ambre.json
rename to src/theme/palettes/ambre.json
diff --git a/src/assets/themes/palettes/azur.json b/src/theme/palettes/azur.json
similarity index 100%
rename from src/assets/themes/palettes/azur.json
rename to src/theme/palettes/azur.json
diff --git a/src/assets/themes/palettes/lavande.json b/src/theme/palettes/lavande.json
similarity index 100%
rename from src/assets/themes/palettes/lavande.json
rename to src/theme/palettes/lavande.json
diff --git a/src/assets/themes/palettes/menthe.json b/src/theme/palettes/menthe.json
similarity index 100%
rename from src/assets/themes/palettes/menthe.json
rename to src/theme/palettes/menthe.json
diff --git a/src/types/index.ts b/src/types/index.ts
index ef3fa0e..85727d4 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,33 +1,30 @@
+// emploi du temps
+
+// start / end au format attendu par calendar-kit
export interface CalendarEvent {
id: string;
- start: { dateTime: string };
- end: { dateTime: string };
title: string;
- subtitle: string;
- description: string;
+ room: string;
+ // description ADE : groupes, enseignants…
+ notes: string;
color: string;
+ start: { dateTime: string };
+ end: { dateTime: string };
}
-export interface EDTResult {
- events: CalendarEvent[];
- // true si les événements proviennent du cache
- offline: boolean;
-}
-
-export interface SearchResult {
+// projet ADE, un par année scolaire
+export interface AdeProject {
id: string;
- text: string;
+ name: string;
}
-export interface AdeProject {
+// cursus trouvé par la recherche ADE
+export interface AdeProgram {
id: string;
name: string;
}
-export interface NextEvent {
- summary: string;
- location: string;
-}
+// widgets et Live Activity
export interface WidgetClass {
title: string;
@@ -44,6 +41,11 @@ export interface NextClassWidgetProps {
configured?: boolean;
}
+export interface WidgetTimelineEntry {
+ date: Date;
+ props: NextClassWidgetProps;
+}
+
export interface ClassActivityProps {
title: string;
room: string;
@@ -55,9 +57,7 @@ export interface ClassActivityProps {
next?: { title: string; room: string; startTime: string };
}
-// ---
-// Salles libres
-// ---
+// salles libres
export interface AdeRoom {
id: string;
@@ -101,11 +101,7 @@ export interface RoomAvailability {
freeUntil: number | null;
}
-export interface RoomStatusCounts {
- free: number;
- tight: number;
- busy: number;
-}
+export type RoomStatusCounts = Record;
export interface RoomEntry {
room: AdeRoom;
@@ -119,12 +115,3 @@ export interface RoomNodeView {
children: RoomNodeView[];
rooms: RoomEntry[];
}
-
-export type HapticIntensity =
- | 'light'
- | 'medium'
- | 'heavy'
- | 'error'
- | 'success'
- | 'warning'
- | 'selection';
diff --git a/src/utils/ade-xml.ts b/src/utils/ade-xml.ts
index 356be64..678fdf9 100644
--- a/src/utils/ade-xml.ts
+++ b/src/utils/ade-xml.ts
@@ -1,17 +1,17 @@
-import type { AdeRoom, RoomBooking } from '../types';
+import type { AdeRoom, RoomBooking } from '@/src/types';
const TAG_BODY = '((?:"[^"]*"|[^>"])*)';
const ROOM_TAG = new RegExp(``, 'g');
const EVENT_TAG = new RegExp(``, 'g');
const RESOURCE_TAG = new RegExp(``, 'g');
-// Entrées de l'arbre ADE qui ne sont pas des salles utilisables : corbeille, cours en ligne,
+// entrées de l'arbre ADE qui ne sont pas des salles utilisables : corbeille, cours en ligne…
const NOT_A_ROOM =
/corbeille|virtuel|distanciel|en ligne|online|zoom|[àa] distance|[àa] attribuer|r[ée]serve|\(test\)|ext[ée]rieur|hors campus/i;
const XML_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
-export function decodeXmlEntities(value: string): string {
+function decodeXmlEntities(value: string): string {
if (!value.includes('&')) return value;
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (match, entity: string) => {
if (entity[0] !== '#') return XML_ENTITIES[entity.toLowerCase()] ?? match;
@@ -46,8 +46,7 @@ function parseHour(value: string | undefined): number | null {
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
-// getResources&category=classroom&detail=4 : ADE renvoie aussi les dossiers (campus, bâtiments)
-// comme des , seul isGroup="true" (detail >= 4) les distingue des vraies salles
+// isGroup="true" (detail >= 4) distingue les dossiers (campus, bâtiments) des vraies salles
export function parseRooms(xml: string): AdeRoom[] {
assertNoAdeError(xml);
const rooms: AdeRoom[] = [];
@@ -63,8 +62,7 @@ export function parseRooms(xml: string): AdeRoom[] {
return rooms;
}
-// getEvents&date=…&detail=8 : une réservation par salle (classroom) d'un événement.
-// `date` est celle des événements, au format JJ/MM/AAAA.
+// une réservation par salle d'un événement ; date au format JJ/MM/AAAA
export function parseBookings(xml: string, date: string): RoomBooking[] {
assertNoAdeError(xml);
const bookings: RoomBooking[] = [];
@@ -95,7 +93,7 @@ export function parseBookings(xml: string, date: string): RoomBooking[] {
}
}
- // ADE renvoie parfois des vides
+ // ADE renvoie parfois des vides
if (events > 0 && usable === 0) throw new Error('Événements ADE incomplets');
return bookings;
}
diff --git a/src/utils/agenda.ts b/src/utils/agenda.ts
new file mode 100644
index 0000000..1f41c88
--- /dev/null
+++ b/src/utils/agenda.ts
@@ -0,0 +1,79 @@
+import type { CalendarEvent } from '@/src/types';
+
+import { daysBetween, formatClock, formatDay, formatDuration, minutesUntil } from './date';
+
+export interface AgendaClass {
+ id: string;
+ title: string;
+ room: string;
+ color: string;
+ start: Date;
+ end: Date;
+}
+
+export interface Agenda {
+ // cours en cours, sinon le prochain
+ next: AgendaClass | null;
+ // cours suivants, le même jour que next
+ later: AgendaClass[];
+ // fin du dernier cours de ce jour-là
+ dayEnd: Date | null;
+}
+
+export interface ClassStatus {
+ ongoing: boolean;
+ // « En cours », « Dans 25 min », « Demain »…
+ label: string;
+ // cours en cours : avancement (0 à 1) et temps restant
+ progress: number;
+ remaining: string;
+}
+
+// "08:00 – 10:00 · 2 h"
+export function formatSchedule(item: { start: Date; end: Date }): string {
+ const length = Math.round((item.end.getTime() - item.start.getTime()) / 60000);
+ return `${formatClock(item.start)} – ${formatClock(item.end)} · ${formatDuration(length)}`;
+}
+
+// cours valides, triés par heure de début
+export function toAgendaClasses(events: CalendarEvent[]): AgendaClass[] {
+ return events
+ .map((event) => ({
+ id: event.id,
+ title: event.title || 'Cours sans titre',
+ room: event.room,
+ color: event.color,
+ start: new Date(event.start.dateTime),
+ end: new Date(event.end.dateTime),
+ }))
+ .filter((item) => !Number.isNaN(item.start.getTime()) && !Number.isNaN(item.end.getTime()))
+ .sort((a, b) => a.start.getTime() - b.start.getTime());
+}
+
+// classes : triées par début
+export function buildAgenda(classes: AgendaClass[], now: Date): Agenda {
+ const [next = null, ...rest] = classes.filter((item) => item.end > now);
+ if (!next) return { next: null, later: [], dayEnd: null };
+
+ const later = rest.filter((item) => daysBetween(next.start, item.start) === 0);
+ const dayEnd = later.reduce((end, item) => (item.end > end ? item.end : end), next.end);
+ return { next, later, dayEnd };
+}
+
+export function getClassStatus(item: AgendaClass, now: Date): ClassStatus {
+ if (item.start <= now) {
+ const total = item.end.getTime() - item.start.getTime();
+ return {
+ ongoing: true,
+ label: 'En cours',
+ progress: total > 0 ? Math.min(1, (now.getTime() - item.start.getTime()) / total) : 1,
+ remaining: `encore ${formatDuration(minutesUntil(now, item.end))}`,
+ };
+ }
+
+ const label =
+ daysBetween(now, item.start) === 0
+ ? `Dans ${formatDuration(minutesUntil(now, item.start))}`
+ : formatDay(item.start, now);
+ return { ongoing: false, label, progress: 0, remaining: '' };
+}
diff --git a/src/utils/api.ts b/src/utils/api.ts
deleted file mode 100644
index 2b5c395..0000000
--- a/src/utils/api.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import * as WebBrowser from 'expo-web-browser';
-import { haptics } from './haptics';
-
-export async function handleURL(url: string): Promise {
- haptics('selection');
- await WebBrowser.openBrowserAsync(url);
-}
\ No newline at end of file
diff --git a/src/utils/browser.ts b/src/utils/browser.ts
new file mode 100644
index 0000000..8ddf60e
--- /dev/null
+++ b/src/utils/browser.ts
@@ -0,0 +1,21 @@
+import { router } from 'expo-router';
+import * as WebBrowser from 'expo-web-browser';
+
+import { haptics } from './haptics';
+
+// pages externes (aide, mentions…) : navigateur du système, sans quitter l'app
+export async function openURL(url: string): Promise {
+ haptics('selection');
+ await WebBrowser.openBrowserAsync(url);
+}
+
+// service de l'ENT : navigateur de l'app, dans l'onglet ENT
+export function openEntApp(id: string): void {
+ haptics('light');
+ router.push({ pathname: '/ent/browser', params: { app: id } });
+}
+
+// pages et documents affichés par la WebView ; le reste (mailto:, tel:, msteams:…) part vers le système
+export function isWebViewUrl(url: string): boolean {
+ return /^(https?|about|data|blob|javascript):/i.test(url);
+}
diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts
deleted file mode 100644
index cf5f128..0000000
--- a/src/utils/calendar.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { File, Paths } from 'expo-file-system';
-import type { CalendarEvent } from '../types';
-
-const CALENDAR_FILE = new File(Paths.document, 'calendar.json');
-
-export async function getCalendarFromCache(): Promise {
- try {
- const json = await CALENDAR_FILE.text();
- return JSON.parse(json) as CalendarEvent[];
- } catch {
- return [];
- }
-}
-
-export async function saveCalendarToFile(data: CalendarEvent[]): Promise {
- try {
- CALENDAR_FILE.write(JSON.stringify(data));
- } catch {}
-}
diff --git a/src/utils/color.ts b/src/utils/color.ts
index 7869958..f3e3d7e 100644
--- a/src/utils/color.ts
+++ b/src/utils/color.ts
@@ -1,135 +1,43 @@
-interface ColorComponents {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-function parseColor(d: string): ColorComponents | null {
- const n = d.length;
- let r: number, g: number, b: number, a: number;
-
- if (n > 9) {
- const parts = d.split(',');
- if (parts.length < 3 || parts.length > 4) return null;
- r = parseInt(parts[0][3] === 'a' ? parts[0].slice(5) : parts[0].slice(4));
- g = parseInt(parts[1]);
- b = parseInt(parts[2]);
- a = parts[3] ? parseFloat(parts[3]) : -1;
- } else {
- if (n === 8 || n === 6 || n < 4) return null;
- let hex = d;
- if (n < 6)
- hex =
- '#' +
- d[1] +
- d[1] +
- d[2] +
- d[2] +
- d[3] +
- d[3] +
- (n > 4 ? d[4] + d[4] : '');
- const num = parseInt(hex.slice(1), 16);
- if (n === 9 || n === 5) {
- r = (num >> 24) & 255;
- g = (num >> 16) & 255;
- b = (num >> 8) & 255;
- a = Math.round((num & 255) / 0.255) / 1000;
- } else {
- r = num >> 16;
- g = (num >> 8) & 255;
- b = num & 255;
- a = -1;
- }
+type Rgb = [r: number, g: number, b: number];
+
+// "#00629f" ou "rgb(0, 98, 159)"
+function parseRgb(color: string): Rgb | null {
+ const hex = color.match(/^#([0-9a-f]{6})$/i);
+ if (hex) {
+ const n = parseInt(hex[1], 16);
+ return [n >> 16, (n >> 8) & 255, n & 255];
}
- return { r, g, b, a };
+ const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null;
}
-export function pSBC(
- p: number,
- c0: string,
- c1?: string,
- l?: boolean,
-): string | null {
- if (
- typeof p !== 'number' ||
- p < -1 ||
- p > 1 ||
- typeof c0 !== 'string' ||
- (c0[0] !== 'r' && c0[0] !== '#') ||
- (c1 && typeof c1 !== 'string')
- )
- return null;
-
- const isHex = c0.length > 9;
- const useHex = c1
- ? c1.length > 9
- ? true
- : c1 === 'c'
- ? !isHex
- : false
- : isHex;
- const f = parseColor(c0);
- const P = p < 0;
- const t =
- c1 && c1 !== 'c'
- ? parseColor(c1)
- : P
- ? { r: 0, g: 0, b: 0, a: -1 }
- : { r: 255, g: 255, b: 255, a: -1 };
- const pAbs = P ? -p : p;
- const P1 = 1 - pAbs;
-
- if (!f || !t) return null;
-
- let r: number, g: number, b: number;
- if (l) {
- r = Math.round(P1 * f.r + pAbs * t.r);
- g = Math.round(P1 * f.g + pAbs * t.g);
- b = Math.round(P1 * f.b + pAbs * t.b);
- } else {
- r = Math.round(Math.pow(P1 * Math.pow(f.r, 2) + pAbs * Math.pow(t.r, 2), 0.5));
- g = Math.round(Math.pow(P1 * Math.pow(f.g, 2) + pAbs * Math.pow(t.g, 2), 0.5));
- b = Math.round(Math.pow(P1 * Math.pow(f.b, 2) + pAbs * Math.pow(t.b, 2), 0.5));
- }
+function toHex([r, g, b]: Rgb): string {
+ return '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
+}
- const fa = f.a;
- const ta = t.a;
- const hasAlpha = fa >= 0 || ta >= 0;
- const a = hasAlpha
- ? fa < 0
- ? ta
- : ta < 0
- ? fa
- : fa * P1 + ta * pAbs
- : 0;
+// "rgb(0, 98, 159)" -> "rgba(0, 98, 159, 0.12)"
+export function withAlpha(color: string, alpha: number): string {
+ const rgb = parseRgb(color);
+ return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` : color;
+}
- if (useHex) {
- return `rgb${hasAlpha ? 'a' : ''}(${r},${g},${b}${hasAlpha ? ',' + Math.round(a * 1000) / 1000 : ''})`;
- }
- return (
- '#' +
- (
- 4294967296 +
- r * 16777216 +
- g * 65536 +
- b * 256 +
- (hasAlpha ? Math.round(a * 255) : 0)
- )
- .toString(16)
- .slice(1, hasAlpha ? undefined : -2)
- );
+// texte lisible (sombre ou blanc) sur un fond de cette couleur
+export function readableOn(color: string): string {
+ const rgb = parseRgb(color);
+ if (!rgb) return '#1B1B1F';
+ const [r, g, b] = rgb.map((c) => {
+ const s = c / 255;
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? '#1B1B1F' : '#FFFFFF';
}
-export function stringToColour(str: string): string {
+// couleur stable d'un cours d'après son nom, éclaircie de 25 % vers le blanc
+export function colorFromString(text: string): string {
let hash = 0;
- for (let i = 0; i < str.length; i++) {
- hash = str.charCodeAt(i) + ((hash << 5) - hash);
- }
- let colour = '#';
- for (let i = 0; i < 3; i++) {
- const value = (hash >> (i * 8)) & 0xff;
- colour += ('00' + value.toString(16)).slice(-2);
+ for (let i = 0; i < text.length; i++) {
+ hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
- return pSBC(0.25, colour) ?? colour;
+ const channels = [0, 1, 2].map((i) => (hash >> (i * 8)) & 0xff);
+ return toHex(channels.map((c) => Math.round(Math.sqrt(0.75 * c * c + 0.25 * 255 * 255))) as Rgb);
}
diff --git a/src/utils/date.ts b/src/utils/date.ts
new file mode 100644
index 0000000..2a6b7ce
--- /dev/null
+++ b/src/utils/date.ts
@@ -0,0 +1,89 @@
+export const MINUTE_MS = 60 * 1000;
+export const DAY_MS = 24 * 60 * MINUTE_MS;
+
+const DAYS = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
+const MONTHS = [
+ 'janvier',
+ 'février',
+ 'mars',
+ 'avril',
+ 'mai',
+ 'juin',
+ 'juillet',
+ 'août',
+ 'septembre',
+ 'octobre',
+ 'novembre',
+ 'décembre',
+];
+
+const pad = (n: number) => String(n).padStart(2, '0');
+
+export function capitalize(text: string): string {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+// jours calendaires entre deux dates (0 = même jour)
+export function daysBetween(from: Date, to: Date): number {
+ const a = new Date(from.getFullYear(), from.getMonth(), from.getDate());
+ const b = new Date(to.getFullYear(), to.getMonth(), to.getDate());
+ return Math.round((b.getTime() - a.getTime()) / DAY_MS);
+}
+
+// minutes entamées, au moins 1
+export function minutesUntil(from: Date, to: Date): number {
+ return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / MINUTE_MS));
+}
+
+// numéro de semaine ISO, comme dans l'emploi du temps
+export function getIsoWeek(date: Date): number {
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+ const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
+ return Math.ceil(((d.getTime() - yearStart) / DAY_MS + 1) / 7);
+}
+
+// 845 -> "14:05"
+export function formatTime(minutes: number): string {
+ return `${pad(Math.floor(minutes / 60) % 24)}:${pad(minutes % 60)}`;
+}
+
+// "14:05"
+export function formatClock(date: Date): string {
+ return formatTime(date.getHours() * 60 + date.getMinutes());
+}
+
+// 30 -> "30 min", 60 -> "1 h", 90 -> "1 h 30"
+export function formatDuration(minutes: number): string {
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (h === 0) return `${m} min`;
+ return m === 0 ? `${h} h` : `${h} h ${pad(m)}`;
+}
+
+// "Jeudi 1er octobre"
+export function formatLongDate(date: Date): string {
+ const day = date.getDate();
+ return capitalize(`${DAYS[date.getDay()]} ${day === 1 ? '1er' : day} ${MONTHS[date.getMonth()]}`);
+}
+
+// "Octobre 2026"
+export function formatMonth(date: Date): string {
+ return capitalize(`${MONTHS[date.getMonth()]} ${date.getFullYear()}`);
+}
+
+// « Aujourd'hui », « Demain », « Lundi », « Lundi 13 octobre »
+export function formatDay(date: Date, now: Date): string {
+ const days = daysBetween(now, date);
+ if (days === 0) return "Aujourd'hui";
+ if (days === 1) return 'Demain';
+ if (days > 1 && days < 7) return capitalize(DAYS[date.getDay()]);
+ return formatLongDate(date);
+}
+
+// « à 18:02 », « le 30/09 à 18:02 »
+export function formatSyncTime(ms: number, now: Date): string {
+ const date = new Date(ms);
+ if (daysBetween(date, now) === 0) return `à ${formatClock(date)}`;
+ return `le ${pad(date.getDate())}/${pad(date.getMonth() + 1)} à ${formatClock(date)}`;
+}
diff --git a/src/utils/deeplink.ts b/src/utils/deeplink.ts
index a4f5b00..ca57214 100644
--- a/src/utils/deeplink.ts
+++ b/src/utils/deeplink.ts
@@ -1,22 +1,22 @@
-// Liens profonds : unicenotes://notes, unicenotes://ent, unicenotes://edt et unicenotes://edt/{code}
+// liens unicenotes:// : notes, ent, app/{id}, edt et edt/{code}
export type DeepLink =
- | { kind: 'notes' }
- | { kind: 'ent' }
- | { kind: 'edt'; code?: string };
+ { kind: 'notes' } | { kind: 'ent' } | { kind: 'app'; id: string } | { kind: 'edt'; code?: string };
-export const EDT_CODE_MAX_LENGTH = 32;
+const EDT_CODE_MAX_LENGTH = 32;
// code ADE
const EDT_CODE_PATTERN = new RegExp(`^[A-Za-z0-9_-]{1,${EDT_CODE_MAX_LENGTH}}$`);
+// identifiant d'un service de l'ENT (src/constants/ent.ts)
+const APP_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
export function isValidEdtCode(code: unknown): code is string {
return typeof code === 'string' && EDT_CODE_PATTERN.test(code);
}
// ni query string ni fragment ni segment en trop, un "/" final est toléré
-const LINK_PATTERN = /^unicenotes:\/\/(notes|ent|edt)(?:\/([^/?#]+))?\/?$/i;
-const RESERVED_HOST_PATTERN = /^unicenotes:\/\/(notes|ent|edt)(?:[/?#]|$)/i;
+const LINK_PATTERN = /^unicenotes:\/\/(notes|ent|edt|app)(?:\/([^/?#]+))?\/?$/i;
+const RESERVED_HOST_PATTERN = /^unicenotes:\/\/(notes|ent|edt|app)(?:[/?#]|$)/i;
export function parseDeepLink(url: unknown): DeepLink | null {
if (typeof url !== 'string') return null;
@@ -30,6 +30,9 @@ export function parseDeepLink(url: unknown): DeepLink | null {
if (segment === undefined) return { kind: 'edt' };
return isValidEdtCode(segment) ? { kind: 'edt', code: segment } : null;
}
+ if (host === 'app') {
+ return segment !== undefined && APP_ID_PATTERN.test(segment) ? { kind: 'app', id: segment } : null;
+ }
if (segment !== undefined) return null;
return host === 'notes' ? { kind: 'notes' } : { kind: 'ent' };
}
diff --git a/src/utils/haptics.ts b/src/utils/haptics.ts
index 6355605..2bd00e1 100644
--- a/src/utils/haptics.ts
+++ b/src/utils/haptics.ts
@@ -1,14 +1,16 @@
import * as Haptics from 'expo-haptics';
-import type { HapticIntensity } from '../types';
-let hapticsEnabled = true;
+export type HapticIntensity = 'light' | 'medium' | 'heavy' | 'selection' | 'success' | 'warning' | 'error';
+
+let enabled = true;
export function setHapticsEnabled(value: boolean): void {
- hapticsEnabled = value;
+ enabled = value;
}
+// conventions d'usage : voir .docs/HAPTICS.md
export function haptics(intensity: HapticIntensity): void {
- if (!hapticsEnabled) return;
+ if (!enabled) return;
switch (intensity) {
case 'light':
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@@ -19,8 +21,8 @@ export function haptics(intensity: HapticIntensity): void {
case 'heavy':
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
break;
- case 'error':
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ case 'selection':
+ Haptics.selectionAsync();
break;
case 'success':
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -28,8 +30,8 @@ export function haptics(intensity: HapticIntensity): void {
case 'warning':
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
break;
- case 'selection':
- Haptics.selectionAsync();
+ case 'error':
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
break;
}
}
diff --git a/src/utils/network.ts b/src/utils/network.ts
new file mode 100644
index 0000000..835c9fe
--- /dev/null
+++ b/src/utils/network.ts
@@ -0,0 +1,12 @@
+// null si la requête échoue ou dépasse le délai (ADE ne répond parfois jamais)
+export function withTimeout(ms: number, run: (signal: AbortSignal) => Promise): Promise {
+ const controller = new AbortController();
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => {
+ controller.abort();
+ resolve(null);
+ }, ms);
+ });
+ return Promise.race([run(controller.signal).catch(() => null), timeout]).finally(() => clearTimeout(timer));
+}
diff --git a/src/utils/rooms.ts b/src/utils/rooms.ts
index 105dd63..6e8383d 100644
--- a/src/utils/rooms.ts
+++ b/src/utils/rooms.ts
@@ -7,24 +7,23 @@ import type {
RoomNodeView,
RoomStatus,
RoomStatusCounts,
-} from '../types';
+} from '@/src/types';
-// Durées proposées
+import { formatDuration, formatTime } from './date';
+
+// durées proposées
export const DURATION_OPTIONS = [30, 60, 90, 120, 180, 240];
-// Marge exigée en plus de la durée voulue pour qu'une salle soit « libre » (sinon « juste »)
+// marge exigée en plus de la durée voulue pour qu'une salle soit « libre » (sinon « juste »)
export const ROOM_MARGIN_MIN = 30;
-// Deux réservations séparées d'au plus ce délai forment un seul bloc
+// deux réservations séparées d'au plus ce délai forment un seul bloc
const CHANGEOVER_MIN = 15;
const OTHER_ROOMS = 'Autres salles';
const KEY_SEPARATOR = '\u001f';
-// ---
-// Heure de Paris : ADE écrit les heures des événements dans le fuseau du campus
-// ---
-
+// heure de Paris : ADE écrit les heures des événements dans le fuseau du campus
export interface ParisClock {
// minutes depuis minuit
minutes: number;
@@ -66,23 +65,7 @@ export function getParisClock(now: Date = new Date()): ParisClock {
};
}
-// 845 -> "14:05"
-export function formatTime(minutes: number): string {
- const pad = (n: number) => String(n).padStart(2, '0');
- return `${pad(Math.floor(minutes / 60) % 24)}:${pad(minutes % 60)}`;
-}
-
-// 30 -> "30 min", 60 -> "1 h", 90 -> "1 h 30"
-export function formatDuration(minutes: number): string {
- const h = Math.floor(minutes / 60);
- const m = minutes % 60;
- if (h === 0) return `${m} min`;
- return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
-}
-
-// ---
-// Arbre campus -> bâtiment -> sous-bâtiment -> salle, d'après le chemin ADE
-// ---
+// arbre campus -> bâtiment -> sous-bâtiment -> salle, d'après le chemin ADE
function compareNames(a: string, b: string): number {
return a.localeCompare(b, 'fr', { numeric: true, sensitivity: 'base' });
@@ -121,10 +104,6 @@ export function buildRoomTree(rooms: AdeRoom[]): RoomNode[] {
return roots;
}
-// ---
-// Disponibilité
-// ---
-
export function groupBookingsByRoom(bookings: RoomBooking[]): Map {
const byRoom = new Map();
for (const booking of bookings) {
@@ -156,13 +135,8 @@ function mergeBookings(sorted: RoomBooking[]): Block[] {
return blocks;
}
-// bookings : réservations d'une seule salle, triées par début.
-// now : minutes depuis minuit. duration : durée voulue, en minutes.
-export function getRoomAvailability(
- bookings: RoomBooking[],
- now: number,
- duration: number,
-): RoomAvailability {
+// bookings d'une seule salle, triées par début ; now (depuis minuit) et duration en minutes
+export function getRoomAvailability(bookings: RoomBooking[], now: number, duration: number): RoomAvailability {
const blocks = mergeBookings(bookings);
const currentIndex = blocks.findIndex((b) => b.start <= now && now < b.end);
@@ -210,7 +184,7 @@ export function describeAvailability(
}
const detail = booking ? `Ensuite : ${booking.title}` : null;
- if (freeUntil === null) return { headline: 'Libre jusqu\'à la fin de la journée', detail };
+ if (freeUntil === null) return { headline: "Libre jusqu'à la fin de la journée", detail };
const window = freeUntil - now;
if (status === 'tight' && window < duration) {
@@ -225,9 +199,7 @@ export function describeAvailability(
};
}
-// ---
-// Vue affichée : arbre trié par disponibilité, avec le décompte de chaque niveau
-// ---
+// vue affichée : arbre trié par disponibilité, avec le décompte de chaque niveau
const STATUS_RANK: Record = { free: 0, tight: 1, busy: 2 };
@@ -238,9 +210,7 @@ function compareEntries(a: RoomEntry, b: RoomEntry): number {
if (x.status !== y.status) return STATUS_RANK[x.status] - STATUS_RANK[y.status];
const diff =
- x.status === 'busy'
- ? (x.freeFrom ?? 0) - (y.freeFrom ?? 0)
- : (y.freeUntil ?? Infinity) - (x.freeUntil ?? Infinity);
+ x.status === 'busy' ? (x.freeFrom ?? 0) - (y.freeFrom ?? 0) : (y.freeUntil ?? Infinity) - (x.freeUntil ?? Infinity);
return diff || compareNames(a.room.name, b.room.name);
}
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
index 9709bbb..4f60c3f 100644
--- a/src/utils/storage.ts
+++ b/src/utils/storage.ts
@@ -1,30 +1,30 @@
-import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as SecureStore from 'expo-secure-store';
-export async function saveSecure(key: string, value: string): Promise {
- await SecureStore.setItemAsync(key, value);
-}
-
-export async function getSecure(key: string): Promise {
- return SecureStore.getItemAsync(key);
-}
-
-export async function deleteSecure(key: string): Promise {
- await SecureStore.deleteItemAsync(key);
-}
-
-export async function saveAsync(key: string, value: string): Promise {
- await AsyncStorage.setItem(key, value);
-}
+// toutes les clés utilisées par l'app, voir .docs/DATA.md
+export type StorageKey =
+ | 'theme'
+ | 'haptics'
+ | 'liveActivities'
+ | 'oobeCompleted'
+ | 'releaseNotesVersion'
+ | 'adeProjectOverride'
+ | 'favoriteRooms'
+ | 'pinnedApps'
+ | 'androidWidgetTimeline';
-export async function getAsync(key: string): Promise {
- return AsyncStorage.getItem(key);
-}
+export type SecureKey = 'adeid';
-export async function removeAsync(key: string): Promise {
- await AsyncStorage.removeItem(key);
-}
+export const storage = {
+ get: (key: StorageKey) => AsyncStorage.getItem(key),
+ set: (key: StorageKey, value: string) => AsyncStorage.setItem(key, value),
+ remove: (key: StorageKey) => AsyncStorage.removeItem(key),
+ clear: () => AsyncStorage.clear(),
+};
-export async function clearAsync(): Promise {
- await AsyncStorage.clear();
-}
+// trousseau iOS / keystore Android
+export const secureStorage = {
+ get: (key: SecureKey) => SecureStore.getItemAsync(key),
+ set: (key: SecureKey, value: string) => SecureStore.setItemAsync(key, value),
+ remove: (key: SecureKey) => SecureStore.deleteItemAsync(key),
+};
diff --git a/src/widgets/ClassActivity.ios.tsx b/src/widgets/ClassActivity.ios.tsx
index b679c9e..727cefd 100644
--- a/src/widgets/ClassActivity.ios.tsx
+++ b/src/widgets/ClassActivity.ios.tsx
@@ -1,13 +1,19 @@
import { Capsule, HStack, Image, ProgressView, Spacer, Text, VStack } from '@expo/ui/swift-ui';
import {
- font, foregroundStyle, frame, lineLimit,
- monospacedDigit, multilineTextAlignment, padding, tint,
+ font,
+ foregroundStyle,
+ frame,
+ lineLimit,
+ monospacedDigit,
+ multilineTextAlignment,
+ padding,
+ tint,
} from '@expo/ui/swift-ui/modifiers';
import { createLiveActivity, type LiveActivityEnvironment } from 'expo-widgets';
-import type { ClassActivityProps } from '../types';
+import type { ClassActivityProps } from '@/src/types';
-// Live Activity
+// bannière de l'écran verrouillé et Dynamic Island
const ClassActivityLayout = (props: ClassActivityProps, env: LiveActivityEnvironment) => {
'widget';
const accent = props.color || 'gray';
@@ -76,11 +82,7 @@ const ClassActivityLayout = (props: ClassActivityProps, env: LiveActivityEnviron
),
compactLeading: icon,
- compactTrailing: finished ? (
- Fini
- ) : (
- timer(14, 52)
- ),
+ compactTrailing: finished ? Fini : timer(14, 52),
minimal: icon,
expandedLeading: (
diff --git a/src/widgets/ClassActivity.tsx b/src/widgets/ClassActivity.tsx
index 3d4c792..6459577 100644
--- a/src/widgets/ClassActivity.tsx
+++ b/src/widgets/ClassActivity.tsx
@@ -1,6 +1,6 @@
import { createLiveActivity } from 'expo-widgets';
-import type { ClassActivityProps } from '../types';
+import type { ClassActivityProps } from '@/src/types';
// Live Activity iOS uniquement : sans effet ailleurs
export const ClassActivity = createLiveActivity('ClassActivity', () => ({ banner: null }));
diff --git a/src/widgets/NextClassWidget.android.tsx b/src/widgets/NextClassWidget.android.tsx
index ecaa676..7888f8a 100644
--- a/src/widgets/NextClassWidget.android.tsx
+++ b/src/widgets/NextClassWidget.android.tsx
@@ -1,6 +1,5 @@
import React from 'react';
-import AsyncStorage from '@react-native-async-storage/async-storage';
import {
FlexWidget,
registerWidgetTaskHandler,
@@ -11,12 +10,12 @@ import {
type WidgetRepresentation,
} from 'react-native-android-widget';
-import type { NextClassWidgetProps, WidgetClass } from '../types';
+import type { NextClassWidgetProps, WidgetClass, WidgetTimelineEntry } from '@/src/types';
+import { storage } from '@/src/utils/storage';
// 4x2, 2x2 et 2x1 par défaut
const WIDGET_NAMES = ['NextClassWidget', 'NextClassWidgetSmall', 'NextClassWidgetMini'];
-const TIMELINE_KEY = 'androidWidgetTimeline';
const WIDE_MIN_DP = 230;
// 1 ligne
const STRIP_MAX_DP = 90;
@@ -51,20 +50,15 @@ const DARK: Palette = {
accent: '#9BCBFF',
};
-// ---
-// Stockage
-// ---
-
-type Entry = { date: Date; props: NextClassWidgetProps };
-
-async function saveTimeline(entries: Entry[]): Promise {
+// Android n'a pas de timeline : elle est stockée et relue à chaque rafraîchissement du widget
+async function saveTimeline(entries: WidgetTimelineEntry[]): Promise {
const stored: StoredEntry[] = entries.map((e) => ({ date: e.date.getTime(), props: e.props }));
- await AsyncStorage.setItem(TIMELINE_KEY, JSON.stringify(stored));
+ await storage.set('androidWidgetTimeline', JSON.stringify(stored));
}
async function loadTimeline(): Promise {
try {
- const raw = await AsyncStorage.getItem(TIMELINE_KEY);
+ const raw = await storage.get('androidWidgetTimeline');
const value: unknown = raw ? JSON.parse(raw) : null;
return Array.isArray(value) && value.length > 0 ? (value as StoredEntry[]) : null;
} catch {
@@ -85,32 +79,19 @@ function currentProps(timeline: StoredEntry[] | null, now: number): NextClassWid
return { courses, configured: true };
}
-// ---
-// Rendu
-// ---
-
function courseColor(c: WidgetClass): ColorProp {
return c.color && /^#[0-9a-f]{6}$/i.test(c.color) ? (c.color as ColorProp) : '#9E9E9E';
}
function Header({ palette }: { palette: Palette }) {
- return (
-
- );
+ return ;
}
function MainCourse({ course, palette, compact }: { course: WidgetClass; palette: Palette; compact: boolean }) {
const time = compact ? `${course.startTime}–${course.endTime}` : `${course.startTime} – ${course.endTime}`;
return (
-
+
) : (
-
+
);
@@ -260,10 +230,7 @@ function NextClassWidget({
{upNext.length > 0 ? (
upNext.slice(0, 2).map((c, i) => )
) : (
-
+
)}
@@ -338,10 +305,7 @@ function redrawAll(): Promise {
);
}
-// ---
-// Tâches de fond
-// ---
-
+// rafraîchissement périodique par Android, enregistré au démarrage (voir index.ts)
registerWidgetTaskHandler(async ({ widgetInfo, widgetAction, renderWidget }) => {
if (!WIDGET_NAMES.includes(widgetInfo.widgetName)) return;
if (widgetAction === 'WIDGET_DELETED' || widgetAction === 'WIDGET_CLICK') return;
@@ -350,16 +314,9 @@ registerWidgetTaskHandler(async ({ widgetInfo, widgetAction, renderWidget }) =>
// même interface que le widget iOS
export const NextClassWidgetInstance = {
- updateTimeline: (entries: Entry[]) => {
+ updateTimeline: (entries: WidgetTimelineEntry[]) => {
saveTimeline(entries)
.then(redrawAll)
.catch(() => {});
},
- updateSnapshot: (props: NextClassWidgetProps) => {
- NextClassWidgetInstance.updateTimeline([{ date: new Date(), props }]);
- },
- getTimeline: () => Promise.resolve([] as Entry[]),
- reload: () => {
- redrawAll().catch(() => {});
- },
};
diff --git a/src/widgets/NextClassWidget.ios.tsx b/src/widgets/NextClassWidget.ios.tsx
index 24aa19e..bedcf13 100644
--- a/src/widgets/NextClassWidget.ios.tsx
+++ b/src/widgets/NextClassWidget.ios.tsx
@@ -1,18 +1,21 @@
-import {
- Capsule, HStack, Image,
- Spacer, Text, VStack
-} from '@expo/ui/swift-ui';
-
+import { Capsule, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui';
import {
- background, fixedSize, font,
- foregroundStyle, frame, lineLimit,
- padding, shapes, widgetURL,
+ background,
+ fixedSize,
+ font,
+ foregroundStyle,
+ frame,
+ lineLimit,
+ padding,
+ shapes,
+ widgetURL,
} from '@expo/ui/swift-ui/modifiers';
-import { createWidget } from 'expo-widgets';
+import { createWidget, type WidgetEnvironment } from 'expo-widgets';
-import type { NextClassWidgetProps, WidgetClass } from '../types';
+import type { NextClassWidgetProps, WidgetClass } from '@/src/types';
-const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
+// 'widget' : la fonction est exécutée par l'extension, elle ne peut rien utiliser en dehors d'elle-même
+const NextClassWidget = (props: NextClassWidgetProps, env: WidgetEnvironment) => {
'widget';
const isSmall = env?.widgetFamily === 'systemSmall';
const blue = env?.colorScheme === 'dark' ? 'rgb(155, 203, 255)' : 'rgb(0, 98, 159)';
@@ -27,15 +30,10 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
endTime: '',
};
- // Logo header commun
+ // en-tête commun
const LogoHeader = (
-
+
UniceNotes
);
@@ -56,9 +54,7 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
{bar(c, 4)}
-
- {c.title}
-
+ {c.title}
{c.room || 'Salle non précisée'}
@@ -88,7 +84,7 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
);
- // État : EDT non configuré
+ // edt non configuré
if (props?.configured === false) {
return message(
isSmall
@@ -97,7 +93,7 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
);
}
- // État : timeline expirée / widget jamais alimenté
+ // timeline expirée ou widget jamais alimenté
if (props?.configured === undefined) {
return message(
isSmall
@@ -106,7 +102,6 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
);
}
- // État normal : cours configurés
const c1 = props?.courses?.[0] ?? EMPTY;
const upNext = props?.courses?.slice(1, 3) ?? [];
@@ -123,7 +118,11 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
// systemMedium : cours en avant à gauche, les suivants dans un panneau à droite
return (
-
+
{LogoHeader}
{mainCourse(c1)}
@@ -142,9 +141,7 @@ const NextClassWidget = (props: NextClassWidgetProps, env: any) => {
{upNext.length > 0 ? (
upNext.map((c, i) => nextCourse(c, i))
) : (
-
- {"Rien d'autre de prévu"}
-
+ {"Rien d'autre de prévu"}
)}
diff --git a/src/widgets/NextClassWidget.tsx b/src/widgets/NextClassWidget.tsx
index ec9e013..ef6bada 100644
--- a/src/widgets/NextClassWidget.tsx
+++ b/src/widgets/NextClassWidget.tsx
@@ -1,6 +1,6 @@
+import type { WidgetTimelineEntry } from '@/src/types';
+
+// pas de widget hors iOS et Android
export const NextClassWidgetInstance = {
- updateSnapshot: (_props: unknown) => {},
- updateTimeline: (_entries: unknown[]) => {},
- getTimeline: () => Promise.resolve([]),
- reload: () => {},
-};
\ No newline at end of file
+ updateTimeline: (_entries: WidgetTimelineEntry[]) => {},
+};