Skip to content

Commit 0889fa6

Browse files
LeoColmanclaude
andauthored
Phase 2: Presence/absence tracking (#7)
Adds attendance tracking and the "how many classes can I still miss" count, ported from the legacy app. ## What's new - **Presence** table (`subjectId`, `date`) + a v2→v3 SQLDelight migration so existing installs upgrade cleanly. `PresenceRepository` records/clears attendance and exposes it per subject. - **`AbsenceCalculator`** (pure, heavily unit-tested): a scheduled class counts as *missed* unless attendance was recorded for that day; only classes strictly before today are counted, so today's still-confirmable class never shows as a miss. - `SubjectsViewModel` exposes a `SubjectSummary` per subject (missed count, remaining allowance, has-class-today, present-today). - `SubjectsPage` shows **"Faltas: missed/max"** (red once the allowance is used up) and a **"Presente hoje"** switch for subjects with a class today. ## Tests - `AbsenceCalculatorTest` — no class times, brand-new subject, weekly misses, recorded attendance, multiple class times, negative remaining. - `PresenceRepositoryTest` — record/clear/toggle, dedupe per day. - `SubjectsViewModelTest` — summaries, attendance lowering the count, today's toggle. ## Screenshot <img src="https://github.com/JopiterApp/JopiterAndroid/raw/5455587cf7ca90dea1204a49e5c9edd245ddc621/docs/screenshots/07-presence-tracking.png" width="280"> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01X5cqJDzZvxqTxMTWcN2jvm Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0876e5d commit 0889fa6

12 files changed

Lines changed: 395 additions & 22 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Jopiter App
3+
* Copyright (C) 2026 Leonardo Colman Lopes
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package app.jopiter.subject
19+
20+
import app.jopiter.subject.model.Subject
21+
import java.time.DayOfWeek
22+
import java.time.LocalDate
23+
import java.time.temporal.TemporalAdjusters.nextOrSame
24+
25+
/**
26+
* Counts missed classes the way the legacy app did: a scheduled class is "missed" unless the student
27+
* recorded attendance ([attendedDates]) for that day. Only classes strictly before [today] are
28+
* counted, so today's still-confirmable class never shows as a miss.
29+
*/
30+
object AbsenceCalculator {
31+
32+
fun missedClasses(subject: Subject, attendedDates: Set<LocalDate>, today: LocalDate): Int =
33+
scheduledOccurrences(subject, today).count { it !in attendedDates }
34+
35+
/** Allowed misses still available. Negative once the subject's limit has been exceeded. */
36+
fun remainingMisses(subject: Subject, attendedDates: Set<LocalDate>, today: LocalDate): Int =
37+
subject.maxMissedClasses - missedClasses(subject, attendedDates, today)
38+
39+
private fun scheduledOccurrences(subject: Subject, today: LocalDate): List<LocalDate> =
40+
subject.classTimes.flatMap { occurrences(it.dayOfWeek, subject.creationDate, today) }
41+
42+
private fun occurrences(day: DayOfWeek, from: LocalDate, until: LocalDate): List<LocalDate> {
43+
val firstOccurrence = from.with(nextOrSame(day))
44+
return generateSequence(firstOccurrence) { it.plusWeeks(1) }
45+
.takeWhile { it.isBefore(until) }
46+
.toList()
47+
}
48+
}

‎app/src/main/java/app/jopiter/subject/SubjectModule.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
package app.jopiter.subject
1919

2020
import app.jopiter.Database
21+
import app.jopiter.subject.repository.PresenceRepository
2122
import app.jopiter.subject.repository.SubjectRepository
2223
import org.koin.androidx.viewmodel.dsl.viewModel
2324
import org.koin.core.parameter.parametersOf
2425
import org.koin.dsl.module
2526

2627
val subjectModule = module {
2728
single { SubjectRepository(get<Database>().subjectQueries, get<Database>().classTimeQueries) }
28-
viewModel { SubjectsViewModel(get()) }
29+
single { PresenceRepository(get<Database>().presenceQueries) }
30+
viewModel { SubjectsViewModel(get(), get()) }
2931
viewModel { parameters -> SubjectEditViewModel(get(), parameters.get()) }
3032
}
3133

‎app/src/main/java/app/jopiter/subject/SubjectsPage.kt‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Arrangement
2222
import androidx.compose.foundation.layout.Box
2323
import androidx.compose.foundation.layout.Column
2424
import androidx.compose.foundation.layout.PaddingValues
25+
import androidx.compose.foundation.layout.Row
2526
import androidx.compose.foundation.layout.fillMaxSize
2627
import androidx.compose.foundation.layout.fillMaxWidth
2728
import androidx.compose.foundation.layout.padding
@@ -32,6 +33,7 @@ import androidx.compose.material.FloatingActionButton
3233
import androidx.compose.material.Icon
3334
import androidx.compose.material.MaterialTheme
3435
import androidx.compose.material.Scaffold
36+
import androidx.compose.material.Switch
3537
import androidx.compose.material.Text
3638
import androidx.compose.material.icons.Icons
3739
import androidx.compose.material.icons.filled.Add
@@ -46,7 +48,6 @@ import androidx.compose.ui.unit.dp
4648
import app.jopiter.R
4749
import app.jopiter.common.TimeFormat
4850
import app.jopiter.common.displayNameRes
49-
import app.jopiter.subject.model.Subject
5051
import org.koin.androidx.compose.koinViewModel
5152

5253
@Composable
@@ -55,7 +56,7 @@ fun SubjectsPage(
5556
onEditSubject: (Long) -> Unit,
5657
viewModel: SubjectsViewModel = koinViewModel()
5758
) {
58-
val subjects by viewModel.subjects.collectAsState()
59+
val summaries by viewModel.summaries.collectAsState()
5960

6061
Scaffold(
6162
floatingActionButton = {
@@ -64,7 +65,7 @@ fun SubjectsPage(
6465
}
6566
}
6667
) { padding ->
67-
if (subjects.isEmpty()) {
68+
if (summaries.isEmpty()) {
6869
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
6970
Text(stringResource(R.string.no_subjects), Modifier.testTag("no_subjects"))
7071
}
@@ -74,29 +75,33 @@ fun SubjectsPage(
7475
contentPadding = PaddingValues(8.dp),
7576
verticalArrangement = Arrangement.spacedBy(8.dp)
7677
) {
77-
items(subjects, key = { it.id }) { subject ->
78-
SubjectCard(subject) { onEditSubject(subject.id) }
78+
items(summaries, key = { it.subject.id }) { summary ->
79+
SubjectCard(
80+
summary = summary,
81+
onClick = { onEditSubject(summary.subject.id) },
82+
onTogglePresence = { viewModel.toggleTodayPresence(summary.subject.id) }
83+
)
7984
}
8085
}
8186
}
8287
}
8388
}
8489

8590
@Composable
86-
private fun SubjectCard(subject: Subject, onClick: () -> Unit) {
91+
private fun SubjectCard(summary: SubjectSummary, onClick: () -> Unit, onTogglePresence: () -> Unit) {
8792
Card(
8893
Modifier.fillMaxWidth().clickable(onClick = onClick).testTag("subject_card"),
8994
elevation = 2.dp
9095
) {
9196
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
92-
Text(subject.name, style = MaterialTheme.typography.h6)
97+
Text(summary.subject.name, style = MaterialTheme.typography.h6)
9398

94-
val subtitle = listOf(subject.code, subject.classroom, subject.lecturer)
99+
val subtitle = listOf(summary.subject.code, summary.subject.classroom, summary.subject.lecturer)
95100
.filter { it.isNotBlank() }
96101
.joinToString(" · ")
97102
if (subtitle.isNotBlank()) Text(subtitle, style = MaterialTheme.typography.body2)
98103

99-
subject.classTimes.forEach { classTime ->
104+
summary.subject.classTimes.forEach { classTime ->
100105
Text(
101106
stringResource(
102107
R.string.class_time_summary,
@@ -107,6 +112,31 @@ private fun SubjectCard(subject: Subject, onClick: () -> Unit) {
107112
style = MaterialTheme.typography.body2
108113
)
109114
}
115+
116+
AbsencesText(summary.missed, summary.subject.maxMissedClasses, summary.remaining)
117+
if (summary.hasClassToday) PresenceToggle(summary.presentToday, onTogglePresence)
110118
}
111119
}
112120
}
121+
122+
@Composable
123+
private fun AbsencesText(missed: Int, maxMissed: Int, remaining: Int) {
124+
Text(
125+
stringResource(R.string.absences_format, missed, maxMissed),
126+
color = if (remaining <= 0) MaterialTheme.colors.error else MaterialTheme.colors.primary,
127+
style = MaterialTheme.typography.subtitle2,
128+
modifier = Modifier.testTag("absences")
129+
)
130+
}
131+
132+
@Composable
133+
private fun PresenceToggle(presentToday: Boolean, onTogglePresence: () -> Unit) {
134+
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
135+
Text(stringResource(R.string.present_today), style = MaterialTheme.typography.body2)
136+
Switch(
137+
checked = presentToday,
138+
onCheckedChange = { onTogglePresence() },
139+
modifier = Modifier.testTag("presence_toggle")
140+
)
141+
}
142+
}

‎app/src/main/java/app/jopiter/subject/SubjectsViewModel.kt‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,47 @@ package app.jopiter.subject
2020
import androidx.lifecycle.ViewModel
2121
import androidx.lifecycle.viewModelScope
2222
import app.jopiter.subject.model.Subject
23+
import app.jopiter.subject.repository.PresenceRepository
2324
import app.jopiter.subject.repository.SubjectRepository
2425
import kotlinx.coroutines.flow.SharingStarted
2526
import kotlinx.coroutines.flow.StateFlow
27+
import kotlinx.coroutines.flow.combine
2628
import kotlinx.coroutines.flow.stateIn
29+
import java.time.LocalDate
2730

28-
/** Exposes the list of subjects (with their class times) for the Subjects screen. */
31+
/** A subject plus its attendance status, for display on the Subjects screen. */
32+
data class SubjectSummary(
33+
val subject: Subject,
34+
val missed: Int,
35+
val remaining: Int,
36+
val hasClassToday: Boolean,
37+
val presentToday: Boolean
38+
)
39+
40+
/** Exposes subjects with their missed-class counts and a per-day presence toggle. */
2941
class SubjectsViewModel(
30-
private val repository: SubjectRepository
42+
private val subjectRepository: SubjectRepository,
43+
private val presenceRepository: PresenceRepository,
44+
private val today: () -> LocalDate = LocalDate::now
3145
) : ViewModel() {
3246

33-
val subjects: StateFlow<List<Subject>> =
34-
repository.subjects.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
47+
val summaries: StateFlow<List<SubjectSummary>> =
48+
combine(subjectRepository.subjects, presenceRepository.attendanceBySubject) { subjects, attendance ->
49+
val day = today()
50+
subjects.map { subject -> subject.summarize(attendance[subject.id].orEmpty(), day) }
51+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
52+
53+
fun delete(subject: Subject) = subjectRepository.delete(subject.id)
54+
55+
fun toggleTodayPresence(subjectId: Long) = presenceRepository.togglePresent(subjectId, today())
3556

36-
fun delete(subject: Subject) = repository.delete(subject.id)
57+
private fun Subject.summarize(attended: Set<LocalDate>, day: LocalDate) = SubjectSummary(
58+
subject = this,
59+
missed = AbsenceCalculator.missedClasses(this, attended, day),
60+
remaining = AbsenceCalculator.remainingMisses(this, attended, day),
61+
hasClassToday = classTimes.any { it.dayOfWeek == day.dayOfWeek },
62+
presentToday = day in attended
63+
)
3764

3865
private companion object {
3966
const val STOP_TIMEOUT_MS = 5000L
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Jopiter App
3+
* Copyright (C) 2026 Leonardo Colman Lopes
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package app.jopiter.subject.repository
19+
20+
import app.cash.sqldelight.coroutines.asFlow
21+
import app.cash.sqldelight.coroutines.mapToList
22+
import app.jopiter.PresenceQueries
23+
import kotlinx.coroutines.Dispatchers
24+
import kotlinx.coroutines.flow.Flow
25+
import kotlinx.coroutines.flow.map
26+
import java.time.LocalDate
27+
28+
/** Stores the dates a student attended a subject's classes (its "presences"). */
29+
class PresenceRepository(private val queries: PresenceQueries) {
30+
31+
@Suppress("InjectDispatcher")
32+
val attendanceBySubject: Flow<Map<Long, Set<LocalDate>>> =
33+
queries.selectAll().asFlow().mapToList(Dispatchers.IO).map { rows ->
34+
rows.groupBy({ it.subjectId }, { LocalDate.parse(it.date) }).mapValues { (_, dates) -> dates.toSet() }
35+
}
36+
37+
fun isPresent(subjectId: Long, date: LocalDate): Boolean =
38+
queries.isPresent(subjectId, date.toString()).executeAsOne() > 0
39+
40+
fun setPresent(subjectId: Long, date: LocalDate, present: Boolean) {
41+
if (present) queries.insert(subjectId, date.toString()) else queries.delete(subjectId, date.toString())
42+
}
43+
44+
fun togglePresent(subjectId: Long, date: LocalDate) =
45+
setPresent(subjectId, date, present = !isPresent(subjectId, date))
46+
}

‎app/src/main/res/values/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
<string name="subject_max_missed_label">Faltas permitidas</string>
3838
<string name="class_times_label">Horários</string>
3939
<string name="add_class_time">Adicionar horário</string>
40+
<string name="absences_format">Faltas: %1$d/%2$d</string>
41+
<string name="present_today">Presente hoje</string>
4042
<string name="remove">Remover</string>
4143
<string name="save">Salvar</string>
4244
<string name="cancel">Cancelar</string>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
CREATE TABLE Presence(
2+
id INTEGER PRIMARY KEY AUTOINCREMENT,
3+
subjectId INTEGER NOT NULL,
4+
date TEXT NOT NULL,
5+
FOREIGN KEY(subjectId) REFERENCES Subject(id) ON DELETE CASCADE ON UPDATE CASCADE
6+
);
7+
8+
CREATE UNIQUE INDEX presence_subject_date_idx ON Presence(subjectId, date);
9+
10+
selectAll:
11+
SELECT * FROM Presence;
12+
13+
selectBySubject:
14+
SELECT * FROM Presence WHERE subjectId = ?;
15+
16+
insert:
17+
INSERT OR IGNORE INTO Presence(subjectId, date) VALUES (?, ?);
18+
19+
delete:
20+
DELETE FROM Presence WHERE subjectId = ? AND date = ?;
21+
22+
deleteBySubject:
23+
DELETE FROM Presence WHERE subjectId = ?;
24+
25+
isPresent:
26+
SELECT COUNT(*) FROM Presence WHERE subjectId = ? AND date = ?;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Migrate schema version 2 -> 3: add attendance (Presence) records per subject and date.
2+
CREATE TABLE Presence(
3+
id INTEGER PRIMARY KEY AUTOINCREMENT,
4+
subjectId INTEGER NOT NULL,
5+
date TEXT NOT NULL,
6+
FOREIGN KEY(subjectId) REFERENCES Subject(id) ON DELETE CASCADE ON UPDATE CASCADE
7+
);
8+
9+
CREATE UNIQUE INDEX presence_subject_date_idx ON Presence(subjectId, date);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Jopiter App
3+
* Copyright (C) 2026 Leonardo Colman Lopes
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package app.jopiter.subject
19+
20+
import app.jopiter.subject.model.ClassTime
21+
import app.jopiter.subject.model.Subject
22+
import io.kotest.core.spec.style.FunSpec
23+
import io.kotest.matchers.shouldBe
24+
import java.time.DayOfWeek
25+
import java.time.DayOfWeek.MONDAY
26+
import java.time.DayOfWeek.WEDNESDAY
27+
import java.time.LocalDate
28+
import java.time.LocalTime
29+
import java.time.temporal.TemporalAdjusters.nextOrSame
30+
31+
class AbsenceCalculatorTest : FunSpec({
32+
33+
val monday: LocalDate = LocalDate.of(2026, 6, 1).with(nextOrSame(MONDAY))
34+
35+
fun subject(maxMissed: Int = 8, creation: LocalDate = monday, days: List<DayOfWeek> = listOf(MONDAY)) =
36+
Subject(
37+
id = 1,
38+
name = "Cálculo",
39+
maxMissedClasses = maxMissed,
40+
creationDate = creation,
41+
classTimes = days.map { ClassTime(it, LocalTime.of(8, 0), LocalTime.of(10, 0)) }
42+
)
43+
44+
test("a subject without class times never has missed classes") {
45+
AbsenceCalculator.missedClasses(subject(days = emptyList()), emptySet(), monday.plusWeeks(4)) shouldBe 0
46+
}
47+
48+
test("a brand new subject has no past classes to miss") {
49+
AbsenceCalculator.missedClasses(subject(), emptySet(), monday) shouldBe 0
50+
}
51+
52+
test("each past weekly class is missed when attendance was never recorded") {
53+
// The classes on monday, +1w and +2w are in the past; the +3w class is 'today' and is not counted.
54+
AbsenceCalculator.missedClasses(subject(), emptySet(), monday.plusWeeks(3)) shouldBe 3
55+
}
56+
57+
test("recorded attendance is not counted as a miss") {
58+
val attended = setOf(monday, monday.plusWeeks(1))
59+
AbsenceCalculator.missedClasses(subject(), attended, monday.plusWeeks(3)) shouldBe 1
60+
}
61+
62+
test("occurrences of every class time are counted") {
63+
val twiceAWeek = subject(days = listOf(MONDAY, WEDNESDAY))
64+
AbsenceCalculator.missedClasses(twiceAWeek, emptySet(), monday.plusWeeks(3)) shouldBe 6
65+
}
66+
67+
test("remaining misses subtracts from the allowance and may go negative") {
68+
AbsenceCalculator.remainingMisses(subject(maxMissed = 8), emptySet(), monday.plusWeeks(3)) shouldBe 5
69+
AbsenceCalculator.remainingMisses(subject(maxMissed = 2), emptySet(), monday.plusWeeks(3)) shouldBe -1
70+
}
71+
})

0 commit comments

Comments
 (0)