From bfde40df4a03f6fa912f8549d3f2673bf46466fa Mon Sep 17 00:00:00 2001 From: kamathprasad9 <54414375+kamathprasad9@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:16:37 -0700 Subject: [PATCH] add option to archive crosses --- app/build.gradle | 17 +- .../intercross/data/EventsRepository.kt | 54 +++- .../intercross/data/IntercrossDatabase.kt | 6 +- .../intercross/data/dao/EventsDao.kt | 29 +- .../migrations/MigrationV4ArchivedCrosses.kt | 14 + .../phenoapps/intercross/data/models/Event.kt | 2 + .../data/viewmodels/EventListViewModel.kt | 58 +++- .../fragments/ArchivedEventsFragment.kt | 223 +++++++++++++++ .../fragments/EventDetailFragment.kt | 63 ++++- .../intercross/fragments/EventsFragment.kt | 143 ++++++---- .../intercross/ui/listItems/EventsListItem.kt | 257 ++++++++++++++++++ .../intercross/ui/lists/EventsList.kt | 94 +++++++ .../phenoapps/intercross/ui/qr/QRCodeImage.kt | 77 ++++++ app/src/main/res/drawable/ic_archive.xml | 1 + app/src/main/res/drawable/ic_unarchive.xml | 1 + .../res/layout/fragment_archived_events.xml | 20 ++ app/src/main/res/layout/fragment_events.xml | 15 +- .../res/menu/archived_crosses_toolbar.xml | 16 ++ app/src/main/res/menu/cross_entry_toolbar.xml | 7 +- app/src/main/res/menu/menu_entry_fragment.xml | 7 +- app/src/main/res/navigation/navigation.xml | 13 + app/src/main/res/values/strings.xml | 14 + 22 files changed, 1043 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/org/phenoapps/intercross/data/migrations/MigrationV4ArchivedCrosses.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/fragments/ArchivedEventsFragment.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/ui/listItems/EventsListItem.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/ui/lists/EventsList.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/ui/qr/QRCodeImage.kt create mode 100644 app/src/main/res/drawable/ic_archive.xml create mode 100644 app/src/main/res/drawable/ic_unarchive.xml create mode 100644 app/src/main/res/layout/fragment_archived_events.xml create mode 100644 app/src/main/res/menu/archived_crosses_toolbar.xml diff --git a/app/build.gradle b/app/build.gradle index d1a44bc7..d54fbfbb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -273,18 +273,23 @@ dependencies { implementation 'androidx.activity:activity-compose:1.12.2' + def composePlugins = "1.10.0" + def material3 = "1.4.0" // Material Design 3 - implementation 'androidx.compose.material3:material3' - implementation "androidx.compose.material3:material3-window-size-class:1.4.0" + implementation "androidx.compose.material3:material3:$material3" + implementation "androidx.compose.material3:material3-window-size-class:$material3" implementation 'androidx.compose.material3.adaptive:adaptive:1.3.0-alpha05' + implementation "androidx.compose.material:material-icons-core" // foundational components - implementation 'androidx.compose.foundation:foundation' - implementation 'androidx.compose.ui:ui' + implementation "androidx.compose.foundation:foundation:$composePlugins" + implementation "androidx.compose.ui:ui:$composePlugins" // Android Studio Preview support - implementation 'androidx.compose.ui:ui-tooling-preview' - debugImplementation 'androidx.compose.ui:ui-tooling' + implementation "androidx.compose.ui:ui-tooling-preview:$composePlugins" + debugImplementation "androidx.compose.ui:ui-tooling:$composePlugins" + + implementation "androidx.compose.runtime:runtime-livedata:$composePlugins" } kapt { diff --git a/app/src/main/java/org/phenoapps/intercross/data/EventsRepository.kt b/app/src/main/java/org/phenoapps/intercross/data/EventsRepository.kt index 55efef52..12bbd43a 100644 --- a/app/src/main/java/org/phenoapps/intercross/data/EventsRepository.kt +++ b/app/src/main/java/org/phenoapps/intercross/data/EventsRepository.kt @@ -1,7 +1,6 @@ package org.phenoapps.intercross.data import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.phenoapps.intercross.data.dao.EventsDao import org.phenoapps.intercross.data.models.Event @@ -34,19 +33,66 @@ class EventsRepository } } - fun deleteById(eid: Long) { + suspend fun deleteById(eid: Long) { - runBlocking { + withContext(IO) { eventsDao.deleteById(eid) } } + suspend fun deleteByIds(eids: List) { + + withContext(IO) { + + eventsDao.deleteByIds(eids) + + } + } + + suspend fun archiveById(eid: Long) { + + withContext(IO) { + + eventsDao.archiveEvent(eid) + + } + } + + suspend fun archiveByIds(eids: List) { + + withContext(IO) { + + eventsDao.archiveEvents(eids) + + } + } + + suspend fun unarchiveById(eid: Long) { + + withContext(IO) { + + eventsDao.unarchiveEvent(eid) + + } + } + + suspend fun unarchiveByIds(eids: List) { + + withContext(IO) { + + eventsDao.unarchiveEvents(eids) + + } + } + fun insert(event: Event): Long = eventsDao.insertEvent(event) fun loadCrosses() = eventsDao.selectAllLive() + fun selectArchivedEvents() = eventsDao.selectArchivedEvents() + companion object { @Volatile private var instance: EventsRepository? = null @@ -56,4 +102,4 @@ class EventsRepository .also { instance = it } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/phenoapps/intercross/data/IntercrossDatabase.kt b/app/src/main/java/org/phenoapps/intercross/data/IntercrossDatabase.kt index cacaec82..624ace9d 100644 --- a/app/src/main/java/org/phenoapps/intercross/data/IntercrossDatabase.kt +++ b/app/src/main/java/org/phenoapps/intercross/data/IntercrossDatabase.kt @@ -5,17 +5,16 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase import org.phenoapps.intercross.data.dao.* import org.phenoapps.intercross.data.migrations.MigrationV2MetaData import org.phenoapps.intercross.data.migrations.MigrationV3WishlistView +import org.phenoapps.intercross.data.migrations.MigrationV4ArchivedCrosses import org.phenoapps.intercross.data.models.* @Database(entities = [Event::class, Parent::class, Wishlist::class, Settings::class, PollenGroup::class, Meta::class, MetadataValues::class], - views = [WishlistView::class], version = 3, exportSchema = true) + views = [WishlistView::class], version = 4, exportSchema = true) @TypeConverters(Converters::class) abstract class IntercrossDatabase : RoomDatabase() { @@ -46,6 +45,7 @@ abstract class IntercrossDatabase : RoomDatabase() { return Room.databaseBuilder(ctx, IntercrossDatabase::class.java, DATABASE_NAME) .addMigrations(MigrationV2MetaData()) //v1 -> v2 migration added JSON based metadata .addMigrations(MigrationV3WishlistView()) // v2 -> v3 migration for WishlistView + .addMigrations(MigrationV4ArchivedCrosses()) // v3 -> v4 added isArchived column to events table .setJournalMode(JournalMode.TRUNCATE) //truncate mode makes it easier to export/import database w/o having to manage WAL files. .build() } diff --git a/app/src/main/java/org/phenoapps/intercross/data/dao/EventsDao.kt b/app/src/main/java/org/phenoapps/intercross/data/dao/EventsDao.kt index f8940318..3cf884a6 100644 --- a/app/src/main/java/org/phenoapps/intercross/data/dao/EventsDao.kt +++ b/app/src/main/java/org/phenoapps/intercross/data/dao/EventsDao.kt @@ -21,7 +21,7 @@ interface EventsDao : BaseDao { @Query("SELECT * FROM events WHERE events.eid == :eid") suspend fun getEvent(eid: Long?): Event - @Query("SELECT * FROM events ORDER BY date DESC") + @Query("SELECT * FROM events WHERE isArchived = 0 ORDER BY date DESC") fun selectAll(): LiveData> @Query(""" @@ -30,7 +30,7 @@ interface EventsDao : BaseDao { FROM events as y WHERE y.mom = x.mom and y.dad = x.dad) as count FROM events as x, parents as male, parents as female - WHERE x.dad = male.codeId and x.mom = female.codeId + WHERE x.dad = male.codeId and x.mom = female.codeId AND x.isArchived = 0 GROUP BY x.mom, "momReadable", x.dad, "dadReadable" """) fun getParentCount(): LiveData> @@ -40,7 +40,7 @@ interface EventsDao : BaseDao { x.person as "person", x.date as "date", COUNT(*) as count FROM events as x, parents as male, parents as female - WHERE x.dad = male.codeId and x.mom = female.codeId + WHERE x.dad = male.codeId and x.mom = female.codeId AND x.isArchived = 0 GROUP BY x.mom, "momReadable", x.dad, "dadReadable", x.person, x.date """) fun getAllParents(): LiveData> @@ -79,6 +79,9 @@ interface EventsDao : BaseDao { @Query("DELETE FROM events WHERE events.eid = :eid") suspend fun deleteById(eid: Long) + @Query("DELETE FROM events WHERE events.eid IN (:eids)") + suspend fun deleteByIds(eids: List) + @Query("SELECT DISTINCT x.codeId FROM events as x WHERE x.codeId = :code") fun getEventsWithCode(code: String): List @@ -103,4 +106,22 @@ interface EventsDao : BaseDao { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertEvent(event: Event): Long -} \ No newline at end of file + + @Query("UPDATE events SET isArchived = 1 WHERE eid = :eid") + suspend fun archiveEvent(eid: Long) + + @Query("UPDATE events SET isArchived = 1 WHERE eid IN (:eids)") + suspend fun archiveEvents(eids: List) + + @Query("UPDATE events SET isArchived = 0 WHERE eid = :eid") + suspend fun unarchiveEvent(eid: Long) + + @Query("UPDATE events SET isArchived = 0 WHERE eid IN (:eids)") + suspend fun unarchiveEvents(eids: List) + + @Query("SELECT * FROM events WHERE isArchived = 0 ORDER BY date DESC") + fun selectActiveEvents(): LiveData> + + @Query("SELECT * FROM events WHERE isArchived = 1 ORDER BY date DESC") + fun selectArchivedEvents(): LiveData> +} diff --git a/app/src/main/java/org/phenoapps/intercross/data/migrations/MigrationV4ArchivedCrosses.kt b/app/src/main/java/org/phenoapps/intercross/data/migrations/MigrationV4ArchivedCrosses.kt new file mode 100644 index 00000000..5793bee9 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/data/migrations/MigrationV4ArchivedCrosses.kt @@ -0,0 +1,14 @@ +package org.phenoapps.intercross.data.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Room database migration class for going from version 2 to 3 + * Version 4 adds isArchived column to events table + */ +class MigrationV4ArchivedCrosses : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE events ADD COLUMN isArchived INTEGER NOT NULL DEFAULT 0") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/phenoapps/intercross/data/models/Event.kt b/app/src/main/java/org/phenoapps/intercross/data/models/Event.kt index 096f46e7..c758ecfa 100644 --- a/app/src/main/java/org/phenoapps/intercross/data/models/Event.kt +++ b/app/src/main/java/org/phenoapps/intercross/data/models/Event.kt @@ -38,6 +38,8 @@ data class Event( var sex: Int = -1, //by default sex is unknown + var isArchived: Boolean = false, + @ColumnInfo(name = "eid") @PrimaryKey(autoGenerate = true) var id: Long? = null): BaseTable() { diff --git a/app/src/main/java/org/phenoapps/intercross/data/viewmodels/EventListViewModel.kt b/app/src/main/java/org/phenoapps/intercross/data/viewmodels/EventListViewModel.kt index b44c1398..d76ecd33 100644 --- a/app/src/main/java/org/phenoapps/intercross/data/viewmodels/EventListViewModel.kt +++ b/app/src/main/java/org/phenoapps/intercross/data/viewmodels/EventListViewModel.kt @@ -15,7 +15,61 @@ class EventListViewModel(private val eventRepo: EventsRepository): BaseViewModel fun deleteById(eid: Long) { - eventRepo.deleteById(eid) + viewModelScope.launch { + + eventRepo.deleteById(eid) + + } + + } + + fun deleteByIds(eids: List) { + + viewModelScope.launch { + + eventRepo.deleteByIds(eids) + + } + + } + + fun archiveById(eid: Long) { + + viewModelScope.launch { + + eventRepo.archiveById(eid) + + } + + } + + fun archiveByIds(eids: List) { + + viewModelScope.launch { + + eventRepo.archiveByIds(eids) + + } + + } + + fun unarchiveById(eid: Long) { + + viewModelScope.launch { + + eventRepo.unarchiveById(eid) + + } + + } + + fun unarchiveByIds(eids: List) { + + viewModelScope.launch { + + eventRepo.unarchiveByIds(eids) + + } } @@ -38,5 +92,7 @@ class EventListViewModel(private val eventRepo: EventsRepository): BaseViewModel val events = eventRepo.selectAll() + val archivedEvents = eventRepo.selectArchivedEvents() + val metadata = eventRepo.getMetadata() } diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/ArchivedEventsFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ArchivedEventsFragment.kt new file mode 100644 index 00000000..bc2cb297 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/fragments/ArchivedEventsFragment.kt @@ -0,0 +1,223 @@ +package org.phenoapps.intercross.fragments + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.phenoapps.intercross.R +import org.phenoapps.intercross.activities.MainActivity +import org.phenoapps.intercross.data.EventsRepository +import org.phenoapps.intercross.data.models.Event +import org.phenoapps.intercross.data.viewmodels.EventListViewModel +import org.phenoapps.intercross.data.viewmodels.factory.EventsListViewModelFactory +import org.phenoapps.intercross.databinding.FragmentArchivedEventsBinding +import org.phenoapps.intercross.ui.lists.EventsList +import org.phenoapps.intercross.ui.theme.AppTheme +import org.phenoapps.intercross.util.SnackbarQueue + +@AndroidEntryPoint +class ArchivedEventsFragment : + IntercrossBaseFragment(R.layout.fragment_archived_events) { + + private val viewModel: EventListViewModel by viewModels { + EventsListViewModelFactory(EventsRepository.getInstance(db.eventsDao())) + } + + private var selectedEventIds by mutableStateOf>(emptySet()) + + private var archivedEvents: List = emptyList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setHasOptionsMenu(true) + } + + override fun FragmentArchivedEventsBinding.afterCreateView() { + + (activity as? MainActivity)?.applyBottomInsets(root) + (activity as MainActivity).setBackButtonToolbar() + + (activity as AppCompatActivity).supportActionBar?.apply { + title = getString(R.string.archived_crosses) + show() + } + + setupComposeArchivedEventsList() + + viewModel.archivedEvents.observe(viewLifecycleOwner) { + archivedEvents = it + } + } + + private fun FragmentArchivedEventsBinding.setupComposeArchivedEventsList() { + composeArchivedEventsList.setContent { + AppTheme { + val events by viewModel.archivedEvents.observeAsState(emptyList()) + + EventsList( + events = events, + onEventClick = { eventId -> + findNavController().navigate( + R.id.event_fragment, + bundleOf("eid" to eventId) + ) + }, + selectedEventIds = selectedEventIds, + swipesEnabled = selectedEventIds.isEmpty(), + enableSwipeStartToEnd = true, + enableSwipeEndToStart = true, + startToEndIconRes = R.drawable.ic_unarchive, + endToStartIconRes = R.drawable.ic_delete, + startToEndContentDescription = getString(R.string.unarchive_event), + endToStartContentDescription = getString(R.string.delete_event), + emptyText = getString(R.string.no_archived_crosses), + onSwipeStartToEnd = { event -> + unarchiveEvent(event) + }, + onSwipeEndToStart = { event -> + deleteEvent(event) + }, + onEventLongPress = { event -> + toggleEventSelection(event) + }, + onSelectionToggle = { event -> + toggleEventSelection(event) + } + ) + } + } + } + + private fun unarchiveEvent(event: Event) { + event.id?.let { eventId -> + viewModel.unarchiveById(eventId) + mSnackbar.push( + SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_unarchived_cross, event.eventDbId) + ) + ) + } + } + + private fun deleteEvent(event: Event) { + event.id?.let { eventId -> + viewModel.deleteById(eventId) + mSnackbar.push( + SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_deleted_cross, event.eventDbId), + getString(R.string.undo) + ) { + restoreDeletedEvents(listOf(event)) + } + ) + } + } + + private fun toggleEventSelection(event: Event) { + val eventId = event.id ?: return + + selectedEventIds = if (eventId in selectedEventIds) { + selectedEventIds - eventId + } else { + selectedEventIds + eventId + } + + activity?.invalidateOptionsMenu() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.archived_crosses_toolbar, menu) + + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.action_unarchive)?.isVisible = selectedEventIds.isNotEmpty() + menu.findItem(R.id.action_delete)?.isVisible = selectedEventIds.isNotEmpty() + + super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_unarchive -> { + showUnarchiveSelectedDialog() + true + } + R.id.action_delete -> { + showDeleteSelectedDialog() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun showUnarchiveSelectedDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.unarchive_selected_crosses_title) + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { _, _ -> + val count = selectedEventIds.size + viewModel.unarchiveByIds(selectedEventIds.toList()) + clearSelection() + mSnackbar.push( + SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_unarchived_cross, count.toString()) + ) + ) + } + .show() + } + + private fun showDeleteSelectedDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.delete_cross_entry_title) + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { _, _ -> + val count = selectedEventIds.size + val deletedEvents = archivedEvents.filter { it.id in selectedEventIds } + viewModel.deleteByIds(selectedEventIds.toList()) + clearSelection() + mSnackbar.push( + SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_deleted_cross, count.toString()), + getString(R.string.undo) + ) { + restoreDeletedEvents(deletedEvents) + } + ) + } + .show() + } + + private fun restoreDeletedEvents(events: List) { + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + events.forEach { event -> + viewModel.insert(event.copy(isArchived = true)) + } + } + } + + private fun clearSelection() { + selectedEventIds = emptySet() + activity?.invalidateOptionsMenu() + } +} diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt index a5a6bb67..2b02b100 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt @@ -198,6 +198,8 @@ class EventDetailFragment: eventDetailLayout.event = it + activity?.invalidateOptionsMenu() + eventDetailLayout.timestamp = if ("_" in it.timestamp) { it.timestamp.split("_")[0] @@ -293,6 +295,23 @@ class EventDetailFragment: super.onCreateOptionsMenu(menu, inflater) } + override fun onPrepareOptionsMenu(menu: Menu) { + + if (::mEvent.isInitialized) { + menu.findItem(R.id.action_archive)?.apply { + if (mEvent.isArchived) { + title = getString(R.string.unarchive) + setIcon(R.drawable.ic_unarchive) + } else { + title = getString(R.string.archive) + setIcon(R.drawable.ic_archive) + } + } + } + + super.onPrepareOptionsMenu(menu) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (::mEvent.isInitialized) { @@ -317,19 +336,45 @@ class EventDetailFragment: // update visibility of metadata section mBinding.metaDataVisibility = getMetaDataVisibility(requireContext()) + + return true } - R.id.action_delete -> { + R.id.action_archive -> { - Dialogs.onOk(AlertDialog.Builder(requireContext()), - getString(R.string.delete_cross_entry_title), - getString(R.string.cancel), - getString(android.R.string.ok)) { + val title = if (mEvent.isArchived) { + getString(R.string.unarchive_cross_entry_title) + } else { + getString(R.string.archive_cross_entry_title) + } - eventsList.deleteById(mEvent.id ?: -1L) + AlertDialog.Builder(requireContext()) + .setTitle(title) + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { _, _ -> + if (mEvent.isArchived) { + eventsList.unarchiveById(mEvent.id ?: -1L) + } else { + eventsList.archiveById(mEvent.id ?: -1L) + } - findNavController().popBackStack() + findNavController().popBackStack() + } + .show() - } + return true + } + R.id.action_delete -> { + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.delete_cross_entry_title) + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { _, _ -> + eventsList.deleteById(mEvent.id ?: -1L) + findNavController().popBackStack() + } + .show() + + return true } } } @@ -433,4 +478,4 @@ class EventDetailFragment: } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/EventsFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/EventsFragment.kt index db775784..cf81bb7e 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/EventsFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/EventsFragment.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.SharedPreferences import android.os.Bundle -import android.os.Handler import android.text.Editable import android.text.TextWatcher import android.util.TypedValue @@ -14,21 +13,22 @@ import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.core.widget.addTextChangedListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import org.phenoapps.intercross.BuildConfig import org.phenoapps.intercross.activities.MainActivity import org.phenoapps.intercross.R -import org.phenoapps.intercross.adapters.EventsAdapter import org.phenoapps.intercross.data.* import org.phenoapps.intercross.data.models.Event import org.phenoapps.intercross.data.models.Parent @@ -44,6 +44,8 @@ import java.util.* import javax.inject.Inject import kotlin.math.roundToInt import androidx.core.content.edit +import org.phenoapps.intercross.ui.lists.EventsList +import org.phenoapps.intercross.ui.theme.AppTheme @AndroidEntryPoint class EventsFragment : IntercrossBaseFragment(R.layout.fragment_events), @@ -91,6 +93,10 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr private var mEvents: List = ArrayList() + private var mArchivedEvents: List = ArrayList() + + private var mScrollToTopAfterNextEventUpdate = false + private var mMetadata: List = ArrayList() private var mEventsEmpty = true @@ -171,10 +177,6 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr } else mBinding.firstText.setText(female) } - recyclerView.adapter = EventsAdapter(this@EventsFragment, viewModel, this@EventsFragment) - - recyclerView.layoutManager = LinearLayoutManager(context) - firstHint = getFirstOrder(requireContext()) secondHint = getSecondOrder(requireContext()) @@ -205,6 +207,10 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr menuInflater.inflate(R.menu.menu_entry_fragment, menu) } + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.action_archived_crosses)?.isVisible = mArchivedEvents.isNotEmpty() + } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_set_experiment -> { @@ -218,6 +224,10 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr showCrossesExport() true } + R.id.action_archived_crosses -> { + findNavController().navigate(R.id.action_to_archived_events_fragment) + true + } else -> false } } @@ -275,7 +285,17 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr } } - (mBinding.recyclerView.adapter as? EventsAdapter)?.submitList(it) + } + } + + viewModel.archivedEvents.observe(viewLifecycleOwner) { + + it?.let { + + mArchivedEvents = it + + activity?.invalidateOptionsMenu() + } } @@ -424,7 +444,7 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr private fun FragmentEventsBinding.setupUI() { - setupRecyclerView() + setupComposeEventsList() setupTextInput() @@ -470,43 +490,68 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr } - private fun FragmentEventsBinding.setupRecyclerView() { - - //setup recycler adapter - recyclerView.adapter = EventsAdapter(this@EventsFragment, viewModel, this@EventsFragment) - - val undoString = getString(R.string.undo) - - //setup on item swipe to delete - ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - return false - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - - (recyclerView.adapter as EventsAdapter) - .currentList[viewHolder.adapterPosition].also { event -> - - event.id?.let { - - viewModel.deleteById(eid = it) - - mSnackbar.push(SnackbarQueue.SnackJob(root, event.eventDbId, undoString) { - - scope.launch { - CrossUtil(requireContext()).submitCrossEvent(activity, - event.femaleObsUnitDbId, event.maleObsUnitDbId, - event.eventDbId, mSettings, settingsModel, viewModel, - mParents, parentsList, mWishlistProgress, mMetadata, metaValuesViewModel - ) - } - }) + private fun FragmentEventsBinding.setupComposeEventsList() { + composeEventsList.setContent { + AppTheme { + val events by viewModel.events.observeAsState(emptyList()) + val listState = rememberLazyListState() + val firstEventId = remember(events) { events.firstOrNull()?.id } + + LaunchedEffect(firstEventId) { + if (mScrollToTopAfterNextEventUpdate && firstEventId != null) { + listState.animateScrollToItem(0) + mScrollToTopAfterNextEventUpdate = false } } + + EventsList( + events = events, + onEventClick = { eventId -> + onEventClick(eventId) + }, + listState = listState, + enableSwipeStartToEnd = true, + enableSwipeEndToStart = true, + startToEndIconRes = R.drawable.ic_archive, + endToStartIconRes = R.drawable.ic_delete, + startToEndContentDescription = getString(R.string.archive_event), + endToStartContentDescription = getString(R.string.delete_event), + onSwipeStartToEnd = { event -> + event.id?.let { eid -> + viewModel.archiveById(eid) + + mSnackbar.push(SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_archived_cross, event.eventDbId), + getString(R.string.undo) + ) { + viewModel.unarchiveById(eid) + }) + } + }, + onSwipeEndToStart = { event -> + event.id?.let { eid -> + viewModel.deleteById(eid) + + mSnackbar.push(SnackbarQueue.SnackJob( + mBinding.root, + getString(R.string.snackbar_deleted_cross, event.eventDbId), + getString(R.string.undo) + ) { + + scope.launch { + CrossUtil(requireContext()).submitCrossEvent(activity, + event.femaleObsUnitDbId, event.maleObsUnitDbId, + event.eventDbId, mSettings, settingsModel, viewModel, + mParents, parentsList, mWishlistProgress, mMetadata, metaValuesViewModel + ) + } + }) + } + } + ) } - }).attachToRecyclerView(recyclerView) + } } private fun resetDataEntry() { @@ -809,6 +854,8 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr metaValuesViewModel ) + mScrollToTopAfterNextEventUpdate = true + activity?.runOnUiThread { checkPrefToOpenCrossEvent(findNavController(), @@ -821,10 +868,6 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr resetDataEntry() - Handler().postDelayed({ - mBinding.recyclerView.scrollToPosition(0) - }, 250) - } else Dialogs.notify(AlertDialog.Builder(requireContext()), getString(R.string.cross_id_already_exists_as_event)) } else { @@ -916,4 +959,4 @@ class EventsFragment : IntercrossBaseFragment(R.layout.fr override fun onEventClick(eventId: Long) { findNavController().navigate(EventsFragmentDirections.actionToEventFragment(eventId)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/phenoapps/intercross/ui/listItems/EventsListItem.kt b/app/src/main/java/org/phenoapps/intercross/ui/listItems/EventsListItem.kt new file mode 100644 index 00000000..9009b498 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/ui/listItems/EventsListItem.kt @@ -0,0 +1,257 @@ +package org.phenoapps.intercross.ui.listItems + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.phenoapps.intercross.R +import org.phenoapps.intercross.data.models.Event +import org.phenoapps.intercross.ui.qr.QRCodeImage +import org.phenoapps.intercross.ui.theme.AppTheme +import org.phenoapps.intercross.util.DateUtil + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EventListItem( + event: Event, + onEventClick: (Long) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + selectionMode: Boolean = false, + swipesEnabled: Boolean = true, + enableSwipeStartToEnd: Boolean = false, + enableSwipeEndToStart: Boolean = true, + @DrawableRes startToEndIconRes: Int = R.drawable.ic_archive, + @DrawableRes endToStartIconRes: Int = R.drawable.ic_delete, + startToEndColor: Color? = null, + endToStartColor: Color? = null, + startToEndContentDescription: String? = null, + endToStartContentDescription: String? = null, + onSwipeStartToEnd: (Event) -> Unit = {}, + onSwipeEndToStart: (Event) -> Unit = {}, + onEventLongPress: (Event) -> Unit = {}, + onSelectionToggle: (Event) -> Unit = {}, +) { + val swipeToDismissBoxState = rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> totalDistance * 0.92f } + ) + + val screenContainerSize = LocalWindowInfo.current.containerSize + val density = LocalDensity.current + val screenHeight = with(density) { screenContainerSize.height.toDp() } + val qrCodeHeight = screenHeight * 0.10f + val resolvedStartToEndColor = startToEndColor ?: AppTheme.colors.primary + val resolvedEndToStartColor = endToStartColor ?: AppTheme.colors.status.error + val resolvedStartToEndContentDescription = + startToEndContentDescription ?: stringResource(R.string.archive_event) + val resolvedEndToStartContentDescription = + endToStartContentDescription ?: stringResource(R.string.delete_event) + + LaunchedEffect(swipeToDismissBoxState.currentValue) { + when (swipeToDismissBoxState.currentValue) { + SwipeToDismissBoxValue.StartToEnd -> { + onSwipeStartToEnd(event) + swipeToDismissBoxState.reset() + } + SwipeToDismissBoxValue.EndToStart -> { + onSwipeEndToStart(event) + swipeToDismissBoxState.reset() + } + SwipeToDismissBoxValue.Settled -> Unit + } + } + + SwipeToDismissBox( + state = swipeToDismissBoxState, + modifier = modifier, + enableDismissFromStartToEnd = swipesEnabled && enableSwipeStartToEnd, + enableDismissFromEndToStart = swipesEnabled && enableSwipeEndToStart, + backgroundContent = { + when (swipeToDismissBoxState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> { + val progress = swipeToDismissBoxState.progress + + SwipeBackground( + progress = progress, + alignment = Alignment.CenterStart, + color = resolvedStartToEndColor, + iconRes = startToEndIconRes, + contentDescription = resolvedStartToEndContentDescription + ) + } + SwipeToDismissBoxValue.EndToStart -> { + val progress = swipeToDismissBoxState.progress + + SwipeBackground( + progress = progress, + alignment = Alignment.CenterEnd, + color = resolvedEndToStartColor, + iconRes = endToStartIconRes, + contentDescription = resolvedEndToStartContentDescription + ) + } + else -> { + // No background for other directions + } + } + } + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .combinedClickable( + onClick = { + if (selectionMode) { + onSelectionToggle(event) + } else { + event.id?.let { onEventClick(it) } + } + }, + onLongClick = { + onEventLongPress(event) + } + ), + colors = CardDefaults.cardColors( + containerColor = if (selected) AppTheme.colors.accentTransparent else AppTheme.colors.background + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp), + border = if (selected) BorderStroke(2.dp, AppTheme.colors.primary) else null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // QR Code/Barcode Image + QRCodeImage(text = event.eventDbId, modifier = Modifier.height(qrCodeHeight)) + + Spacer(modifier = Modifier.width(16.dp)) + + // Text Information + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val formattedTimestamp = if ("_" in event.timestamp) { + DateUtil().getEntireTimestamp(event.timestamp) + } else { + event.timestamp + } + + listOf( + event.femaleObsUnitDbId, + event.maleObsUnitDbId, + formattedTimestamp, + event.eventDbId, + event.person + ).filter { it.isNotBlank() } + .forEach { value -> + Text(text = value) + } + } + } + } + } +} + +@Composable +private fun SwipeBackground( + progress: Float, + alignment: Alignment, + color: Color, + @DrawableRes iconRes: Int, + contentDescription: String, +) { + val iconProgress = ((progress - 0.35f) / 0.65f).coerceIn(0f, 1f) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 4.dp), + contentAlignment = alignment + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(start = 8.dp, end = 8.dp) + .align(alignment), + colors = CardDefaults.cardColors( + containerColor = color + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = alignment + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier + .size(24.dp) + .graphicsLayer { + alpha = iconProgress + scaleX = 0.85f + (iconProgress * 0.15f) + scaleY = 0.85f + (iconProgress * 0.15f) + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EventListItemPreview() { + EventListItem( + event = Event( + eventDbId = "eventDbId", + maleObsUnitDbId = "maleParent", + femaleObsUnitDbId = "femaleParent", + timestamp = "2026-01-13_14_40_49_234", + person = "Person" + ), + onEventClick = { }, + modifier = Modifier, + ) +} diff --git a/app/src/main/java/org/phenoapps/intercross/ui/lists/EventsList.kt b/app/src/main/java/org/phenoapps/intercross/ui/lists/EventsList.kt new file mode 100644 index 00000000..6ae5228b --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/ui/lists/EventsList.kt @@ -0,0 +1,94 @@ +package org.phenoapps.intercross.ui.lists + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Text +import org.phenoapps.intercross.R +import org.phenoapps.intercross.data.models.Event +import org.phenoapps.intercross.ui.theme.AppTheme +import org.phenoapps.intercross.ui.listItems.EventListItem + +@Composable +fun EventsList( + events: List, + onEventClick: (Long) -> Unit, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + selectedEventIds: Set = emptySet(), + selectionMode: Boolean = selectedEventIds.isNotEmpty(), + swipesEnabled: Boolean = true, + enableSwipeStartToEnd: Boolean = false, + enableSwipeEndToStart: Boolean = true, + @DrawableRes startToEndIconRes: Int = R.drawable.ic_archive, + @DrawableRes endToStartIconRes: Int = R.drawable.ic_delete, + startToEndColor: Color? = null, + endToStartColor: Color? = null, + startToEndContentDescription: String? = null, + endToStartContentDescription: String? = null, + emptyText: String? = null, + onSwipeStartToEnd: (Event) -> Unit = {}, + onSwipeEndToStart: (Event) -> Unit = {}, + onEventLongPress: (Event) -> Unit = {}, + onSelectionToggle: (Event) -> Unit = {}, +) { + val resolvedStartToEndColor = startToEndColor ?: AppTheme.colors.primary + val resolvedEndToStartColor = endToStartColor ?: AppTheme.colors.status.error + val resolvedStartToEndContentDescription = + startToEndContentDescription ?: stringResource(R.string.archive_event) + val resolvedEndToStartContentDescription = + endToStartContentDescription ?: stringResource(R.string.delete_event) + val resolvedEmptyText = emptyText ?: stringResource(R.string.summary_empty) + + if (events.isEmpty()) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = resolvedEmptyText) + } + return + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = events, + key = { it.id ?: it.eventDbId } + ) { event -> + EventListItem( + event = event, + onEventClick = onEventClick, + selected = event.id?.let { it in selectedEventIds } == true, + selectionMode = selectionMode, + swipesEnabled = swipesEnabled, + enableSwipeStartToEnd = enableSwipeStartToEnd, + enableSwipeEndToStart = enableSwipeEndToStart, + startToEndIconRes = startToEndIconRes, + endToStartIconRes = endToStartIconRes, + startToEndColor = resolvedStartToEndColor, + endToStartColor = resolvedEndToStartColor, + startToEndContentDescription = resolvedStartToEndContentDescription, + endToStartContentDescription = resolvedEndToStartContentDescription, + onSwipeStartToEnd = onSwipeStartToEnd, + onSwipeEndToStart = onSwipeEndToStart, + onEventLongPress = onEventLongPress, + onSelectionToggle = onSelectionToggle + ) + } + } +} diff --git a/app/src/main/java/org/phenoapps/intercross/ui/qr/QRCodeImage.kt b/app/src/main/java/org/phenoapps/intercross/ui/qr/QRCodeImage.kt new file mode 100644 index 00000000..04db0f7f --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/ui/qr/QRCodeImage.kt @@ -0,0 +1,77 @@ +package org.phenoapps.intercross.ui.qr + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.Color as ComposeColor +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set + +@Composable +fun QRCodeImage( + text: String, + modifier: Modifier = Modifier, +) { + val qrCodeBitmap = remember(text) { + generateQRCode(text) + } + + if (qrCodeBitmap != null) { + Image( + bitmap = qrCodeBitmap.asImageBitmap(), + contentDescription = "QR Code for $text", + modifier = modifier + ) + } else { + // Fallback placeholder + Box( + modifier = modifier.background(color = ComposeColor.Gray), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ShoppingCart, + contentDescription = "QR Code placeholder" + ) + } + } +} + +private fun generateQRCode(text: String): Bitmap? { + return runCatching { + val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 256, 256) + + val w = bitMatrix.width + val h = bitMatrix.height + val pixels = IntArray(w * h) + + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + + val bitmap = createBitmap(w, h, Bitmap.Config.RGB_565) + bitmap.setPixels(pixels, 0, w, 0, 0, w, h) + bitmap + }.getOrNull() +} + +@Preview +@Composable +private fun QRCodeImagePreview() { + QRCodeImage(text = "123") +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_archive.xml b/app/src/main/res/drawable/ic_archive.xml new file mode 100644 index 00000000..10de58b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_archive.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_unarchive.xml b/app/src/main/res/drawable/ic_unarchive.xml new file mode 100644 index 00000000..5e36d830 --- /dev/null +++ b/app/src/main/res/drawable/ic_unarchive.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/layout/fragment_archived_events.xml b/app/src/main/res/layout/fragment_archived_events.xml new file mode 100644 index 00000000..1dfe09d4 --- /dev/null +++ b/app/src/main/res/layout/fragment_archived_events.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_events.xml b/app/src/main/res/layout/fragment_events.xml index ba41e062..52878ca0 100644 --- a/app/src/main/res/layout/fragment_events.xml +++ b/app/src/main/res/layout/fragment_events.xml @@ -1,6 +1,5 @@ - @@ -28,7 +27,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/fragment_events_group" - app:constraint_referenced_ids="dataEntryLayout, recyclerView, bottom_nav_bar"/> + app:constraint_referenced_ids="dataEntryLayout, composeEventsList, bottom_nav_bar"/> - + app:layout_constraintBottom_toTopOf="@id/bottom_nav_bar" /> diff --git a/app/src/main/res/menu/archived_crosses_toolbar.xml b/app/src/main/res/menu/archived_crosses_toolbar.xml new file mode 100644 index 00000000..45246229 --- /dev/null +++ b/app/src/main/res/menu/archived_crosses_toolbar.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/menu/cross_entry_toolbar.xml b/app/src/main/res/menu/cross_entry_toolbar.xml index 0bc74de8..d72fd724 100644 --- a/app/src/main/res/menu/cross_entry_toolbar.xml +++ b/app/src/main/res/menu/cross_entry_toolbar.xml @@ -11,4 +11,9 @@ android:icon="@drawable/ic_delete" android:title="@string/delete" app:showAsAction="ifRoom"/> - \ No newline at end of file + + diff --git a/app/src/main/res/menu/menu_entry_fragment.xml b/app/src/main/res/menu/menu_entry_fragment.xml index 0a0c8358..0468a00f 100644 --- a/app/src/main/res/menu/menu_entry_fragment.xml +++ b/app/src/main/res/menu/menu_entry_fragment.xml @@ -11,4 +11,9 @@ android:icon="@drawable/ic_export" android:title="@string/export" app:showAsAction="ifRoom" /> - \ No newline at end of file + + diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml index 729bcfb0..7fa74f3d 100644 --- a/app/src/main/res/navigation/navigation.xml +++ b/app/src/main/res/navigation/navigation.xml @@ -70,6 +70,19 @@ android:id="@+id/action_from_settings_to_naming_workflow_fragment" app:destination="@id/behavior_preferences_fragment" app:popUpTo="@id/events_fragment" /> + + + + + Female Male Delete + Archive + Unarchive + Archive event + Unarchive event + Delete event + Archived Crosses + Archived: %s + Unarchived: %s + Deleted: %s Delete all Print None @@ -155,6 +164,10 @@ Scan a single barcode Scan a sequence of barcodes Delete this cross entry? + Archive this cross entry? + Unarchive this cross entry? + Unarchive selected crosses? + No archived crosses. No device paired Printer is open Printer is paused @@ -186,6 +199,7 @@ Barcode Scan Events Event + Archived Crosses Parents Settings Patterns