diff --git a/.gitignore b/.gitignore
index fbe1afd..6106a3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,17 @@ output/*.xlsx
templates/*.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
.vscode/
.idea/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64d6513..bb01259 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,54 @@
# Changelog
+## 2025-11-14 - Android App Implementation
+
+### Feature
+Added native Android mobile app for duty roster management with the same NRW Variante 2 (streng) payroll calculation logic as the Python/Excel version.
+
+### Details
+
+**New Android App:**
+- Location: `android-app/` directory
+- Language: Kotlin
+- Min SDK: Android 7.0 (API 24)
+- Target SDK: Android 14 (API 34)
+
+**Features:**
+- Month selection interface (2025-2030)
+- Duty entry with employee name and share (Anteil)
+- Automatic payroll calculation
+- Results display with detailed breakdown per employee
+- In-memory data storage
+
+**Business Logic:**
+- Same NRW holidays (2025-2026)
+- Same WE-Tag detection (Friday, Saturday, Sunday, holidays, day before holiday)
+- Same WT-Tag classification
+- Same compensation rates (WT: 250€, WE: 450€)
+- Same threshold logic (≥ 2.0 WE units)
+- Same deduction rules (1.0 unit, Friday priority)
+- Same Variante 2 behavior (no WE compensation below threshold)
+
+**Testing:**
+- Comprehensive unit tests for PayrollCalculator
+- All test cases passed (under threshold, at threshold, over threshold, Friday priority, multiple employees)
+
+**Documentation:**
+- Android-specific README with setup instructions
+- Main README updated to mention Android app
+- .gitignore updated for Android build artifacts
+
+### Usage
+
+See [android-app/README.md](android-app/README.md) for detailed Android setup and usage instructions.
+
+### Known Limitations
+- Data is not persisted (in-memory only)
+- No data export/import functionality
+- German language only
+
+---
+
## 2025-11-14 - Fix Excel Formula Syntax Error
### Issue
diff --git a/README.md b/README.md
index e41d467..920383b 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,16 @@
# 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 (Fr–So), Feiertagen und Vortagen
- ✅ 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
.
-├── src/
+├── src/ # Python source code
│ ├── build_template.py # Erstellt die Basis-Vorlage
│ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen
│ └── 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
├── templates/ # Basis-Vorlage
├── requirements.txt # Python-Abhängigkeiten (openpyxl)
diff --git a/android-app/IMPLEMENTATION_SUMMARY.md b/android-app/IMPLEMENTATION_SUMMARY.md
new file mode 100644
index 0000000..55e510f
--- /dev/null
+++ b/android-app/IMPLEMENTATION_SUMMARY.md
@@ -0,0 +1,239 @@
+# Android App Implementation Summary
+
+## Overview
+
+Successfully implemented a native Android mobile application for the Dienstplan NRW project, providing the same duty roster management and payroll calculation functionality as the existing Python/Excel solution, but in a mobile-friendly format.
+
+## What Was Implemented
+
+### 1. Complete Android Project Structure
+- **Package**: `com.dienstplan.nrw`
+- **Language**: Kotlin
+- **Min SDK**: Android 7.0 (API 24) - covers ~98% of active Android devices
+- **Target SDK**: Android 14 (API 34)
+- **Build System**: Gradle with Kotlin DSL
+- **Architecture**: Simple MVVM-inspired pattern with clear separation of concerns
+
+### 2. Core Business Logic (100% Parity with Python/Excel)
+
+#### PayrollCalculator
+Implements NRW Variante 2 (streng) rules:
+- WT compensation: 250€ per unit (always paid)
+- WE compensation: 450€ per unit (only if threshold ≥ 2.0 reached)
+- Threshold: 2.0 WE units per employee per month
+- Deduction: Exactly 1.0 WE unit after threshold reached
+- Deduction priority: Friday first, then other WE days
+- Below threshold: WE shifts = 0€ (NOT converted to WT)
+
+#### HolidayProvider
+- NRW public holidays for 2025-2026
+- Same holiday dates as Python implementation
+- Holiday detection for threshold classification
+- Day-before-holiday detection (Vortag)
+
+#### Day Classification
+- **WE-Tag** (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before holiday
+- **WT-Tag** (Weekday): All other days
+- Uses Calendar.DAY_OF_WEEK for reliable detection
+
+### 3. User Interface
+
+#### MainActivity
+- Month selection (dropdown for 1-12)
+- Year selection (2025-2030)
+- Navigation to duty entry and results screens
+- Material Design components
+
+#### DutyEntryActivity
+- RecyclerView-based duty list
+- Date picker dialog for selecting duty dates
+- Employee name and share input (0.0-1.0)
+- Add/delete duty functionality
+- Validation for valid share values
+
+#### ResultsActivity
+- RecyclerView-based results list
+- Per-employee payroll breakdown showing:
+ - WT units
+ - WE Friday units
+ - WE Other units
+ - WE Total
+ - Threshold reached (JA/NEIN)
+ - Payout WT (€)
+ - Payout WE (€)
+ - Total payout (€)
+
+### 4. Data Management
+
+#### DutyDataStore
+- Simple in-memory data storage
+- CRUD operations for duty entries
+- Month-based filtering
+- Date generation for month selection
+
+**Note**: Production implementation should use Room database for persistence.
+
+### 5. Testing
+
+#### Unit Tests (PayrollCalculatorTest.kt)
+Comprehensive test coverage including:
+1. **Under threshold test**: 1.75 WE + 1.0 WT → WE payout 0€, WT payout 250€
+2. **Exactly at threshold test**: 2.0 WE → WE payout 450€ (1.0 unit after deduction)
+3. **Over threshold test**: 3.5 WE → WE payout 1125€ (2.5 units after deduction)
+4. **Friday deduction priority test**: Verifies deduction comes from Friday first
+5. **Multiple employees test**: Separate calculations per employee
+
+All tests pass and validate the business logic matches the specification.
+
+### 6. Resource Files
+
+#### Layouts
+- `activity_main.xml`: Month selection screen
+- `activity_duty_entry.xml`: Duty entry list screen
+- `activity_results.xml`: Results display screen
+- `item_duty_entry.xml`: Duty entry list item (CardView)
+- `item_result.xml`: Result list item (CardView with detailed breakdown)
+- `dialog_add_duty.xml`: Dialog for adding a new duty
+
+#### Strings (German)
+- All UI text in German (matching the domain)
+- Month names array
+- Error messages
+- Labels and titles
+
+#### Themes & Colors
+- Material Design theme
+- Primary color: #4472C4 (matching Excel template header color)
+- Consistent color scheme throughout
+
+### 7. Documentation
+
+#### android-app/README.md
+Complete documentation including:
+- Feature list
+- Business rules explanation
+- Installation instructions (Android Studio and command line)
+- Usage guide
+- Project structure
+- Customization guide
+- Known limitations
+- Future enhancements
+
+#### Main README.md Updates
+- Added section about available versions (Python/Excel and Android)
+- Updated project structure to include android-app
+
+#### CHANGELOG.md
+- Detailed entry for Android app implementation
+- Features, testing, and limitations documented
+
+## Technical Decisions
+
+### Why Kotlin?
+- Modern Android development standard
+- Concise, expressive syntax
+- Null safety built-in
+- Excellent interop with Java libraries
+
+### Why In-Memory Storage?
+- Simplifies initial implementation
+- Easy to replace with Room database later
+- Sufficient for proof-of-concept
+- Documented as known limitation
+
+### Why Material Design?
+- Android standard UI framework
+- Consistent user experience
+- Built-in accessibility features
+- Professional appearance
+
+## Validation
+
+### Business Logic Verification
+✅ All payroll calculation test cases pass
+✅ Holiday detection matches Python implementation
+✅ WE-Tag classification matches specification
+✅ Threshold logic matches Variante 2 (streng) rules
+✅ Deduction priority (Friday first) implemented correctly
+
+### Python/Excel Compatibility
+✅ Python template generation still works
+✅ No changes to existing Python code
+✅ Business rules identical between implementations
+
+### Code Quality
+✅ No CodeQL security issues detected
+✅ Clean separation of concerns
+✅ Well-documented code with comments
+✅ Consistent naming conventions
+✅ Proper error handling and validation
+
+## Security Summary
+
+**Security Scan**: No vulnerabilities detected by CodeQL
+**Data Storage**: In-memory only (no sensitive data persisted)
+**Input Validation**: Share values validated (0.0-1.0 range)
+**No External Dependencies**: Only official Android/Google libraries used
+
+## Known Limitations
+
+1. **Data Persistence**: Data is lost when app closes (in-memory only)
+2. **No Export**: Cannot export results to Excel/PDF
+3. **No Import**: Cannot import existing Excel files
+4. **Limited Holiday Data**: Only NRW 2025-2026
+5. **Single Bundesland**: Only NRW supported
+6. **No Offline Sync**: No cloud backup/restore
+7. **German Only**: No internationalization
+
+## Future Enhancements (Recommended)
+
+1. **Data Persistence**: Implement Room database
+2. **Export/Import**: Excel and PDF export, Excel import
+3. **Multi-Bundesland**: Support other German states
+4. **Extended Holiday Data**: Add years beyond 2026
+5. **Cloud Sync**: Optional cloud backup
+6. **Localization**: Add English translation
+7. **Dark Mode**: Theme support
+8. **Tablet Layout**: Optimized for larger screens
+
+## Files Added
+
+```
+android-app/
+├── README.md (Documentation)
+├── build.gradle.kts (Root build config)
+├── settings.gradle.kts (Project settings)
+├── gradle.properties (Gradle properties)
+├── app/
+│ ├── build.gradle.kts (App build config)
+│ ├── proguard-rules.pro (ProGuard rules)
+│ ├── src/
+│ │ ├── main/
+│ │ │ ├── AndroidManifest.xml (App manifest)
+│ │ │ ├── java/com/dienstplan/nrw/
+│ │ │ │ ├── MainActivity.kt (Month selection)
+│ │ │ │ ├── DutyEntryActivity.kt (Duty entry)
+│ │ │ │ ├── ResultsActivity.kt (Results display)
+│ │ │ │ ├── model/
+│ │ │ │ │ ├── DutyEntry.kt (Data model)
+│ │ │ │ │ ├── Holiday.kt (Data model)
+│ │ │ │ │ └── PayrollResult.kt (Data model)
+│ │ │ │ └── data/
+│ │ │ │ ├── DutyDataStore.kt (Data storage)
+│ │ │ │ ├── HolidayProvider.kt (Holiday data)
+│ │ │ │ └── PayrollCalculator.kt (Business logic)
+│ │ │ └── res/
+│ │ │ ├── layout/ (6 XML layouts)
+│ │ │ └── values/ (strings, colors, themes)
+│ │ └── test/
+│ │ └── java/com/dienstplan/nrw/
+│ │ └── PayrollCalculatorTest.kt (Unit tests)
+```
+
+Total: 27 new files
+
+## Conclusion
+
+The Android app successfully brings the Dienstplan NRW functionality to mobile devices while maintaining 100% business logic parity with the Python/Excel implementation. The app is ready for use with the understanding that data is not persisted between sessions (by design for this initial implementation).
+
+The implementation is clean, well-tested, documented, and follows Android best practices. It provides a solid foundation for future enhancements such as data persistence, export/import functionality, and additional features.
diff --git a/android-app/README.md b/android-app/README.md
new file mode 100644
index 0000000..7694129
--- /dev/null
+++ b/android-app/README.md
@@ -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.
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..906e2d0
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -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")
+}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..ef3d48b
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -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.** { *; }
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..67ff5ab
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt
new file mode 100644
index 0000000..fa3d39a
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt
@@ -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(R.id.etEmployeeName)
+ val etShare = dialogView.findViewById(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() {
+
+ private var duties = listOf()
+ private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
+
+ fun submitList(newDuties: List) {
+ 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
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt
new file mode 100644
index 0000000..46d9486
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt
new file mode 100644
index 0000000..f3199cc
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt
@@ -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() {
+
+ private var results = listOf()
+
+ fun submitList(newResults: List) {
+ 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)
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt
new file mode 100644
index 0000000..91d5819
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt
@@ -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()
+ 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 {
+ val monthKey = getMonthKey(year, month)
+ return duties.filter { it.monthKey == monthKey }
+ }
+
+ /**
+ * Get all duties.
+ */
+ fun getAllDuties(): List = 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 {
+ val calendar = Calendar.getInstance()
+ calendar.set(year, month - 1, 1, 0, 0, 0)
+ calendar.set(Calendar.MILLISECOND, 0)
+
+ val dates = mutableListOf()
+ 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
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt
new file mode 100644
index 0000000..cb5b70a
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt
@@ -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 = 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")
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt
new file mode 100644
index 0000000..9323553
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt
@@ -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, year: Int, month: Int): List {
+ // 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): 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
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt
new file mode 100644
index 0000000..e8dc6d0
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt
@@ -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
+ }
+}
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt
new file mode 100644
index 0000000..19067df
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt
@@ -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"
+)
diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt
new file mode 100644
index 0000000..337d30d
--- /dev/null
+++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/android-app/app/src/main/res/layout/activity_duty_entry.xml b/android-app/app/src/main/res/layout/activity_duty_entry.xml
new file mode 100644
index 0000000..b85d093
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_duty_entry.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..a843f5e
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/activity_results.xml b/android-app/app/src/main/res/layout/activity_results.xml
new file mode 100644
index 0000000..0181512
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_results.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/dialog_add_duty.xml b/android-app/app/src/main/res/layout/dialog_add_duty.xml
new file mode 100644
index 0000000..5362bd2
--- /dev/null
+++ b/android-app/app/src/main/res/layout/dialog_add_duty.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/item_duty_entry.xml b/android-app/app/src/main/res/layout/item_duty_entry.xml
new file mode 100644
index 0000000..1dd7da3
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_duty_entry.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/layout/item_result.xml b/android-app/app/src/main/res/layout/item_result.xml
new file mode 100644
index 0000000..a50b845
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_result.xml
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..561bd20
--- /dev/null
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,14 @@
+
+
+ #4472C4
+ #2952A3
+ #03DAC5
+ #018786
+ #FFFFFF
+ #000000
+ #F5F5F5
+ #9E9E9E
+ #616161
+ #F44336
+ #4CAF50
+
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0758ece
--- /dev/null
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,74 @@
+
+
+ Dienstplan NRW
+
+
+ Dienstplan Generator
+ Monat auswählen
+ Dienste eintragen
+ Auswertung anzeigen
+ %s %d
+
+
+ Dienste eintragen
+ Mitarbeiter
+ Anteil (0.0 - 1.0)
+ Dienst hinzufügen
+ Dienste speichern
+ Datum
+
+
+ Auswertung
+ WT Einheiten
+ WE Freitag
+ WE Andere
+ WE Gesamt
+ Schwelle erreicht
+ Auszahlung WT
+ Auszahlung WE
+ Auszahlung Gesamt
+ JA
+ NEIN
+ €
+
+
+ Abbrechen
+ OK
+ Löschen
+ Bearbeiten
+
+
+ Januar
+ Februar
+ März
+ April
+ Mai
+ Juni
+ Juli
+ August
+ September
+ Oktober
+ November
+ Dezember
+
+
+ Anteil muss zwischen 0.0 und 1.0 liegen
+ Bitte Mitarbeiternamen eingeben
+ Bitte wählen Sie einen Monat aus
+
+
+
+ - @string/january
+ - @string/february
+ - @string/march
+ - @string/april
+ - @string/may
+ - @string/june
+ - @string/july
+ - @string/august
+ - @string/september
+ - @string/october
+ - @string/november
+ - @string/december
+
+
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..d1bd601
--- /dev/null
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt
new file mode 100644
index 0000000..2403c0f
--- /dev/null
+++ b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt
@@ -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")
+ }
+}
diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts
new file mode 100644
index 0000000..1d51f15
--- /dev/null
+++ b/android-app/build.gradle.kts
@@ -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
+}
diff --git a/android-app/gradle.properties b/android-app/gradle.properties
new file mode 100644
index 0000000..93aa6f6
--- /dev/null
+++ b/android-app/gradle.properties
@@ -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
diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts
new file mode 100644
index 0000000..045f60f
--- /dev/null
+++ b/android-app/settings.gradle.kts
@@ -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")