Skip to content

Commit 4a7c013

Browse files
committed
feat(ios): add widget
1 parent 34300a2 commit 4a7c013

8 files changed

Lines changed: 279 additions & 92 deletions

File tree

app.json

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
},
5353
"bugsnag": {
5454
"apiKey": "$(BUGSNAG_API_KEY)"
55-
},
55+
},
5656
"github_hash": "$(EAS_BUILD_GIT_COMMIT_HASH)"
5757
},
5858
"owner": "hugofnm",
@@ -114,7 +114,25 @@
114114
],
115115
"expo-font",
116116
"expo-secure-store",
117-
"expo-web-browser"
117+
"expo-web-browser",
118+
[
119+
"expo-widgets",
120+
{
121+
"bundleIdentifier": "fr.hugofnm.unicenotes.widgets",
122+
"groupIdentifier": "group.fr.hugofnm.unicenotes",
123+
"widgets": [
124+
{
125+
"name": "NextClassWidget",
126+
"displayName": "Prochains Cours",
127+
"description": "Affiche vos deux prochains cours UniceNotes",
128+
"supportedFamilies": [
129+
"systemSmall",
130+
"systemMedium"
131+
]
132+
}
133+
]
134+
}
135+
]
118136
],
119137
"experiments": {
120138
"typedRoutes": true

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@bugsnag/expo": "^55.0.0",
14+
"@expo/ui": "~55.0.15",
1415
"@expo/vector-icons": "^15.0.3",
1516
"@gorhom/bottom-sheet": "^5.2.13",
1617
"@howljs/calendar-kit": "^2.0.4",
@@ -45,6 +46,7 @@
4546
"expo-store-review": "~55.0.13",
4647
"expo-system-ui": "~55.0.17",
4748
"expo-web-browser": "~55.0.15",
49+
"expo-widgets": "^55.0.17",
4850
"ical.js": "^2.2.1",
4951
"lottie-react-native": "~7.3.1",
5052
"react": "19.2.0",

src/app/home.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { edtService } from '@/src/services/edt';
55
import type { NextEvent } from '@/src/types';
66
import { handleURL } from '@/src/utils/api';
77
import { haptics } from '@/src/utils/haptics';
8+
import { NextClassWidgetInstance } from '@/src/widgets/NextClassWidget';
89
import BottomSheet, {
910
BottomSheetBackdrop,
1011
BottomSheetBackdropProps,
@@ -87,6 +88,13 @@ export default function HomeScreen() {
8788
const result = await edtService.getNextEvent(adeid ?? 'demo');
8889
setNextEvent(result);
8990
setNextEventLoaded(true);
91+
try {
92+
const courses = await edtService.getNextTwoCourses(adeid ?? 'demo');
93+
NextClassWidgetInstance.updateSnapshot({ courses });
94+
} catch {
95+
// Widget non disponible en mode dev ou non configuré
96+
console.warn('NextClassWidget: Impossible de récupérer les prochains cours pour le widget');
97+
}
9098
}
9199
}
92100

src/services/edt.ts

Lines changed: 98 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { File, Paths } from 'expo-file-system';
2+
import ICAL from 'ical.js';
3+
import type { CalendarEvent, NextEvent, WidgetClass } from '../types';
24
import { stringToColour } from '../utils/color';
3-
import type { CalendarEvent, NextEvent } from '../types';
4-
5-
const ICAL = require('ical.js') as {
6-
parse: (input: string) => unknown[];
7-
Component: new (jcalData: unknown) => ICALComponent;
8-
Event: new (component: unknown) => ICALEvent;
9-
};
105

116
interface ICALComponent {
127
getAllSubcomponents(name: string): unknown[];
@@ -23,96 +18,86 @@ interface ICALEvent {
2318

2419
const ADE_BASE = 'https://edtweb.univ-cotedazur.fr';
2520
const CALENDAR_FILE = new File(Paths.document, 'calendar.json');
21+
const ONGOING_THRESHOLD_MS = 15 * 60 * 1000;
22+
23+
function getAcademicYearDateRange(): string {
24+
const now = new Date();
25+
const month = now.getMonth() + 1;
26+
const year = now.getFullYear();
27+
const startYear = month >= 9 ? year : year - 1;
28+
const endYear = startYear + 1;
29+
return `&firstDate=${startYear}-09-01&lastDate=${endYear}-08-31`;
30+
}
2631

27-
class EDTDate {
28-
ADE_DATE: string;
29-
30-
constructor() {
31-
const now = new Date();
32-
const month = now.getMonth() + 1;
33-
const year = now.getFullYear();
34-
const startYear = month >= 9 ? year : year - 1;
35-
const endYear = startYear + 1;
36-
this.ADE_DATE = `&firstDate=${startYear}-09-01&lastDate=${endYear}-08-31`;
37-
}
32+
function getCurrentAcademicYearString(): string {
33+
const now = new Date();
34+
const startYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
35+
return `${startYear}-${startYear + 1}`;
3836
}
3937

40-
class EDTConfig {
41-
async getSessionId(): Promise<string | null> {
42-
try {
43-
const res = await fetch(
44-
`${ADE_BASE}/jsp/webapi?function=connect&login=Individuel&password=`,
45-
);
46-
if (!res.ok) return null;
47-
const text = await res.text();
48-
const match = text.match(/<session id="(.*?)"\s*\/>/);
49-
return match ? match[1] : null;
50-
} catch {
51-
return null;
52-
}
38+
async function fetchSessionId(): Promise<string | null> {
39+
try {
40+
const res = await fetch(`${ADE_BASE}/jsp/webapi?function=connect&login=Individuel&password=`);
41+
if (!res.ok) return null;
42+
const text = await res.text();
43+
const match = text.match(/<session id="(.*?)"\s*\/>/);
44+
return match ? match[1] : null;
45+
} catch {
46+
return null;
5347
}
48+
}
5449

55-
async getProjects(sessionId: string): Promise<string | null> {
56-
try {
57-
const res = await fetch(
58-
`${ADE_BASE}/jsp/webapi?function=getProjects&sessionId=${sessionId}&detail=2`,
59-
);
60-
if (!res.ok) return null;
61-
const text = await res.text();
62-
const now = new Date();
63-
const startYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
64-
const yearStr = `${startYear}-${startYear + 1}`;
65-
const projectRegex = /<project\s+id="(\d+)"\s+name="([^"]*)"/g;
66-
const matches = text.matchAll(projectRegex);
67-
68-
for (const match of matches) {
69-
const [_, id, name] = match;
70-
71-
if (name.includes(yearStr) && name.toLowerCase().includes('prod')) {
72-
return id;
73-
}
50+
async function fetchProjectId(sessionId: string): Promise<string | null> {
51+
try {
52+
const res = await fetch(
53+
`${ADE_BASE}/jsp/webapi?function=getProjects&sessionId=${sessionId}&detail=2`,
54+
);
55+
if (!res.ok) return null;
56+
const text = await res.text();
57+
const yearStr = getCurrentAcademicYearString();
58+
const projectRegex = /<project\s+id="(\d+)"\s+name="([^"]*)"/g;
59+
60+
for (const [_, id, name] of text.matchAll(projectRegex)) {
61+
if (name.includes(yearStr) && name.toLowerCase().includes('prod')) {
62+
return id;
7463
}
75-
76-
return null;
77-
} catch {
78-
return null;
7964
}
65+
66+
return null;
67+
} catch {
68+
return null;
8069
}
70+
}
71+
72+
async function resolveProjectId(): Promise<string | null> {
73+
const sessionId = await fetchSessionId();
74+
if (!sessionId) return null;
75+
return fetchProjectId(sessionId);
76+
}
8177

82-
async getConfig(): Promise<string | null> {
83-
const sessionId = await this.getSessionId();
84-
if (!sessionId) return null;
85-
return this.getProjects(sessionId);
78+
async function getCalendarFromCache(): Promise<CalendarEvent[]> {
79+
try {
80+
const json = await CALENDAR_FILE.text();
81+
return JSON.parse(json) as CalendarEvent[];
82+
} catch {
83+
return [];
8684
}
8785
}
8886

8987
export class EDT {
9088
private ADE_PROJECT: string | null = null;
91-
private ADE_DATE: string;
92-
READY = false;
89+
private readonly ADE_DATE: string;
90+
private readonly _ready: Promise<void>;
9391

9492
constructor() {
95-
this.ADE_DATE = new EDTDate().ADE_DATE;
96-
new EDTConfig().getConfig().then((projectId) => {
97-
this.ADE_PROJECT = projectId;
98-
this.READY = true;
93+
this.ADE_DATE = getAcademicYearDateRange();
94+
this._ready = resolveProjectId().then((id) => {
95+
this.ADE_PROJECT = id;
9996
});
10097
}
10198

10299
private waitUntilReady(): Promise<void> {
103-
if (this.READY) return Promise.resolve();
104-
return new Promise<void>((resolve) => {
105-
const interval = setInterval(() => {
106-
if (this.READY) {
107-
clearInterval(interval);
108-
resolve();
109-
}
110-
}, 100);
111-
setTimeout(() => {
112-
clearInterval(interval);
113-
resolve();
114-
}, 10000);
115-
});
100+
return this._ready;
116101
}
117102

118103
async fetchEDT(adeid: string): Promise<string | null> {
@@ -133,16 +118,16 @@ export class EDT {
133118
parseICal(icalData: string): ICALEvent[] {
134119
try {
135120
const parsed = ICAL.parse(icalData);
136-
const comp = new ICAL.Component(parsed);
137-
return comp.getAllSubcomponents('vevent').map((v) => new ICAL.Event(v));
121+
const comp = new ICAL.Component(parsed) as ICALComponent;
122+
return comp.getAllSubcomponents('vevent').map((v) => new ICAL.Event(v as ICAL.Component) as ICALEvent);
138123
} catch {
139124
return [];
140125
}
141126
}
142127

143128
findNextEvent(events: ICALEvent[]): NextEvent {
144129
const now = new Date();
145-
const windowStart = new Date(now.getTime() - 15 * 60 * 1000);
130+
const windowStart = new Date(now.getTime() - ONGOING_THRESHOLD_MS);
146131

147132
const sorted = events
148133
.map((e) => ({
@@ -157,6 +142,38 @@ export class EDT {
157142
return { summary: next.summary, location: next.location };
158143
}
159144

145+
findNextTwoCourses(events: ICALEvent[]): WidgetClass[] {
146+
const now = new Date();
147+
const windowStart = new Date(now.getTime() - 15 * 60 * 1000);
148+
149+
const pad = (n: number) => String(n).padStart(2, '0');
150+
const fmt = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
151+
152+
return events
153+
.map((e) => ({
154+
start: e.startDate.toJSDate(),
155+
end: e.endDate.toJSDate(),
156+
title: e.summary ?? 'Cours inconnu',
157+
room: e.location ?? '',
158+
}))
159+
.filter((e) => e.start >= windowStart)
160+
.sort((a, b) => a.start.getTime() - b.start.getTime())
161+
.slice(0, 2)
162+
.map((e) => ({
163+
title: e.title,
164+
room: e.room,
165+
startTime: fmt(e.start),
166+
endTime: fmt(e.end),
167+
}));
168+
}
169+
170+
async getNextTwoCourses(adeid: string): Promise<WidgetClass[]> {
171+
const icalData = await this.fetchEDT(adeid);
172+
if (!icalData) return [];
173+
const events = this.parseICal(icalData);
174+
return this.findNextTwoCourses(events);
175+
}
176+
160177
async getNextEvent(adeid: string): Promise<NextEvent> {
161178
const icalData = await this.fetchEDT(adeid);
162179
if (!icalData) {
@@ -190,13 +207,4 @@ export class EDT {
190207
}
191208
}
192209

193-
async function getCalendarFromCache(): Promise<CalendarEvent[]> {
194-
try {
195-
const json = await CALENDAR_FILE.text();
196-
return JSON.parse(json) as CalendarEvent[];
197-
} catch {
198-
return [];
199-
}
200-
}
201-
202-
export const edtService = new EDT();
210+
export const edtService = new EDT();

src/types/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ export interface NextEvent {
1818
location: string;
1919
}
2020

21+
export interface WidgetClass {
22+
title: string;
23+
room: string;
24+
startTime: string;
25+
endTime: string;
26+
}
27+
28+
export interface NextClassWidgetProps {
29+
courses: WidgetClass[];
30+
}
31+
2132
export type HapticIntensity =
2233
| 'light'
2334
| 'medium'

0 commit comments

Comments
 (0)