diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt index 427b11c375..c190c8041a 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt @@ -35,7 +35,7 @@ class ProgramEventsRobot(val composeTestRule: ComposeContentTestRule) : BaseRobo } fun clickOnAddEvent() { - onView(withId(R.id.addEventButton)).perform(click()) + composeTestRule.onNodeWithTag("ADD_EVENT_BUTTON").performClick() } fun clickOnMap() { diff --git a/app/src/main/java/org/dhis2/model/SnackbarMessage.kt b/app/src/main/java/org/dhis2/model/SnackbarMessage.kt new file mode 100644 index 0000000000..26f907b7f2 --- /dev/null +++ b/app/src/main/java/org/dhis2/model/SnackbarMessage.kt @@ -0,0 +1,8 @@ +package org.dhis2.model + +import java.util.UUID + +data class SnackbarMessage( + val id: UUID = UUID.randomUUID(), + val message: String = "", +) diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailActivity.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailActivity.kt index 642fbe283d..6fc126bfb0 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailActivity.kt @@ -7,31 +7,18 @@ import android.transition.ChangeBounds import android.transition.Transition import android.transition.TransitionManager import android.view.View +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.constraintlayout.widget.ConstraintSet -import androidx.databinding.DataBindingUtil import androidx.lifecycle.viewModelScope import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import dhis2.org.analytics.charts.ui.GroupAnalyticsFragment import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.dhis2.R -import org.dhis2.bindings.app import org.dhis2.bindings.clipWithRoundedCorners import org.dhis2.bindings.dp +import org.dhis2.bindings.userComponent import org.dhis2.commons.Constants import org.dhis2.commons.date.DateUtils import org.dhis2.commons.date.DateUtils.OnFromToSelector @@ -60,12 +47,10 @@ import org.dhis2.utils.analytics.DATA_CREATION import org.dhis2.utils.category.CategoryDialog import org.dhis2.utils.category.CategoryDialog.Companion.TAG import org.dhis2.utils.customviews.RxDateDialog -import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.dhis2.utils.granularsync.SyncStatusDialog import org.dhis2.utils.granularsync.shouldLaunchSyncDialog import org.hisp.dhis.android.core.period.DatePeriod import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme import timber.log.Timber import java.util.Date @@ -110,17 +95,22 @@ class ProgramEventDetailActivity : initInjection() themeManager?.setProgramTheme(programUid) super.onCreate(savedInstanceState) - initEventFilters() - initViewModel() - binding = DataBindingUtil.setContentView(this, R.layout.activity_program_event_detail) - binding.presenter = presenter - binding.totalFilters = FilterManager.getInstance().totalFilters - setupBottomNavigation() - binding.fragmentContainer.clipWithRoundedCorners(16.dp) - binding.filterLayout.adapter = filtersAdapter - presenter.init() - binding.syncButton.setOnClickListener { showSyncDialogProgram() } + setContent { + DHIS2Theme { + ProgramEventDetailScreen( + programEventsViewModel, + presenter, + networkUtils, + { binding = it }, + { + initBindings() + initEventFilters() + initViewModel() + }, + ) + } + } if (intent.shouldLaunchSyncDialog()) { showSyncDialogProgram() @@ -140,65 +130,13 @@ class ProgramEventDetailActivity : } } - private fun setupBottomNavigation() { - binding.navigationBar.setContent { - DHIS2Theme { - val uiState by programEventsViewModel.navigationBarUIState - val isBackdropActive by programEventsViewModel.backdropActive.observeAsState(false) - var selectedItemIndex by remember(uiState) { - mutableIntStateOf( - uiState.items.indexOfFirst { - it.id == uiState.selectedItem - }, - ) - } - - LaunchedEffect(uiState.selectedItem) { - when (uiState.selectedItem) { - NavigationPage.LIST_VIEW -> { - programEventsViewModel.showList() - } - - NavigationPage.MAP_VIEW -> { - networkUtils.performIfOnline( - context = this@ProgramEventDetailActivity, - action = { - presenter.trackEventProgramMap() - programEventsViewModel.showMap() - }, - onDialogDismissed = { - selectedItemIndex = 0 - }, - noNetworkMessage = getString(R.string.msg_network_connection_maps), - ) - } - - NavigationPage.ANALYTICS -> { - presenter.trackEventProgramAnalytics() - programEventsViewModel.showAnalytics() - } - - else -> { - // no-op - } - } - } - - AnimatedVisibility( - visible = uiState.items.size > 1 && isBackdropActive.not(), - enter = slideInVertically(animationSpec = tween(200)) { it }, - exit = slideOutVertically(animationSpec = tween(200)) { it }, - ) { - NavigationBar( - modifier = Modifier.fillMaxWidth(), - items = uiState.items, - selectedItemIndex = selectedItemIndex, - ) { page -> - programEventsViewModel.onNavigationPageChanged(page) - } - } - } - } + private fun initBindings() { + binding.presenter = presenter + binding.totalFilters = FilterManager.getInstance().totalFilters + binding.fragmentContainer.clipWithRoundedCorners(16.dp) + binding.filterLayout.adapter = filtersAdapter + binding.syncButton.setOnClickListener { showSyncDialogProgram() } + binding.totalFilters = FilterManager.getInstance().totalFilters } private fun initExtras() { @@ -206,7 +144,7 @@ class ProgramEventDetailActivity : } private fun initInjection() { - component = app().userComponent() + component = userComponent() ?.plus( ProgramEventDetailModule( this, @@ -243,9 +181,7 @@ class ProgramEventDetailActivity : programEventsViewModel.onRecreationActivity(false) } } - programEventsViewModel.writePermission.observe(this) { canWrite: Boolean -> - binding.addEventButton.visibility = if (canWrite) View.VISIBLE else View.GONE - } + programEventsViewModel.currentScreen.observe(this) { currentScreen: EventProgramScreen? -> currentScreen?.let { when (it) { @@ -257,12 +193,6 @@ class ProgramEventDetailActivity : } } - override fun onResume() { - super.onResume() - binding.addEventButton.isEnabled = true - binding.totalFilters = FilterManager.getInstance().totalFilters - } - private fun showSyncDialogProgram() { SyncStatusDialog.Builder() .withContext(this) @@ -273,12 +203,9 @@ class ProgramEventDetailActivity : } }) .onNoConnectionListener { - val contextView = findViewById(R.id.navigationBar) - Snackbar.make( - contextView, - R.string.sync_offline_check_connection, - Snackbar.LENGTH_SHORT, - ).show() + programEventsViewModel.displayMessage( + getString(R.string.sync_offline_check_connection), + ) } .show("EVENT_SYNC") } @@ -368,13 +295,6 @@ class ProgramEventDetailActivity : ConstraintSet.BOTTOM, 0, ) - initSet.connect( - R.id.addEventButton, - ConstraintSet.BOTTOM, - R.id.fragmentContainer, - ConstraintSet.BOTTOM, - 16.dp, - ) } else { initSet.connect( R.id.fragmentContainer, @@ -390,13 +310,6 @@ class ProgramEventDetailActivity : ConstraintSet.TOP, 0, ) - initSet.connect( - R.id.addEventButton, - ConstraintSet.BOTTOM, - R.id.navigationBar, - ConstraintSet.TOP, - 16.dp, - ) } initSet.applyTo(binding.backdropLayout) } @@ -429,8 +342,6 @@ class ProgramEventDetailActivity : programStageUid = it, ) } - } else { - enableAddEventButton(true) } } .build() @@ -438,10 +349,6 @@ class ProgramEventDetailActivity : } } - private fun enableAddEventButton(enable: Boolean) { - binding.addEventButton.isEnabled = enable - } - override fun setWritePermission(canWrite: Boolean) { programEventsViewModel.writePermission.value = canWrite } @@ -524,47 +431,36 @@ class ProgramEventDetailActivity : } }) .onNoConnectionListener { - val contextView = findViewById(R.id.rootView) - Snackbar.make( - contextView, - R.string.sync_offline_check_connection, - Snackbar.LENGTH_SHORT, - ).show() + programEventsViewModel.displayMessage( + getString(R.string.sync_offline_check_connection), + ) } .show(FRAGMENT_TAG) } private fun showList() { supportFragmentManager.beginTransaction().replace( - R.id.fragmentContainer, + binding.fragmentContainer.id, EventListFragment(), "EVENT_LIST", ).commitNow() - binding.addEventButton.visibility = - if (programEventsViewModel.writePermission.value == true) { - View.VISIBLE - } else { - View.GONE - } binding.filter.visibility = View.VISIBLE } private fun showMap() { supportFragmentManager.beginTransaction().replace( - R.id.fragmentContainer, + binding.fragmentContainer.id, EventMapFragment(), "EVENT_MAP", ).commitNow() - binding.addEventButton.visibility = View.GONE binding.filter.visibility = View.VISIBLE } private fun showAnalytics() { supportFragmentManager.beginTransaction().replace( - R.id.fragmentContainer, + binding.fragmentContainer.id, GroupAnalyticsFragment.forProgram(programUid), ).commitNow() - binding.addEventButton.visibility = View.GONE binding.filter.visibility = View.GONE } diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailScreen.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailScreen.kt new file mode 100644 index 0000000000..d590ccbbce --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailScreen.kt @@ -0,0 +1,179 @@ +package org.dhis2.usescases.programEventDetail + +import android.view.LayoutInflater +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.viewinterop.AndroidView +import org.dhis2.R +import org.dhis2.commons.network.NetworkUtils +import org.dhis2.databinding.ActivityProgramEventDetailBinding +import org.dhis2.model.SnackbarMessage +import org.dhis2.usescases.programEventDetail.ProgramEventDetailViewModel.EventProgramScreen +import org.dhis2.utils.customviews.navigationbar.NavigationPage +import org.hisp.dhis.mobile.ui.designsystem.component.FAB +import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow + +@Composable +fun ProgramEventDetailScreen( + programEventsViewModel: ProgramEventDetailViewModel, + presenter: ProgramEventDetailPresenter, + networkUtils: NetworkUtils, + onBindingReady: (ActivityProgramEventDetailBinding) -> Unit, + onViewReady: () -> Unit, +) { + val context = LocalContext.current + val isBackdropActive by programEventsViewModel.backdropActive.observeAsState(false) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarMessage by programEventsViewModel.snackbarMessage.collectAsState(SnackbarMessage()) + + LaunchedEffect(snackbarMessage) { + if (snackbarMessage.message.isNotEmpty()) { + snackbarHostState.showSnackbar(snackbarMessage.message) + } + } + + Scaffold( + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), + snackbarData = data, + containerColor = SurfaceColor.SurfaceBright, + contentColor = TextColor.OnSurface, + ) + } + }, + floatingActionButton = { + val writePermission by programEventsViewModel.writePermission.observeAsState( + false, + ) + val currentScreen by programEventsViewModel.currentScreen.observeAsState() + val displayFAB by remember { + derivedStateOf { + when (currentScreen) { + EventProgramScreen.LIST -> true + else -> false + } && writePermission && + isBackdropActive.not() + } + } + AnimatedVisibility( + visible = displayFAB, + enter = scaleIn(), + exit = scaleOut(), + ) { + FAB( + modifier = Modifier.testTag("ADD_EVENT_BUTTON"), + onClick = presenter::addEvent, + icon = { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "add event", + tint = TextColor.OnPrimary, + ) + }, + ) + } + }, + bottomBar = { + val uiState by programEventsViewModel.navigationBarUIState + + var selectedItemIndex by remember(uiState) { + mutableIntStateOf( + uiState.items.indexOfFirst { + it.id == uiState.selectedItem + }, + ) + } + + LaunchedEffect(uiState.selectedItem) { + when (uiState.selectedItem) { + NavigationPage.LIST_VIEW -> { + programEventsViewModel.showList() + } + + NavigationPage.MAP_VIEW -> { + networkUtils.performIfOnline( + context = context, + action = { + presenter.trackEventProgramMap() + programEventsViewModel.showMap() + }, + onDialogDismissed = { + selectedItemIndex = 0 + }, + noNetworkMessage = context.getString(R.string.msg_network_connection_maps), + ) + } + + NavigationPage.ANALYTICS -> { + presenter.trackEventProgramAnalytics() + programEventsViewModel.showAnalytics() + } + + else -> { + // no-op + } + } + } + + AnimatedVisibility( + visible = uiState.items.size > 1 && isBackdropActive.not(), + enter = slideInVertically(animationSpec = tween(200)) { it }, + exit = slideOutVertically(animationSpec = tween(200)) { it }, + ) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + items = uiState.items, + selectedItemIndex = selectedItemIndex, + onItemClick = programEventsViewModel::onNavigationPageChanged, + ) + } + }, + ) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(it), + factory = { context -> + ActivityProgramEventDetailBinding.inflate( + LayoutInflater.from(context), + ).also(onBindingReady).root + }, + update = { + onViewReady() + presenter.init() + }, + ) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt index 425587c116..be1235dbee 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt @@ -16,12 +16,14 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.dhis2.R import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.maps.layer.basemaps.BaseMapStyle import org.dhis2.maps.usecases.MapStyleConfiguration +import org.dhis2.model.SnackbarMessage import org.dhis2.tracker.NavigationBarUIState import org.dhis2.tracker.events.CreateEventUseCase import org.dhis2.utils.customviews.navigationbar.NavigationPage @@ -63,6 +65,9 @@ class ProgramEventDetailViewModel( private val _navigationBarUIState = mutableStateOf(NavigationBarUIState()) val navigationBarUIState: State> = _navigationBarUIState + private val _snackbarMessage = MutableSharedFlow() + val snackbarMessage = _snackbarMessage.asSharedFlow() + init { viewModelScope.launch { loadBottomBarItems() } } @@ -167,4 +172,10 @@ class ProgramEventDetailViewModel( } } } + + fun displayMessage(msg: String) { + viewModelScope.launch(dispatcher.io()) { + _snackbarMessage.emit(SnackbarMessage(message = msg)) + } + } } diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/eventMap/EventMapFragment.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/eventMap/EventMapFragment.kt index 61271eccb1..36c2023b0b 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/eventMap/EventMapFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/eventMap/EventMapFragment.kt @@ -221,6 +221,7 @@ class EventMapFragment : } override fun onDestroy() { + programEventsViewModel.setProgress(false) presenter.onDestroy() super.onDestroy() } diff --git a/app/src/main/java/org/dhis2/utils/HelpManager.java b/app/src/main/java/org/dhis2/utils/HelpManager.java index 4c069b37e3..0d995029e9 100644 --- a/app/src/main/java/org/dhis2/utils/HelpManager.java +++ b/app/src/main/java/org/dhis2/utils/HelpManager.java @@ -25,8 +25,8 @@ public class HelpManager { private NestedScrollView scrollView; public enum TutorialName { - SETTINGS_FRAGMENT, PROGRAM_FRAGMENT, TEI_DASHBOARD, TEI_SEARCH, PROGRAM_EVENT_LIST, - EVENT_DETAIL, EVENT_SUMMARY, EVENT_INITIAL + SETTINGS_FRAGMENT, PROGRAM_FRAGMENT, TEI_DASHBOARD, + EVENT_INITIAL } public static HelpManager getInstance() { @@ -67,8 +67,6 @@ public void show(ActivityGlobalAbstract activity, TutorialName name, SparseBoole case PROGRAM_FRAGMENT -> help = programFragmentTutorial(activity, stepCondition); case SETTINGS_FRAGMENT -> help = settingsFragmentTutorial(activity); case TEI_DASHBOARD -> help = teiDashboardTutorial(activity); - case TEI_SEARCH -> help = teiSearchTutorial(activity); - case PROGRAM_EVENT_LIST -> help = programEventListTutorial(activity, stepCondition); case EVENT_INITIAL -> help = eventInitialTutorial(activity, stepCondition); } if (!help.isEmpty()) @@ -99,48 +97,6 @@ private List eventInitialTutorial(ActivityGlobalAbstract acti return steps; } - private List programEventListTutorial(ActivityGlobalAbstract activity, SparseBooleanArray stepCondition) { - ArrayList steps = new ArrayList<>(); - - FancyShowCaseView tuto1 = new FancyShowCaseView.Builder(activity) - .title(activity.getString(R.string.tuto_program_event_1)) - .enableAutoTextPosition() - .closeOnTouch(true) - .build(); - steps.add(tuto1); - - if (stepCondition.get(2)) { - FancyShowCaseView tuto2 = new FancyShowCaseView.Builder(activity) - .title(activity.getString(R.string.tuto_program_event_2)) - .enableAutoTextPosition() - .focusOn(activity.findViewById(R.id.addEventButton)) - .closeOnTouch(true) - .build(); - steps.add(tuto2); - } - return steps; - } - - private List teiSearchTutorial(ActivityGlobalAbstract activity) { - FancyShowCaseView tuto1 = new FancyShowCaseView.Builder(activity) - .title(activity.getString(R.string.tuto_search_1_v2)) - .enableAutoTextPosition() - .closeOnTouch(true) - .build(); - FancyShowCaseView tuto2 = new FancyShowCaseView.Builder(activity) - .title(activity.getString(R.string.tuto_search_2)) - .enableAutoTextPosition() - .focusShape(FocusShape.ROUNDED_RECTANGLE) - .focusOn(activity.findViewById(R.id.program_spinner)) - .closeOnTouch(true) - .build(); - - ArrayList steps = new ArrayList<>(); - steps.add(tuto1); - steps.add(tuto2); - return steps; - } - private List teiDashboardTutorial(ActivityGlobalAbstract activity) { FancyShowCaseView tuto2 = null; FancyShowCaseView tuto1 = new FancyShowCaseView.Builder(activity) diff --git a/app/src/main/res/layout/activity_program_event_detail.xml b/app/src/main/res/layout/activity_program_event_detail.xml index 385a676100..72e09e38d9 100644 --- a/app/src/main/res/layout/activity_program_event_detail.xml +++ b/app/src/main/res/layout/activity_program_event_detail.xml @@ -38,9 +38,9 @@ style="@style/ActionIcon" android:layout_marginStart="4dp" android:onClick="@{()->presenter.onBackClick()}" - app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_arrow_back" tools:ignore="ContentDescription" /> @@ -63,8 +63,8 @@ + app:layout_constraintTop_toBottomOf="@+id/toolbar_guideline" /> - - - - + app:layout_constraintTop_toBottomOf="@id/backdropGuideTop" /> diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java index db35d904bd..967fc36412 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java @@ -363,7 +363,6 @@ public FlowableProcessor getOuTreeProcessor() { } public Flowable asFlowable() { - this.scope = null; return filterProcessor; }