diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1e58eb5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,4 @@ + + + +- [ ] I prefixed my PR title with the change type, generally `feat: ` or `fix: ` (e.g., `feat: Add merchandise distribution`) \ No newline at end of file diff --git a/.github/workflows/task-list-checker.yml b/.github/workflows/task-list-checker.yml new file mode 100644 index 0000000..6ea610d --- /dev/null +++ b/.github/workflows/task-list-checker.yml @@ -0,0 +1,16 @@ +# https://github.com/Shopify/task-list-checker +name: pr +permissions: + statuses: write +on: + pull_request: + types: [opened, edited, synchronize, reopened] +jobs: + task-list-checker: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }} + steps: + - name: Check for incomplete task list items + uses: Shopify/task-list-checker@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7bb6a67..e0e3c41 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,11 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true diff --git a/app/src/main/java/org/robojackets/apiary/ApiaryMobileApplication.kt b/app/src/main/java/org/robojackets/apiary/ApiaryMobileApplication.kt index 27fc2f8..2fd08d4 100644 --- a/app/src/main/java/org/robojackets/apiary/ApiaryMobileApplication.kt +++ b/app/src/main/java/org/robojackets/apiary/ApiaryMobileApplication.kt @@ -15,7 +15,7 @@ class ApiaryMobileApplication : Application() { override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { + if (BuildConfig.BUILD_TYPE == "debug") { Timber.plant(Timber.DebugTree()) } else { SentryAndroid.init(this) { options -> diff --git a/attendance/build.gradle.kts b/attendance/build.gradle.kts index 2a6a0c5..123f2a2 100644 --- a/attendance/build.gradle.kts +++ b/attendance/build.gradle.kts @@ -57,6 +57,10 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt index eb038c9..ce2523d 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt @@ -155,6 +155,8 @@ class AttendanceViewModel @Inject constructor( ) { meetingsRepository.getEvents().onSuccess { attendableEvents.value = this.data.events + .sortedByDescending { it.startTime } + Timber.d(this.data.events.toString()) }.onError { Timber.e(this.toString(), "Could not fetch attendable events due to an error") error.value = "Unable to fetch events" diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index ab545e9..d353318 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -67,6 +67,10 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 16661d1..25a8bd2 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -62,6 +62,10 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true diff --git a/base/src/main/java/org/robojackets/apiary/base/model/Event.kt b/base/src/main/java/org/robojackets/apiary/base/model/Event.kt index 503152b..62b27f1 100644 --- a/base/src/main/java/org/robojackets/apiary/base/model/Event.kt +++ b/base/src/main/java/org/robojackets/apiary/base/model/Event.kt @@ -1,6 +1,8 @@ package org.robojackets.apiary.base.model +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.util.Date @JsonClass(generateAdapter = true) data class EventsHolder( @@ -16,8 +18,10 @@ data class EventHolder( data class Event( val id: Int, val name: String, -// val startTime: Instant, -// val endTime: Instant + @Json(name = "start_time") + val startTime: Date?, + @Json(name = "end_time") + val endTime: Date? ) { fun toAttendable(): Attendable { return Attendable( diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index 77346b5..36278a7 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt @@ -80,7 +80,7 @@ fun BuzzCardPrompt( cardType = nfcLib.getCardType(it) if (cardType == CardType.DESFireEV1 || cardType == CardType.DESFireEV3) { - val desFireEV1 = DESFireFactory.getInstance().getDESFire(nfcLib.customModules) + val desfire = DESFireFactory.getInstance().getDESFire(nfcLib.customModules) // Below info figured out by :ross: mostly using the NFC TagInfo app val buzzApplication = 0xBBBBCD @@ -88,8 +88,8 @@ fun BuzzCardPrompt( var buzzData = ByteArray(48) val buzzString: String? - desFireEV1.selectApplication(buzzApplication) - buzzData = desFireEV1.readData(buzzFile, 0, buzzData.size) + desfire.selectApplication(buzzApplication) + buzzData = desfire.readData(buzzFile, 0, buzzData.size) // a string containing "gtid=proxID", such as "901234567=123456" buzzString = String(buzzData, StandardCharsets.UTF_8) @@ -122,7 +122,7 @@ fun BuzzCardPrompt( lastTap = buzzCardTap onBuzzCardTap(buzzCardTap) } else { - Timber.i("Unknown card type ($cardType) presented") + Timber.w("Unknown card type ($cardType) presented") error = NotABuzzCard } } catch (e: NxpNfcLibException) { diff --git a/build.gradle.kts b/build.gradle.kts index f94e18e..d8f7836 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ buildscript { } dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") - classpath("com.android.tools.build:gradle:8.6.0-rc01") + classpath("com.android.tools.build:gradle:8.6.0") classpath("com.google.dagger:hilt-android-gradle-plugin:2.51.1") // This version needs to // match the version for other Hilt dependencies defined in Dependencies.kt classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") @@ -92,4 +92,4 @@ dependencyAnalysis { excludeClasses(".*\\.internal\\..*") } } -} \ No newline at end of file +} diff --git a/gitversion.yml b/gitversion.yml new file mode 100644 index 0000000..eaa0aef --- /dev/null +++ b/gitversion.yml @@ -0,0 +1,4 @@ +major-version-bump-message: "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([\\w\\s-,/\\\\]*\\))?(!:|:.*\\n\\n((.+\\n)+\\n)?BREAKING CHANGE:\\s.+)" +minor-version-bump-message: "^(feat)(\\([\\w\\s-,/\\\\]*\\))?:" +patch-version-bump-message: "^(fix|perf)(\\([\\w\\s-,/\\\\]*\\))?:" +no-bump-message: "^(no_release)(\\([\\w\\s-,/\\\\]*\\))?:" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e49c70f..2a3f862 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Dec 15 17:03:27 EST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/merchandise/build.gradle.kts b/merchandise/build.gradle.kts index 8b5e845..7c053b9 100644 --- a/merchandise/build.gradle.kts +++ b/merchandise/build.gradle.kts @@ -64,6 +64,10 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt index 63da67c..ad7cecb 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt @@ -4,11 +4,17 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class MerchandiseItemsHolder( - val merchandise: List = emptyList() + val merchandise: List +) + +@JsonClass(generateAdapter = true) +data class MerchandiseItemHolder( + val merchandise: MerchandiseItem ) @JsonClass(generateAdapter = true) data class MerchandiseItem( val id: Int, val name: String, + val distributable: Boolean, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 11c55e7..242eb77 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -98,7 +98,7 @@ class MerchandiseViewModel @Inject constructor( fun checkUserAccess( forceRefresh: Boolean = false, - onSuccess: () -> Unit = {} + onSuccess: () -> Unit = {}, ) { if (user.value != null && !forceRefresh) { return @@ -128,12 +128,10 @@ class MerchandiseViewModel @Inject constructor( fun loadMerchandiseItems( forceRefresh: Boolean = false, - selectedItemId: Int? = null, ) { merchandiseItemsListError.value = null if (merchandiseItems.value?.isNotEmpty() == true && !forceRefresh) { Timber.d("Using cached merchandise items") - selectedItemId?.let { selectItemForDistribution(it) } return } @@ -141,7 +139,6 @@ class MerchandiseViewModel @Inject constructor( viewModelScope.launch { merchandiseRepository.listMerchandiseItems().onSuccess { merchandiseItems.value = this.data.merchandise - selectedItemId?.let { selectItemForDistribution(it) } loadingMerchandiseItems.value = false }.onError { Timber.e(this.toString(), "Could not fetch merchandise items due to an error") @@ -154,6 +151,50 @@ class MerchandiseViewModel @Inject constructor( } } } + @Suppress("TooGenericExceptionCaught") + fun selectMerchandiseItemForDistribution(itemId: Int) { + loadingMerchandiseItems.value = true + viewModelScope.launch { + merchandiseRepository.getMerchandiseItem(itemId).onSuccess { + selectedItem.value = this.data.merchandise + + if (!this.data.merchandise.distributable) { + Timber.w("Merchandise item is not distributable: ${this.data.merchandise}") + error.value = "${this.data.merchandise.name} is not distributable" + } + loadingMerchandiseItems.value = false + }.onError { + // `this.errorBody` can only be consumed once. If you add a log statement + // including it, then the deserializeErrorBody call will fail + var errorModel: ApiErrorMessage? = null + try { + // Sandwich docs on deserializing errors: https://skydoves.github.io/sandwich/retrofit/#error-body-deserialization + // where A is the return type of the outer API call (getMerchandiseItem) + // and B is the type to parse the error body as + // If Android Studio is constantly suggesting to import the deserializeErrorBody + // method, or you get build errors like "None of the following candidates is + // applicable because of receiver type mismatch," it's probably because + // you're specifying the wrong type for A + errorModel = + this.deserializeErrorBody() + } catch (e: Exception) { + Timber.e(e, "Could not deserialize error body") + } + Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") + + Timber.w("Could not fetch merchandise item details: ${errorModel?.message}") + error.value = errorModel?.message ?: "Unable to load merchandise item details" + loadingMerchandiseItems.value = false + }.onException { + Timber.e( + this.throwable, + "Could not fetch merchandise item details due to an exception" + ) + error.value = "Unable to load merchandise item details" + loadingMerchandiseItems.value = false + } + } + } fun navigateToMerchandiseItemDistribution(item: MerchandiseItem) { navManager.navigate(NavigationActions.Merchandise.merchandiseIndexToDistribution(item.id)) @@ -163,16 +204,6 @@ class MerchandiseViewModel @Inject constructor( navManager.navigate(NavigationActions.Merchandise.merchandiseDistributionToIndex()) } - private fun selectItemForDistribution(merchandiseItemId: Int) { - val item = merchandiseItems.value?.find { it.id == merchandiseItemId } - if (item != null) { - selectedItem.value = item - } else { - error.value = "Could not find merchandise item with ID $merchandiseItemId" - Timber.e("Could not find merchandise item with ID $merchandiseItemId") - } - } - @Suppress("TooGenericExceptionCaught") fun onBuzzCardTap(buzzCardTap: BuzzCardTap) { if (screenState.value != MerchandiseDistributionScreenState.ReadyForTap) { @@ -225,7 +256,10 @@ class MerchandiseViewModel @Inject constructor( error.value = errorModel?.message ?: "Failed to fetch distribution status" }.onException { - Timber.e(this.throwable, "Failed to fetch distribution status due to an exception") + Timber.e( + this.throwable, + "Failed to fetch distribution status due to an exception" + ) error.value = "Failed to fetch distribution status" screenState.value = MerchandiseDistributionScreenState.ShowPickupStatusDialog } @@ -275,7 +309,10 @@ class MerchandiseViewModel @Inject constructor( error.value = errorModel?.message ?: "Error recording merchandise distribution" screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog }.onException { - Timber.e(this.throwable, "Unable to record merchandise distribution due to an exception") + Timber.e( + this.throwable, + "Unable to record merchandise distribution due to an exception" + ) error.value = "Error recording merchandise distribution" screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog } @@ -283,6 +320,7 @@ class MerchandiseViewModel @Inject constructor( } fun dismissPickupDialog() { + error.value = null screenState.value = MerchandiseDistributionScreenState.ReadyForTap } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt index d360f17..36fb545 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -2,6 +2,7 @@ package org.robojackets.apiary.merchandise.network import com.skydoves.sandwich.ApiResponse import org.robojackets.apiary.merchandise.model.DistributionHolder +import org.robojackets.apiary.merchandise.model.MerchandiseItemHolder import org.robojackets.apiary.merchandise.model.MerchandiseItemsHolder import retrofit2.http.Field import retrofit2.http.FormUrlEncoded @@ -13,6 +14,11 @@ interface MerchandiseApiService { @GET("/api/v1/merchandise") suspend fun getMerchandiseItems(): ApiResponse + @GET("/api/v1/merchandise/{itemId}") + suspend fun getMerchandiseItem( + @Path("itemId") itemId: Int, + ): ApiResponse + @GET("/api/v1/merchandise/{itemId}/distribute/{gtid}") suspend fun getDistributionStatus( @Path("itemId") itemId: Int, diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt index d524b9f..159defc 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -8,6 +8,7 @@ class MerchandiseRepository @Inject constructor( val merchandiseApiService: MerchandiseApiService, ) { suspend fun listMerchandiseItems() = merchandiseApiService.getMerchandiseItems() + suspend fun getMerchandiseItem(itemId: Int) = merchandiseApiService.getMerchandiseItem(itemId) suspend fun getDistributionStatus(itemId: Int, gtid: Int) = merchandiseApiService.getDistributionStatus(itemId, gtid) suspend fun distributeItem(itemId: Int, gtid: Int, providedVia: String) = diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt index 46de252..bad4652 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt @@ -58,7 +58,8 @@ fun PreviewCurrentlySelectedItem() { CurrentlySelectedItem( MerchandiseItem( 2, - "Test item with a super duper long name so it will get cut off" + "Test item with a super duper long name so it will get cut off", + true, ) ) {} } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt index 1ca8dcd..bdea239 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -8,6 +8,7 @@ import com.nxp.nfclib.NxpNfcLib import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.util.ContentPadding import org.robojackets.apiary.base.ui.util.LoadingSpinner +import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenState import org.robojackets.apiary.merchandise.model.MerchandiseViewModel import timber.log.Timber @@ -19,7 +20,7 @@ fun MerchandiseDistributionScreen( ) { LaunchedEffect(merchandiseItemId) { Timber.d("Launched effect: $merchandiseItemId") - viewModel.loadMerchandiseItems(selectedItemId = merchandiseItemId) + viewModel.selectMerchandiseItemForDistribution(merchandiseItemId) } val state by viewModel.state.collectAsState() @@ -29,24 +30,14 @@ fun MerchandiseDistributionScreen( state.merchandiseItemsListError != null -> { ErrorMessageWithRetry( title = "Failed to load merchandise item", - onRetry = { - viewModel.loadMerchandiseItems( - forceRefresh = true, - selectedItemId = merchandiseItemId - ) - }, + onRetry = { viewModel.selectMerchandiseItemForDistribution(merchandiseItemId) }, prioritizeRetryButton = true, ) } - state.error != null -> { + state.error != null && state.screenState == MerchandiseDistributionScreenState.ReadyForTap -> { ErrorMessageWithRetry( title = state.error ?: "Merchandise distribution is temporarily unavailable", - onRetry = { - viewModel.loadMerchandiseItems( - forceRefresh = true, - selectedItemId = merchandiseItemId - ) - }, + onRetry = { viewModel.selectMerchandiseItemForDistribution(merchandiseItemId) }, prioritizeRetryButton = false, ) } diff --git a/navigation/build.gradle.kts b/navigation/build.gradle.kts index e466d60..29dee23 100644 --- a/navigation/build.gradle.kts +++ b/navigation/build.gradle.kts @@ -41,6 +41,10 @@ android { getByName("release") { isMinifyEnabled = false } + getByName("debug") { + isMinifyEnabled = false + isShrinkResources = false + } } buildFeatures { compose = true