@@ -83,6 +83,15 @@ private fun AppEntry() {
8383 var showSplash by remember { mutableStateOf(true ) }
8484 var startupCredentials by remember { mutableStateOf<Pair <String , String >? > (null ) }
8585 var appSettings by remember { mutableStateOf(AppSettingsStore .get(context)) }
86+ var showNotificationDialog by remember { mutableStateOf(false ) }
87+
88+ // Permission launcher for initial prompt
89+ val permissionLauncher = rememberLauncherForActivityResult(
90+ ActivityResultContracts .RequestPermission ()
91+ ) { granted ->
92+ NotificationPromptStore .markInitialPromptShown(context)
93+ // If denied, we'll handle it via the daily re-prompt
94+ }
8695
8796 LaunchedEffect (Unit ) {
8897 val splashStart = System .currentTimeMillis()
@@ -97,6 +106,28 @@ private fun AppEntry() {
97106 showSplash = false
98107 }
99108
109+ // Handle notification prompt logic after splash
110+ LaunchedEffect (showSplash) {
111+ if (showSplash) return @LaunchedEffect
112+
113+ if (android.os.Build .VERSION .SDK_INT >= android.os.Build .VERSION_CODES .TIRAMISU ) {
114+ if (! NotificationPromptStore .hasShownInitialPrompt(context)) {
115+ // First launch: request permission directly
116+ permissionLauncher.launch(android.Manifest .permission.POST_NOTIFICATIONS )
117+ return @LaunchedEffect
118+ }
119+ }
120+
121+ // Track daily opens and check for re-prompt
122+ val dailyCount = NotificationPromptStore .incrementDailyOpen(context)
123+ if (dailyCount >= 3 &&
124+ ! NotificationGate .areNotificationsEnabled(context) &&
125+ ! NotificationPromptStore .hasDailyPromptBeenShown(context)
126+ ) {
127+ showNotificationDialog = true
128+ }
129+ }
130+
100131 AssignmentCheckerTheme (themeMode = appSettings.themeMode) {
101132 if (showSplash) {
102133 AppSplashScreen ()
@@ -109,9 +140,68 @@ private fun AppEntry() {
109140 onSettingsChange = { appSettings = it }
110141 )
111142 }
143+
144+ // Notification re-prompt dialog
145+ if (showNotificationDialog) {
146+ AlertDialog (
147+ onDismissRequest = {
148+ showNotificationDialog = false
149+ NotificationPromptStore .markDailyPromptShown(context)
150+ },
151+ icon = {
152+ Icon (
153+ Icons .Default .Notifications ,
154+ contentDescription = null ,
155+ tint = MaterialTheme .colorScheme.primary,
156+ modifier = Modifier .size(32 .dp)
157+ )
158+ },
159+ title = {
160+ Text (
161+ " Stay Updated" ,
162+ fontWeight = FontWeight .Bold
163+ )
164+ },
165+ text = {
166+ Text (
167+ " Enable notifications to get instant alerts for new assignments, deadline reminders, and important updates from your portal." ,
168+ color = MaterialTheme .colorScheme.onSurfaceVariant
169+ )
170+ },
171+ confirmButton = {
172+ Button (
173+ onClick = {
174+ showNotificationDialog = false
175+ NotificationPromptStore .markDailyPromptShown(context)
176+ // Open app notification settings
177+ val intent = android.content.Intent ().apply {
178+ action = android.provider.Settings .ACTION_APP_NOTIFICATION_SETTINGS
179+ putExtra(android.provider.Settings .EXTRA_APP_PACKAGE , context.packageName)
180+ }
181+ context.startActivity(intent)
182+ },
183+ shape = RoundedCornerShape (12 .dp)
184+ ) {
185+ Text (" Turn On" , fontWeight = FontWeight .Bold )
186+ }
187+ },
188+ dismissButton = {
189+ TextButton (
190+ onClick = {
191+ showNotificationDialog = false
192+ NotificationPromptStore .markDailyPromptShown(context)
193+ }
194+ ) {
195+ Text (" Not Now" )
196+ }
197+ },
198+ shape = RoundedCornerShape (20 .dp)
199+ )
200+ }
112201 }
113202}
114203
204+ @OptIn(ExperimentalMaterial3Api ::class )
115205@Composable
116206fun MainScreen (
117207 viewModel : MainViewModel ,
@@ -144,6 +234,8 @@ fun MainScreen(
144234 val context = LocalContext .current
145235 var activeUploads by remember { mutableStateOf<List <QueuedUpload >>(emptyList()) }
146236 var activeDownloads by remember { mutableStateOf<List <QueuedDownload >>(emptyList()) }
237+ var showTimetableModal by remember { mutableStateOf(false ) }
238+ var timetableError by remember { mutableStateOf<String ?>(null ) }
147239
148240 LaunchedEffect (Unit ) {
149241 while (true ) {
@@ -163,7 +255,6 @@ fun MainScreen(
163255 ! isLoggedIn -> AppPage .LOGIN
164256 currentScreen == ScreenType .SETTINGS -> AppPage .SETTINGS
165257 currentScreen == ScreenType .DOWNLOADS -> AppPage .DOWNLOADS
166- currentScreen == ScreenType .TIMETABLE -> AppPage .TIMETABLE
167258 currentScreen == ScreenType .HISTORICAL -> AppPage .HISTORICAL
168259 else -> AppPage .PENDING
169260 }
@@ -234,11 +325,18 @@ fun MainScreen(
234325 historical = dashboardData.historicalAssignments
235326 )
236327 }
237-
238- val fetchedTimetable = viewModel.loadTimetable()
239- if (fetchedTimetable.isNotEmpty()) {
240- timetableLectures = fetchedTimetable
241- TimetableCacheStore .saveSnapshot(context, fetchedTimetable)
328+ timetableError = null
329+ try {
330+ val fetchedTimetable = viewModel.loadTimetable()
331+ if (fetchedTimetable.isNotEmpty()) {
332+ timetableLectures = fetchedTimetable
333+ TimetableCacheStore .saveSnapshot(context, fetchedTimetable)
334+ }
335+ } catch (e: PortalSystemException ) {
336+ timetableError = e.message
337+ Log .e(" MainActivity" , " Timetable fetch failed: ${e.message} " )
338+ } catch (e: Exception ) {
339+ Log .e(" MainActivity" , " Timetable fetch unknown error" , e)
242340 }
243341 }
244342
@@ -284,10 +382,7 @@ fun MainScreen(
284382 currentScreen = ScreenType .PENDING
285383 pendingExitConfirmation = false
286384 }
287- AppPage .TIMETABLE -> {
288- currentScreen = ScreenType .PENDING
289- pendingExitConfirmation = false
290- }
385+
291386 AppPage .DOWNLOADS -> {
292387 currentScreen = ScreenType .PENDING
293388 pendingExitConfirmation = false
@@ -724,22 +819,7 @@ fun MainScreen(
724819 },
725820 lastSyncedMs = lastSyncedMs,
726821 timetableLectures = timetableLectures,
727- onNavigateToTimetable = { currentScreen = ScreenType .TIMETABLE }
728- )
729- }
730- AppPage .TIMETABLE -> {
731- TimetableScreen (
732- lectures = timetableLectures,
733- isRefreshing = isPendingRefreshing,
734- onRefresh = {
735- if (isPendingRefreshing) return @TimetableScreen
736- isPendingRefreshing = true
737- scope.launch {
738- runCatching { refreshAssignmentsState() }
739- isPendingRefreshing = false
740- }
741- },
742- lastSyncedMs = lastSyncedMs
822+ onNavigateToTimetable = { showTimetableModal = true }
743823 )
744824 }
745825 AppPage .DOWNLOADS -> {
@@ -920,20 +1000,26 @@ fun MainScreen(
9201000 val loadingMessage = when {
9211001 pageForUi == AppPage .LOGIN -> " Signing in..."
9221002 loadingTargetScreen == ScreenType .HISTORICAL -> " Loading historical assignments..."
923- loadingTargetScreen == ScreenType .TIMETABLE -> " Loading timetable..."
9241003 else -> " Loading assignments..."
9251004 }
9261005 LoadingStatusOverlay (message = loadingMessage)
9271006 }
9281007
929- if (isLoggedIn && ! showCaptchaDialog) {
930- Box (modifier = Modifier .fillMaxSize(), contentAlignment = Alignment .BottomCenter ) {
931- AssignlyBottomNavigation (
932- currentScreen = currentScreen,
933- onNavigate = { currentScreen = it }
934- )
935- }
936- }
1008+
1009+ }
1010+ }
1011+
1012+ if (showTimetableModal) {
1013+ ModalBottomSheet (
1014+ onDismissRequest = { showTimetableModal = false },
1015+ containerColor = MaterialTheme .colorScheme.background,
1016+ dragHandle = { BottomSheetDefaults .DragHandle () }
1017+ ) {
1018+ TimetableBottomSheetContent (
1019+ lectures = timetableLectures,
1020+ timetableError = timetableError,
1021+ onClose = { showTimetableModal = false }
1022+ )
9371023 }
9381024 }
9391025
0 commit comments