├── app ├── .gitignore ├── keystore.jks ├── src │ ├── main │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ │ ├── ic_launcher-playstore.png │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── theme.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher_round.xml │ │ │ │ └── ic_launcher.xml │ │ │ ├── drawable │ │ │ │ ├── baseline_add_24.xml │ │ │ │ ├── baseline_warning_24.xml │ │ │ │ ├── baseline_delete_24.xml │ │ │ │ ├── baseline_arrow_back_24.xml │ │ │ │ ├── baseline_keyboard_arrow_left_24.xml │ │ │ │ ├── baseline_keyboard_arrow_right_24.xml │ │ │ │ ├── baseline_privacy_tip_24.xml │ │ │ │ ├── baseline_person_24.xml │ │ │ │ ├── baseline_contrast_24.xml │ │ │ │ ├── baseline_edit_24.xml │ │ │ │ ├── baseline_calendar_today_24.xml │ │ │ │ ├── baseline_table_chart_24.xml │ │ │ │ ├── baseline_save_24.xml │ │ │ │ ├── baseline_colorize_24.xml │ │ │ │ ├── baseline_access_time_24.xml │ │ │ │ ├── baseline_format_color_fill_24.xml │ │ │ │ ├── baseline_color_lens_24.xml │ │ │ │ ├── baseline_palette_24.xml │ │ │ │ ├── github.xml │ │ │ │ └── baseline_settings_24.xml │ │ │ ├── xml │ │ │ │ └── data_extraction_rules.xml │ │ │ └── values-ru │ │ │ │ └── strings.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── github │ │ │ │ └── rahul_gill │ │ │ │ └── attendance │ │ │ │ ├── util │ │ │ │ ├── Constants.kt │ │ │ │ ├── ApplicationContextInitializer.kt │ │ │ │ ├── DateTimeFormats.kt │ │ │ │ └── Preference.kt │ │ │ │ ├── db │ │ │ │ ├── CourseDetailsOverallItem.kt │ │ │ │ ├── ExtraClassDetails.kt │ │ │ │ ├── ClassDetail.kt │ │ │ │ ├── ExtraClassTimings.kt │ │ │ │ ├── CourseClassStatus.kt │ │ │ │ ├── SqldelightAdapters.kt │ │ │ │ ├── FutureThingCalculations.kt │ │ │ │ └── TodayCourseItem.kt │ │ │ │ ├── AttendanceApp.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── prefs │ │ │ │ └── PreferenceManager.kt │ │ │ │ └── ui │ │ │ │ ├── comps │ │ │ │ ├── Theme.kt │ │ │ │ ├── ScheduleItem.kt │ │ │ │ ├── SetClassStatusSheet.kt │ │ │ │ ├── Dialog.kt │ │ │ │ ├── Tabs.kt │ │ │ │ ├── Preference.kt │ │ │ │ ├── AddClassBottomSheet.kt │ │ │ │ └── Popup.kt │ │ │ │ └── screens │ │ │ │ ├── CourseEditScreen.kt │ │ │ │ └── CreateCourseScreen.kt │ │ ├── sqldelight │ │ │ ├── migrations │ │ │ │ └── 1.sqm │ │ │ └── com │ │ │ │ └── github │ │ │ │ └── rahul_gill │ │ │ │ └── attendance │ │ │ │ └── app.sq │ │ └── AndroidManifest.xml │ ├── debug │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── github │ │ │ └── rahul_gill │ │ │ └── attendance │ │ │ └── db │ │ │ └── FutureThingCalculationsTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── github │ │ └── rahul_gill │ │ └── attendance │ │ └── ScreenGrabTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── .github ├── FUNDING.yml ├── workflows │ ├── gradlew-validation.yml │ ├── android-build.yml │ └── pre-merge.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── Gemfile ├── fastlane ├── metadata │ └── android │ │ ├── en-US │ │ ├── title.txt │ │ ├── short_description.txt │ │ ├── images │ │ │ ├── icon.png │ │ │ └── phoneScreenshots │ │ │ │ ├── 5_settings_1715112573323.png │ │ │ │ ├── 4_create_course_1715112572076.png │ │ │ │ ├── 3_course_details_1715112569747.png │ │ │ │ ├── 1_main_screen_today_1715112567124.png │ │ │ │ └── 2_main_screen_overall_courses_1715112568781.png │ │ └── full_description.txt │ │ └── screenshots.html ├── Appfile ├── Screengrabfile └── Fastfile ├── .idea ├── .gitignore ├── kotlinc.xml ├── AndroidProjectSystem.xml ├── migrations.xml ├── deploymentTargetSelector.xml ├── androidTestResultsUserPreferences.xml ├── runConfigurations.xml └── inspectionProfiles │ └── Project_Default.xml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── config └── detekt │ ├── baseline.xml │ └── argsfile ├── gradle.properties ├── settings.gradle.kts ├── README.md ├── gradlew.bat ├── .gitignore ├── gradlew └── Gemfile.lock /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: rahul-gill 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Self Attendance Tracker -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /app/keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/keystore.jks -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Attendance Tracker for students with focus on UI and usability -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #F5E893 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rahul-gill/Self-Attendance-Tracker/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/values/theme.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 66 | 67 | 68 |

en-US

69 |
70 | 71 | 72 | 75 | 76 | 77 | 82 | 87 | 92 | 97 | 102 | 103 |
73 | phoneScreenshots 74 |
78 | 79 | en-US phoneScreenshots 80 | 81 | 83 | 84 | en-US phoneScreenshots 85 | 86 | 88 | 89 | en-US phoneScreenshots 90 | 91 | 93 | 94 | en-US phoneScreenshots 95 | 96 | 98 | 99 | en-US phoneScreenshots 100 | 101 |
104 |
105 | 106 |
107 |
108 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/rahul_gill/attendance/ui/comps/Tabs.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance.ui.comps 2 | 3 | 4 | import androidx.compose.foundation.Canvas 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.PaddingValues 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.RowScope 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.height 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.foundation.shape.RoundedCornerShape 19 | import androidx.compose.material.ripple.rememberRipple 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.minimumInteractiveComponentSize 23 | import androidx.compose.material3.ripple 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.mutableIntStateOf 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.draw.clip 32 | import androidx.compose.ui.geometry.Offset 33 | import androidx.compose.ui.graphics.Color 34 | import androidx.compose.ui.graphics.StrokeCap 35 | import androidx.compose.ui.layout.onGloballyPositioned 36 | import androidx.compose.ui.platform.LocalDensity 37 | import androidx.compose.ui.semantics.Role 38 | import androidx.compose.ui.text.font.FontWeight 39 | import androidx.compose.ui.unit.dp 40 | 41 | 42 | /** 43 | * Base composable for a group of multiple tabs, to be used as primary or secondary navigation utility. 44 | * Is actually only a wrapped [Row] 45 | * 46 | * @param modifier The [Modifier] to apply to the container 47 | * @param tabs The content, preferably multiple [TabItem]s or [CustomTabItem]s 48 | */ 49 | @Composable 50 | fun Tabs( 51 | modifier: Modifier = Modifier, 52 | tabs: @Composable RowScope.() -> Unit 53 | ) { 54 | Row( 55 | modifier = Modifier 56 | .fillMaxWidth() 57 | .then(modifier), 58 | verticalAlignment = Alignment.CenterVertically, 59 | horizontalArrangement = Arrangement.SpaceEvenly 60 | ) { 61 | tabs() 62 | } 63 | } 64 | 65 | 66 | 67 | /** 68 | * Composable for a one-ui style [TabItem], to be used in a [Tabs] row. 69 | * Note: For proper usage, every [TabItem] should have a weight of 1, to be applied via [Modifier.weight()] 70 | * 71 | * @param modifier The [Modifier] to be applied to the container 72 | * @param colors The [TabsColors] to apply 73 | * @param onClick The callback invoked when the [TabItem] is clicked 74 | * @param text The text to be shown on the tab 75 | * @param selected Whether this tab is selected or not 76 | * @param interactionSource The [MutableInteractionSource] 77 | */ 78 | @Composable 79 | fun TabItem( 80 | modifier: Modifier = Modifier, 81 | colors: TabsColors = tabsColors(), 82 | onClick: () -> Unit, 83 | enabled: Boolean = true, 84 | text: String, 85 | selected: Boolean, 86 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 87 | ) { 88 | Column( 89 | modifier = Modifier 90 | .clip(TabsDefaults.itemShape) 91 | .clickable( 92 | interactionSource = interactionSource, 93 | indication = ripple( 94 | color = colors.itemRipple 95 | ), 96 | role = Role.Tab, 97 | onClick = onClick, 98 | enabled = enabled 99 | ) 100 | .minimumInteractiveComponentSize() 101 | .padding(TabsDefaults.itemPadding) 102 | .then(modifier), 103 | verticalArrangement = Arrangement 104 | .spacedBy( 105 | TabsDefaults.itemIndicatorSpacing, 106 | alignment = Alignment.CenterVertically 107 | ), 108 | horizontalAlignment = Alignment.CenterHorizontally 109 | ) { 110 | var textWidth by remember { 111 | mutableIntStateOf(0) 112 | } 113 | 114 | Text( 115 | modifier = Modifier //We need to measure the width of the text at runtime 116 | .onGloballyPositioned { 117 | textWidth = it.size.width 118 | }, 119 | text = text, 120 | color = colors.itemIndicator, 121 | style = if(selected) MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) else MaterialTheme.typography.bodyLarge 122 | ) 123 | 124 | 125 | Box( 126 | modifier = Modifier 127 | .width(with(LocalDensity.current) { textWidth.toDp() }) 128 | .height(TabsDefaults.itemIndicatorHeight) 129 | ) { 130 | Canvas( 131 | modifier = Modifier 132 | .fillMaxSize() 133 | ) { 134 | drawLine( 135 | color = if (selected) colors.itemIndicator else Color.Transparent, 136 | start = Offset( 137 | x = 0F, 138 | y = size.height / 2F 139 | ), 140 | end = Offset( 141 | x = size.width, 142 | y = size.height / 2F 143 | ), 144 | strokeWidth = TabsDefaults.itemIndicatorHeight.toPx(), 145 | cap = StrokeCap.Round 146 | ) 147 | } 148 | } 149 | } 150 | } 151 | @Composable 152 | fun CustomTabItem( 153 | modifier: Modifier = Modifier, 154 | colors: TabsColors = tabsColors(), 155 | onClick: () -> Unit, 156 | enabled: Boolean = true, 157 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 158 | content: @Composable () -> Unit, 159 | ) { 160 | Box( 161 | modifier = modifier 162 | .height(TabsDefaults.customButtonHeight) 163 | .clip(TabsDefaults.itemShape) 164 | .clickable( 165 | interactionSource = interactionSource, 166 | indication = ripple( 167 | color = colors.itemRipple 168 | ), 169 | role = Role.Tab, 170 | onClick = onClick, 171 | enabled = enabled 172 | ), 173 | contentAlignment = Alignment.Center 174 | ) { 175 | content() 176 | } 177 | } 178 | 179 | 180 | /** 181 | * Contains the colors needed to constructs a [TabItem] 182 | */ 183 | data class TabsColors( 184 | 185 | val itemRipple: Color, 186 | 187 | val itemIndicator: Color, 188 | 189 | 190 | ) 191 | 192 | 193 | /** 194 | * Constructs the default [TabsColors] 195 | * 196 | * @param itemRipple Ripple color for the onclick-animation 197 | * @param itemIndicator Color of the selected item indicator 198 | */ 199 | @Composable 200 | fun tabsColors( 201 | itemRipple: Color = MaterialTheme.colorScheme.onSurface, 202 | itemIndicator: Color = MaterialTheme.colorScheme.primary, 203 | ): TabsColors = TabsColors( 204 | itemRipple = itemRipple, 205 | itemIndicator = itemIndicator 206 | ) 207 | 208 | /** 209 | * Contains default values for the [TabItem] 210 | */ 211 | object TabsDefaults { 212 | 213 | val itemIndicatorHeight = 2.dp 214 | 215 | val itemIndicatorSpacing = 1.dp 216 | 217 | val itemShape = RoundedCornerShape( 218 | size = 26.dp 219 | ) 220 | 221 | val itemSubShape = RoundedCornerShape( 222 | size = 23.dp 223 | ) 224 | 225 | val itemPadding = PaddingValues( 226 | start = 10.dp, 227 | end = 10.dp, 228 | top = 14.dp, 229 | bottom = 14.dp - itemIndicatorSpacing - itemIndicatorHeight 230 | ) 231 | 232 | val itemSubPadding = PaddingValues( 233 | all = 8.dp 234 | ) 235 | 236 | val customButtonHeight = 43.dp 237 | 238 | } -------------------------------------------------------------------------------- /app/src/main/res/values-ru/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Посещаемость 3 | Создать курс 4 | Название курса 5 | Сохранить 6 | Выбрать расписание 7 | ОК 8 | Название не может быть пустым 9 | Добавьте хотя бы один класс для этого курса 10 | Понедельник 11 | Воскресенье 12 | Суббота 13 | Пятница 14 | Четверг 15 | Среда 16 | Вторник 17 | Готово 18 | Отмена 19 | 20 | course_details_card_%1$d 21 | course_details_page 22 | 23 | app_theme 24 | Тема 25 | pure_dark 26 | Pure Dark 27 | material_you_dynamic_color 28 | Динамические цвета 29 | Оформление 30 | default_main_pager_tab 31 | Главный экран 32 | Настройки 33 | Формат времени 34 | Формат даты 35 | %.2f%% 36 | 37 | 38 | 39 | system 40 | light 41 | dark 42 | 43 | 44 | Как в системе 45 | Светлая тема 46 | Темная тема 47 | 48 | 49 | 50 | 51 | today 52 | overall 53 | 54 | 55 | Сегодня 56 | Общее 57 | 58 | 59 | 60 | d MMM, yyyy 61 | EEEE, MMMM d, yyyy 62 | MM/dd/yyy 63 | MMM dd, yyyy 64 | dd MMMM yyy 65 | 66 | 67 | hh:mm a 68 | HH:mm 69 | 70 | 71 | О курсу 72 | Доп. занятия 73 | Посещаемость 74 | 75 | 76 | 77 | День недели 78 | Начало 79 | Конец 80 | 81 | 82 | 83 | Дата 84 | Начало 85 | Конец 86 | 87 | 88 | Статус для %1$s с %2$s до %3$s%4$s %5$s 89 | (Доп. занятие) 90 | %1$s 91 | %1$s - %2$s 92 | По расписанию 93 | Доп. занятие 94 | 95 | Удалить курс: %1$s 96 | Вы уверены, что хотите удалить этот курс? Все данные будут навсегда утеряны. 97 | 98 | Нужно посещать: %1$d 99 | %1$s; %2$s - %3$s 100 | Удалить доп. занятие? 101 | Вы уверены, что хотите удалить занятие %1$s с %2$s до %3$s? 102 | 103 | Присутствие: %1$d 104 | Отсутствие: %1$d 105 | Отменено: %1$d 106 | 107 | Редактировать %1$s 108 | Было: %1$s 109 | Было: %d%% 110 | Теперь: %d%% 111 | 112 | Тема 113 | Назад 114 | Внешний вид 115 | Цвет темы 116 | Как в системе 117 | Черный фон 118 | Как в системе 119 | Светлая 120 | Темная 121 | Основной цвет 122 | Формат даты и времени 123 | Главный экран 124 | Занятия сегодня 125 | Другие настройки 126 | О приложении 127 | Исходный код 128 | Автор: Rahul Gill 129 | Очистить 130 | "Нужно посещать: %1$d%%" 131 | Добавить занятие 132 | Удалить занятие 133 | Курсы 134 | Нет занятий сегодня 135 | Нет курсов 136 | Курс %1$s создан 137 | Был 138 | Отсутствовал 139 | Отменено 140 | Не задано 141 | Текущая посещаемость 142 | Расписание 143 | Необходимая посещаемость 144 | Удалено из расписания 145 | Удалить из расписания 146 | Удалить %1$s с %2$s до %3$s 147 | Добавить занятие 148 | Посещаемость %1$s 149 | Удалить запись 150 | Удалить 151 | Время окончания должно быть позже начала 152 | Выберите день, время начала и окончания? 153 | Подсказка 154 | Курс обновлен 155 | Добавить в расписание 156 | Удаление записи также удалит и связанные данные о посещаемости 157 | Удалить и записи посещаемости 158 | Только удалить из расписания 159 | Исключить из расписания 160 | Включить в расписание 161 | Исключено из расписания 162 | Действие 163 | Для непомеченных занятий 164 | Считать присутствием 165 | Считать отсутствием 166 | Ничего не делать 167 | Можно пропустить: %1$d занятий 168 | Нужно посетить: %1$d занятий 169 | Нельзя пропустить следующее 170 | Политика конфиденциальности 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Attendance Tracker 3 | Create a course 4 | Course name 5 | Save 6 | Select schedule of classes 7 | OK 8 | Course name can\'t be blank 9 | Add at least one class for this course 10 | Monday 11 | Sunday 12 | Saturday 13 | Friday 14 | Thursday 15 | Wednesday 16 | Tuesday 17 | Done 18 | Cancel 19 | 20 | course_details_card_%1$d 21 | course_details_page 22 | 23 | app_theme 24 | App Theme 25 | pure_dark 26 | Pure Dark in Dark Theme 27 | material_you_dynamic_color 28 | Material You Dynamic Color 29 | Theming 30 | default_main_pager_tab 31 | Default Main Page Tab 32 | Settings 33 | Time Format 34 | Date Format 35 | %.2f%% 36 | 37 | 38 | 39 | system 40 | light 41 | dark 42 | 43 | 44 | Follow System theme 45 | Light theme 46 | Dark theme 47 | 48 | 49 | 50 | 51 | today 52 | overall 53 | 54 | 55 | Today 56 | Overall 57 | 58 | 59 | 60 | d MMM, yyyy 61 | EEEE, MMMM d, yyyy 62 | MM/dd/yyy 63 | MMM dd, yyyy 64 | dd MMMM yyy 65 | 66 | 67 | hh:mm a 68 | HH:mm 69 | 70 | 71 | Course Info 72 | Extra Classes 73 | Attendance Record 74 | 75 | 76 | 77 | Weekday 78 | Start Time 79 | End Time 80 | 81 | 82 | 83 | Date 84 | Start Time 85 | End Time 86 | 87 | 88 | Set Attendance Status for %1$s from %2$s to %3$s%4$s %5$s 89 | (Extra class) 90 | on %1$s 91 | %1$s to %2$s 92 | Scheduled Class 93 | Extra Class 94 | 95 | Delete course: %1$s 96 | Do you really want to delete this course item? You\'ll lose all the data associated with this course. 97 | 98 | Required Attendance: %1$d 99 | %1$s; %2$s to %3$s 100 | Delete extra class? 101 | Do you really want to delete the extra class on %1$s from %2$s to %3$s 102 | 103 | Presents: %1$d 104 | Absents: %1$d 105 | Cancelled classes: %1$d 106 | 107 | Edit details for %1$s 108 | Previous name: %1$s 109 | Previous value:: %d%% 110 | Required Attendance updated: %d%% 111 | 112 | App Theme 113 | Go to previous screen 114 | Look and Feel 115 | Custom Color Scheme Seed 116 | Follow System Colors 117 | Pure black background 118 | Follow System 119 | Light 120 | Dark 121 | Color to generate color scheme from 122 | Date Time Formatting 123 | Default Home Tab 124 | Today\'s Classes 125 | Other UI Options 126 | About 127 | Source Code 128 | Author: Rahul Gill 129 | Clear text 130 | "Required Attendance: %1$d%%" 131 | Add Class 132 | Remove Class 133 | Courses 134 | No classes today 135 | No courses added yet 136 | Course %1$s Created 137 | Present 138 | Absent 139 | Cancelled 140 | Not Set 141 | Current Attendance Percentage 142 | Weekly Schedule 143 | Required Attendance Percentage 144 | Deleted schedule item 145 | Delete schedule item 146 | Delete schedule item on %1$s from %2$s to %3$s 147 | Add class on this schedule 148 | %1$s Attendance Records 149 | Delete Record 150 | Delete 151 | End time should come after start time 152 | Select weekday, start time and end time for the new class 153 | Tip 154 | Course Updated 155 | Add schedule item 156 | Deleting schedule item will delete attendance records related to it 157 | Delete related attendance records too 158 | Only delete schedule item 159 | Exclude schedule item from schedule 160 | Include schedule item to schedule 161 | Excluded from schedule 162 | Behaviour 163 | Unset classes behaviour 164 | Consider as presents 165 | Consider as Absents 166 | Do nothing 167 | Can be absent in %1$d classes 168 | Need to attend %1$d more classes 169 | Cannot miss next claas 170 | Privacy Policy 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/rahul_gill/attendance/ui/screens/CreateCourseScreen.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance.ui.screens 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 6 | import androidx.compose.foundation.layout.FlowRow 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.imePadding 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.verticalScroll 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 18 | import androidx.compose.material.icons.filled.Clear 19 | import androidx.compose.material.icons.filled.Close 20 | import androidx.compose.material3.ExperimentalMaterial3Api 21 | import androidx.compose.material3.ExtendedFloatingActionButton 22 | import androidx.compose.material3.Icon 23 | import androidx.compose.material3.IconButton 24 | import androidx.compose.material3.LargeTopAppBar 25 | import androidx.compose.material3.MaterialTheme 26 | import androidx.compose.material3.OutlinedButton 27 | import androidx.compose.material3.OutlinedCard 28 | import androidx.compose.material3.OutlinedTextField 29 | import androidx.compose.material3.Scaffold 30 | import androidx.compose.material3.Slider 31 | import androidx.compose.material3.SnackbarHost 32 | import androidx.compose.material3.SnackbarHostState 33 | import androidx.compose.material3.Text 34 | import androidx.compose.material3.TopAppBarDefaults 35 | import androidx.compose.material3.rememberTopAppBarState 36 | import androidx.compose.runtime.Composable 37 | import androidx.compose.runtime.getValue 38 | import androidx.compose.runtime.mutableIntStateOf 39 | import androidx.compose.runtime.mutableStateListOf 40 | import androidx.compose.runtime.mutableStateOf 41 | import androidx.compose.runtime.remember 42 | import androidx.compose.runtime.rememberCoroutineScope 43 | import androidx.compose.runtime.saveable.listSaver 44 | import androidx.compose.runtime.saveable.rememberSaveable 45 | import androidx.compose.runtime.setValue 46 | import androidx.compose.runtime.toMutableStateList 47 | import androidx.compose.ui.Alignment 48 | import androidx.compose.ui.Modifier 49 | import androidx.compose.ui.input.nestedscroll.nestedScroll 50 | import androidx.compose.ui.platform.LocalContext 51 | import androidx.compose.ui.platform.testTag 52 | import androidx.compose.ui.res.painterResource 53 | import androidx.compose.ui.res.stringResource 54 | import androidx.compose.ui.unit.dp 55 | import com.github.rahul_gill.attendance.R 56 | import com.github.rahul_gill.attendance.db.DBOps 57 | import com.github.rahul_gill.attendance.ui.comps.AddClassBottomSheet 58 | import com.github.rahul_gill.attendance.db.ClassDetail 59 | import com.github.rahul_gill.attendance.ui.comps.ScheduleItem 60 | import com.github.rahul_gill.attendance.util.timeFormatter 61 | import kotlinx.coroutines.launch 62 | 63 | 64 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 65 | @Composable 66 | fun CreateCourseScreen( 67 | onGoBack: () -> Unit, 68 | onSave: (courseName: String, requiredPercentage: Int, classes: List) -> Unit 69 | ) { 70 | var courseName by rememberSaveable { 71 | mutableStateOf("") 72 | } 73 | var requiredAttendancePercentage by rememberSaveable { 74 | mutableIntStateOf(75) 75 | } 76 | val classesForTheCourse = rememberSaveable( 77 | saver = listSaver( 78 | save = { it.toList() }, 79 | restore = { it.toMutableStateList() } 80 | ) 81 | ) { 82 | mutableStateListOf() 83 | } 84 | 85 | var showAddClassBottomSheet by rememberSaveable { 86 | mutableStateOf(false) 87 | } 88 | var classToUpdateIndex: Int? by rememberSaveable { 89 | mutableStateOf(null) 90 | } 91 | val scope = rememberCoroutineScope() 92 | val snackbarHostState = remember { SnackbarHostState() } 93 | val context = LocalContext.current 94 | 95 | val scrollBehavior = 96 | TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) 97 | Scaffold( 98 | snackbarHost = { 99 | SnackbarHost(hostState = snackbarHostState) 100 | }, 101 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), 102 | topBar = { 103 | LargeTopAppBar(title = { 104 | Text( 105 | text = stringResource(id = R.string.create_a_course), 106 | ) 107 | }, navigationIcon = { 108 | IconButton(onClick = onGoBack, modifier = Modifier.testTag("go_back")) { 109 | Icon( 110 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 111 | contentDescription = stringResource(id = R.string.go_back_screen) 112 | ) 113 | } 114 | }, scrollBehavior = scrollBehavior 115 | ) 116 | }, 117 | floatingActionButton = { 118 | ExtendedFloatingActionButton( 119 | onClick = onClickSave@{ 120 | if (courseName.isBlank()) { 121 | scope.launch { 122 | snackbarHostState.showSnackbar( 123 | message = context.getString(R.string.error_course_name_blank), 124 | withDismissAction = true 125 | ) 126 | } 127 | return@onClickSave 128 | } 129 | if (classesForTheCourse.isEmpty()) { 130 | scope.launch { 131 | snackbarHostState.showSnackbar( 132 | message = context.getString(R.string.error_no_classes_for_course), 133 | withDismissAction = true 134 | ) 135 | } 136 | return@onClickSave 137 | } 138 | onSave(courseName.trim(), requiredAttendancePercentage, classesForTheCourse.toList()) 139 | onGoBack() 140 | }, 141 | modifier = Modifier.imePadding() 142 | ) { 143 | Icon( 144 | painter = painterResource(id = R.drawable.baseline_save_24), 145 | contentDescription = null 146 | ) 147 | Text(text = stringResource(id = R.string.save)) 148 | } 149 | } 150 | ) { innerPadding -> 151 | Column( 152 | modifier = Modifier 153 | .verticalScroll(rememberScrollState()) 154 | .padding(innerPadding) 155 | .padding(horizontal = 16.dp) 156 | ) { 157 | Spacer(modifier = Modifier.height(16.dp)) 158 | OutlinedTextField( 159 | value = courseName, 160 | onValueChange = { courseName = it }, 161 | maxLines = 1, 162 | trailingIcon = { 163 | if (courseName.isNotBlank()) { 164 | IconButton(onClick = { courseName = "" }) { 165 | Icon( 166 | imageVector = Icons.Default.Clear, 167 | contentDescription = stringResource(id = R.string.clear_text) 168 | ) 169 | } 170 | } 171 | }, 172 | modifier = Modifier.fillMaxWidth().testTag("course_name_input"), 173 | label = { 174 | Text(text = stringResource(id = R.string.course_name)) 175 | } 176 | ) 177 | Spacer(modifier = Modifier.height(16.dp)) 178 | Text( 179 | text = stringResource( 180 | id = R.string.required_attendance_text, 181 | requiredAttendancePercentage 182 | ) 183 | ) 184 | Spacer(modifier = Modifier.height(8.dp)) 185 | Slider( 186 | value = requiredAttendancePercentage.toFloat(), 187 | onValueChange = { requiredAttendancePercentage = it.toInt() }, 188 | steps = 100, 189 | valueRange = 1f..100f 190 | ) 191 | Spacer(modifier = Modifier.height(16.dp)) 192 | Text( 193 | text = stringResource(id = R.string.select_schedule_of_classes), 194 | style = MaterialTheme.typography.titleLarge 195 | ) 196 | Column { 197 | classesForTheCourse.forEachIndexed { index, classDetail -> 198 | Spacer(modifier = Modifier.height(8.dp)) 199 | ScheduleItem( 200 | item = classDetail, 201 | onClick = { 202 | classToUpdateIndex = index 203 | showAddClassBottomSheet = true 204 | }, 205 | onCloseClick = { 206 | classesForTheCourse.removeAt(index) 207 | } 208 | ) 209 | } 210 | } 211 | 212 | Spacer(modifier = Modifier.height(8.dp)) 213 | 214 | OutlinedButton(onClick = { showAddClassBottomSheet = true }, 215 | modifier = Modifier.testTag("add_class_button")) { 216 | Text(text = stringResource(id = R.string.add_class)) 217 | } 218 | } 219 | } 220 | 221 | if (showAddClassBottomSheet) { 222 | AddClassBottomSheet( 223 | initialState = classToUpdateIndex?.run { classesForTheCourse[this] }, 224 | onDismissRequest = { showAddClassBottomSheet = false }, 225 | onCreateClass = { params -> 226 | classToUpdateIndex?.let { 227 | classesForTheCourse[it] = params 228 | classToUpdateIndex = null 229 | } ?: classesForTheCourse.add(params) 230 | } 231 | ) 232 | } 233 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/rahul_gill/attendance/ui/comps/Preference.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance.ui.comps 2 | 3 | 4 | //import androidx.compose.ui.tooling.preview.Preview 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.PaddingValues 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.heightIn 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.width 17 | import androidx.compose.foundation.layout.wrapContentWidth 18 | import androidx.compose.foundation.rememberScrollState 19 | import androidx.compose.foundation.selection.selectable 20 | import androidx.compose.foundation.selection.selectableGroup 21 | import androidx.compose.foundation.shape.RoundedCornerShape 22 | import androidx.compose.foundation.verticalScroll 23 | import androidx.compose.material3.LocalTextStyle 24 | import androidx.compose.material3.MaterialTheme 25 | import androidx.compose.material3.RadioButton 26 | import androidx.compose.material3.Switch 27 | import androidx.compose.material3.Text 28 | import androidx.compose.material3.TextButton 29 | import androidx.compose.material3.minimumInteractiveComponentSize 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableIntStateOf 33 | import androidx.compose.runtime.mutableStateOf 34 | import androidx.compose.runtime.remember 35 | import androidx.compose.runtime.setValue 36 | import androidx.compose.ui.Alignment 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.draw.clip 39 | import androidx.compose.ui.graphics.Color 40 | import androidx.compose.ui.res.stringResource 41 | import androidx.compose.ui.semantics.Role 42 | import androidx.compose.ui.text.font.FontWeight 43 | import androidx.compose.ui.unit.dp 44 | import androidx.compose.ui.unit.sp 45 | import com.github.rahul_gill.attendance.R 46 | 47 | 48 | private val GroupHeaderStartPadding = 16.dp 49 | private const val GroupHeaderFontSizeMultiplier = 0.85f 50 | private val PrefItemMinHeight = 72.dp 51 | 52 | @Composable 53 | fun PreferenceGroupHeader( 54 | modifier: Modifier = Modifier, 55 | title: String, 56 | color: Color = MaterialTheme.colorScheme.primary 57 | ) { 58 | Box( 59 | Modifier 60 | .padding(start = GroupHeaderStartPadding) 61 | .fillMaxWidth() 62 | .then(modifier), 63 | contentAlignment = Alignment.CenterStart 64 | ) { 65 | Text( 66 | title, 67 | color = color, 68 | fontSize = LocalTextStyle.current.fontSize.times(GroupHeaderFontSizeMultiplier), 69 | fontWeight = FontWeight.SemiBold 70 | ) 71 | } 72 | } 73 | 74 | @Composable 75 | fun GenericPreference( 76 | title: String, 77 | modifier: Modifier = Modifier, 78 | leadingIcon: @Composable (() -> Unit)? = null, 79 | trailingContent: @Composable (() -> Unit)? = null, 80 | onClick: (() -> Unit)? = null, 81 | summary: String? = null, 82 | contentPadding: PaddingValues = PaddingValues(24.dp), 83 | placeholderSpaceForLeadingIcon: Boolean = true 84 | ) { 85 | Row( 86 | modifier = Modifier 87 | 88 | .clip(RoundedCornerShape(20)) 89 | .clickable( 90 | onClick = onClick ?: {} 91 | ) 92 | .heightIn(min = PrefItemMinHeight) 93 | .fillMaxWidth() 94 | .padding(contentPadding) 95 | .then(modifier), 96 | verticalAlignment = Alignment.CenterVertically 97 | ) { 98 | if (leadingIcon == null && placeholderSpaceForLeadingIcon) { 99 | Box(Modifier.minimumInteractiveComponentSize()) {} 100 | } else if (leadingIcon != null) { 101 | Box( 102 | modifier = Modifier.minimumInteractiveComponentSize().align(Alignment.Top), 103 | contentAlignment = Alignment.Center 104 | ) { 105 | leadingIcon() 106 | } 107 | } 108 | Spacer(modifier = Modifier.width(8.dp)) 109 | Column( 110 | modifier = Modifier 111 | .weight(1f) 112 | .align(Alignment.CenterVertically) 113 | ) { 114 | Text( 115 | text = title, 116 | fontSize = 19.sp 117 | ) 118 | if (summary != null) { 119 | Spacer(modifier = Modifier.height(4.dp)) 120 | Text( 121 | text = summary, 122 | fontSize = 15.sp, 123 | modifier = Modifier.wrapContentWidth() 124 | ) 125 | } 126 | } 127 | if (trailingContent != null) { 128 | Spacer( 129 | modifier = Modifier 130 | .width(8.dp) 131 | ) 132 | trailingContent() 133 | } 134 | } 135 | } 136 | 137 | 138 | @Composable 139 | fun ListPreference( 140 | title: String, 141 | items: List, 142 | selectedItemIndex: Int, 143 | onItemSelection: (Int) -> Unit, 144 | itemToDescription: @Composable (Int) -> String, 145 | modifier: Modifier = Modifier, 146 | leadingIcon: @Composable (() -> Unit)? = null, 147 | placeholderForIcon: Boolean = true, 148 | selectItemOnClick: Boolean = true 149 | ) { 150 | val isShowingSelectionDialog = remember { 151 | mutableStateOf(false) 152 | } 153 | GenericPreference( 154 | title = title, 155 | leadingIcon = leadingIcon, 156 | summary = itemToDescription(selectedItemIndex), 157 | modifier = modifier, 158 | placeholderSpaceForLeadingIcon = placeholderForIcon, 159 | onClick = { 160 | isShowingSelectionDialog.value = true 161 | }, 162 | ) 163 | if (isShowingSelectionDialog.value) { 164 | var dialogSelectedItemIndex by remember { 165 | mutableIntStateOf(selectedItemIndex) 166 | } 167 | AlertDialog( 168 | onDismissRequest = { 169 | isShowingSelectionDialog.value = false 170 | }, 171 | title = { 172 | Text(text = title) 173 | }, 174 | body = { 175 | Column( 176 | Modifier 177 | .selectableGroup() 178 | .verticalScroll(rememberScrollState()) 179 | ) { 180 | items.forEachIndexed { index, choice -> 181 | Row( 182 | Modifier 183 | .fillMaxWidth() 184 | .height(56.dp) 185 | .selectable( 186 | selected = (index == dialogSelectedItemIndex), 187 | onClick = { 188 | dialogSelectedItemIndex = index 189 | if (selectItemOnClick) { 190 | onItemSelection(index) 191 | } 192 | }, 193 | role = Role.RadioButton 194 | ) 195 | .padding(horizontal = 16.dp), 196 | verticalAlignment = Alignment.CenterVertically 197 | ) { 198 | RadioButton( 199 | selected = (index == dialogSelectedItemIndex), 200 | onClick = null 201 | ) 202 | Text( 203 | text = itemToDescription(index), 204 | style = MaterialTheme.typography.bodyLarge, 205 | modifier = Modifier.padding(start = 16.dp) 206 | ) 207 | } 208 | } 209 | } 210 | }, 211 | buttonBar = { 212 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 213 | TextButton(onClick = { isShowingSelectionDialog.value = false }) { 214 | Text(text = stringResource(id = R.string.cancel)) 215 | } 216 | Spacer(modifier = Modifier.width(8.dp)) 217 | TextButton(onClick = { 218 | if (!selectItemOnClick) { 219 | onItemSelection(dialogSelectedItemIndex) 220 | } 221 | isShowingSelectionDialog.value = false 222 | }) { 223 | Text(text = stringResource(id = R.string.ok)) 224 | } 225 | } 226 | } 227 | ) 228 | } 229 | } 230 | 231 | @Composable 232 | fun SwitchPreference( 233 | title: String, 234 | isChecked: Boolean, 235 | modifier: Modifier = Modifier, 236 | summary: String? = null, 237 | onCheckedChange: ((Boolean) -> Unit)? = null, 238 | isEnabled: Boolean = true, 239 | leadingIcon: @Composable (() -> Unit)? = null, 240 | placeholderForIcon: Boolean = true, 241 | ) { 242 | println(" 4523423 SwitchPreference isChecked: $isChecked") 243 | GenericPreference( 244 | title = title, 245 | onClick = { 246 | if (onCheckedChange != null) { 247 | 248 | println(" 4523423 SwitchPreference set isChecked: ${!isChecked}") 249 | onCheckedChange(!isChecked) 250 | } 251 | }, 252 | modifier = modifier, 253 | summary = summary, 254 | leadingIcon = leadingIcon, 255 | placeholderSpaceForLeadingIcon = placeholderForIcon, 256 | trailingContent = { 257 | 258 | println("4523423 calling Switch isChecked: $isChecked") 259 | Switch( 260 | modifier = modifier, 261 | checked = isChecked, 262 | onCheckedChange = null, 263 | enabled = isEnabled 264 | ) 265 | }) 266 | } 267 | 268 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/rahul_gill/attendance/ui/comps/AddClassBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance.ui.comps 2 | 3 | import android.text.format.DateFormat 4 | import android.widget.Toast 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.PaddingValues 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.heightIn 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.pager.HorizontalPager 16 | import androidx.compose.foundation.pager.rememberPagerState 17 | import androidx.compose.foundation.selection.selectable 18 | import androidx.compose.foundation.selection.selectableGroup 19 | import androidx.compose.material3.ExperimentalMaterial3Api 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.RadioButton 22 | import androidx.compose.material3.Text 23 | import androidx.compose.material3.TextButton 24 | import androidx.compose.material3.TimePicker 25 | import androidx.compose.material3.TimePickerState 26 | import androidx.compose.material3.rememberTimePickerState 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.LaunchedEffect 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.runtime.mutableIntStateOf 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.rememberCoroutineScope 34 | import androidx.compose.runtime.saveable.Saver 35 | import androidx.compose.runtime.saveable.rememberSaveable 36 | import androidx.compose.runtime.setValue 37 | import androidx.compose.ui.Alignment 38 | import androidx.compose.ui.Modifier 39 | import androidx.compose.ui.layout.onSizeChanged 40 | import androidx.compose.ui.platform.LocalContext 41 | import androidx.compose.ui.platform.LocalDensity 42 | import androidx.compose.ui.platform.testTag 43 | import androidx.compose.ui.res.stringArrayResource 44 | import androidx.compose.ui.res.stringResource 45 | import androidx.compose.ui.semantics.Role 46 | import androidx.compose.ui.unit.dp 47 | import com.github.rahul_gill.attendance.R 48 | import com.github.rahul_gill.attendance.db.ClassDetail 49 | import kotlinx.coroutines.launch 50 | import java.time.DayOfWeek 51 | import java.time.LocalDate 52 | import java.time.LocalTime 53 | import java.time.format.TextStyle 54 | import java.util.Locale 55 | 56 | 57 | private fun defaultClassDetailWithTimeAdjusted(): ClassDetail { 58 | val start = LocalTime.now().withMinute(0) 59 | return ClassDetail( 60 | startTime = start, 61 | endTime = start.plusHours(1) 62 | ) 63 | } 64 | 65 | 66 | @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) 67 | @Composable 68 | fun AddClassBottomSheet( 69 | initialState: ClassDetail? = null, 70 | onDismissRequest: () -> Unit, 71 | onCreateClass: (ClassDetail) -> Unit 72 | ) { 73 | var dayOfWeekSelected by rememberSaveable { 74 | mutableStateOf(initialState?.dayOfWeek ?: LocalDate.now().dayOfWeek) 75 | } 76 | val context = LocalContext.current 77 | val startTimeState = rememberTimePickerState( 78 | initialHour = initialState?.startTime?.hour ?: 0, 79 | initialMinute = initialState?.startTime?.minute ?: 0, 80 | is24Hour = DateFormat.is24HourFormat(context), 81 | ) 82 | var endTimeState by rememberSaveable( 83 | saver = Saver( 84 | save = { 85 | listOf( 86 | it.value.hour, 87 | it.value.minute, 88 | it.value.is24hour 89 | ) 90 | }, 91 | restore = { value -> 92 | mutableStateOf( 93 | TimePickerState( 94 | initialHour = value[0] as Int, 95 | initialMinute = value[1] as Int, 96 | is24Hour = value[2] as Boolean 97 | ) 98 | ) 99 | } 100 | ) 101 | ) { 102 | mutableStateOf( 103 | TimePickerState( 104 | initialHour = initialState?.endTime?.hour ?: 0, 105 | initialMinute = initialState?.endTime?.minute ?: 0, 106 | is24Hour = DateFormat.is24HourFormat(context), 107 | ) 108 | ) 109 | } 110 | LaunchedEffect(startTimeState.hour, startTimeState.minute) { 111 | endTimeState = TimePickerState( 112 | initialHour = (startTimeState.hour + 1) % 24, 113 | initialMinute = startTimeState.minute, 114 | is24Hour = DateFormat.is24HourFormat(context), 115 | ) 116 | } 117 | LaunchedEffect(endTimeState.hour, endTimeState.minute) { 118 | val newEnd = LocalTime.of(endTimeState.hour, endTimeState.minute) 119 | val start = LocalTime.of(startTimeState.hour, startTimeState.minute) 120 | if (newEnd <= start) { 121 | Toast.makeText( 122 | context, 123 | context.getString(R.string.err_end_time_should_be_after_start_time), 124 | Toast.LENGTH_SHORT 125 | ).show() 126 | endTimeState = TimePickerState( 127 | initialHour = (startTimeState.hour + 1) % 24, 128 | initialMinute = startTimeState.minute, 129 | is24Hour = DateFormat.is24HourFormat(context), 130 | ) 131 | } 132 | } 133 | BaseDialog( 134 | onDismissRequest = onDismissRequest, 135 | dialogPadding = PaddingValues(0.dp) 136 | ) { 137 | Text( 138 | text = stringResource(R.string.select_weekday_start_time_and_end_time_for_the_new_class), 139 | style = MaterialTheme.typography.titleMedium, 140 | modifier = Modifier.padding(16.dp) 141 | ) 142 | val tabs = stringArrayResource(id = R.array.add_schedule_class_bottom_sheet_tabs) 143 | val pagerState = rememberPagerState(pageCount = { tabs.size }) 144 | val scope = rememberCoroutineScope() 145 | 146 | Tabs { 147 | tabs.forEachIndexed { index, tabName -> 148 | TabItem( 149 | onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, 150 | text = tabName, 151 | selected = pagerState.currentPage == index 152 | ) 153 | } 154 | } 155 | var pagerMinSize by remember { 156 | mutableIntStateOf(0) 157 | } 158 | HorizontalPager( 159 | state = pagerState, 160 | modifier = Modifier.heightIn(min = with(LocalDensity.current) { pagerMinSize.toDp() }) 161 | ) { page -> 162 | when (page) { 163 | 0 -> { 164 | Column( 165 | Modifier 166 | .selectableGroup() 167 | .onSizeChanged { pagerMinSize = maxOf(pagerMinSize, it.height) }) { 168 | DayOfWeek.entries.forEach { dayOfWeek -> 169 | Row( 170 | Modifier 171 | .fillMaxWidth() 172 | .height(56.dp) 173 | .selectable( 174 | selected = (dayOfWeekSelected == dayOfWeek), 175 | onClick = { dayOfWeekSelected = dayOfWeek }, 176 | role = Role.RadioButton 177 | ) 178 | .padding(horizontal = 16.dp), 179 | verticalAlignment = Alignment.CenterVertically 180 | ) { 181 | RadioButton( 182 | selected = (dayOfWeek == dayOfWeekSelected), 183 | onClick = null // null recommended for accessibility with screenreaders 184 | ) 185 | Text( 186 | text = dayOfWeek.getDisplayName( 187 | TextStyle.FULL, 188 | Locale.getDefault() 189 | ), 190 | style = MaterialTheme.typography.bodyLarge, 191 | modifier = Modifier.padding(start = 16.dp) 192 | ) 193 | } 194 | } 195 | } 196 | } 197 | 1 -> { 198 | Box( 199 | modifier = Modifier 200 | .fillMaxWidth() 201 | .onSizeChanged { pagerMinSize = maxOf(pagerMinSize, it.height) }, 202 | contentAlignment = Alignment.Center 203 | ) { 204 | TimePicker(state = startTimeState) 205 | } 206 | } 207 | 2 -> { 208 | Box( 209 | modifier = Modifier 210 | .fillMaxWidth() 211 | .onSizeChanged { pagerMinSize = maxOf(pagerMinSize, it.height) }, 212 | contentAlignment = Alignment.Center 213 | ) { 214 | TimePicker(state = endTimeState) 215 | } 216 | } 217 | } 218 | } 219 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 220 | TextButton(onClick = onDismissRequest) { 221 | Text(text = stringResource(id = R.string.cancel)) 222 | } 223 | TextButton(onClick = { 224 | onCreateClass( 225 | ClassDetail( 226 | dayOfWeek = dayOfWeekSelected, 227 | startTime = LocalTime.of(startTimeState.hour, startTimeState.minute), 228 | endTime = LocalTime.of(endTimeState.hour, endTimeState.minute) 229 | ) 230 | ) 231 | onDismissRequest() 232 | }, modifier = Modifier.testTag("sheet_add_class_button") 233 | ) { 234 | Text(text = stringResource(id = R.string.ok)) 235 | } 236 | } 237 | } 238 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/github/rahul_gill/attendance/ScreenGrabTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance 2 | 3 | import androidx.activity.ComponentActivity 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Surface 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.input.key.Key 10 | import androidx.compose.ui.test.ExperimentalTestApi 11 | import androidx.compose.ui.test.junit4.AndroidComposeTestRule 12 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 13 | import androidx.compose.ui.test.junit4.createComposeRule 14 | import androidx.compose.ui.test.onNodeWithTag 15 | import androidx.compose.ui.test.onNodeWithText 16 | import androidx.compose.ui.test.performClick 17 | import androidx.compose.ui.test.performKeyInput 18 | import androidx.compose.ui.test.pressKey 19 | import androidx.test.ext.junit.rules.ActivityScenarioRule 20 | import androidx.test.ext.junit.runners.AndroidJUnit4 21 | import com.github.rahul_gill.attendance.db.AttendanceCounts 22 | import com.github.rahul_gill.attendance.db.AttendanceRecordHybrid 23 | import com.github.rahul_gill.attendance.db.ClassDetail 24 | import com.github.rahul_gill.attendance.db.CourseClassStatus 25 | import com.github.rahul_gill.attendance.db.CourseDetailsOverallItem 26 | import com.github.rahul_gill.attendance.db.DBOps 27 | import com.github.rahul_gill.attendance.db.ExtraClassTimings 28 | import com.github.rahul_gill.attendance.prefs.PreferenceManager 29 | import com.github.rahul_gill.attendance.prefs.UnsetClassesBehavior 30 | import com.github.rahul_gill.attendance.ui.RootNavHost 31 | import com.github.rahul_gill.attendance.ui.comps.AttendanceAppTheme 32 | import com.github.rahul_gill.attendance.ui.comps.ColorSchemeType 33 | import kotlinx.coroutines.delay 34 | import kotlinx.coroutines.flow.first 35 | import kotlinx.coroutines.flow.firstOrNull 36 | import kotlinx.coroutines.runBlocking 37 | import org.junit.Before 38 | import org.junit.Rule 39 | import org.junit.Test 40 | import org.junit.runner.RunWith 41 | import tools.fastlane.screengrab.Screengrab 42 | import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy 43 | import tools.fastlane.screengrab.locale.LocaleTestRule 44 | import java.time.DayOfWeek 45 | import java.time.LocalDate 46 | import java.time.LocalTime 47 | import java.time.temporal.TemporalAdjusters 48 | 49 | 50 | @RunWith(AndroidJUnit4::class) 51 | class ScreenGrabTest { 52 | @get:Rule 53 | val composeTestRule = createComposeRule() 54 | 55 | @Rule 56 | @JvmField 57 | val localeTestRule = LocaleTestRule() 58 | 59 | @Before 60 | fun init() { 61 | val dbOps = DBOps.instance 62 | Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy()) 63 | courses.forEach { 64 | dbOps.createCourse( 65 | name = it.courseName, 66 | requiredAttendancePercentage = it.requiredAttendance, 67 | schedule = listOf( 68 | ClassDetail( 69 | dayOfWeek = DayOfWeek.MONDAY, 70 | ), 71 | ClassDetail( 72 | dayOfWeek = DayOfWeek.WEDNESDAY, 73 | ) 74 | ) 75 | ) 76 | } 77 | runBlocking { 78 | dbOps.getCoursesDetailsList().firstOrNull()?.let { courses -> 79 | courses.forEachIndexed { courseIndex, course -> 80 | val schedule = dbOps.getScheduleClassesForCourse(course.courseId).first() 81 | val dateX = LocalDate.now() 82 | .with(TemporalAdjusters.firstInMonth(schedule.first().dayOfWeek)) 83 | for (i in 0..10) { 84 | dbOps.markAttendanceForScheduleClass( 85 | attendanceId = null, 86 | classStatus = if (course.courseName == "Chemistry" && (i == 2 || i == 4)) 87 | CourseClassStatus.Absent 88 | else CourseClassStatus.Present, 89 | scheduleId = schedule.first().scheduleId, 90 | date = dateX.minusWeeks(i.toLong()), 91 | courseId = course.courseId 92 | ) 93 | } 94 | if (courseIndex % 2 == 1) { 95 | dbOps.createExtraClasses( 96 | courseId = course.courseId, 97 | timings = ExtraClassTimings( 98 | date = LocalDate.now(), 99 | startTime = LocalTime.of(13, 0), 100 | endTime = LocalTime.of(14, 0) 101 | ) 102 | ) 103 | } else { 104 | dbOps.markAttendanceForScheduleClass( 105 | attendanceId = null, 106 | classStatus = CourseClassStatus.Unset, 107 | scheduleId = schedule.first().scheduleId, 108 | date = LocalDate.now(), 109 | courseId = course.courseId 110 | ) 111 | } 112 | } 113 | } 114 | } 115 | } 116 | 117 | @OptIn(ExperimentalTestApi::class) 118 | @Test 119 | fun mainScreenScreenshots() { 120 | composeTestRule.setContent { 121 | AttendanceAppTheme( 122 | colorSchemeType = ColorSchemeType.WithSeed(Color.Green) 123 | ) { 124 | Surface( 125 | modifier = Modifier 126 | .fillMaxSize(), 127 | color = MaterialTheme.colorScheme.background 128 | ) { 129 | RootNavHost( 130 | ) 131 | } 132 | } 133 | } 134 | //main screen 135 | composeTestRule.takeScreenshot("1_main_screen_today") 136 | composeTestRule.onNodeWithTag(testTag = "courses_button").performClick() 137 | composeTestRule.takeScreenshot("2_main_screen_overall_courses") 138 | //course details 139 | composeTestRule.onNodeWithText(text = "Chemistry").performClick() 140 | composeTestRule.takeScreenshot("3_course_details") 141 | composeTestRule.onNodeWithTag(testTag = "go_back").performClick() 142 | //create course 143 | composeTestRule.onNodeWithTag(testTag = "create_course_button").performClick() 144 | composeTestRule.onNodeWithTag(testTag = "course_name_input").run { 145 | performClick() 146 | performKeyInput { listOf(Key.M, Key.A, Key.T, Key.H, Key.S).forEach { pressKey(it) } } 147 | } 148 | composeTestRule.onNodeWithTag(testTag = "add_class_button").performClick() 149 | composeTestRule.onNodeWithTag(testTag = "sheet_add_class_button").performClick() 150 | composeTestRule.takeScreenshot("4_create_course") 151 | composeTestRule.onNodeWithTag(testTag = "go_back").performClick() 152 | //settings 153 | composeTestRule.onNodeWithTag(testTag = "go_to_settings").performClick() 154 | composeTestRule.takeScreenshot("5_settings") 155 | } 156 | 157 | 158 | private val courses = listOf( 159 | "Physics", 160 | "Chemistry", 161 | "Social", 162 | "Fighting lab", 163 | "French", 164 | "Mathematics", 165 | "English", 166 | "Politics" 167 | ).mapIndexed { indx, name -> 168 | val presents = 10 169 | val absents = if (indx == 4) 2 else 0 170 | val cancels = 0 171 | CourseDetailsOverallItem( 172 | courseId = indx.toLong(), 173 | courseName = name, 174 | requiredAttendance = 75.0, 175 | currentAttendancePercentage = 100.0 * presents / (presents + absents), 176 | presents = presents.toInt(), 177 | absents = absents.toInt(), 178 | cancels = cancels.toInt() 179 | ) 180 | } 181 | private val demoTodayClasses: List> 182 | get() { 183 | val date = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) 184 | var startTime = LocalTime.of(10, 0) 185 | val endTime = { startTime.plusHours(1) } 186 | val list = mutableListOf() 187 | 188 | for (i in 0..7) { 189 | if (i == 5) { 190 | AttendanceRecordHybrid.ExtraClass( 191 | extraClassId = i.toLong(), 192 | startTime = startTime, 193 | endTime = endTime(), 194 | courseName = courses[i].courseName, 195 | date = date, 196 | classStatus = CourseClassStatus.Present, 197 | courseId = i.toLong() 198 | ) 199 | } else { 200 | list.add( 201 | AttendanceRecordHybrid.ScheduledClass( 202 | attendanceId = i.toLong(), 203 | scheduleId = i.toLong(), 204 | startTime = startTime, 205 | endTime = endTime(), 206 | courseName = courses[i].courseName, 207 | date = date, 208 | classStatus = if (i == 2) CourseClassStatus.Absent else CourseClassStatus.Present, 209 | courseId = i.toLong() 210 | ) 211 | ) 212 | } 213 | startTime = startTime.plusHours(1) 214 | } 215 | 216 | 217 | return list.map { record -> 218 | Pair( 219 | record, 220 | courses.find { it.courseId == record.courseId }!!.run { 221 | val presentsLater = 222 | (presents + if (PreferenceManager.unsetClassesBehavior.value == UnsetClassesBehavior.ConsiderPresent) 223 | unsets else 0).toLong() 224 | val absentsLater = 225 | (absents + if (PreferenceManager.unsetClassesBehavior.value == UnsetClassesBehavior.ConsiderAbsent) 226 | unsets else 0).toLong() 227 | AttendanceCounts( 228 | if (absentsLater + presentsLater == 0L) 100.0 else 100.0 * presentsLater / (absentsLater + presentsLater), 229 | presentsLater, 230 | absentsLater, 231 | cancels.toLong(), 232 | unsets.toLong(), 233 | requiredAttendance 234 | ) 235 | } 236 | ) 237 | } 238 | } 239 | 240 | private fun AndroidComposeTestRule, T>.takeScreenshot( 241 | screenshotName: String 242 | ) { 243 | runBlocking { 244 | awaitIdle() 245 | delay(100) 246 | Screengrab.screenshot(screenshotName) 247 | } 248 | } 249 | 250 | private fun ComposeContentTestRule.takeScreenshot( 251 | screenshotName: String 252 | ) { 253 | runBlocking { 254 | awaitIdle() 255 | delay(100) 256 | Screengrab.screenshot(screenshotName) 257 | } 258 | } 259 | } -------------------------------------------------------------------------------- /app/src/main/sqldelight/com/github/rahul_gill/attendance/app.sq: -------------------------------------------------------------------------------- 1 | import com.github.rahul_gill.attendance.db.CourseClassStatus; 2 | import java.time.DayOfWeek; 3 | import java.time.LocalDate; 4 | import java.time.LocalTime; 5 | 6 | CREATE TABLE Course ( 7 | courseId INTEGER PRIMARY KEY AUTOINCREMENT, 8 | courseName TEXT NOT NULL, 9 | requiredAttendance REAL NOT NULL 10 | ); 11 | 12 | CREATE TABLE Schedule ( 13 | scheduleId INTEGER PRIMARY KEY AUTOINCREMENT, 14 | courseId INTEGER NOT NULL, 15 | weekday INTEGER AS DayOfWeek NOT NULL, 16 | startTime TEXT AS LocalTime NOT NULL, 17 | endTime TEXT AS LocalTime NOT NULL, 18 | includedInSchedule INTEGER NOT NULL DEFAULT 0, 19 | CONSTRAINT fk_course 20 | FOREIGN KEY (courseId) 21 | REFERENCES Course (courseId) 22 | ON DELETE CASCADE 23 | ); 24 | 25 | CREATE TABLE Attendance ( 26 | attendanceId INTEGER PRIMARY KEY AUTOINCREMENT, 27 | scheduleId INTEGER, 28 | courseId INTEGER, 29 | classStatus TEXT AS CourseClassStatus NOT NULL, 30 | date TEXT AS LocalDate NOT NULL, 31 | CONSTRAINT fk_schedule 32 | FOREIGN KEY (scheduleId) 33 | REFERENCES Schedule (scheduleId) 34 | ON DELETE CASCADE, 35 | CONSTRAINT fk_course 36 | FOREIGN KEY (courseId) 37 | REFERENCES Course (courseId) 38 | ON DELETE CASCADE 39 | ); 40 | 41 | CREATE TABLE ExtraClasses ( 42 | extraClassId INTEGER PRIMARY KEY AUTOINCREMENT, 43 | courseId INTEGER NOT NULL, 44 | date TEXT AS LocalDate NOT NULL, 45 | startTime TEXT AS LocalTime NOT NULL, 46 | endTime TEXT AS LocalTime NOT NULL, 47 | classStatus TEXT AS CourseClassStatus NOT NULL, 48 | CONSTRAINT fk_course 49 | FOREIGN KEY (courseId) 50 | REFERENCES Course (courseId) 51 | ON DELETE CASCADE 52 | ); 53 | 54 | getCourseListForToday: 55 | SELECT Attendance.attendanceId, Schedule.scheduleId, Course.courseId, Course.courseName, Schedule.startTime, Schedule.endTime, 56 | CASE WHEN Attendance.classStatus IS NULL THEN 'Unset' 57 | ELSE Attendance.classStatus 58 | END AS classStatus, 59 | Attendance.date 60 | FROM Schedule 61 | JOIN Course ON Schedule.courseId = Course.courseId AND Schedule.weekday = strftime('%w', 'now') 62 | LEFT JOIN Attendance ON Schedule.scheduleId = Attendance.scheduleId AND Attendance.date = DATE('now', 'localtime') 63 | WHERE Schedule.includedInSchedule <> 0 64 | AND DATE('now', 'localtime') = Attendance.date 65 | OR (Attendance.scheduleId IS NULL AND DATE('now', 'localtime') = DATE('now', 'localtime')); 66 | 67 | getExtraClassesListForToday: 68 | SELECT Course.courseId, Course.courseName, ExtraClasses.startTime, ExtraClasses.endTime, ExtraClasses.classStatus, ExtraClasses.extraClassId, ExtraClasses.date 69 | FROM Course 70 | JOIN ExtraClasses ON Course.courseId = ExtraClasses.courseId 71 | WHERE ExtraClasses.date = DATE('now', 'localtime'); 72 | 73 | changeActivateStatusOfScheduleItem: 74 | UPDATE Schedule 75 | SET includedInSchedule = :activate 76 | WHERE scheduleId = :scheduleId; 77 | 78 | 79 | 80 | createCourse: 81 | INSERT INTO Course (courseName, requiredAttendance) 82 | VALUES ( ?, ?); 83 | 84 | udpateCourse: 85 | UPDATE Course SET courseName = ?, requiredAttendance = ? 86 | WHERE courseId = ?; 87 | 88 | deleteScheduleItem: 89 | DELETE FROM Schedule WHERE Schedule.scheduleId = ?; 90 | 91 | deleteAttendanceRecordsOnSchedule: 92 | DELETE FROM Attendance WHERE Attendance.scheduleId = ?; 93 | 94 | 95 | 96 | getLastInsertRowID: 97 | SELECT last_insert_rowid(); 98 | 99 | createScheduleItemForCourse: 100 | INSERT INTO Schedule (courseId, weekday, startTime, endTime, includedInSchedule) 101 | VALUES ( ?, ?, ?, ?, ?); 102 | 103 | updateScheduleItemForCourse: 104 | UPDATE Schedule 105 | SET weekday = ?, startTime = ?, endTime = ?, includedInSchedule = ? 106 | WHERE scheduleId = ?; 107 | 108 | scheduleExists: 109 | SELECT COUNT(*) FROM Schedule WHERE scheduleId = ?; 110 | 111 | getCourseDetails: 112 | SELECT 113 | Course.courseId, 114 | Course.courseName, 115 | Course.requiredAttendance, 116 | GROUP_CONCAT(Schedule.weekday || ' ' || Schedule.startTime || ' ' || Schedule.endTime) AS scheduleDetails, 117 | COALESCE((SELECT COUNT(*) FROM Attendance WHERE Attendance.scheduleId IN (SELECT scheduleId FROM Schedule WHERE Schedule.courseId = Course.courseId) AND Attendance.classStatus = 'Present') / NULLIF((SELECT COUNT(*) FROM Attendance WHERE Attendance.scheduleId IN (SELECT scheduleId FROM Schedule WHERE Schedule.courseId = Course.courseId)), 0) * 100, 0) AS currentAttendancePercentage, 118 | (SELECT COUNT(*) FROM Schedule WHERE Schedule.courseId = Course.courseId AND Schedule.weekday = strftime('%w', 'now')) AS hasClassesToday, 119 | (SELECT GROUP_CONCAT(startTime || '-' || endTime) FROM Schedule WHERE Schedule.courseId = Course.courseId AND Schedule.weekday = strftime('%w', 'now')) AS classesToday 120 | FROM 121 | Course 122 | JOIN 123 | Schedule ON Course.courseId = Schedule.courseId 124 | WHERE 125 | Schedule.includedInSchedule = 1 AND Course.courseId = ? 126 | GROUP BY 127 | Course.courseName, Course.requiredAttendance; 128 | 129 | -- TODO: take into account unset items which have a date before today (or not or both with two queries) 130 | getCoursesDetailsList: 131 | SELECT 132 | Course.courseId, 133 | Course.courseName, 134 | Course.requiredAttendance, 135 | (SELECT COUNT(*) FROM Schedule WHERE Schedule.courseId = Course.courseId AND Schedule.weekday = strftime('%w', 'now')) AS numClassesToday, 136 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Present') 137 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Present')) AS nPresents, 138 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Absent') 139 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Absent')) AS nAbsents, 140 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Cancelled') 141 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Cancelled')) AS nCancels, 142 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Unset') 143 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Unset')) AS nUnsets 144 | FROM 145 | Course; 146 | 147 | getCoursesDetailsWithId: 148 | SELECT 149 | Course.courseId, 150 | Course.courseName, 151 | Course.requiredAttendance, 152 | (SELECT COUNT(*) FROM Schedule WHERE Schedule.courseId = Course.courseId AND Schedule.weekday = strftime('%w', 'now')) AS numClassesToday, 153 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Present') 154 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Present')) AS nPresents, 155 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Absent') 156 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Absent')) AS nAbsents, 157 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Cancelled') 158 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Cancelled')) AS nCancels, 159 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Unset') 160 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Unset')) AS nUnsets 161 | FROM 162 | Course 163 | WHERE Course.courseId = ?; 164 | 165 | getScheduleClassesForCourse: 166 | SELECT * FROM Schedule WHERE Schedule.courseId = ? ORDER BY Schedule.includedInSchedule, Schedule.weekday, Schedule.startTime; 167 | 168 | getExtraClasssesForCourse: 169 | SELECT * FROM ExtraClasses WHERE ExtraClasses.courseId = ? ORDER BY ExtraClasses.date DESC, ExtraClasses.startTime; 170 | 171 | createExtraClass: 172 | INSERT INTO ExtraClasses(courseId, date, startTime, endTime, classStatus) 173 | VALUES (?, ?, ?, ?, ?); 174 | 175 | updateExtraClassStatus: 176 | UPDATE ExtraClasses 177 | SET classStatus = :status 178 | WHERE extraClassId = :extraClassId; 179 | 180 | markAttendance: 181 | INSERT OR REPLACE INTO Attendance (attendanceId, classStatus, scheduleId, date, courseId) 182 | VALUES (?, ?, ?, ?, ?); 183 | 184 | markAttendanceInsert: 185 | INSERT INTO Attendance (classStatus, scheduleId, date, courseId) 186 | VALUES ( ?, ?, ?, ?); 187 | 188 | 189 | markAttendanceUpdate: 190 | UPDATE Attendance SET classStatus = :status WHERE attendanceId = :attendanceId; 191 | 192 | checkOverlappingSchedule: 193 | SELECT COUNT(*) = 0 194 | FROM Schedule 195 | WHERE :weekDay = Schedule.weekday AND( 196 | --completely inside 197 | (:newStartTime >= startTime AND :newStartTime <= endTime) OR 198 | --overlapping on left side 199 | (:newEndTime > startTime AND :newEndTime <= endTime) OR 200 | --overlapping on left side 201 | (:newStartTime >= startTime AND :newStartTime < endTime) OR 202 | --other entry completely inside it 203 | (:newStartTime <= startTime AND :newEndTime >= endTime) 204 | ); 205 | 206 | 207 | deleteCourse: 208 | DELETE FROM Course WHERE Course.courseId = ?; 209 | 210 | deleteExtraClass: 211 | DELETE FROM ExtraClasses WHERE ExtraClasses.extraClassId = ?; 212 | 213 | deleteScheduleAttendanceRecord: 214 | DELETE FROM Attendance WHERE Attendance.attendanceId = ?; 215 | 216 | 217 | markedAttendancesForCourse: 218 | SELECT a.attendanceId AS entityId, a.scheduleId AS scheduleId, a.date, s.startTime, s.endTime, a.classStatus, 0 AS isExtraCLass, course.courseName, course.courseId 219 | FROM Attendance a, Schedule s, Course course 220 | WHERE a.scheduleId = s.scheduleId AND s.courseId = :courseId AND s.courseId = course.courseId 221 | UNION 222 | SELECT e.extraClassId AS entityId, NULL AS scheduleId, e.date, e.startTime, e.endTime, e.classStatus, 1 AS isExtraCLass , course.courseName, course.courseId 223 | FROM ExtraClasses e, Course course 224 | WHERE e.courseId = :courseId AND e.courseId = course.courseId; 225 | 226 | getCourseDetailsSingle: 227 | SELECT 228 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Present') 229 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Present')) AS nPresents, 230 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Absent') 231 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Absent')) AS nAbsents, 232 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Cancelled') 233 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Cancelled')) AS nCancels, 234 | 235 | ((SELECT COUNT(*) AS presents FROM Attendance WHERE scheduleId IN (SELECT scheduleId FROM Schedule WHERE courseId = Course.courseId) AND classStatus = 'Unset') 236 | + (SELECT COUNT(*) AS presents FROM ExtraClasses WHERE courseId = Course.courseId AND classStatus = 'Unset')) AS nUnsets, 237 | Course.requiredAttendance 238 | FROM Course 239 | WHERE courseId = ?; 240 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/rahul_gill/attendance/ui/comps/Popup.kt: -------------------------------------------------------------------------------- 1 | package com.github.rahul_gill.attendance.ui.comps 2 | 3 | 4 | import androidx.compose.animation.core.MutableTransitionState 5 | import androidx.compose.animation.core.animateFloat 6 | import androidx.compose.animation.core.rememberTransition 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.animation.core.updateTransition 9 | import androidx.compose.foundation.background 10 | import androidx.compose.foundation.border 11 | import androidx.compose.foundation.clickable 12 | import androidx.compose.foundation.interaction.MutableInteractionSource 13 | import androidx.compose.foundation.layout.Arrangement 14 | import androidx.compose.foundation.layout.Box 15 | import androidx.compose.foundation.layout.Column 16 | import androidx.compose.foundation.layout.ColumnScope 17 | import androidx.compose.foundation.layout.IntrinsicSize 18 | import androidx.compose.foundation.layout.PaddingValues 19 | import androidx.compose.foundation.layout.Row 20 | import androidx.compose.foundation.layout.Spacer 21 | import androidx.compose.foundation.layout.fillMaxWidth 22 | import androidx.compose.foundation.layout.padding 23 | import androidx.compose.foundation.layout.width 24 | import androidx.compose.foundation.shape.RoundedCornerShape 25 | import androidx.compose.material.ripple.rememberRipple 26 | import androidx.compose.material3.LocalContentColor 27 | import androidx.compose.material3.MaterialTheme 28 | import androidx.compose.material3.Text 29 | import androidx.compose.material3.ripple 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.CompositionLocalProvider 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.draw.alpha 37 | import androidx.compose.ui.draw.clip 38 | import androidx.compose.ui.draw.shadow 39 | import androidx.compose.ui.graphics.Color 40 | import androidx.compose.ui.graphics.graphicsLayer 41 | import androidx.compose.ui.platform.LocalDensity 42 | import androidx.compose.ui.semantics.Role 43 | import androidx.compose.ui.unit.dp 44 | import androidx.compose.ui.window.Popup 45 | import androidx.compose.ui.window.PopupPositionProvider 46 | import androidx.compose.ui.window.PopupProperties 47 | 48 | /** 49 | * Composable for a oneui-style popup emnu for selecting different kind of items. 50 | * Can be used in spinners or other menus 51 | * 52 | * TODO: Exit animation is not playing due to implementation struggles 53 | * 54 | * @param modifier The [Modifier] to apply to the container 55 | * @param colors The [MenuColors] to apply 56 | * @param visible Whether the menu is currently visible 57 | * @param onDismissRequest Callback for when the menu is dismissed 58 | * @param properties The [PopupProperties] to apply 59 | * @param content The content to put inside the Menu. Preferably [SelectableMenuItem]s. Arranged along the y-Axis 60 | */ 61 | @Composable 62 | fun PopupMenu( 63 | modifier: Modifier = Modifier, 64 | colors: MenuColors = menuColors(), 65 | visible: Boolean = true, 66 | onDismissRequest: () -> Unit, 67 | properties: PopupProperties = PopupProperties( 68 | focusable = true 69 | ), 70 | content: @Composable ColumnScope.() -> Unit 71 | ) { 72 | Popup( 73 | onDismissRequest = onDismissRequest, 74 | properties = properties 75 | ) { 76 | PopupContent( 77 | modifier = modifier, 78 | visible = visible, 79 | colors = colors 80 | ) { 81 | content() 82 | } 83 | } 84 | } 85 | 86 | 87 | /** 88 | * Overload that takes in a [PopupPositionProvider] 89 | * Can be used in spinners or other menus 90 | * 91 | * TODO: Exit animation is not playing due to implementation struggles 92 | * 93 | * @param modifier The [Modifier] to apply to the container 94 | * @param colors The [MenuColors] to apply 95 | * @param visible Whether the menu is currently visible 96 | * @param onDismissRequest Callback for when the menu is dismissed 97 | * @param properties The [PopupProperties] to apply 98 | * @param popupPositionProvider The [PopupPositionProvider] to position the popup 99 | * @param content The content to put inside the Menu. Preferably [SelectableMenuItem]s. Arranged along the y-Axis 100 | */ 101 | @Composable 102 | fun PopupMenu( 103 | modifier: Modifier = Modifier, 104 | colors: MenuColors = menuColors(), 105 | visible: Boolean = true, 106 | onDismissRequest: () -> Unit, 107 | properties: PopupProperties = PopupProperties( 108 | focusable = true 109 | ), 110 | popupPositionProvider: PopupPositionProvider, 111 | content: @Composable ColumnScope.() -> Unit 112 | ) { 113 | Popup( 114 | onDismissRequest = onDismissRequest, 115 | properties = properties, 116 | popupPositionProvider = popupPositionProvider 117 | ) { 118 | PopupContent( 119 | modifier = modifier, 120 | visible = visible, 121 | colors = colors 122 | ) { 123 | content() 124 | } 125 | } 126 | } 127 | 128 | @Composable 129 | private fun PopupContent( 130 | modifier: Modifier = Modifier, 131 | visible: Boolean, 132 | colors: MenuColors, 133 | content: @Composable ColumnScope.() -> Unit 134 | ) { 135 | val expandedState = remember { MutableTransitionState(false) } 136 | expandedState.targetState = visible 137 | 138 | val transition = rememberTransition(expandedState, "Menu fade in/out") 139 | 140 | val size by transition.animateFloat( 141 | transitionSpec = { 142 | tween(MenuDefaults.animDuration) 143 | }, 144 | label = "Menu fade in/out size" 145 | ) { 146 | if (it) 1F else MenuDefaults.animSizeMin 147 | } 148 | 149 | val alpha by transition.animateFloat( 150 | transitionSpec = { 151 | tween(MenuDefaults.animDuration) 152 | }, 153 | label = "Menu fade in/out size" 154 | ) { 155 | if (it) 1F else 0F 156 | } 157 | 158 | Box( 159 | modifier = modifier 160 | .alpha(alpha) 161 | .graphicsLayer { 162 | scaleX = size 163 | scaleY = size 164 | } 165 | .width(IntrinsicSize.Max) 166 | .padding(MenuDefaults.margin) 167 | .shadow( 168 | elevation = MenuDefaults.elevation * alpha, 169 | shape = MenuDefaults.shape 170 | ) 171 | .background( 172 | colors.background, 173 | shape = MenuDefaults.shape 174 | ) 175 | .clip( 176 | shape = MenuDefaults.shape 177 | ) 178 | .border( 179 | width = with(LocalDensity.current) { MenuDefaults.strokeWidthPx.toDp() }, 180 | color = colors.stroke, 181 | shape = MenuDefaults.shape 182 | ), 183 | contentAlignment = Alignment.TopStart 184 | ) { 185 | Column( 186 | verticalArrangement = Arrangement.Top, 187 | horizontalAlignment = Alignment.Start 188 | ) { 189 | content(this) 190 | } 191 | } 192 | } 193 | 194 | /** 195 | * Contains the colors that define a [PopupMenu] 196 | */ 197 | data class MenuColors( 198 | 199 | val background: Color, 200 | 201 | val stroke: Color 202 | 203 | ) 204 | 205 | /** 206 | * Constructs the default colors for a [PopupMenu] 207 | * 208 | * @param background The color used for the background of the menu 209 | * @param stroke The color used to outline the popup 210 | * @return The [MenuColors] 211 | */ 212 | @Composable 213 | fun menuColors( 214 | background: Color = MaterialTheme.colorScheme.surface, 215 | stroke: Color = MaterialTheme.colorScheme.primary 216 | ): MenuColors = MenuColors( 217 | background = background, 218 | stroke = stroke 219 | ) 220 | 221 | /** 222 | * Contains default values for a [PopupMenu] 223 | */ 224 | object MenuDefaults { 225 | 226 | val shape = RoundedCornerShape( 227 | 25 228 | ) 229 | 230 | val elevation = 4.dp 231 | 232 | val margin = 16.dp 233 | 234 | const val animDuration = 500 235 | 236 | const val animSizeMin = 0.75F 237 | 238 | const val strokeWidthPx = 1 239 | 240 | } 241 | 242 | 243 | 244 | 245 | 246 | @Composable 247 | fun SelectableMenuItem( 248 | modifier: Modifier = Modifier, 249 | colors: MenuItemColors = menuItemColors(), 250 | onSelect: (() -> Unit)? = null, 251 | enabled: Boolean = true, 252 | label: String, 253 | selected: Boolean = false, 254 | labelStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium, 255 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } 256 | ) { 257 | Row( 258 | modifier = modifier 259 | .fillMaxWidth() 260 | .clickable( 261 | interactionSource = interactionSource, 262 | indication = ripple( 263 | color = colors.ripple 264 | ), 265 | role = Role.DropdownList, 266 | onClick = { onSelect?.let { it() } }, 267 | enabled = enabled 268 | ) 269 | .padding(MenuItemDefaults.padding), 270 | verticalAlignment = Alignment.CenterVertically, 271 | horizontalArrangement = Arrangement.SpaceBetween 272 | ) { 273 | CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { 274 | Text( 275 | text = label, 276 | style = labelStyle, 277 | color = MaterialTheme.colorScheme.primary 278 | ) 279 | Spacer( 280 | modifier = Modifier 281 | .width(MenuItemDefaults.iconSpacing) 282 | ) 283 | } 284 | } 285 | } 286 | 287 | 288 | /** 289 | * Composable for a oneui-style menu item, to be used in combination with a [PopupMenu] in a menu. 290 | * 291 | * TODO: Add support for start-icons 292 | * 293 | * @param modifier The modifier to apply 294 | * @param label The string-label 295 | * @param onClick The callback for when an item is clicked 296 | * @param labelStyle The [TextStyle] of the string-label 297 | * @param interactionSource The [MutableInteractionSource] 298 | * @param colors The [MenuItemColors] to apply 299 | * @param padding The [PaddingValues] to apply 300 | */ 301 | @Composable 302 | fun MenuItem( 303 | modifier: Modifier = Modifier, 304 | colors: MenuItemColors = menuItemColors(), 305 | onClick: (() -> Unit)? = null, 306 | enabled: Boolean = true, 307 | label: String, 308 | labelStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium, 309 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } 310 | ) { 311 | Row( 312 | modifier = modifier 313 | .fillMaxWidth() 314 | .clickable( 315 | interactionSource = interactionSource, 316 | indication = ripple( 317 | color = colors.ripple 318 | ), 319 | role = Role.DropdownList, 320 | onClick = { onClick?.let { it() } }, 321 | enabled = enabled 322 | ) 323 | .padding(MenuItemDefaults.padding), 324 | verticalAlignment = Alignment.CenterVertically, 325 | horizontalArrangement = Arrangement.SpaceBetween 326 | ) { 327 | Text( 328 | text = label, 329 | style = labelStyle 330 | ) 331 | } 332 | } 333 | 334 | /** 335 | * Contains the colors that define a [SelectableMenuItem] 336 | */ 337 | data class MenuItemColors( 338 | 339 | val ripple: Color 340 | 341 | ) 342 | 343 | /** 344 | * Constructs the default [MenuItemColors] 345 | * 346 | * @param ripple The ripple color when clicking an item 347 | * @return The [MenuItemColors] 348 | */ 349 | @Composable 350 | fun menuItemColors( 351 | ripple: Color = MaterialTheme.colorScheme.onSurfaceVariant 352 | ): MenuItemColors = MenuItemColors( 353 | ripple = ripple 354 | ) 355 | 356 | /** 357 | * Contains default values for a [SelectableMenuItem] 358 | */ 359 | object MenuItemDefaults { 360 | 361 | val padding = PaddingValues( 362 | top = 13.dp, 363 | end = 24.dp, 364 | bottom = 13.dp, 365 | start = 24.dp 366 | ) 367 | 368 | val iconSpacing = 8.dp 369 | 370 | } --------------------------------------------------------------------------------