nordiccalendar

A simple Material 3 native calendar app fitting my personal requirements
git clone git://archive.git.mtrnord.blog/MTRNord/nordiccalendar.git
Log | Files | Refs | README | LICENSE

commit 25143143789adcf099983cc09562f945383b6b71
parent 0648f78d41aa83ec2c4ef1696210d0db655e5372
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Tue, 29 Jul 2025 20:33:19 +0200

Accessibility fixes and tests

Diffstat:
A.github/workflows/test.yml | 33+++++++++++++++++++++++++++++++++
Mapp/build.gradle.kts | 2++
Aapp/src/androidTest/java/space/midnightthoughts/nordiccalendar/AccessibilityTest.kt | 42++++++++++++++++++++++++++++++++++++++++++
Dapp/src/androidTest/java/space/midnightthoughts/nordiccalendar/ExampleInstrumentedTest.kt | 25-------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt | 18+++++++++++++-----
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 31+++++++++++++++++++++++++++----
Mgradle/libs.versions.toml | 4++++
8 files changed, 179 insertions(+), 74 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test Build + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Build and run unit tests + run: ./gradlew test + - name: Build and run instrumented tests (UI & Accessibility) + run: ./gradlew connectedAndroidTest + env: + # Set up a virtual display for instrumented tests + DISPLAY: ':99.0' + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + app/build/reports/ + app/build/outputs/androidTest-results/ + diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -84,6 +84,8 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.accessibility.test) + androidTestImplementation(libs.androidx.test.rules) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } \ No newline at end of file diff --git a/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/AccessibilityTest.kt b/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/AccessibilityTest.kt @@ -0,0 +1,42 @@ +package space.midnightthoughts.nordiccalendar + +import android.Manifest +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccessibilityTest { + @get:Rule + val composeTestRule = createAndroidComposeRule<MainActivity>() + + @get:Rule + var mRuntimePermissionRule: GrantPermissionRule? = + GrantPermissionRule.grant( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR, + Manifest.permission.INTERNET, + ) + + @Test + fun calendarScreen_accessibilityCheck() { + composeTestRule.enableAccessibilityChecks() + + // Navigate to the day screen + composeTestRule.onNodeWithTag("tab_2") + .performClick() + + + // Any action (such as performClick) will perform accessibility checks too: + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } +} + diff --git a/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/ExampleInstrumentedTest.kt b/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/ExampleInstrumentedTest.kt @@ -1,24 +0,0 @@ -package space.midnightthoughts.nordiccalendar - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("space.midnightthoughts.nordiccalendar", appContext.packageName) - } -} -\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt @@ -51,6 +51,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.components.AppScaffold @@ -73,7 +75,7 @@ sealed class Destinations(val route: String) { @AndroidEntryPoint class MainActivity : ComponentActivity() { - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val currentVersion = try { @@ -83,50 +85,66 @@ class MainActivity : ComponentActivity() { } val showOnboarding = OnboardingPrefs.isOnboardingNeeded(this, currentVersion) + setContent { NordicCalendarTheme { val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = if (showOnboarding) Destinations.Intro.route else Destinations.Calendar.route - ) { - composable(Destinations.Intro.route) { - IntroScreen(navController) { // Callback nach Abschluss - OnboardingPrefs.setOnboardingDone(this@MainActivity, currentVersion) - navController.navigate(Destinations.Calendar.route) { - popUpTo(Destinations.Intro.route) { inclusive = true } - } - } + + // TODO: Recheck calendar permissions and show dialog if not granted + val permissionsState = rememberMultiplePermissionsState( + listOf( + android.Manifest.permission.READ_CALENDAR, + android.Manifest.permission.WRITE_CALENDAR + ), + ) + + if (!permissionsState.allPermissionsGranted) { + LaunchedEffect(Unit) { + permissionsState.launchMultiplePermissionRequest() } - composable( - route = Destinations.Calendar.route, - arguments = listOf( - navArgument("tab") { - type = NavType.IntType - defaultValue = 0 + } else { + NavHost( + navController = navController, + startDestination = if (showOnboarding) Destinations.Intro.route else Destinations.Calendar.route + ) { + composable(Destinations.Intro.route) { + IntroScreen(navController) { // Callback nach Abschluss + OnboardingPrefs.setOnboardingDone(this@MainActivity, currentVersion) + navController.navigate(Destinations.Calendar.route) { + popUpTo(Destinations.Intro.route) { inclusive = true } + } } - ) - ) { backStackEntry -> - CalendarView( - backStackEntry = backStackEntry, - navController = navController - ) - } - composable(Destinations.EventDetails.route) { backStackEntry -> - EventDetailsView( - backStackEntry = backStackEntry, - navController = navController - ) - } - composable(Destinations.Settings.route) { backStackEntry -> - SettingsView( - navController = navController - ) - } - composable(Destinations.About.route) { backStackEntry -> - AboutView( - navController = navController - ) + } + composable( + route = Destinations.Calendar.route, + arguments = listOf( + navArgument("tab") { + type = NavType.IntType + defaultValue = 0 + } + ) + ) { backStackEntry -> + CalendarView( + backStackEntry = backStackEntry, + navController = navController + ) + } + composable(Destinations.EventDetails.route) { backStackEntry -> + EventDetailsView( + backStackEntry = backStackEntry, + navController = navController + ) + } + composable(Destinations.Settings.route) { backStackEntry -> + SettingsView( + navController = navController + ) + } + composable(Destinations.About.route) { backStackEntry -> + AboutView( + navController = navController + ) + } } } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt @@ -1,6 +1,7 @@ package space.midnightthoughts.nordiccalendar.components import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Menu @@ -20,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavController import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.R @@ -64,17 +66,23 @@ fun AppScaffold( }, navigationIcon = { if (isBackButtonVisible) { - IconButton(onClick = { - scope.launch { drawerState.close() } - navController.popBackStack() - }) { + IconButton( + onClick = { + scope.launch { drawerState.close() } + navController.popBackStack() + }, + modifier = Modifier.size(48.dp) + ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back) ) } } else { - IconButton(onClick = { scope.launch { drawerState.open() } }) { + IconButton( + onClick = { scope.launch { drawerState.open() } }, + modifier = Modifier.size(48.dp) + ) { Icon( Icons.Default.Menu, contentDescription = stringResource(R.string.menu) diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -54,6 +55,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CollectionInfo import androidx.compose.ui.semantics.CollectionItemInfo @@ -349,12 +351,27 @@ fun CalendarTabBar(selectedTab: Int, onTabSelected: (Int) -> Unit) { stringResource(R.string.tab_week), stringResource(R.string.tab_day) ) - TabRow(selectedTabIndex = selectedTab) { + TabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.semantics { + collectionInfo = CollectionInfo( + rowCount = 1, + columnCount = tabTitles.size, + ) + } + ) { tabTitles.forEachIndexed { index, title -> Tab( selected = selectedTab == index, onClick = { onTabSelected(index) }, - text = { Text(title) } + text = { Text(title) }, + modifier = Modifier + .semantics { + collectionItemInfo = CollectionItemInfo( + 0, 0, index, 0, + ) + } + .testTag("tab_$index") ) } } @@ -433,7 +450,10 @@ fun DateRangeHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton(onClick = onPrev) { + IconButton( + onClick = onPrev, + modifier = Modifier.size(48.dp) + ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.previous_period) @@ -448,7 +468,10 @@ fun DateRangeHeader( } } } - IconButton(onClick = onNext) { + IconButton( + onClick = onNext, + modifier = Modifier.size(48.dp) + ) { Icon( Icons.AutoMirrored.Filled.ArrowForward, contentDescription = stringResource(R.string.next_period) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ hiltNavigationCompose = "1.2.0" maplibreCompose = "0.10.4" ktorVersion = "3.2.3" preferenceVersion = "2.1.0" +accessibilityTestVersion = "1.8.3" +testRulesVersion = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -48,6 +50,8 @@ ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serializatio ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktorVersion" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktorVersion" } compose-preference = { group = "me.zhanghai.compose.preference", name = "preference", version.ref = "preferenceVersion" } +androidx-compose-ui-accessibility-test = { group = "androidx.compose.ui", name = "ui-test-junit4-accessibility", version.ref = "accessibilityTestVersion" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "testRulesVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }