commit a0cef3250a2ab790da571cb1f34d15d9e3d80305 Author: MTRNord <MTRNord@users.noreply.github.com> Date: Mon, 28 Jul 2025 00:06:41 +0200 Initial commit Diffstat:
45 files changed, 2234 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore @@ -0,0 +1 @@ +/build +\ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "space.midnightthoughts.nordiccalendar" + compileSdk = 36 + + defaultConfig { + applicationId = "space.midnightthoughts.nordiccalendar" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0-exp" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.accompanist.permissions) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.foundation.foundation) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} +\ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +\ No newline at end of file diff --git a/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/ExampleInstrumentedTest.kt b/app/src/androidTest/java/space/midnightthoughts/nordiccalendar/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.READ_CALENDAR" /> + <uses-permission android:name="android.permission.WRITE_CALENDAR" /> + + <application + android:allowBackup="true" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/backup_rules" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.NordicCalendar"> + <activity + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.NordicCalendar"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> +\ 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 @@ -0,0 +1,317 @@ +package space.midnightthoughts.nordiccalendar + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.components.AppScaffold +import space.midnightthoughts.nordiccalendar.onboarding.OnBoardItem +import space.midnightthoughts.nordiccalendar.onboarding.onBoardingData +import space.midnightthoughts.nordiccalendar.screens.CalendarScreen +import space.midnightthoughts.nordiccalendar.ui.theme.NordicCalendarTheme +import space.midnightthoughts.nordiccalendar.util.OnboardingPrefs +import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel + +sealed class Destinations(val route: String) { + object Intro : Destinations("intro") + object Calendar : Destinations("calendar?tab={tab}") // Route mit optionalem Tab-Argument + object Settings : Destinations("settings") + object About : Destinations("about") + object EventDetails : Destinations("eventDetails/{eventId}") +} + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val currentVersion = try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-exp" + } catch (_: PackageManager.NameNotFoundException) { + "0.1.0-exp" + } + 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 } + } + } + } + composable( + route = Destinations.Calendar.route, + arguments = listOf( + navArgument("tab") { + type = NavType.IntType + defaultValue = 0 + } + ) + ) { backStackEntry -> + val calendarViewModel: CalendarViewModel = viewModel() + val selectedTab by calendarViewModel.selectedTab.collectAsState() + val calendars by calendarViewModel.calendars.collectAsState() + val selectedCalendars by calendarViewModel.selectedCalendars.collectAsState() + val events by calendarViewModel.events.collectAsState() + val isRefreshing by calendarViewModel.isRefreshing.collectAsState() + val tabArg = backStackEntry.arguments?.getInt("tab") + // Tab nur beim initialen Aufruf setzen, nicht bei jedem Recomposition + val didSetTab = remember { mutableStateOf(false) } + if (!didSetTab.value && tabArg != null) { + calendarViewModel.setTab(tabArg) + didSetTab.value = true + } + AppScaffold( + title = "NordicCalendar", + calendars = calendars, + selectedCalendars = selectedCalendars, + selectedDestination = "calendar", + onCalendarToggle = { cal -> calendarViewModel.toggleCalendar(cal) }, + onSettingsClick = { navController.navigate(Destinations.Settings.route) }, + onAboutClick = { navController.navigate(Destinations.About.route) }, + onCalendarClick = { navController.navigate(Destinations.Calendar.route + "&tab=$selectedTab") }, + floatingActionButton = { + FloatingActionButton(onClick = { /* Event hinzufügen (später) */ }) { + Icon(Icons.Default.Add, contentDescription = "Event hinzufügen") + } + } + ) { innerPadding -> + CalendarScreen( + selectedTab = selectedTab, + onTabSelected = { calendarViewModel.setTab(it) }, + events = events, + modifier = innerPadding, + isRefreshing = isRefreshing, + onRefresh = { calendarViewModel.updateEvents() }, + navController = navController + ) + } + } + composable(Destinations.EventDetails.route) { backStackEntry -> + val eventId = backStackEntry.arguments?.getString("eventId") + AppScaffold( + title = "Event Details", + calendars = emptyList(), + selectedCalendars = emptyList(), + selectedDestination = "eventDetails", + onCalendarToggle = {}, + onSettingsClick = { navController.navigate(Destinations.Settings.route) }, + onAboutClick = { navController.navigate(Destinations.About.route) }, + onCalendarClick = { navController.navigate(Destinations.Calendar.route + "&tab=2") }, + onBackClick = { + navController.navigate(Destinations.Calendar.route + "&tab=2") { + popUpTo(Destinations.EventDetails.route) { inclusive = true } + } + } + ) { innerPadding -> + Column( + modifier = innerPadding + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Details für Event ID: $eventId") + Spacer(modifier = Modifier.height(24.dp)) + // Weitere Event-Details hier + } + } + } + composable(Destinations.Settings.route) { + val calendarViewModel: CalendarViewModel = viewModel() + val calendars by calendarViewModel.calendars.collectAsState() + val selectedCalendars by calendarViewModel.selectedCalendars.collectAsState() + AppScaffold( + title = "Einstellungen", + calendars = calendars, + selectedCalendars = selectedCalendars, + selectedDestination = "settings", + onCalendarToggle = { cal -> calendarViewModel.toggleCalendar(cal) }, + onSettingsClick = { navController.navigate(Destinations.Settings.route) }, + onAboutClick = { navController.navigate(Destinations.About.route) }, + onCalendarClick = { navController.navigate(Destinations.Calendar.route) } + ) { innerPadding -> + Column( + modifier = innerPadding + .fillMaxSize() + ) { + Text("Einstellungen", modifier = Modifier.padding(16.dp)) + // Weitere Einstellungen hier + } + } + } + composable(Destinations.About.route) { + val calendarViewModel: CalendarViewModel = viewModel() + val calendars by calendarViewModel.calendars.collectAsState() + val selectedCalendars by calendarViewModel.selectedCalendars.collectAsState() + AppScaffold( + title = "Über NordicCalendar", + calendars = calendars, + selectedCalendars = selectedCalendars, + selectedDestination = "about", + onCalendarToggle = { cal -> calendarViewModel.toggleCalendar(cal) }, + onSettingsClick = { navController.navigate(Destinations.Settings.route) }, + onAboutClick = { navController.navigate(Destinations.About.route) }, + onCalendarClick = { navController.navigate(Destinations.Calendar.route) } + ) { innerPadding -> + Column( + modifier = innerPadding + .fillMaxSize() + ) { + Text("Über NordicCalendar", modifier = Modifier.padding(16.dp)) + // Weitere Infos hier + } + } + } + } + } + } + } +} + +// Pass Navigation Actions: Create a function to handle navigation and pass it to screens. +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@Composable +fun IntroScreen(navController: NavHostController, onFinish: (() -> Unit)? = null) { + val navigateToHome: () -> Unit = { + onFinish?.invoke() ?: navController.navigate(Destinations.Calendar.route) + } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { onBoardingData.size }) + val coroutineScope = rememberCoroutineScope() + + // Keep state if the user has allowed permmissions to hide the finish button using mutable state + val hasPermissions = remember { mutableStateOf(false) } + + NordicCalendarTheme { + Scaffold { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { page -> + OnBoardItem(onBoardingData[page], hasPermissions) + } + + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + + ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + repeat(onBoardingData.size) { index -> + val isSelected = pagerState.currentPage == index + Box( + modifier = Modifier + .padding(4.dp) + .width(if (isSelected) 18.dp else 8.dp) + .height(if (isSelected) 8.dp else 8.dp) + .border( + width = 1.dp, + color = Color(0xFF707784), + shape = RoundedCornerShape(10.dp) + ) + .background( + color = if (isSelected) Color(0xFF3B6C64) else Color( + 0xFFFFFFFF + ), + shape = CircleShape + ) + ) + } + } + + + if (pagerState.currentPage < pagerState.pageCount - 1 || (pagerState.currentPage == pagerState.pageCount - 1 && hasPermissions.value)) Text( + text = if (pagerState.currentPage == pagerState.pageCount - 1) "Finish" else "Next", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + ), + modifier = Modifier.clickable { + Log.d( + "Pager", + "Current Page: ${pagerState.currentPage}, Page Count: ${pagerState.pageCount}" + ) + if (pagerState.currentPage == pagerState.pageCount - 1) { + navigateToHome() + } + if (pagerState.currentPage < pagerState.pageCount - 1) { + val nextPage = pagerState.currentPage + 1 + coroutineScope.launch { pagerState.animateScrollToPage(nextPage) } + } + + } + ) + + } + } + } + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt @@ -0,0 +1,84 @@ +package space.midnightthoughts.nordiccalendar.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.util.Calendar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + title: String, + calendars: List<Calendar>, + selectedCalendars: List<Calendar>, + selectedDestination: String, + onCalendarToggle: (Calendar) -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onCalendarClick: () -> Unit, + floatingActionButton: (@Composable () -> Unit)? = null, + onBackClick: (() -> Unit)? = null, + content: @Composable (Modifier) -> Unit +) { + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + SidebarDrawer( + calendars = calendars, + selectedCalendars = selectedCalendars, + onCalendarToggle = onCalendarToggle, + onSettingsClick = { + scope.launch { drawerState.close() } + onSettingsClick() + }, + onAboutClick = { + scope.launch { drawerState.close() } + onAboutClick() + }, + onCalendarClick = { + scope.launch { drawerState.close() } + onCalendarClick() + }, + selectedDestination = selectedDestination + ) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon(Icons.Filled.ArrowBack, contentDescription = "Zurück") + } + } else { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Default.Menu, contentDescription = "Menü") + } + } + } + ) + }, + floatingActionButton = floatingActionButton ?: { }, + ) { innerPadding -> + content(Modifier.padding(innerPadding)) + } + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/SidebarDrawer.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/SidebarDrawer.kt @@ -0,0 +1,76 @@ +package space.midnightthoughts.nordiccalendar.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import space.midnightthoughts.nordiccalendar.util.Calendar + +@Composable +fun SidebarDrawer( + calendars: List<Calendar>, + selectedCalendars: List<Calendar>, + onCalendarToggle: (Calendar) -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onCalendarClick: () -> Unit, + selectedDestination: String +) { + ModalDrawerSheet { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, start = 8.dp, end = 8.dp, bottom = 8.dp) + ) { + NavigationDrawerItem( + label = { Text("Kalender") }, + selected = selectedDestination == "calendar", + onClick = onCalendarClick, + modifier = Modifier.fillMaxWidth(), + colors = NavigationDrawerItemDefaults.colors() + ) + NavigationDrawerItem( + label = { Text("Einstellungen") }, + selected = selectedDestination == "settings", + onClick = onSettingsClick, + modifier = Modifier.fillMaxWidth(), + colors = NavigationDrawerItemDefaults.colors() + ) + NavigationDrawerItem( + label = { Text("Über") }, + selected = selectedDestination == "about", + onClick = onAboutClick, + modifier = Modifier.fillMaxWidth(), + colors = NavigationDrawerItemDefaults.colors() + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + Spacer(Modifier.height(8.dp)) + Text("Kalenderauswahl", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + calendars.forEach { calendar -> + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Checkbox( + checked = selectedCalendars.contains(calendar), + onCheckedChange = { onCalendarToggle(calendar) } + ) + Text(calendar.name, modifier = Modifier.padding(start = 8.dp)) + } + } + } + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/onboarding/Onboarding.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/onboarding/Onboarding.kt @@ -0,0 +1,128 @@ +package space.midnightthoughts.nordiccalendar.onboarding + +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +data class OnBoardModel( + val imageRes: Int? = null, + val title: String, + val description: String, + val permissionRequest: List<String> = emptyList(), + val showPermissionRequest: Boolean = false +) + +val onBoardingData = listOf( + // Explain the purpose of the app + OnBoardModel( + title = "Welcome to Nordic Calendar", + description = "A simple open source calendar app for Android that helps you keep track of Nordic holidays and events." + ), + // Ask for permissions + OnBoardModel( + title = "Permissions Required", + description = "To provide you with the best experience, we need access to your calendar permissions. " + + "This allows us to add Nordic holidays and events directly to your calendar.", + permissionRequest = listOf( + android.Manifest.permission.READ_CALENDAR, + android.Manifest.permission.WRITE_CALENDAR + ), + showPermissionRequest = true + ), +) + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun OnBoardItem(page: OnBoardModel, hasPermissions: MutableState<Boolean>) { + val permissionsState = rememberMultiplePermissionsState( + page.permissionRequest + ) { + hasPermissions.value = it.values.all { granted -> + granted + } + } + Log.d("OnBoardItem", "Permissions state: ${permissionsState.allPermissionsGranted}") + hasPermissions.value = permissionsState.allPermissionsGranted + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + page.imageRes?.let { + Image( + painter = painterResource(id = page.imageRes), + contentDescription = null, + modifier = Modifier + .height(350.dp) + .width(350.dp) + .padding(bottom = 20.dp) + ) + } + Text( + text = page.title, style = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + ) + Text( + text = page.description, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.W400, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + ) + + // Show permission request if needed + if (!permissionsState.allPermissionsGranted && page.showPermissionRequest) { + Text( + text = "Please grant the required permissions to continue.", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ), + modifier = Modifier.padding(top = 10.dp) + ) + Button(onClick = { + permissionsState.launchMultiplePermissionRequest() + }) { + Text( + text = "Request Permissions", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onPrimary + ) + ) + } + } + } + +} +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -0,0 +1,273 @@ +package space.midnightthoughts.nordiccalendar.screens + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.util.Event +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@SuppressLint("UnusedBoxWithConstraintsScope") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen( + selectedTab: Int, + onTabSelected: (Int) -> Unit, + events: List<Event>, + modifier: Modifier = Modifier, + isRefreshing: Boolean, + onRefresh: (() -> Unit), + navController: NavController +) { + Log.d( + "CalendarScreen", + "Rendering with selectedTab: $selectedTab, events count: ${events.size}, isRefreshing: $isRefreshing" + ) + val tabTitles = listOf("Monat", "Woche", "Tag") + val pullToRefreshState = rememberPullToRefreshState() + PullToRefreshBox( + state = pullToRefreshState, + modifier = modifier, + onRefresh = onRefresh, + isRefreshing = isRefreshing, + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = isRefreshing, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullToRefreshState + ) + }, + ) { + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = selectedTab) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { onTabSelected(index) }, + text = { Text(title) } + ) + } + } + Spacer(Modifier.height(8.dp)) + if (events.isEmpty()) { + Text("Keine Events gefunden.", modifier = Modifier.padding(16.dp)) + } else if (selectedTab == 2) { + val hourHeightDp = 64.dp + val timeColumnWidth = 64.dp + val density = LocalDensity.current + val hourHeightPx = with(density) { hourHeightDp.toPx() } + val dayStart = events.minOfOrNull { it.startTime }?.let { + val cal = Calendar.getInstance() + cal.timeInMillis = it + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + cal.timeInMillis + } ?: Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + val hourFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + // Aktualisiere die "Jetzt"-Linie jede Sekunde + LaunchedEffect(selectedTab) { + while (selectedTab == 2) { + now = System.currentTimeMillis() + delay(1000) + } + } + val nowMinutes = ((now - dayStart) / 60000f) + val nowOffsetY = ((nowMinutes + 30f) / 60f) * hourHeightPx + LaunchedEffect(selectedTab) { + coroutineScope.launch { + val visibleHeightPx = with(density) { 600.dp.toPx() } + val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) + scrollState.scrollTo(scrollTo) + } + } + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + val maxWidthPx = with(density) { maxWidth.toPx() } + val lineThickness = 4.dp + val lineColor = MaterialTheme.colorScheme.error + // Stunden-Divider + Column(modifier = Modifier.fillMaxWidth()) { + for (hour in 0..23) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(hourHeightDp) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + hourFormat.format(Date(dayStart + hour * 60 * 60 * 1000)), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(timeColumnWidth) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + } + } + fun assignColumns(events: List<Event>): List<Triple<Event, Int, Int>> { + val sorted = events.sortedBy { it.startTime } + val active = mutableListOf<Pair<Event, Int>>() // Event, column + val result = + mutableListOf<Triple<Event, Int, Int>>() // Event, column, maxColumns + for (event in sorted) { + // Entferne abgelaufene Events + active.removeAll { it.first.endTime <= event.startTime } + // Finde freie Spalte + val usedColumns = active.map { it.second }.toSet() + val col = + (0..active.size).firstOrNull { it !in usedColumns } ?: active.size + active.add(event to col) + // maxColumns = aktuelle Anzahl aktiver Events + val maxColumns = active.size + result.add(Triple(event, col, maxColumns)) + } + return result + } + + val eventColumns = assignColumns(events) + eventColumns.forEach { (event, col, maxColumns) -> + val startMinutes = ((event.startTime - dayStart) / 60000f) + val endMinutes = ((event.endTime - dayStart) / 60000f) + val offsetY = ((startMinutes + 30f) / 60f) * hourHeightPx + val eventHeightPx = ((endMinutes - startMinutes) / 60f) * hourHeightPx + val columnWidthPx = + (maxWidthPx - with(density) { timeColumnWidth.toPx() }) / maxColumns + val offsetX = + (col * columnWidthPx).toInt() + with(density) { timeColumnWidth.toPx() }.toInt() + Card( + modifier = Modifier + .width(with(density) { columnWidthPx.toDp() }) + .padding(end = 8.dp) + .offset { IntOffset(offsetX, offsetY.toInt()) } + .height(with(density) { eventHeightPx.toDp() }) + .clickable { navController.navigate("eventDetails/${event.id}") }, + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + border = BorderStroke( + 1.dp, + color = MaterialTheme.colorScheme.outline + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(event.title, style = MaterialTheme.typography.bodyLarge) + Spacer(modifier = Modifier.height(4.dp)) + Text( + hourFormat.format(Date(event.startTime)) + " - " + hourFormat.format( + Date(event.endTime) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + if (!event.description.isNullOrBlank()) { + Text( + event.description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + // "Jetzt"-Linie und Label, nur wenn im Tagesbereich + if (nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24) { + Row( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, nowOffsetY.toInt()) }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Jetzt", + color = lineColor, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) + androidx.compose.foundation.Canvas( + modifier = Modifier + .fillMaxWidth() + .height(lineThickness) + .padding(end = 16.dp) + ) { + drawLine( + color = lineColor, + start = androidx.compose.ui.geometry.Offset( + 0f, + size.height / 2 + ), + end = androidx.compose.ui.geometry.Offset( + size.width, + size.height / 2 + ), + strokeWidth = size.height, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + } + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(events) { event -> + Text(event.title, modifier = Modifier.padding(8.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Color.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package space.midnightthoughts.nordiccalendar.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Theme.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package space.midnightthoughts.nordiccalendar.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun NordicCalendarTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Type.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package space.midnightthoughts.nordiccalendar.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt @@ -0,0 +1,236 @@ +package space.midnightthoughts.nordiccalendar.util + +import android.content.ContentResolver +import android.content.ContentUris +import android.net.Uri +import android.provider.CalendarContract +import android.util.Log + +data class Calendar( + val id: Long, + val name: String, + val color: Int, + val accountName: String, + val accountType: String, + val syncEvents: Boolean, + val visible: Boolean +) + +data class Event( + val id: Long, + val title: String, + val description: String?, + val startTime: Long, + val endTime: Long, + val calendarId: Long, + val location: String?, + val allDay: Boolean, + val organizer: String?, + val attendees: List<String> = emptyList() +) + +// Helper for the android calendar provider/CalendarContract +class CalendarData { + // Get available calendars from the Android Calendar Provider + fun getCalendars(contentResolver: ContentResolver): List<Calendar> { + val calendars = mutableListOf<Calendar>() + + + val EVENT_PROJECTION = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.NAME, + CalendarContract.Calendars.CALENDAR_COLOR, + CalendarContract.Calendars.VISIBLE, + CalendarContract.Calendars.SYNC_EVENTS, + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + ) + val PROJECTION_ID_INDEX = 0 + val PROJECTION_DISPLAY_NAME_INDEX = 1 + val PROJECTION_NAME_INDEX = 2 + val PROJECTION_CALENDAR_COLOR_INDEX = 3 + val PROJECTION_VISIBLE_INDEX = 4 + val PROJECTION_SYNC_EVENTS_INDEX = 5 + val PROJECTION_ACCOUNT_NAME_INDEX = 6 + val PROJECTION_ACCOUNT_TYPE_INDEX = 7 + + val uri = CalendarContract.Calendars.CONTENT_URI + val selection = "" + val selectionArgs = emptyArray<String>() + val cur = contentResolver.query( + uri, + EVENT_PROJECTION, + selection, + selectionArgs, + null + ) + while (cur?.moveToNext() == true) { + val calId = cur.getLong(PROJECTION_ID_INDEX) + val displayName = cur.getString(PROJECTION_DISPLAY_NAME_INDEX) + val name = cur.getString(PROJECTION_NAME_INDEX) + val color = cur.getInt(PROJECTION_CALENDAR_COLOR_INDEX) + val visible = cur.getInt(PROJECTION_VISIBLE_INDEX) + val syncEvents = cur.getInt(PROJECTION_SYNC_EVENTS_INDEX) + val accountName = cur.getString(PROJECTION_ACCOUNT_NAME_INDEX) + val accountType = cur.getString(PROJECTION_ACCOUNT_TYPE_INDEX) + calendars.add( + Calendar( + id = calId, + name = name ?: displayName, + color = color, + accountName = accountName ?: "", + accountType = accountType ?: "", + syncEvents = syncEvents == 1, + visible = visible == 1 + ) + ) + } + cur?.close() + + return calendars + } + + fun getEventsForCalendar( + contentResolver: ContentResolver, + calendarId: Long, + startMillis: Long = java.util.Calendar.getInstance().run { + // Monday this week + set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.MONDAY) + set(java.util.Calendar.HOUR_OF_DAY, 0) + set(java.util.Calendar.MINUTE, 0) + set(java.util.Calendar.SECOND, 0) + timeInMillis + }, + endMillis: Long = java.util.Calendar.getInstance().run { + // Sunday this week end of day + set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.SUNDAY) + set(java.util.Calendar.HOUR_OF_DAY, 23) + set(java.util.Calendar.MINUTE, 59) + set(java.util.Calendar.SECOND, 59) + timeInMillis + } + ): List<Event> { + val events = mutableListOf<Event>() + + val INSTANCE_PROJECTION = arrayOf( + CalendarContract.Instances._ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.DESCRIPTION, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.CALENDAR_ID, + CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.ORGANIZER + ) + val PROJECTION_ID_INDEX = 0 + val PROJECTION_TITLE_INDEX = 1 + val PROJECTION_DESCRIPTION_INDEX = 2 + val PROJECTION_BEGIN_INDEX = 3 + val PROJECTION_END_INDEX = 4 + val PROJECTION_CALENDAR_ID_INDEX = 5 + val PROJECTION_EVENT_LOCATION_INDEX = 6 + val PROJECTION_ALL_DAY_INDEX = 7 + val PROJECTION_ORGANIZER_INDEX = 8 + + val builder: Uri.Builder = CalendarContract.Instances.CONTENT_URI.buildUpon() + ContentUris.appendId(builder, startMillis) + ContentUris.appendId(builder, endMillis) + + val cur = contentResolver.query( + builder.build(), + INSTANCE_PROJECTION, + "${CalendarContract.Instances.CALENDAR_ID} = ?", + arrayOf(calendarId.toString()), + "${CalendarContract.Instances.BEGIN} ASC" + ) + while (cur?.moveToNext() == true) { + val id = cur.getLong(PROJECTION_ID_INDEX) + val title = cur.getString(PROJECTION_TITLE_INDEX) ?: "No Title" + val description = cur.getString(PROJECTION_DESCRIPTION_INDEX) + val startTime = cur.getLong(PROJECTION_BEGIN_INDEX) + val endTime = cur.getLong(PROJECTION_END_INDEX) + val calId = cur.getLong(PROJECTION_CALENDAR_ID_INDEX) + val location = cur.getString(PROJECTION_EVENT_LOCATION_INDEX) + val allDay = cur.getInt(PROJECTION_ALL_DAY_INDEX) == 1 + val organizer = cur.getString(PROJECTION_ORGANIZER_INDEX) + + // Fetch attendees if needed (not included in this example for brevity) + + events.add( + Event( + id = id, + title = title, + description = description, + startTime = startTime, + endTime = endTime, + calendarId = calId, + location = location, + allDay = allDay, + organizer = organizer + ) + ) + } + cur?.close() + + return events + } + + fun getEventsForCalendars( + contentResolver: ContentResolver, + calendarIds: List<Long>, + startMillis: Long = java.util.Calendar.getInstance().run { + set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.MONDAY) + set(java.util.Calendar.HOUR_OF_DAY, 0) + set(java.util.Calendar.MINUTE, 0) + set(java.util.Calendar.SECOND, 0) + timeInMillis + }, + endMillis: Long = java.util.Calendar.getInstance().run { + set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.SUNDAY) + set(java.util.Calendar.HOUR_OF_DAY, 23) + set(java.util.Calendar.MINUTE, 59) + set(java.util.Calendar.SECOND, 59) + timeInMillis + } + ): List<Event> { + if (calendarIds.isEmpty()) return emptyList() + val calendars = getCalendars(contentResolver) + .filter { it.id in calendarIds } + if (calendars.isEmpty()) { + Log.w("CalendarData", "No calendars found for IDs: ${calendarIds.joinToString()}") + return emptyList() + } + Log.d( + "CalendarData", + "Fetching events for calendars: ${calendars.joinToString { it.name + " (${it.id})" }}" + ) + val events = mutableListOf<Event>() + // Fetch events for each calendar + for (calendar in calendars) { + val currentEvents = getEventsForCalendar( + contentResolver, + calendar.id, + startMillis, + endMillis + ) + if (currentEvents.isNotEmpty()) { + events.addAll(currentEvents).also { + // Log each calendar's events + Log.d( + "CalendarData", + "Fetched ${currentEvents.size} events for calendar: ${calendar.name} (${calendar.id})" + ) + } + } + } + return events.sortedBy { it.startTime }.also { + // Log the number of events fetched + Log.d( + "CalendarData", + "Fetched ${it.size} events for calendars: ${calendarIds.joinToString()}" + ) + } + } +} +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/util/OnboardingPrefs.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/util/OnboardingPrefs.kt @@ -0,0 +1,36 @@ +package space.midnightthoughts.nordiccalendar.util + +import android.content.Context +import android.util.Log +import androidx.core.content.edit + +object OnboardingPrefs { + private const val PREFS_NAME = "onboarding_prefs" + private const val KEY_ONBOARDING_DONE = "onboarding_done" + private const val KEY_ONBOARDING_VERSION = "onboarding_version" + + fun isOnboardingNeeded(context: Context, currentVersion: String): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val done = prefs.getBoolean(KEY_ONBOARDING_DONE, false) + val savedVersion = prefs.getString(KEY_ONBOARDING_VERSION, null) + Log.d( + "OnboardingPrefs", + "Onboarding done: $done, saved version: $savedVersion, current version: $currentVersion" + ) + return !done || savedVersion != currentVersion + } + + fun setOnboardingDone(context: Context, currentVersion: String) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { + putBoolean(KEY_ONBOARDING_DONE, true) + .putString(KEY_ONBOARDING_VERSION, currentVersion) + } + } + + fun resetOnboarding(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { clear() } + } +} + diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -0,0 +1,154 @@ +package space.midnightthoughts.nordiccalendar.viewmodels + +import android.app.Application +import android.content.ContentResolver +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.util.Calendar +import space.midnightthoughts.nordiccalendar.util.CalendarData +import space.midnightthoughts.nordiccalendar.util.Event + +class CalendarViewModel(app: Application) : AndroidViewModel(app) { + val isRefreshing = MutableStateFlow(false) + private val calendarData = CalendarData() + private val contentResolver: ContentResolver = app.contentResolver + + private val _calendars = MutableStateFlow<List<Calendar>>(emptyList()) + val calendars: StateFlow<List<Calendar>> = _calendars.asStateFlow() + + private val _selectedCalendars = MutableStateFlow<List<Calendar>>(emptyList()) + val selectedCalendars: StateFlow<List<Calendar>> = _selectedCalendars.asStateFlow() + + private val _events = MutableStateFlow<List<Event>>(emptyList()) + val events: StateFlow<List<Event>> = _events.asStateFlow() + + private val _selectedTab = MutableStateFlow(0) + val selectedTab: StateFlow<Int> = _selectedTab.asStateFlow() + + private var startMillis: Long = getDefaultStartMillis(0) + private var endMillis: Long = getDefaultEndMillis(0) + + init { + loadCalendars() + } + + private fun loadCalendars() { + isRefreshing.value = true + viewModelScope.launch { + val allCalendars = calendarData.getCalendars(contentResolver) + _calendars.value = allCalendars + _selectedCalendars.value = allCalendars.filter { it.visible } + updateEvents() + isRefreshing.value = false + } + } + + fun toggleCalendar(calendar: Calendar) { + val current = _selectedCalendars.value.toMutableList() + if (current.contains(calendar)) { + current.remove(calendar) + } else { + current.add(calendar) + } + _selectedCalendars.value = current + updateEvents() + } + + fun setTab(tab: Int) { + _selectedTab.value = tab + startMillis = getDefaultStartMillis(tab) + endMillis = getDefaultEndMillis(tab) + updateEvents() + } + + private fun getDefaultStartMillis(tab: Int): Long { + val cal = java.util.Calendar.getInstance() + when (tab) { + 0 -> { // Monat + cal.set(java.util.Calendar.DAY_OF_MONTH, 1) + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + return cal.timeInMillis + } + + 1 -> { // Woche + cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + return cal.timeInMillis + } + + 2 -> { // Tag + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + return cal.timeInMillis + } + + else -> return cal.timeInMillis + } + } + + private fun getDefaultEndMillis(tab: Int): Long { + val cal = java.util.Calendar.getInstance() + when (tab) { + 0 -> { // Monat + cal.set( + java.util.Calendar.DAY_OF_MONTH, + cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) + ) + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + return cal.timeInMillis + } + + 1 -> { // Woche + cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek + 6) + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + return cal.timeInMillis + } + + 2 -> { // Tag + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + return cal.timeInMillis + } + + else -> return cal.timeInMillis + } + } + + fun updateEvents() { + isRefreshing.value = true + // Zeitintervall immer neu berechnen, damit Pull-to-Refresh in jedem Tab funktioniert + val tab = _selectedTab.value + startMillis = getDefaultStartMillis(tab) + endMillis = getDefaultEndMillis(tab) + Log.d( + "CalendarViewModel", + "Updating events for selected calendars: ${_selectedCalendars.value.map { it.name }}" + ) + viewModelScope.launch { + val selectedIds = _selectedCalendars.value.map { it.id } + Log.d("CalendarViewModel", "Selected calendar IDs: $selectedIds") + _events.value = calendarData.getEventsForCalendars( + contentResolver, + selectedIds, + startMillis, + endMillis + ) + isRefreshing.value = false + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#3DDC84" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="85.84757" + android:endY="92.4963" + android:startX="42.9492" + android:startY="49.59793" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> +\ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> +\ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> +\ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp Binary files differ. diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp Binary files differ. diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp Binary files differ. diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp Binary files differ. diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp Binary files differ. diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="purple_200">#FFBB86FC</color> + <color name="purple_500">#FF6200EE</color> + <color name="purple_700">#FF3700B3</color> + <color name="teal_200">#FF03DAC5</color> + <color name="teal_700">#FF018786</color> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> +</resources> +\ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ +<resources> + <string name="app_name">Nordic Calendar</string> +</resources> +\ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="Theme.NordicCalendar" parent="android:Theme.Material.Light.NoActionBar" /> +</resources> +\ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample backup rules file; uncomment and customize as necessary. + See https://developer.android.com/guide/topics/data/autobackup + for details. + Note: This file is ignored for devices older than API 31 + See https://developer.android.com/about/versions/12/backup-restore +--> +<full-backup-content> + <!-- + <include domain="sharedpref" path="."/> + <exclude domain="sharedpref" path="device.xml"/> +--> + <include + domain="file" + path="." + disableIfNoEncryptionCapabilities="true" + requireFlags="clientSideEncryption" /> +</full-backup-content> +\ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample data extraction rules file; uncomment and customize as necessary. + See https://developer.android.com/about/versions/12/backup-restore#xml-changes + for details. +--> +<data-extraction-rules> + <cloud-backup> + <!-- TODO: Use <include> and <exclude> to control what is backed up. + <include .../> + <exclude .../> + --> + </cloud-backup> + <!-- + <device-transfer> + <include .../> + <exclude .../> + </device-transfer> + --> +</data-extraction-rules> +\ No newline at end of file diff --git a/app/src/test/java/space/midnightthoughts/nordiccalendar/ExampleUnitTest.kt b/app/src/test/java/space/midnightthoughts/nordiccalendar/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package space.midnightthoughts.nordiccalendar + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} +\ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} +\ No newline at end of file diff --git a/gradle.properties b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +\ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -0,0 +1,38 @@ +[versions] +agp = "8.11.1" +kotlin = "2.2.0" +coreKtx = "1.16.0" +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +lifecycleRuntimeKtx = "2.9.2" +activityCompose = "1.10.1" +composeBom = "2025.07.00" +accompanistPermissions = "0.37.3" +navigationCompose = "2.9.2" +foundation = "1.8.3" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differ. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Jul 27 20:33:54 CEST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Nordic Calendar" +include(":app")