Add Android app implementation with core features

Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-14 17:24:17 +00:00
parent 3069fcd8ba
commit 976d494d9f
28 changed files with 1784 additions and 3 deletions

11
.gitignore vendored
View file

@ -12,6 +12,17 @@ output/*.xlsx
templates/*.xlsx templates/*.xlsx
~$*.xlsx ~$*.xlsx
# Android
android-app/.gradle/
android-app/build/
android-app/app/build/
android-app/local.properties
android-app/.gradle
android-app/.idea/
android-app/*.iml
android-app/captures/
android-app/app/release/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/

View file

@ -1,8 +1,16 @@
# Dienstplan Generator (NRW - Variante 2) # Dienstplan Generator (NRW - Variante 2)
Python-Projekt zum automatischen Erstellen von Dienstplänen mit Vergütungsberechnung nach NRW-Regeln (Variante 2 "streng"). Projekt zum automatischen Erstellen von Dienstplänen mit Vergütungsberechnung nach NRW-Regeln (Variante 2 "streng").
## Features ## Verfügbare Versionen
### Python/Excel Version (Desktop)
Python-basierter Generator für Excel-Dienstpläne.
### Android App (Mobile) 🆕
Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README.md](android-app/README.md) für Details.
## Features (Python/Excel Version)
- ✅ Automatische Erkennung von Wochenenden (FrSo), Feiertagen und Vortagen - ✅ Automatische Erkennung von Wochenenden (FrSo), Feiertagen und Vortagen
- ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten) - ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten)
@ -55,10 +63,14 @@ Die Datei landet in `output/Dienstplan_YYYY_MM_NRW.xlsx`.
```text ```text
. .
├── src/ ├── src/ # Python source code
│ ├── build_template.py # Erstellt die Basis-Vorlage │ ├── build_template.py # Erstellt die Basis-Vorlage
│ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen │ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen
│ └── read_excel.py # Liest xlsx-Dateien aus │ └── read_excel.py # Liest xlsx-Dateien aus
├── android-app/ # Android mobile app
│ ├── app/ # Android app source code
│ ├── build.gradle.kts # Build configuration
│ └── README.md # Android app documentation
├── output/ # Generierte Monatspläne ├── output/ # Generierte Monatspläne
├── templates/ # Basis-Vorlage ├── templates/ # Basis-Vorlage
├── requirements.txt # Python-Abhängigkeiten (openpyxl) ├── requirements.txt # Python-Abhängigkeiten (openpyxl)

156
android-app/README.md Normal file
View file

@ -0,0 +1,156 @@
# Android Dienstplan NRW App
Android mobile app for managing duty rosters (Dienstplan) with automatic payroll calculations according to NRW rules (Variante 2 - streng).
## Features
- ✅ Month selection interface
- ✅ Simple duty entry with employee name and share (Anteil)
- ✅ Automatic payroll calculation
- ✅ NRW holiday recognition
- ✅ Weekend/Holiday shift classification
- ✅ Threshold-based WE compensation (Variante 2 - streng)
- ✅ Results display with detailed breakdown
## Business Rules (Variante 2 - streng)
Same as the Python/Excel implementation:
- **WE-Tag** (Weekend/Holiday): Friday, Saturday, Sunday, public holidays, day before public holiday
- **WT-Tag** (Weekday): All other days
- **WT compensation**: Always 250€ per unit
- **WE compensation**: Only paid if monthly total ≥ 2.0 WE units
- If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit
- Deduction priority: Friday first, then other WE days
- Below threshold: 0€ for WE shifts
## Requirements
- Android Studio Arctic Fox or later
- Android SDK 24+ (Android 7.0 Nougat)
- Kotlin 1.9.10
- Gradle 8.1.4
## Installation
### Option 1: Android Studio
1. Open Android Studio
2. Select "Open an Existing Project"
3. Navigate to the `android-app` directory
4. Wait for Gradle sync to complete
5. Click "Run" or press Shift+F10
### Option 2: Command Line
```bash
cd android-app
./gradlew build
./gradlew installDebug
```
## Usage
1. **Select Month**: Choose year and month from the dropdowns on the main screen
2. **Enter Duties**: Click "Dienste eintragen" to add duty entries
- Select a date from the month
- Enter employee name
- Enter share/portion (Anteil) between 0.0 and 1.0
- Save the duty
3. **View Results**: Click "Auswertung anzeigen" to see payroll calculations
- Shows breakdown per employee
- Displays WT units, WE units, threshold status, and payouts
## Project Structure
```
android-app/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/dienstplan/nrw/
│ │ │ │ ├── MainActivity.kt # Main screen with month selection
│ │ │ │ ├── DutyEntryActivity.kt # Duty entry screen
│ │ │ │ ├── ResultsActivity.kt # Results/Auswertung screen
│ │ │ │ ├── model/
│ │ │ │ │ ├── DutyEntry.kt # Duty entry data model
│ │ │ │ │ ├── Holiday.kt # Holiday data model
│ │ │ │ │ └── PayrollResult.kt # Payroll calculation result
│ │ │ │ └── data/
│ │ │ │ ├── DutyDataStore.kt # In-memory data storage
│ │ │ │ ├── HolidayProvider.kt # NRW holidays data
│ │ │ │ └── PayrollCalculator.kt # Business logic engine
│ │ │ ├── res/ # Resources (layouts, strings, colors)
│ │ │ └── AndroidManifest.xml
│ │ └── test/ # Unit tests
│ └── build.gradle.kts # App build configuration
├── build.gradle.kts # Project build configuration
├── settings.gradle.kts # Project settings
└── README.md # This file
```
## Data Storage
Currently uses in-memory storage (`DutyDataStore`). Data is lost when the app is closed.
For production use, this should be replaced with:
- Room database for persistent local storage
- Or backend API integration for cloud storage
## Testing
### Unit Tests
Run unit tests with:
```bash
./gradlew test
```
### UI Tests
Run instrumented tests with:
```bash
./gradlew connectedAndroidTest
```
## Customization
### Changing Payroll Rules
Edit `PayrollCalculator.kt` and modify the constants:
- `RATE_WT`: Weekday rate (default 250€)
- `RATE_WE`: Weekend rate (default 450€)
- `WE_THRESHOLD`: Threshold for WE compensation (default 2.0)
- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 1.0)
### Adding Holidays
Edit `HolidayProvider.kt` and add entries to the holiday lists for additional years.
## Known Limitations
- Data is not persisted (in-memory only)
- No data export/import functionality
- No multi-user support
- Only NRW holidays (2025-2026)
- German language only
## Future Enhancements
- [ ] Room database for data persistence
- [ ] Export to Excel/PDF
- [ ] Import from Excel
- [ ] Multi-Bundesland support
- [ ] Data backup/restore
- [ ] Dark mode support
- [ ] Tablet layout optimization
## License
MIT
## Credits
Based on the Python/Excel Dienstplan Generator implementation by Kenearos.

View file

@ -0,0 +1,67 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dienstplan.nrw"
compileSdk = 34
defaultConfig {
applicationId = "com.dienstplan.nrw"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Room database
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
annotationProcessor("androidx.room:room-compiler:$roomVersion")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
// RecyclerView
implementation("androidx.recyclerview:recyclerview:1.3.2")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

9
android-app/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,9 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in ${sdk.dir}/tools/proguard/proguard-android.txt
# Keep data classes
-keep class com.dienstplan.nrw.model.** { *; }
# Keep ViewBinding classes
-keep class com.dienstplan.nrw.databinding.** { *; }

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DienstplanNRW"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".DutyEntryActivity"
android:label="@string/duty_entry_title"
android:parentActivityName=".MainActivity" />
<activity
android:name=".ResultsActivity"
android:label="@string/results_title"
android:parentActivityName=".MainActivity" />
</application>
</manifest>

View file

@ -0,0 +1,177 @@
package com.dienstplan.nrw
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.dienstplan.nrw.data.DutyDataStore
import com.dienstplan.nrw.databinding.ActivityDutyEntryBinding
import com.dienstplan.nrw.databinding.ItemDutyEntryBinding
import com.dienstplan.nrw.model.DutyEntry
import java.text.SimpleDateFormat
import java.util.*
class DutyEntryActivity : AppCompatActivity() {
private lateinit var binding: ActivityDutyEntryBinding
private lateinit var adapter: DutyEntryAdapter
private var year: Int = 0
private var month: Int = 0
private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDutyEntryBinding.inflate(layoutInflater)
setContentView(binding.root)
year = intent.getIntExtra("year", 0)
month = intent.getIntExtra("month", 0)
if (year == 0 || month == 0) {
finish()
return
}
setupUI()
loadDuties()
}
private fun setupUI() {
val monthName = getMonthName(month)
binding.tvSelectedMonth.text = String.format("%s %d", monthName, year)
adapter = DutyEntryAdapter { duty ->
deleteDuty(duty)
}
binding.rvDuties.layoutManager = LinearLayoutManager(this)
binding.rvDuties.adapter = adapter
binding.btnAddDuty.setOnClickListener {
showAddDutyDialog()
}
binding.btnSave.setOnClickListener {
Toast.makeText(this, "Dienste gespeichert", Toast.LENGTH_SHORT).show()
finish()
}
}
private fun loadDuties() {
val duties = DutyDataStore.getDutiesForMonth(year, month)
adapter.submitList(duties)
}
private fun showAddDutyDialog() {
val dates = DutyDataStore.getDatesInMonth(year, month)
val dateStrings = dates.map { dateFormat.format(it) }.toTypedArray()
AlertDialog.Builder(this)
.setTitle("Datum auswählen")
.setItems(dateStrings) { _, which ->
val selectedDate = dates[which]
showEmployeeDialog(selectedDate)
}
.show()
}
private fun showEmployeeDialog(date: Date) {
val builder = AlertDialog.Builder(this)
val inflater = layoutInflater
val dialogView = inflater.inflate(R.layout.dialog_add_duty, null)
val etName = dialogView.findViewById<android.widget.EditText>(R.id.etEmployeeName)
val etShare = dialogView.findViewById<android.widget.EditText>(R.id.etShare)
builder.setView(dialogView)
.setTitle("Dienst hinzufügen - ${dateFormat.format(date)}")
.setPositiveButton("Hinzufügen") { _, _ ->
val name = etName.text.toString().trim()
val shareStr = etShare.text.toString().trim()
if (name.isEmpty()) {
Toast.makeText(this, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
val share = shareStr.toDoubleOrNull() ?: 0.0
if (share <= 0.0 || share > 1.0) {
Toast.makeText(this, R.string.error_invalid_share, Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
val monthKey = DutyDataStore.getMonthKey(year, month)
val duty = DutyEntry(
date = date,
employeeName = name,
share = share,
monthKey = monthKey
)
DutyDataStore.addDuty(duty)
loadDuties()
}
.setNegativeButton("Abbrechen", null)
.show()
}
private fun deleteDuty(duty: DutyEntry) {
DutyDataStore.deleteDuty(duty.id)
loadDuties()
}
private fun getMonthName(month: Int): String {
val months = resources.getStringArray(R.array.months)
return if (month in 1..12) months[month - 1] else ""
}
}
class DutyEntryAdapter(
private val onDelete: (DutyEntry) -> Unit
) : RecyclerView.Adapter<DutyEntryAdapter.ViewHolder>() {
private var duties = listOf<DutyEntry>()
private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
fun submitList(newDuties: List<DutyEntry>) {
duties = newDuties.sortedBy { it.date }
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemDutyEntryBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(duties[position])
}
override fun getItemCount() = duties.size
inner class ViewHolder(
private val binding: ItemDutyEntryBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(duty: DutyEntry) {
binding.tvDate.text = dateFormat.format(duty.date)
binding.etEmployeeName.setText(duty.employeeName)
binding.etShare.setText(duty.share.toString())
binding.btnDelete.setOnClickListener {
onDelete(duty)
}
// Disable editing in the list view
binding.etEmployeeName.isEnabled = false
binding.etShare.isEnabled = false
}
}
}

View file

@ -0,0 +1,102 @@
package com.dienstplan.nrw
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.dienstplan.nrw.databinding.ActivityMainBinding
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var selectedYear: Int = 0
private var selectedMonth: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupMonthSpinner()
setupYearSpinner()
setupButtons()
// Set default to current month
val calendar = Calendar.getInstance()
selectedYear = calendar.get(Calendar.YEAR)
selectedMonth = calendar.get(Calendar.MONTH) + 1 // Calendar.MONTH is 0-based
}
private fun setupMonthSpinner() {
val months = resources.getStringArray(R.array.months)
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, months)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerMonth.adapter = adapter
binding.spinnerMonth.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedMonth = position + 1 // Months are 1-based
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// Do nothing
}
}
// Set to current month
val currentMonth = Calendar.getInstance().get(Calendar.MONTH)
binding.spinnerMonth.setSelection(currentMonth)
}
private fun setupYearSpinner() {
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val years = (2025..2030).map { it.toString() }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, years)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerYear.adapter = adapter
binding.spinnerYear.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedYear = 2025 + position
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// Do nothing
}
}
// Set to current year
val yearIndex = currentYear - 2025
if (yearIndex >= 0 && yearIndex < years.size) {
binding.spinnerYear.setSelection(yearIndex)
}
}
private fun setupButtons() {
binding.btnEnterDuties.setOnClickListener {
if (selectedYear > 0 && selectedMonth > 0) {
val intent = Intent(this, DutyEntryActivity::class.java)
intent.putExtra("year", selectedYear)
intent.putExtra("month", selectedMonth)
startActivity(intent)
} else {
Toast.makeText(this, R.string.error_no_month_selected, Toast.LENGTH_SHORT).show()
}
}
binding.btnViewResults.setOnClickListener {
if (selectedYear > 0 && selectedMonth > 0) {
val intent = Intent(this, ResultsActivity::class.java)
intent.putExtra("year", selectedYear)
intent.putExtra("month", selectedMonth)
startActivity(intent)
} else {
Toast.makeText(this, R.string.error_no_month_selected, Toast.LENGTH_SHORT).show()
}
}
}
}

View file

@ -0,0 +1,102 @@
package com.dienstplan.nrw
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.dienstplan.nrw.data.DutyDataStore
import com.dienstplan.nrw.data.PayrollCalculator
import com.dienstplan.nrw.databinding.ActivityResultsBinding
import com.dienstplan.nrw.databinding.ItemResultBinding
import com.dienstplan.nrw.model.PayrollResult
class ResultsActivity : AppCompatActivity() {
private lateinit var binding: ActivityResultsBinding
private lateinit var adapter: ResultsAdapter
private var year: Int = 0
private var month: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityResultsBinding.inflate(layoutInflater)
setContentView(binding.root)
year = intent.getIntExtra("year", 0)
month = intent.getIntExtra("month", 0)
if (year == 0 || month == 0) {
finish()
return
}
setupUI()
calculateAndDisplayResults()
}
private fun setupUI() {
val monthName = getMonthName(month)
binding.tvSelectedMonth.text = String.format("%s %d", monthName, year)
adapter = ResultsAdapter()
binding.rvResults.layoutManager = LinearLayoutManager(this)
binding.rvResults.adapter = adapter
}
private fun calculateAndDisplayResults() {
val duties = DutyDataStore.getDutiesForMonth(year, month)
val calculator = PayrollCalculator()
val results = calculator.calculatePayroll(duties, year, month)
adapter.submitList(results)
}
private fun getMonthName(month: Int): String {
val months = resources.getStringArray(R.array.months)
return if (month in 1..12) months[month - 1] else ""
}
}
class ResultsAdapter : RecyclerView.Adapter<ResultsAdapter.ViewHolder>() {
private var results = listOf<PayrollResult>()
fun submitList(newResults: List<PayrollResult>) {
results = newResults
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemResultBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(results[position])
}
override fun getItemCount() = results.size
class ViewHolder(
private val binding: ItemResultBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(result: PayrollResult) {
binding.tvEmployeeName.text = result.employeeName
binding.tvWTUnits.text = String.format("%.2f", result.wtUnits)
binding.tvWEFriday.text = String.format("%.2f", result.weFriday)
binding.tvWEOther.text = String.format("%.2f", result.weOther)
binding.tvWETotal.text = String.format("%.2f", result.weTotal)
binding.tvThresholdReached.text = if (result.thresholdReached) "JA" else "NEIN"
binding.tvPayoutWT.text = String.format("%.2f €", result.payoutWT)
binding.tvPayoutWE.text = String.format("%.2f €", result.payoutWE)
binding.tvPayoutTotal.text = String.format("%.2f €", result.payoutTotal)
}
}
}

View file

@ -0,0 +1,88 @@
package com.dienstplan.nrw.data
import com.dienstplan.nrw.model.DutyEntry
import java.text.SimpleDateFormat
import java.util.*
/**
* Simple in-memory data store for duty entries.
* In a production app, this would be replaced with a Room database.
*/
object DutyDataStore {
private val duties = mutableListOf<DutyEntry>()
private var nextId = 1L
/**
* Add a new duty entry.
*/
fun addDuty(duty: DutyEntry): DutyEntry {
val dutyWithId = duty.copy(id = nextId++)
duties.add(dutyWithId)
return dutyWithId
}
/**
* Update an existing duty entry.
*/
fun updateDuty(duty: DutyEntry) {
val index = duties.indexOfFirst { it.id == duty.id }
if (index >= 0) {
duties[index] = duty
}
}
/**
* Delete a duty entry.
*/
fun deleteDuty(dutyId: Long) {
duties.removeAll { it.id == dutyId }
}
/**
* Get all duties for a specific month.
*/
fun getDutiesForMonth(year: Int, month: Int): List<DutyEntry> {
val monthKey = getMonthKey(year, month)
return duties.filter { it.monthKey == monthKey }
}
/**
* Get all duties.
*/
fun getAllDuties(): List<DutyEntry> = duties.toList()
/**
* Clear all duties.
*/
fun clearAll() {
duties.clear()
nextId = 1L
}
/**
* Get month key in YYYYMM format.
*/
fun getMonthKey(year: Int, month: Int): String {
return String.format("%04d%02d", year, month)
}
/**
* Get all dates in a month.
*/
fun getDatesInMonth(year: Int, month: Int): List<Date> {
val calendar = Calendar.getInstance()
calendar.set(year, month - 1, 1, 0, 0, 0)
calendar.set(Calendar.MILLISECOND, 0)
val dates = mutableListOf<Date>()
val maxDay = calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
for (day in 1..maxDay) {
calendar.set(Calendar.DAY_OF_MONTH, day)
dates.add(calendar.time)
}
return dates
}
}

View file

@ -0,0 +1,87 @@
package com.dienstplan.nrw.data
import com.dienstplan.nrw.model.Holiday
import java.text.SimpleDateFormat
import java.util.*
/**
* Provides NRW public holidays for years 2025-2026.
* This matches the holiday data in the Python implementation.
*/
object HolidayProvider {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
private val nrwHolidays2025 = listOf(
Holiday(parseDate("2025-01-01"), "Neujahr", "NRW"),
Holiday(parseDate("2025-04-18"), "Karfreitag", "NRW"),
Holiday(parseDate("2025-04-21"), "Ostermontag", "NRW"),
Holiday(parseDate("2025-05-01"), "Tag der Arbeit", "NRW"),
Holiday(parseDate("2025-05-29"), "Christi Himmelfahrt", "NRW"),
Holiday(parseDate("2025-06-09"), "Pfingstmontag", "NRW"),
Holiday(parseDate("2025-06-19"), "Fronleichnam", "NRW"),
Holiday(parseDate("2025-10-03"), "Tag der Deutschen Einheit", "NRW"),
Holiday(parseDate("2025-11-01"), "Allerheiligen", "NRW"),
Holiday(parseDate("2025-12-25"), "1. Weihnachtstag", "NRW"),
Holiday(parseDate("2025-12-26"), "2. Weihnachtstag", "NRW")
)
private val nrwHolidays2026 = listOf(
Holiday(parseDate("2026-01-01"), "Neujahr", "NRW"),
Holiday(parseDate("2026-04-03"), "Karfreitag", "NRW"),
Holiday(parseDate("2026-04-06"), "Ostermontag", "NRW"),
Holiday(parseDate("2026-05-01"), "Tag der Arbeit", "NRW"),
Holiday(parseDate("2026-05-14"), "Christi Himmelfahrt", "NRW"),
Holiday(parseDate("2026-05-25"), "Pfingstmontag", "NRW"),
Holiday(parseDate("2026-06-04"), "Fronleichnam", "NRW"),
Holiday(parseDate("2026-10-03"), "Tag der Deutschen Einheit", "NRW"),
Holiday(parseDate("2026-11-01"), "Allerheiligen", "NRW"),
Holiday(parseDate("2026-12-25"), "1. Weihnachtstag", "NRW"),
Holiday(parseDate("2026-12-26"), "2. Weihnachtstag", "NRW")
)
private val allHolidays = nrwHolidays2025 + nrwHolidays2026
/**
* Get all NRW holidays.
*/
fun getAllHolidays(): List<Holiday> = allHolidays
/**
* Check if a date is a public holiday.
*/
fun isHoliday(date: Date): Boolean {
val calendar = Calendar.getInstance()
calendar.time = date
calendar.set(Calendar.HOUR_OF_DAY, 0)
calendar.set(Calendar.MINUTE, 0)
calendar.set(Calendar.SECOND, 0)
calendar.set(Calendar.MILLISECOND, 0)
val normalizedDate = calendar.time
return allHolidays.any {
val holidayCalendar = Calendar.getInstance()
holidayCalendar.time = it.date
holidayCalendar.set(Calendar.HOUR_OF_DAY, 0)
holidayCalendar.set(Calendar.MINUTE, 0)
holidayCalendar.set(Calendar.SECOND, 0)
holidayCalendar.set(Calendar.MILLISECOND, 0)
normalizedDate.equals(holidayCalendar.time)
}
}
/**
* Check if a date is the day before a public holiday.
*/
fun isDayBeforeHoliday(date: Date): Boolean {
val calendar = Calendar.getInstance()
calendar.time = date
calendar.add(Calendar.DAY_OF_MONTH, 1)
return isHoliday(calendar.time)
}
private fun parseDate(dateString: String): Date {
return dateFormat.parse(dateString) ?: throw IllegalArgumentException("Invalid date: $dateString")
}
}

View file

@ -0,0 +1,133 @@
package com.dienstplan.nrw.data
import com.dienstplan.nrw.model.DutyEntry
import com.dienstplan.nrw.model.PayrollResult
import java.util.*
import kotlin.math.max
import kotlin.math.min
/**
* Payroll calculator implementing NRW Variante 2 (streng) rules.
*
* Business rules:
* - WE-Tag (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before public holiday
* - WT-Tag (Weekday): All other days
* - WT compensation: Always 250 per unit
* - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold)
* - If threshold reached: 450 per WE unit, then deduct exactly 1.0 WE unit
* - Deduction priority: Friday first, then other WE days
* - Below threshold: 0 for WE shifts (NOT converted to WT)
*/
class PayrollCalculator {
companion object {
private const val RATE_WT = 250.0 // Satz_WT
private const val RATE_WE = 450.0 // Satz_WE
private const val WE_THRESHOLD = 2.0 // WE_Schwelle
private const val DEDUCTION_AFTER_THRESHOLD = 1.0 // Abzug_nach_WE_Schwelle
private const val TOLERANCE = 0.0001 // For floating-point comparisons
}
/**
* Calculate payroll results for all employees in the given month.
*/
fun calculatePayroll(duties: List<DutyEntry>, year: Int, month: Int): List<PayrollResult> {
// Group duties by employee
val dutiesByEmployee = duties.groupBy { it.employeeName }
return dutiesByEmployee.map { (employeeName, employeeDuties) ->
calculateForEmployee(employeeName, employeeDuties)
}.sortedBy { it.employeeName }
}
/**
* Calculate payroll result for a single employee.
*/
private fun calculateForEmployee(employeeName: String, duties: List<DutyEntry>): PayrollResult {
var wtUnits = 0.0
var weFriday = 0.0
var weOther = 0.0
for (duty in duties) {
val date = duty.date
if (isWETag(date)) {
if (isFriday(date)) {
weFriday += duty.share
} else {
weOther += duty.share
}
} else {
// WT-Tag (weekday, not weekend)
wtUnits += duty.share
}
}
val weTotal = weFriday + weOther
// Check if threshold is reached
val thresholdReached = weTotal >= (WE_THRESHOLD - TOLERANCE)
// Calculate deduction (only if threshold reached)
val deductionTotal = if (thresholdReached) DEDUCTION_AFTER_THRESHOLD else 0.0
val deductionFriday = min(deductionTotal, weFriday)
val deductionOther = max(0.0, deductionTotal - deductionFriday)
// Calculate paid WE units (Variante 2: only if threshold reached)
val wePaid = if (weTotal < (WE_THRESHOLD - TOLERANCE)) {
0.0 // Below threshold: no WE compensation
} else {
(weFriday - deductionFriday) + (weOther - deductionOther)
}
// Calculate payouts
val payoutWT = wtUnits * RATE_WT
val payoutWE = wePaid * RATE_WE
val payoutTotal = payoutWT + payoutWE
return PayrollResult(
employeeName = employeeName,
wtUnits = wtUnits,
weFriday = weFriday,
weOther = weOther,
weTotal = weTotal,
thresholdReached = thresholdReached,
deductionTotal = deductionTotal,
deductionFriday = deductionFriday,
deductionOther = deductionOther,
wePaid = wePaid,
payoutWT = payoutWT,
payoutWE = payoutWE,
payoutTotal = payoutTotal
)
}
/**
* Check if a date is a WE-Tag (Weekend/Holiday).
* WE-Tag = Friday, Saturday, Sunday, public holiday, or day before public holiday.
*/
private fun isWETag(date: Date): Boolean {
val calendar = Calendar.getInstance()
calendar.time = date
val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK)
// Friday, Saturday, Sunday (Calendar.FRIDAY = 6, SATURDAY = 7, SUNDAY = 1)
val isWeekend = dayOfWeek == Calendar.FRIDAY ||
dayOfWeek == Calendar.SATURDAY ||
dayOfWeek == Calendar.SUNDAY
val isHoliday = HolidayProvider.isHoliday(date)
val isDayBeforeHoliday = HolidayProvider.isDayBeforeHoliday(date)
return isWeekend || isHoliday || isDayBeforeHoliday
}
/**
* Check if a date is a Friday.
*/
private fun isFriday(date: Date): Boolean {
val calendar = Calendar.getInstance()
calendar.time = date
return calendar.get(Calendar.DAY_OF_WEEK) == Calendar.FRIDAY
}
}

View file

@ -0,0 +1,22 @@
package com.dienstplan.nrw.model
import java.util.Date
/**
* Represents a single duty entry in the plan.
* Maps to the tblPlan in the Excel implementation.
*/
data class DutyEntry(
val id: Long = 0,
val date: Date,
val employeeName: String,
val share: Double, // Anteil (0.0 - 1.0)
val monthKey: String // YYYYMM format
) {
/**
* Check if this entry is valid.
*/
fun isValid(): Boolean {
return employeeName.isNotBlank() && share in 0.0..1.0
}
}

View file

@ -0,0 +1,13 @@
package com.dienstplan.nrw.model
import java.util.Date
/**
* Represents a public holiday in NRW.
* Maps to the tblFeiertage in the Excel implementation.
*/
data class Holiday(
val date: Date,
val name: String,
val bundesland: String = "NRW"
)

View file

@ -0,0 +1,44 @@
package com.dienstplan.nrw.model
/**
* Payroll calculation result for an employee for a specific month.
* Maps to the Auswertung sheet in the Excel implementation.
*/
data class PayrollResult(
val employeeName: String,
val wtUnits: Double, // WT_Einheiten (weekday units)
val weFriday: Double, // WE_Freitag (Friday weekend units)
val weOther: Double, // WE_Andere (other weekend units)
val weTotal: Double, // WE_Gesamt (total weekend units)
val thresholdReached: Boolean, // Schwelle_erreicht
val deductionTotal: Double, // Abzug_gesamt
val deductionFriday: Double, // Abzug_Freitag
val deductionOther: Double, // Abzug_Andere
val wePaid: Double, // WE_bezahlt (paid weekend units after threshold check)
val payoutWT: Double, // Auszahlung_WT
val payoutWE: Double, // Auszahlung_WE
val payoutTotal: Double // Auszahlung_Gesamt
) {
companion object {
/**
* Create an empty result for an employee with no duties.
*/
fun empty(employeeName: String): PayrollResult {
return PayrollResult(
employeeName = employeeName,
wtUnits = 0.0,
weFriday = 0.0,
weOther = 0.0,
weTotal = 0.0,
thresholdReached = false,
deductionTotal = 0.0,
deductionFriday = 0.0,
deductionOther = 0.0,
wePaid = 0.0,
payoutWT = 0.0,
payoutWE = 0.0,
payoutTotal = 0.0
)
}
}
}

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".DutyEntryActivity">
<TextView
android:id="@+id/tvSelectedMonth"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="November 2025" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvDuties"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/tvSelectedMonth"
app:layout_constraintBottom_toTopOf="@id/btnAddDuty"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_duty_entry" />
<Button
android:id="@+id/btnAddDuty"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/add_duty"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/btnSave"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/save_duties"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_title"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginTop="32dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/tvSelectMonth"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/select_month"
android:textSize="16sp"
android:layout_marginTop="48dp"
app:layout_constraintTop_toBottomOf="@id/tvTitle"
app:layout_constraintStart_toStartOf="parent" />
<Spinner
android:id="@+id/spinnerMonth"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/tvSelectMonth"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/spinnerYear"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp" />
<Spinner
android:id="@+id/spinnerYear"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/tvSelectMonth"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnEnterDuties"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/enter_duties"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@id/spinnerMonth"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnViewResults"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/view_results"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/btnEnterDuties"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".ResultsActivity">
<TextView
android:id="@+id/tvSelectedMonth"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="November 2025" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvResults"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/tvSelectedMonth"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_result" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/etEmployeeName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/employee_name"
android:inputType="textPersonName" />
<EditText
android:id="@+id/etShare"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/duty_share"
android:inputType="numberDecimal"
android:layout_marginTop="8dp" />
</LinearLayout>

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="01.11.2025" />
<EditText
android:id="@+id/etEmployeeName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/employee_name"
android:layout_marginTop="8dp"
android:inputType="textPersonName"
app:layout_constraintTop_toBottomOf="@id/tvDate"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/etShare"
android:layout_marginEnd="8dp" />
<EditText
android:id="@+id/etShare"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:hint="@string/duty_share"
android:inputType="numberDecimal"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/tvDate"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/delete"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/etEmployeeName"
app:layout_constraintEnd_toEndOf="parent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View file

@ -0,0 +1,194 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvEmployeeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="18sp"
tools:text="Max Mustermann" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/wt_units" />
<TextView
android:id="@+id/tvWTUnits"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="5.0" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/we_friday" />
<TextView
android:id="@+id/tvWEFriday"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="1.0" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/we_other" />
<TextView
android:id="@+id/tvWEOther"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="1.0" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/we_total"
android:textStyle="bold" />
<TextView
android:id="@+id/tvWETotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
tools:text="2.0" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/threshold_reached" />
<TextView
android:id="@+id/tvThresholdReached"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="JA" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/gray"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/payout_wt" />
<TextView
android:id="@+id/tvPayoutWT"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="1250.00 €" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/payout_we" />
<TextView
android:id="@+id/tvPayoutWE"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="450.00 €" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/payout_total"
android:textStyle="bold"
android:textSize="16sp" />
<TextView
android:id="@+id/tvPayoutTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
tools:text="1700.00 €" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#4472C4</color>
<color name="primary_dark">#2952A3</color>
<color name="secondary">#03DAC5</color>
<color name="secondary_dark">#018786</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
<color name="gray_light">#F5F5F5</color>
<color name="gray">#9E9E9E</color>
<color name="gray_dark">#616161</color>
<color name="red">#F44336</color>
<color name="green">#4CAF50</color>
</resources>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Dienstplan NRW</string>
<!-- Main Activity -->
<string name="main_title">Dienstplan Generator</string>
<string name="select_month">Monat auswählen</string>
<string name="enter_duties">Dienste eintragen</string>
<string name="view_results">Auswertung anzeigen</string>
<string name="month_year_format">%s %d</string>
<!-- Duty Entry Activity -->
<string name="duty_entry_title">Dienste eintragen</string>
<string name="employee_name">Mitarbeiter</string>
<string name="duty_share">Anteil (0.0 - 1.0)</string>
<string name="add_duty">Dienst hinzufügen</string>
<string name="save_duties">Dienste speichern</string>
<string name="date_label">Datum</string>
<!-- Results Activity -->
<string name="results_title">Auswertung</string>
<string name="wt_units">WT Einheiten</string>
<string name="we_friday">WE Freitag</string>
<string name="we_other">WE Andere</string>
<string name="we_total">WE Gesamt</string>
<string name="threshold_reached">Schwelle erreicht</string>
<string name="payout_wt">Auszahlung WT</string>
<string name="payout_we">Auszahlung WE</string>
<string name="payout_total">Auszahlung Gesamt</string>
<string name="yes">JA</string>
<string name="no">NEIN</string>
<string name="euro"></string>
<!-- Common -->
<string name="cancel">Abbrechen</string>
<string name="ok">OK</string>
<string name="delete">Löschen</string>
<string name="edit">Bearbeiten</string>
<!-- Months -->
<string name="january">Januar</string>
<string name="february">Februar</string>
<string name="march">März</string>
<string name="april">April</string>
<string name="may">Mai</string>
<string name="june">Juni</string>
<string name="july">Juli</string>
<string name="august">August</string>
<string name="september">September</string>
<string name="october">Oktober</string>
<string name="november">November</string>
<string name="december">Dezember</string>
<!-- Errors -->
<string name="error_invalid_share">Anteil muss zwischen 0.0 und 1.0 liegen</string>
<string name="error_empty_name">Bitte Mitarbeiternamen eingeben</string>
<string name="error_no_month_selected">Bitte wählen Sie einen Monat aus</string>
<!-- String Arrays -->
<string-array name="months">
<item>@string/january</item>
<item>@string/february</item>
<item>@string/march</item>
<item>@string/april</item>
<item>@string/may</item>
<item>@string/june</item>
<item>@string/july</item>
<item>@string/august</item>
<item>@string/september</item>
<item>@string/october</item>
<item>@string/november</item>
<item>@string/december</item>
</string-array>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="Theme.DienstplanNRW" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_dark</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorSecondaryVariant">@color/secondary_dark</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
</style>
</resources>

View file

@ -0,0 +1,157 @@
package com.dienstplan.nrw
import com.dienstplan.nrw.data.PayrollCalculator
import com.dienstplan.nrw.model.DutyEntry
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.text.SimpleDateFormat
import java.util.*
/**
* Unit tests for PayrollCalculator.
* Tests the business logic of Variante 2 (streng) payroll calculations.
*/
class PayrollCalculatorTest {
private lateinit var calculator: PayrollCalculator
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
@Before
fun setup() {
calculator = PayrollCalculator()
}
/**
* Test Case 1: Under threshold (1.75 WE + 1.0 WT)
* Expected: WE payout = 0, WT payout = 250
*/
@Test
fun testUnderThreshold() {
val duties = listOf(
// Weekday duty
DutyEntry(date = parseDate("2025-11-03"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Monday
// Weekend duties (below threshold)
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 0.75, monthKey = "202511"), // Friday
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511") // Saturday
)
val results = calculator.calculatePayroll(duties, 2025, 11)
assertEquals(1, results.size)
val result = results[0]
assertEquals("Test", result.employeeName)
assertEquals(1.0, result.wtUnits, 0.001)
assertEquals(1.75, result.weTotal, 0.001)
assertFalse(result.thresholdReached)
assertEquals(0.0, result.wePaid, 0.001)
assertEquals(250.0, result.payoutWT, 0.001)
assertEquals(0.0, result.payoutWE, 0.001)
assertEquals(250.0, result.payoutTotal, 0.001)
}
/**
* Test Case 2: Exactly at threshold (2.0 WE)
* Expected: WE payout = 450 (1.0 unit after deduction), threshold reached
*/
@Test
fun testExactlyAtThreshold() {
val duties = listOf(
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Friday
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511") // Saturday
)
val results = calculator.calculatePayroll(duties, 2025, 11)
assertEquals(1, results.size)
val result = results[0]
assertEquals(2.0, result.weTotal, 0.001)
assertTrue(result.thresholdReached)
assertEquals(1.0, result.deductionTotal, 0.001)
assertEquals(1.0, result.wePaid, 0.001)
assertEquals(450.0, result.payoutWE, 0.001)
}
/**
* Test Case 3: Over threshold (3.5 WE)
* Expected: WE payout = 1125 (2.5 units after deduction)
*/
@Test
fun testOverThreshold() {
val duties = listOf(
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Friday
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Saturday
DutyEntry(date = parseDate("2025-11-09"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Sunday
DutyEntry(date = parseDate("2025-11-14"), employeeName = "Test", share = 0.5, monthKey = "202511") // Friday
)
val results = calculator.calculatePayroll(duties, 2025, 11)
assertEquals(1, results.size)
val result = results[0]
assertEquals(3.5, result.weTotal, 0.001)
assertTrue(result.thresholdReached)
assertEquals(2.5, result.wePaid, 0.001)
assertEquals(1125.0, result.payoutWE, 0.001)
}
/**
* Test Case 4: Friday deduction priority
* Expected: Deduction comes from Friday first
*/
@Test
fun testFridayDeductionPriority() {
val duties = listOf(
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 0.4, monthKey = "202511"), // Friday
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 0.6, monthKey = "202511"), // Saturday
DutyEntry(date = parseDate("2025-11-09"), employeeName = "Test", share = 1.0, monthKey = "202511") // Sunday
)
val results = calculator.calculatePayroll(duties, 2025, 11)
assertEquals(1, results.size)
val result = results[0]
assertEquals(2.0, result.weTotal, 0.001)
assertEquals(0.4, result.weFriday, 0.001)
assertEquals(1.6, result.weOther, 0.001)
assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first
assertEquals(0.6, result.deductionOther, 0.001) // Rest from other
assertEquals(1.0, result.wePaid, 0.001)
}
/**
* Test Case 5: Multiple employees
* Expected: Separate calculations for each employee
*/
@Test
fun testMultipleEmployees() {
val duties = listOf(
// Employee A: under threshold
DutyEntry(date = parseDate("2025-11-07"), employeeName = "A", share = 1.0, monthKey = "202511"),
// Employee B: over threshold
DutyEntry(date = parseDate("2025-11-07"), employeeName = "B", share = 1.5, monthKey = "202511"),
DutyEntry(date = parseDate("2025-11-08"), employeeName = "B", share = 1.0, monthKey = "202511")
)
val results = calculator.calculatePayroll(duties, 2025, 11)
assertEquals(2, results.size)
// Results are sorted by name
val resultA = results.find { it.employeeName == "A" }!!
val resultB = results.find { it.employeeName == "B" }!!
// A: below threshold
assertFalse(resultA.thresholdReached)
assertEquals(0.0, resultA.payoutWE, 0.001)
// B: above threshold
assertTrue(resultB.thresholdReached)
assertEquals(2.5, resultB.weTotal, 0.001)
assertEquals(1.5, resultB.wePaid, 0.001)
assertEquals(675.0, resultB.payoutWE, 0.001)
}
private fun parseDate(dateString: String): Date {
return dateFormat.parse(dateString) ?: throw IllegalArgumentException("Invalid date")
}
}

View file

@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.1.4" apply false
id("org.jetbrains.kotlin.android") version "1.9.10" apply false
}

View file

@ -0,0 +1,5 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official

View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Dienstplan NRW"
include(":app")