Add PDF maintenance report generation feature for Android and iOS

- Add generateTasksReport API endpoint in ResidenceApi
- Implement report generation button in Android residence detail screen
- Add report generation state management in shared ResidenceViewModel
- Add report generation button to iOS residence detail view toolbar
- Implement iOS-specific report generation logic in ResidenceViewModel
- Display loading spinner and success/error alerts for report generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-09 10:12:22 -06:00
parent da0493e165
commit e6dc54017b
5 changed files with 148 additions and 0 deletions

View File

@@ -214,8 +214,37 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// PDF Report Generation
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
if (email != null) {
setBody(mapOf("email" to email))
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}
@kotlinx.serialization.Serializable
data class GenerateReportResponse(
val message: String,
val residence_name: String,
val recipient_email: String
)
@kotlinx.serialization.Serializable
data class PaginatedResponse<T>(
val count: Int,

View File

@@ -46,11 +46,14 @@ fun ResidenceDetailScreen(
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
val uncancelTaskState by residenceViewModel.uncancelTaskState.collectAsState()
val generateReportState by residenceViewModel.generateReportState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var showManageUsersDialog by remember { mutableStateOf(false) }
var showReportSnackbar by remember { mutableStateOf(false) }
var reportMessage by remember { mutableStateOf("") }
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
@@ -105,6 +108,24 @@ fun ResidenceDetailScreen(
}
}
// Handle generate report state
LaunchedEffect(generateReportState) {
when (generateReportState) {
is ApiResult.Success -> {
val response = (generateReportState as ApiResult.Success).data
reportMessage = response.message
showReportSnackbar = true
residenceViewModel.resetGenerateReportState()
}
is ApiResult.Error -> {
reportMessage = (generateReportState as ApiResult.Error).message
showReportSnackbar = true
residenceViewModel.resetGenerateReportState()
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
@@ -155,7 +176,17 @@ fun ResidenceDetailScreen(
)
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(showReportSnackbar) {
if (showReportSnackbar) {
snackbarHostState.showSnackbar(reportMessage)
showReportSnackbar = false
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Property Details", fontWeight = FontWeight.Bold) },
@@ -169,6 +200,23 @@ fun ResidenceDetailScreen(
if (residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
// Generate Report button
IconButton(
onClick = {
residenceViewModel.generateTasksReport(residenceId)
},
enabled = generateReportState !is ApiResult.Loading
) {
if (generateReportState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Description, contentDescription = "Generate Report")
}
}
// Manage Users button - only show for primary owners
if (residence.isPrimaryOwner) {
IconButton(onClick = {

View File

@@ -46,6 +46,9 @@ class ResidenceViewModel : ViewModel() {
private val _updateTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.CustomTask>>(ApiResult.Idle)
val updateTaskState: StateFlow<ApiResult<com.mycrib.shared.models.CustomTask>> = _updateTaskState
private val _generateReportState = MutableStateFlow<ApiResult<com.mycrib.shared.network.GenerateReportResponse>>(ApiResult.Idle)
val generateReportState: StateFlow<ApiResult<com.mycrib.shared.network.GenerateReportResponse>> = _generateReportState
fun loadResidences() {
viewModelScope.launch {
_residencesState.value = ApiResult.Loading
@@ -189,4 +192,20 @@ class ResidenceViewModel : ViewModel() {
fun resetUpdateTaskState() {
_updateTaskState.value = ApiResult.Idle
}
fun generateTasksReport(residenceId: Int, email: String? = null) {
viewModelScope.launch {
_generateReportState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_generateReportState.value = residenceApi.generateTasksReport(token, residenceId, email)
} else {
_generateReportState.value = ApiResult.Error("Not authenticated", 401)
}
}
}
fun resetGenerateReportState() {
_generateReportState.value = ApiResult.Idle
}
}

View File

@@ -15,6 +15,7 @@ struct ResidenceDetailView: View {
@State private var selectedTaskForEdit: TaskDetail?
@State private var selectedTaskForComplete: TaskDetail?
@State private var hasAppeared = false
@State private var showReportAlert = false
var body: some View {
ZStack {
@@ -105,6 +106,20 @@ struct ResidenceDetailView: View {
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
// Generate Report button
if viewModel.selectedResidence != nil {
Button(action: {
viewModel.generateTasksReport(residenceId: residenceId)
}) {
if viewModel.isGeneratingReport {
ProgressView()
} else {
Image(systemName: "doc.text")
}
}
.disabled(viewModel.isGeneratingReport)
}
// Manage Users button - only show for primary owners
if let residence = viewModel.selectedResidence, residence.isPrimaryOwner {
Button(action: {
@@ -164,6 +179,16 @@ struct ResidenceDetailView: View {
loadResidenceTasks()
}
}
.onChange(of: viewModel.reportMessage) { message in
if message != nil {
showReportAlert = true
}
}
.alert("Maintenance Report", isPresented: $showReportAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(viewModel.reportMessage ?? "")
}
.onAppear {
loadResidenceData()
}

View File

@@ -10,6 +10,8 @@ class ResidenceViewModel: ObservableObject {
@Published var selectedResidence: Residence?
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isGeneratingReport: Bool = false
@Published var reportMessage: String?
// MARK: - Private Properties
private let residenceApi: ResidenceApi
@@ -144,6 +146,31 @@ class ResidenceViewModel: ObservableObject {
}
}
func generateTasksReport(residenceId: Int32, email: String? = nil) {
guard let token = tokenStorage.getToken() else {
reportMessage = "Not authenticated"
return
}
isGeneratingReport = true
reportMessage = nil
residenceApi.generateTasksReport(token: token, residenceId: residenceId, email: email) { result, error in
defer { self.isGeneratingReport = false }
if let successResult = result as? ApiResultSuccess<GenerateReportResponse> {
if let response = successResult.data {
self.reportMessage = response.message
} else {
self.reportMessage = "Report generated, but no message returned."
}
} else if let errorResult = result as? ApiResultError {
self.reportMessage = errorResult.message
} else if let error = error {
self.reportMessage = error.localizedDescription
}
}
}
func clearError() {
errorMessage = nil
}