From 9ac06ba10504a4c36b6fab4d6e225bbdf8e5b855 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Thu, 12 Jun 2025 21:33:48 +0000
Subject: [PATCH] Jules was unable to complete the task in time. Please review
the work done so far and provide feedback for Jules to continue.
---
AdManager.kt | 76 +++++
AndroidManifest.xml | 30 ++
Particle.kt | 23 ++
Skin.kt | 13 +
SkinAdapter.kt | 89 ++++++
SkinsActivity.kt | 120 +++++++
UpgradeAdapter.kt | 93 ++++++
activity_main.xml | 109 +++++++
activity_skins.xml | 58 ++++
activity_upgrades.xml | 58 ++++
build.gradle | 47 +++
button_click.ogg | 1 +
coin_earned.ogg | 1 +
colors.xml | 22 ++
data/local/SharedPreferencesManager.kt | 91 ++++++
data/model/IpGeoResponse.kt | 12 +
data/network/IpApiService.kt | 7 +
data/network/IpApiServiceImpl.kt | 73 +++++
data/repository/AdRepositoryImpl.kt | 10 +
data/repository/GameRepositoryImpl.kt | 10 +
data/repository/GeoRepositoryImpl.kt | 11 +
domain/model/AdNetworkStrategy.kt | 7 +
domain/model/FallingObject.kt | 15 +
domain/model/UpgradeItem.kt | 25 ++
domain/repository/AdRepository.kt | 6 +
domain/repository/GameRepository.kt | 6 +
domain/repository/GeoRepository.kt | 7 +
domain/usecase/CheckInternetUseCase.kt | 9 +
domain/usecase/GetCountryUseCase.kt | 18 ++
ic_placeholder_coin.xml | 12 +
ic_placeholder_gift.xml | 10 +
ic_placeholder_play.xml | 10 +
ic_placeholder_skins.xml | 11 +
ic_placeholder_upgrade_item.xml | 10 +
ic_placeholder_upgrades.xml | 10 +
item_skin.xml | 40 +++
item_upgrade.xml | 63 ++++
power_up_activated.ogg | 1 +
presentation/ui/GameView.kt | 349 ++++++++++++++++++++
presentation/ui/MainActivity.kt | 425 +++++++++++++++++++++++++
presentation/ui/MainViewModel.kt | 341 ++++++++++++++++++++
presentation/ui/UpgradesActivity.kt | 151 +++++++++
rounded_button_background.xml | 15 +
settings.gradle | 16 +
shred_pop.ogg | 1 +
strings.xml | 9 +
upgrade_purchased.ogg | 1 +
util/ConnectivityReceiver.kt | 28 ++
util/NetworkUtils.kt | 26 ++
49 files changed, 2576 insertions(+)
create mode 100644 AdManager.kt
create mode 100644 AndroidManifest.xml
create mode 100644 Particle.kt
create mode 100644 Skin.kt
create mode 100644 SkinAdapter.kt
create mode 100644 SkinsActivity.kt
create mode 100644 UpgradeAdapter.kt
create mode 100644 activity_main.xml
create mode 100644 activity_skins.xml
create mode 100644 activity_upgrades.xml
create mode 100644 build.gradle
create mode 100644 button_click.ogg
create mode 100644 coin_earned.ogg
create mode 100644 colors.xml
create mode 100644 data/local/SharedPreferencesManager.kt
create mode 100644 data/model/IpGeoResponse.kt
create mode 100644 data/network/IpApiService.kt
create mode 100644 data/network/IpApiServiceImpl.kt
create mode 100644 data/repository/AdRepositoryImpl.kt
create mode 100644 data/repository/GameRepositoryImpl.kt
create mode 100644 data/repository/GeoRepositoryImpl.kt
create mode 100644 domain/model/AdNetworkStrategy.kt
create mode 100644 domain/model/FallingObject.kt
create mode 100644 domain/model/UpgradeItem.kt
create mode 100644 domain/repository/AdRepository.kt
create mode 100644 domain/repository/GameRepository.kt
create mode 100644 domain/repository/GeoRepository.kt
create mode 100644 domain/usecase/CheckInternetUseCase.kt
create mode 100644 domain/usecase/GetCountryUseCase.kt
create mode 100644 ic_placeholder_coin.xml
create mode 100644 ic_placeholder_gift.xml
create mode 100644 ic_placeholder_play.xml
create mode 100644 ic_placeholder_skins.xml
create mode 100644 ic_placeholder_upgrade_item.xml
create mode 100644 ic_placeholder_upgrades.xml
create mode 100644 item_skin.xml
create mode 100644 item_upgrade.xml
create mode 100644 power_up_activated.ogg
create mode 100644 presentation/ui/GameView.kt
create mode 100644 presentation/ui/MainActivity.kt
create mode 100644 presentation/ui/MainViewModel.kt
create mode 100644 presentation/ui/UpgradesActivity.kt
create mode 100644 rounded_button_background.xml
create mode 100644 settings.gradle
create mode 100644 shred_pop.ogg
create mode 100644 strings.xml
create mode 100644 upgrade_purchased.ogg
create mode 100644 util/ConnectivityReceiver.kt
create mode 100644 util/NetworkUtils.kt
diff --git a/AdManager.kt b/AdManager.kt
new file mode 100644
index 0000000..796802d
--- /dev/null
+++ b/AdManager.kt
@@ -0,0 +1,76 @@
+package com.example.myapp.presentation.ads
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import com.example.myapp.domain.model.AdNetworkStrategy
+
+class AdManager(private val context: Context, private val strategy: AdNetworkStrategy) {
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val TAG = "AdManager"
+
+ fun initializeAds() {
+ when (strategy) {
+ AdNetworkStrategy.IRANIAN_ADS -> Log.d(TAG, "[IRANIAN_ADS] Initializing Iranian Ads SDK (e.g., Tapsell)")
+ AdNetworkStrategy.GLOBAL_ADS -> Log.d(TAG, "[GLOBAL_ADS] Initializing Global Ads SDK (e.g., AdMob/Google Ad Manager)")
+ AdNetworkStrategy.UNKNOWN -> Log.d(TAG, "[UNKNOWN] No specific ad strategy, defaulting or waiting.")
+ }
+ // Actual SDK initialization would happen here
+ }
+
+ // --- Rewarded Video Ads ---
+ fun loadRewardedVideoAd(onLoaded: () -> Unit, onFailed: () -> Unit) {
+ Log.d(TAG, "[$strategy] Loading Rewarded Video Ad...")
+ // Simulate ad loading delay
+ handler.postDelayed({
+ val success = Math.random() > 0.2 // Simulate 80% success rate
+ if (success) {
+ Log.d(TAG, "[$strategy] Rewarded Video Ad Loaded.")
+ onLoaded()
+ } else {
+ Log.e(TAG, "[$strategy] Rewarded Video Ad Failed to Load.")
+ onFailed()
+ }
+ }, 2000) // 2-second delay
+ }
+
+ fun showRewardedVideoAd(onRewarded: (rewardAmount: Int) -> Unit, onClosed: () -> Unit) {
+ Log.d(TAG, "[$strategy] Showing Rewarded Video Ad...")
+ // Simulate ad showing and reward
+ handler.postDelayed({
+ val rewardAmount = 100 // Dummy reward
+ Log.d(TAG, "[$strategy] Rewarded Video Ad: User Rewarded with $rewardAmount coins.")
+ onRewarded(rewardAmount)
+
+ handler.postDelayed({
+ Log.d(TAG, "[$strategy] Rewarded Video Ad Closed.")
+ onClosed()
+ }, 500) // Short delay for closing
+ }, 1500) // 1.5-second delay for ad "viewing"
+ }
+
+ // --- Interstitial Ads ---
+ fun loadInterstitialAd(onLoaded: () -> Unit, onFailed: () -> Unit) {
+ Log.d(TAG, "[$strategy] Loading Interstitial Ad...")
+ handler.postDelayed({
+ val success = Math.random() > 0.2 // Simulate 80% success rate
+ if (success) {
+ Log.d(TAG, "[$strategy] Interstitial Ad Loaded.")
+ onLoaded()
+ } else {
+ Log.e(TAG, "[$strategy] Interstitial Ad Failed to Load.")
+ onFailed()
+ }
+ }, 2000) // 2-second delay
+ }
+
+ fun showInterstitialAd(onClosed: () -> Unit) {
+ Log.d(TAG, "[$strategy] Showing Interstitial Ad...")
+ handler.postDelayed({
+ Log.d(TAG, "[$strategy] Interstitial Ad Shown and Closed.")
+ onClosed()
+ }, 1500) // 1.5-second delay
+ }
+}
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..a2bcbe4
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+ {/* Requirement: Portrait-only mode */}
+
+
+
+
+
+
+
+
diff --git a/Particle.kt b/Particle.kt
new file mode 100644
index 0000000..7807406
--- /dev/null
+++ b/Particle.kt
@@ -0,0 +1,23 @@
+package com.example.myapp.presentation.model
+
+import android.graphics.Color
+
+data class Particle(
+ var x: Float,
+ var y: Float,
+ var color: Int,
+ var radius: Float,
+ var xSpeed: Float,
+ var ySpeed: Float,
+ var alpha: Int = 255,
+ var lifetime: Int // e.g., in frames or milliseconds
+) {
+ companion object {
+ // Predefined rainbow colors for Rainbow Burst effect
+ val RAINBOW_COLORS = listOf(
+ Color.RED, Color.rgb(255, 165, 0), Color.YELLOW, // Orange
+ Color.GREEN, Color.BLUE, Color.rgb(75, 0, 130), // Indigo
+ Color.rgb(128, 0, 128) // Violet
+ )
+ }
+}
diff --git a/Skin.kt b/Skin.kt
new file mode 100644
index 0000000..c014102
--- /dev/null
+++ b/Skin.kt
@@ -0,0 +1,13 @@
+package com.example.myapp.domain.model
+
+import androidx.annotation.DrawableRes
+
+data class Skin(
+ val id: String,
+ val name: String,
+ @DrawableRes val iconResId: Int, // For displaying the skin in a list
+ var isUnlocked: Boolean,
+ val unlockCost: Int, // 0 if unlocked by default or through non-coin means
+ // Potentially, add fields for actual visual assets if not just a color tint
+ // e.g., val mainColor: Int? = null, val detailColor: Int? = null, val spriteName: String? = null
+)
diff --git a/SkinAdapter.kt b/SkinAdapter.kt
new file mode 100644
index 0000000..314041e
--- /dev/null
+++ b/SkinAdapter.kt
@@ -0,0 +1,89 @@
+package com.example.myapp.presentation.ui
+
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.example.myapp.R
+import com.example.myapp.domain.model.Skin
+
+class SkinAdapter(
+ private val onUnlockClicked: (skin: Skin) -> Unit,
+ private val onSelectClicked: (skin: Skin) -> Unit
+) : ListAdapter(SkinDiffCallback()) {
+
+ private var currentCoins: Int = 0
+ private var selectedSkinId: String? = null
+
+ fun setCurrentCoins(coins: Int) {
+ currentCoins = coins
+ // notifyDataSetChanged() // Could be inefficient, better to handle in bind or use payloads
+ }
+
+ fun setSelectedSkinId(skinId: String?) {
+ selectedSkinId = skinId
+ notifyDataSetChanged() // Necessary to update selection highlights
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SkinViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_skin, parent, false) // item_skin.xml needs to be created
+ return SkinViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: SkinViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item, currentCoins, selectedSkinId == item.id, onUnlockClicked, onSelectClicked)
+ }
+
+ class SkinViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val nameTextView: TextView = itemView.findViewById(R.id.skinNameTextView)
+ private val iconImageView: ImageView = itemView.findViewById(R.id.skinIconImageView)
+ private val actionButton: Button = itemView.findViewById(R.id.skinActionButton)
+ private val itemLayout: View = itemView // For background tinting if selected
+
+ fun bind(
+ skin: Skin,
+ currentCoins: Int,
+ isSelected: Boolean,
+ onUnlockClicked: (skin: Skin) -> Unit,
+ onSelectClicked: (skin: Skin) -> Unit
+ ) {
+ nameTextView.text = skin.name
+ iconImageView.setImageResource(skin.iconResId) // Placeholder icons
+
+ if (isSelected) {
+ itemLayout.setBackgroundColor(Color.LTGRAY) // Highlight selected
+ actionButton.text = "Selected"
+ actionButton.isEnabled = false
+ } else {
+ itemLayout.setBackgroundColor(Color.TRANSPARENT) // Default background
+ if (skin.isUnlocked) {
+ actionButton.text = "Select"
+ actionButton.isEnabled = true
+ actionButton.setOnClickListener { onSelectClicked(skin) }
+ } else {
+ actionButton.text = "Unlock (${skin.unlockCost})"
+ actionButton.isEnabled = currentCoins >= skin.unlockCost
+ actionButton.setOnClickListener { onUnlockClicked(skin) }
+ }
+ }
+ }
+ }
+
+ class SkinDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Skin, newItem: Skin): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: Skin, newItem: Skin): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/SkinsActivity.kt b/SkinsActivity.kt
new file mode 100644
index 0000000..7ad286a
--- /dev/null
+++ b/SkinsActivity.kt
@@ -0,0 +1,120 @@
+package com.example.myapp.presentation.ui
+
+import android.media.AudioAttributes
+import android.media.SoundPool
+import android.os.Bundle
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.example.myapp.R
+import com.example.myapp.domain.model.Skin
+
+class SkinsActivity : AppCompatActivity() {
+
+ private lateinit var viewModel: MainViewModel
+ private lateinit var skinsRecyclerView: RecyclerView
+ private lateinit var skinAdapter: SkinAdapter // To be created
+ private lateinit var currentCoinsTextView: TextView
+ private lateinit var backButton: Button
+
+ private var soundPool: SoundPool? = null
+ private var buttonClickSoundId: Int = 0
+ private var itemUnlockedSoundId: Int = 0
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_skins)
+
+ viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
+
+ currentCoinsTextView = findViewById(R.id.currentCoinsTextViewSkins)
+ backButton = findViewById(R.id.backButtonSkins)
+
+ setupSoundPool()
+ loadSounds()
+ setupRecyclerView()
+ observeViewModel()
+
+ backButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ finish()
+ }
+ }
+
+ private fun setupRecyclerView() {
+ skinsRecyclerView = findViewById(R.id.skinsRecyclerView)
+ skinAdapter = SkinAdapter(
+ onUnlockClicked = { skin ->
+ playSound(buttonClickSoundId)
+ if (viewModel.unlockSkin(skin.id)) {
+ playSound(itemUnlockedSoundId)
+ Toast.makeText(this, "${skin.name} Unlocked!", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, "Not enough coins or already unlocked!", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onSelectClicked = { skin ->
+ playSound(buttonClickSoundId)
+ viewModel.selectSkin(skin.id)
+ Toast.makeText(this, "${skin.name} Selected!", Toast.LENGTH_SHORT).show()
+ // Adapter will update based on LiveData observation for selected state
+ }
+ )
+ skinsRecyclerView.adapter = skinAdapter
+ // Using GridLayoutManager for a more skin-selection friendly UI
+ skinsRecyclerView.layoutManager = GridLayoutManager(this, 3) // 3 items per row
+ }
+
+ private fun observeViewModel() {
+ viewModel.objectSkins.observe(this, Observer { skins ->
+ skinAdapter.submitList(skins)
+ })
+
+ viewModel.currentCoins.observe(this, Observer { coins ->
+ currentCoinsTextView.text = "Coins: $coins"
+ skinAdapter.setCurrentCoins(coins)
+ // Could call notifyDataSetChanged or more specific updates if needed for button states
+ })
+
+ viewModel.selectedSkinId.observe(this, Observer { selectedId ->
+ skinAdapter.setSelectedSkinId(selectedId)
+ // Adapter needs to handle highlighting the selected item
+ })
+ }
+
+ private fun setupSoundPool() {
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ soundPool = SoundPool.Builder()
+ .setMaxStreams(2)
+ .setAudioAttributes(audioAttributes)
+ .build()
+ }
+
+ private fun loadSounds() {
+ soundPool?.let {
+ buttonClickSoundId = it.load(this, R.raw.button_click, 1)
+ itemUnlockedSoundId = it.load(this, R.raw.upgrade_purchased, 1) // Re-use upgrade sound for unlock
+ }
+ }
+
+ private fun playSound(soundId: Int) {
+ if (soundId > 0) {
+ soundPool?.play(soundId, 0.8f, 0.8f, 1, 0, 1f)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ soundPool?.release()
+ soundPool = null
+ }
+}
diff --git a/UpgradeAdapter.kt b/UpgradeAdapter.kt
new file mode 100644
index 0000000..0264841
--- /dev/null
+++ b/UpgradeAdapter.kt
@@ -0,0 +1,93 @@
+package com.example.myapp.presentation.ui
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.example.myapp.R
+import com.example.myapp.domain.model.UpgradeItem
+
+class UpgradeAdapter(
+ private val onUpgradeClicked: (upgradeItem: UpgradeItem) -> Unit,
+ private val onUnlockAdClicked: (upgradeItem: UpgradeItem) -> Unit
+) : ListAdapter(UpgradeDiffCallback()) {
+
+ private var currentCoins: Int = 0
+
+ fun setCurrentCoins(coins: Int) {
+ currentCoins = coins
+ // No need to call notifyDataSetChanged() here if only button enable/disable state changes,
+ // which can be handled in onBindViewHolder. If item content itself changes due to coins,
+ // then submitting the list again or specific notifyItemChanged calls would be needed.
+ // For simplicity, we might re-bind or rely on view holder updates.
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UpgradeViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_upgrade, parent, false) // item_upgrade.xml needs to be created
+ return UpgradeViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: UpgradeViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item, currentCoins, onUpgradeClicked, onUnlockAdClicked)
+ }
+
+ class UpgradeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val nameTextView: TextView = itemView.findViewById(R.id.upgradeNameTextView)
+ private val descriptionTextView: TextView = itemView.findViewById(R.id.upgradeDescriptionTextView)
+ private val levelTextView: TextView = itemView.findViewById(R.id.upgradeLevelTextView)
+ private val iconImageView: ImageView = itemView.findViewById(R.id.upgradeIconImageView)
+ private val actionButton: Button = itemView.findViewById(R.id.upgradeActionButton)
+
+ fun bind(
+ item: UpgradeItem,
+ currentCoins: Int,
+ onUpgradeClicked: (upgradeItem: UpgradeItem) -> Unit,
+ onUnlockAdClicked: (upgradeItem: UpgradeItem) -> Unit
+ ) {
+ nameTextView.text = item.name
+ descriptionTextView.text = item.description
+ iconImageView.setImageResource(item.iconResId) // Actual icons needed
+
+ if (item.isUnlockedByAd) {
+ if (item.isAdUnlocked) {
+ levelTextView.text = "Unlocked"
+ actionButton.text = "Active"
+ actionButton.isEnabled = false
+ } else {
+ levelTextView.text = "Unlock via Ad"
+ actionButton.text = "Watch Ad"
+ actionButton.isEnabled = true
+ actionButton.setOnClickListener { onUnlockAdClicked(item) }
+ }
+ } else {
+ levelTextView.text = "Level: ${item.currentLevel}/${item.maxLevel}"
+ if (item.isMaxLevel()) {
+ actionButton.text = "Max Level"
+ actionButton.isEnabled = false
+ } else {
+ val cost = item.getNextLevelCost()
+ actionButton.text = "Cost: $cost"
+ actionButton.isEnabled = cost != null && currentCoins >= cost
+ actionButton.setOnClickListener { onUpgradeClicked(item) }
+ }
+ }
+ }
+ }
+
+ class UpgradeDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: UpgradeItem, newItem: UpgradeItem): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: UpgradeItem, newItem: UpgradeItem): Boolean {
+ return oldItem == newItem // Relies on UpgradeItem being a data class
+ }
+ }
+}
diff --git a/activity_main.xml b/activity_main.xml
new file mode 100644
index 0000000..01c5a27
--- /dev/null
+++ b/activity_main.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/activity_skins.xml b/activity_skins.xml
new file mode 100644
index 0000000..7f2f276
--- /dev/null
+++ b/activity_skins.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/activity_upgrades.xml b/activity_upgrades.xml
new file mode 100644
index 0000000..94f55a9
--- /dev/null
+++ b/activity_upgrades.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..812fc04
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,47 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'com.example.myapp'
+ compileSdk 33
+
+ defaultConfig {
+ applicationId "com.example.myapp"
+ minSdk 21 // Requirement: minSdkVersion set to 21
+ targetSdk 33
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ // Ensure Kotlin is configured as the primary language (already handled by plugins and kotlinOptions)
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.9.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.8.0'
+
+ // OkHttp for networking
+ implementation("com.squareup.okhttp3:okhttp:4.9.3")
+
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+}
diff --git a/button_click.ogg b/button_click.ogg
new file mode 100644
index 0000000..2e82a66
--- /dev/null
+++ b/button_click.ogg
@@ -0,0 +1 @@
+// Placeholder for button click sound effect. In a real scenario, this would be an OGG Vorbis audio file.
diff --git a/coin_earned.ogg b/coin_earned.ogg
new file mode 100644
index 0000000..26d282e
--- /dev/null
+++ b/coin_earned.ogg
@@ -0,0 +1 @@
+// Placeholder for coin earned sound (e.g., "ching!"). OGG Vorbis format.
diff --git a/colors.xml b/colors.xml
new file mode 100644
index 0000000..675bf21
--- /dev/null
+++ b/colors.xml
@@ -0,0 +1,22 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
+
+ #FFFDE7
+ #FFAB91
+ #FF8A65
+ #795548
+ #FFFFFF
+
+ #4FC3F7
+ #AED581
+ #FF8A80
+ #A1887F
+
diff --git a/data/local/SharedPreferencesManager.kt b/data/local/SharedPreferencesManager.kt
new file mode 100644
index 0000000..8e8b923
--- /dev/null
+++ b/data/local/SharedPreferencesManager.kt
@@ -0,0 +1,91 @@
+package com.example.myapp.data.local
+
+import android.content.Context
+import android.content.SharedPreferences
+
+class SharedPreferencesManager(context: Context) {
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences("GamePrefs", Context.MODE_PRIVATE)
+
+ companion object {
+ const val LAST_DAILY_REWARD_CLAIM_TIME = "last_daily_reward_claim_time"
+ const val CURRENT_COINS = "current_coins"
+ // Keys for other data to be persisted later
+ const val UNLOCKED_SKIN_IDS = "unlocked_skin_ids"
+ const val SELECTED_SKIN_ID = "selected_skin_id"
+ const val UPGRADE_LEVEL_PREFIX = "upgrade_level_" // e.g., upgrade_level_faster_blades
+ const val BOOSTER_MULTIPLIER = "booster_multiplier"
+ const val BOOSTER_END_TIME = "booster_end_time"
+ }
+
+ // --- Daily Reward ---
+ fun getLastDailyRewardClaimTime(): Long {
+ return prefs.getLong(LAST_DAILY_REWARD_CLAIM_TIME, 0L)
+ }
+
+ fun saveLastDailyRewardClaimTime(timeMillis: Long) {
+ prefs.edit().putLong(LAST_DAILY_REWARD_CLAIM_TIME, timeMillis).apply()
+ }
+
+ // --- Coins ---
+ fun getCurrentCoins(): Int {
+ return prefs.getInt(CURRENT_COINS, 0) // Default to 0 coins
+ }
+
+ fun saveCurrentCoins(coins: Int) {
+ prefs.edit().putInt(CURRENT_COINS, coins).apply()
+ }
+
+ // --- Upgrade Levels ---
+ fun getUpgradeLevel(upgradeId: String): Int {
+ return prefs.getInt("$UPGRADE_LEVEL_PREFIX$upgradeId", 0)
+ }
+
+ fun saveUpgradeLevel(upgradeId: String, level: Int) {
+ prefs.edit().putInt("$UPGRADE_LEVEL_PREFIX$upgradeId", level).apply()
+ }
+
+ // --- Unlocked Skins ---
+ fun getUnlockedSkinIds(): Set {
+ return prefs.getStringSet(UNLOCKED_SKIN_IDS, emptySet()) ?: emptySet()
+ }
+
+ fun saveUnlockedSkinIds(skinIds: Set) {
+ prefs.edit().putStringSet(UNLOCKED_SKIN_IDS, skinIds).apply()
+ }
+
+ // --- Selected Skin ---
+ fun getSelectedSkinId(): String? {
+ return prefs.getString(SELECTED_SKIN_ID, null)
+ }
+
+ fun saveSelectedSkinId(skinId: String?) {
+ if (skinId == null) {
+ prefs.edit().remove(SELECTED_SKIN_ID).apply()
+ } else {
+ prefs.edit().putString(SELECTED_SKIN_ID, skinId).apply()
+ }
+ }
+
+ // --- Coin Booster ---
+ fun getBoosterMultiplier(): Float {
+ return prefs.getFloat(BOOSTER_MULTIPLIER, 1.0f)
+ }
+
+ fun saveBoosterMultiplier(multiplier: Float) {
+ prefs.edit().putFloat(BOOSTER_MULTIPLIER, multiplier).apply()
+ }
+
+ fun getBoosterEndTime(): Long {
+ return prefs.getLong(BOOSTER_END_TIME, 0L)
+ }
+
+ fun saveBoosterEndTime(endTimeMillis: Long) {
+ prefs.edit().putLong(BOOSTER_END_TIME, endTimeMillis).apply()
+ }
+
+ fun clearBooster() {
+ prefs.edit().remove(BOOSTER_MULTIPLIER).remove(BOOSTER_END_TIME).apply()
+ }
+}
diff --git a/data/model/IpGeoResponse.kt b/data/model/IpGeoResponse.kt
new file mode 100644
index 0000000..6701098
--- /dev/null
+++ b/data/model/IpGeoResponse.kt
@@ -0,0 +1,12 @@
+package com.example.myapp.data.model
+
+// Using manual JSON parsing, so no need for @SerializedName for now.
+// If using Gson/Moshi later, uncomment and use them.
+
+data class IpGeoResponse(
+ val status: String, // e.g., "success", "fail"
+ val country: String?, // e.g., "Canada"
+ val countryCode: String?, // e.g., "CA"
+ val message: String? // Only present on failure, e.g., "private range" or "invalid query"
+ // Other fields from ip-api.com like query, regionName, city, lat, lon, etc., can be added if needed.
+)
diff --git a/data/network/IpApiService.kt b/data/network/IpApiService.kt
new file mode 100644
index 0000000..4396908
--- /dev/null
+++ b/data/network/IpApiService.kt
@@ -0,0 +1,7 @@
+package com.example.myapp.data.network
+
+import com.example.myapp.data.model.IpGeoResponse
+
+interface IpApiService {
+ suspend fun getGeoInfo(): IpGeoResponse?
+}
diff --git a/data/network/IpApiServiceImpl.kt b/data/network/IpApiServiceImpl.kt
new file mode 100644
index 0000000..27df499
--- /dev/null
+++ b/data/network/IpApiServiceImpl.kt
@@ -0,0 +1,73 @@
+package com.example.myapp.data.network
+
+import com.example.myapp.data.model.IpGeoResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import java.io.IOException
+
+class IpApiServiceImpl : IpApiService {
+
+ private val client = OkHttpClient()
+ // Using HTTP for now as specified, but HTTPS is preferred: "https://ip-api.com/json"
+ private val apiUrl = "http://ip-api.com/json"
+
+ override suspend fun getGeoInfo(): IpGeoResponse? {
+ return withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(apiUrl)
+ .build()
+
+ try {
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ // Log error or handle specific HTTP error codes
+ System.err.println("API Error: ${response.code} ${response.message}")
+ return@withContext null
+ }
+
+ val responseBody = response.body?.string()
+ if (responseBody == null) {
+ System.err.println("API Error: Empty response body")
+ return@withContext null
+ }
+
+ // Manual JSON parsing
+ try {
+ val jsonObject = JSONObject(responseBody)
+ val status = jsonObject.getString("status")
+ val country = jsonObject.optString("country", null)
+ val countryCode = jsonObject.optString("countryCode", null)
+ val message = jsonObject.optString("message", null)
+
+ IpGeoResponse(
+ status = status,
+ country = country,
+ countryCode = countryCode,
+ message = message
+ )
+ } catch (e: Exception) {
+ System.err.println("JSON Parsing Error: ${e.message}")
+ // Attempt to parse a failure message if status indicates failure
+ try {
+ val jsonObject = JSONObject(responseBody)
+ val status = jsonObject.getString("status")
+ val message = jsonObject.optString("message", "Error parsing response")
+ if ("fail" == status) {
+ return@withContext IpGeoResponse(status, null, null, message)
+ }
+ } catch (e2: Exception) {
+ // Ignore if this also fails
+ }
+ null
+ }
+ }
+ } catch (e: IOException) {
+ System.err.println("Network Error: ${e.message}")
+ null
+ }
+ }
+ }
+}
diff --git a/data/repository/AdRepositoryImpl.kt b/data/repository/AdRepositoryImpl.kt
new file mode 100644
index 0000000..21e1fec
--- /dev/null
+++ b/data/repository/AdRepositoryImpl.kt
@@ -0,0 +1,10 @@
+package com.example.myapp.data.repository
+
+import com.example.myapp.domain.repository.AdRepository
+
+class AdRepositoryImpl : AdRepository {
+ override fun getAdId(): String {
+ // Placeholder implementation
+ return "sample_ad_id_123"
+ }
+}
diff --git a/data/repository/GameRepositoryImpl.kt b/data/repository/GameRepositoryImpl.kt
new file mode 100644
index 0000000..8c69e85
--- /dev/null
+++ b/data/repository/GameRepositoryImpl.kt
@@ -0,0 +1,10 @@
+package com.example.myapp.data.repository
+
+import com.example.myapp.domain.repository.GameRepository
+
+class GameRepositoryImpl : GameRepository {
+ override fun getGameScore(): Int {
+ // Placeholder implementation
+ return 100
+ }
+}
diff --git a/data/repository/GeoRepositoryImpl.kt b/data/repository/GeoRepositoryImpl.kt
new file mode 100644
index 0000000..853833d
--- /dev/null
+++ b/data/repository/GeoRepositoryImpl.kt
@@ -0,0 +1,11 @@
+package com.example.myapp.data.repository
+
+import com.example.myapp.data.model.IpGeoResponse
+import com.example.myapp.data.network.IpApiService
+import com.example.myapp.domain.repository.GeoRepository
+
+class GeoRepositoryImpl(private val ipApiService: IpApiService) : GeoRepository {
+ override suspend fun getGeoInfo(): IpGeoResponse? {
+ return ipApiService.getGeoInfo()
+ }
+}
diff --git a/domain/model/AdNetworkStrategy.kt b/domain/model/AdNetworkStrategy.kt
new file mode 100644
index 0000000..d33bd57
--- /dev/null
+++ b/domain/model/AdNetworkStrategy.kt
@@ -0,0 +1,7 @@
+package com.example.myapp.domain.model
+
+enum class AdNetworkStrategy {
+ IRANIAN_ADS,
+ GLOBAL_ADS,
+ UNKNOWN
+}
diff --git a/domain/model/FallingObject.kt b/domain/model/FallingObject.kt
new file mode 100644
index 0000000..1686b72
--- /dev/null
+++ b/domain/model/FallingObject.kt
@@ -0,0 +1,15 @@
+package com.example.myapp.domain.model
+
+enum class ObjectType {
+ TOY, FRUIT, BLOCK
+}
+
+data class FallingObject(
+ var x: Float,
+ var y: Float,
+ val type: ObjectType,
+ var size: Float,
+ var speed: Float,
+ var isShredding: Boolean = false, // For shred animation
+ var alpha: Int = 255 // For fade out animation
+)
diff --git a/domain/model/UpgradeItem.kt b/domain/model/UpgradeItem.kt
new file mode 100644
index 0000000..fdbf268
--- /dev/null
+++ b/domain/model/UpgradeItem.kt
@@ -0,0 +1,25 @@
+package com.example.myapp.domain.model
+
+import androidx.annotation.DrawableRes
+
+data class UpgradeItem(
+ val id: String,
+ val name: String,
+ val description: String,
+ @DrawableRes val iconResId: Int, // Placeholder, will use R.drawable later
+ val maxLevel: Int,
+ var currentLevel: Int = 0,
+ val costs: List, // Cost for level 1, level 2, etc. (index currentLevel for next cost)
+ val isUnlockedByAd: Boolean = false,
+ var isAdUnlocked: Boolean = false, // Relevant if isUnlockedByAd is true
+ var effectValue: Float = 1f // Generic way to represent the upgrade's current effect magnitude
+) {
+ fun isMaxLevel(): Boolean = currentLevel >= maxLevel
+ fun getNextLevelCost(): Int? {
+ return if (!isMaxLevel() && costs.size > currentLevel) {
+ costs[currentLevel]
+ } else {
+ null
+ }
+ }
+}
diff --git a/domain/repository/AdRepository.kt b/domain/repository/AdRepository.kt
new file mode 100644
index 0000000..45a9542
--- /dev/null
+++ b/domain/repository/AdRepository.kt
@@ -0,0 +1,6 @@
+package com.example.myapp.domain.repository
+
+interface AdRepository {
+ // Placeholder for ad-related functions
+ fun getAdId(): String
+}
diff --git a/domain/repository/GameRepository.kt b/domain/repository/GameRepository.kt
new file mode 100644
index 0000000..a9260c3
--- /dev/null
+++ b/domain/repository/GameRepository.kt
@@ -0,0 +1,6 @@
+package com.example.myapp.domain.repository
+
+interface GameRepository {
+ // Placeholder for game-related functions
+ fun getGameScore(): Int
+}
diff --git a/domain/repository/GeoRepository.kt b/domain/repository/GeoRepository.kt
new file mode 100644
index 0000000..43d2654
--- /dev/null
+++ b/domain/repository/GeoRepository.kt
@@ -0,0 +1,7 @@
+package com.example.myapp.domain.repository
+
+import com.example.myapp.data.model.IpGeoResponse
+
+interface GeoRepository {
+ suspend fun getGeoInfo(): IpGeoResponse?
+}
diff --git a/domain/usecase/CheckInternetUseCase.kt b/domain/usecase/CheckInternetUseCase.kt
new file mode 100644
index 0000000..a8e7423
--- /dev/null
+++ b/domain/usecase/CheckInternetUseCase.kt
@@ -0,0 +1,9 @@
+package com.example.myapp.domain.usecase
+
+class CheckInternetUseCase {
+ // Placeholder for use case logic
+ fun execute(): Boolean {
+ // In a real scenario, this would check network connectivity
+ return true // Placeholder
+ }
+}
diff --git a/domain/usecase/GetCountryUseCase.kt b/domain/usecase/GetCountryUseCase.kt
new file mode 100644
index 0000000..f4ba733
--- /dev/null
+++ b/domain/usecase/GetCountryUseCase.kt
@@ -0,0 +1,18 @@
+package com.example.myapp.domain.usecase
+
+import com.example.myapp.domain.repository.GeoRepository
+
+class GetCountryUseCase(private val geoRepository: GeoRepository) {
+ suspend fun execute(): String? {
+ val geoInfo = geoRepository.getGeoInfo()
+ // Check status and return countryCode, handle "fail" status or null response
+ if (geoInfo != null && "success" == geoInfo.status) {
+ return geoInfo.countryCode
+ }
+ // Log error or specific message if needed
+ if (geoInfo != null && "fail" == geoInfo.status) {
+ System.err.println("GeoAPI failed: ${geoInfo.message}")
+ }
+ return null // Or a default/unknown country code
+ }
+}
diff --git a/ic_placeholder_coin.xml b/ic_placeholder_coin.xml
new file mode 100644
index 0000000..4402d4e
--- /dev/null
+++ b/ic_placeholder_coin.xml
@@ -0,0 +1,12 @@
+ {/* Assuming it's on a colored button */}
+
+
+
+
diff --git a/ic_placeholder_gift.xml b/ic_placeholder_gift.xml
new file mode 100644
index 0000000..529aac7
--- /dev/null
+++ b/ic_placeholder_gift.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ic_placeholder_play.xml b/ic_placeholder_play.xml
new file mode 100644
index 0000000..f41d839
--- /dev/null
+++ b/ic_placeholder_play.xml
@@ -0,0 +1,10 @@
+ {/* Or use @color/text_color_on_button */}
+
+
diff --git a/ic_placeholder_skins.xml b/ic_placeholder_skins.xml
new file mode 100644
index 0000000..7f3f743
--- /dev/null
+++ b/ic_placeholder_skins.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/ic_placeholder_upgrade_item.xml b/ic_placeholder_upgrade_item.xml
new file mode 100644
index 0000000..224bd45
--- /dev/null
+++ b/ic_placeholder_upgrade_item.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ic_placeholder_upgrades.xml b/ic_placeholder_upgrades.xml
new file mode 100644
index 0000000..bda0171
--- /dev/null
+++ b/ic_placeholder_upgrades.xml
@@ -0,0 +1,10 @@
+ {/* Or use @color/text_color_on_button */}
+
+
diff --git a/item_skin.xml b/item_skin.xml
new file mode 100644
index 0000000..2a74ba2
--- /dev/null
+++ b/item_skin.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/item_upgrade.xml b/item_upgrade.xml
new file mode 100644
index 0000000..cfa808f
--- /dev/null
+++ b/item_upgrade.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/power_up_activated.ogg b/power_up_activated.ogg
new file mode 100644
index 0000000..66fb5e5
--- /dev/null
+++ b/power_up_activated.ogg
@@ -0,0 +1 @@
+// Placeholder for power-up activated sound. OGG Vorbis format.
diff --git a/presentation/ui/GameView.kt b/presentation/ui/GameView.kt
new file mode 100644
index 0000000..bf0c80e
--- /dev/null
+++ b/presentation/ui/GameView.kt
@@ -0,0 +1,349 @@
+package com.example.myapp.presentation.ui
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.media.AudioAttributes
+import android.media.SoundPool
+import android.util.AttributeSet
+import android.util.Log // Added for logging
+import android.view.View
+import android.view.animation.LinearInterpolator
+import com.example.myapp.R
+import com.example.myapp.domain.model.FallingObject
+import com.example.myapp.domain.model.ObjectType
+import com.example.myapp.presentation.model.Particle // Import Particle
+import java.util.concurrent.CopyOnWriteArrayList
+import kotlin.random.Random
+
+class GameView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ private val objects = CopyOnWriteArrayList()
+ private val objectPaint = Paint()
+ private val particlePaint = Paint() // Paint for particles
+ private val shredderPaint = Paint().apply { color = Color.parseColor("#A1887F") } // Using color from colors.xml
+ private var shredderRect = RectF()
+ private var baseShredderWidth = 0f
+ private var currentShredderWidth = 0f
+
+ // Particle System
+ private val particles = CopyOnWriteArrayList()
+ private val particleGravity = 0.3f // Adjusted gravity
+ private val particleBaseLifetime = 50 // Avg frames a particle will last
+
+ // Upgradeable parameters
+ private var shredderSpeedFactor = 1.0f
+ private var shredderWidthFactor = 1.0f
+
+ // Skin
+ private var currentSelectedSkinId: String? = "default"
+
+ private var sessionScore = 0
+
+ private var soundPool: SoundPool? = null
+ private var shredSoundId: Int = 0
+ var coinSoundId: Int = 0 // Made public for preloading if needed, or keep private
+
+ private var lastSpawnTime = 0L
+ private val spawnInterval = 1000L
+
+ private var gameAnimator: ValueAnimator? = null
+ var isGameRunning = false // Made public for MainActivity to check for play/pause logic
+
+ init {
+ setupSoundPool()
+ addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) {
+ loadSounds()
+ // startGameLoop() // Game shouldn't auto-start; let MainActivity control this via play button
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ stopGameLoop()
+ soundPool?.release()
+ soundPool = null
+ }
+ })
+ }
+
+ private fun setupSoundPool() {
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ soundPool = SoundPool.Builder()
+ .setMaxStreams(10) // Increased streams for more simultaneous sounds
+ .setAudioAttributes(audioAttributes)
+ .build()
+ }
+
+ private fun loadSounds() {
+ soundPool?.let { sp ->
+ shredSoundId = sp.load(context, R.raw.shred_pop, 1)
+ coinSoundId = sp.load(context, R.raw.coin_earned, 1) // Load coin sound
+ }
+ }
+
+ fun startGameLoopIfNotRunning() {
+ if (!isGameRunning) {
+ resetGameVisuals() // Ensure game visuals are reset before starting
+ startGameLoop()
+ } else if (gameAnimator?.isPaused == true) {
+ resumeGame()
+ }
+ }
+
+ private fun resetGameVisuals() {
+ objects.clear()
+ particles.clear()
+ // sessionScore is reset by MainActivity/ViewModel logic if needed before calling this
+ }
+
+
+ interface GameOverListener {
+ fun onGameOver(sessionScore: Int)
+ }
+ private var gameOverListener: GameOverListener? = null
+ fun setGameOverListener(listener: GameOverListener) {
+ this.gameOverListener = listener
+ }
+
+ fun setSelectedSkinId(skinId: String?) {
+ currentSelectedSkinId = skinId ?: "default"
+ invalidate() // Redraw with new skin
+ }
+
+ fun setUpgradeEffect(upgradeId: String, effectValue: Float) {
+ when (upgradeId) {
+ "faster_blades" -> shredderSpeedFactor = effectValue.coerceAtLeast(0.5f)
+ "more_blades" -> {
+ shredderWidthFactor = effectValue.coerceAtLeast(0.5f)
+ updateShredderRect()
+ }
+ "bubble_shred" -> {
+ if (effectValue == 1.0f) {
+ Log.d("GameView", "Bubble Shred power-up is active (placeholder effect).")
+ // TODO: Implement actual visual or gameplay effect for bubble shred here
+ }
+ }
+ }
+ }
+
+ fun getScore(): Int { return sessionScore }
+
+ fun resetGameAndScore() { // Called by MainActivity to start a fresh game
+ sessionScore = 0
+ resetGameVisuals()
+ isGameRunning = false
+ startGameLoopIfNotRunning()
+ }
+
+
+ fun pauseGame() {
+ isGameRunning = false
+ gameAnimator?.pause()
+ }
+
+ fun resumeGame() {
+ if (!isGameRunning && (gameAnimator?.isPaused == true || gameAnimator == null)) {
+ isGameRunning = true
+ if (gameAnimator == null) {
+ startGameLoop() // If never started or was cancelled
+ } else {
+ gameAnimator?.resume()
+ }
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ baseShredderWidth = w.toFloat()
+ updateShredderRect()
+ }
+
+ private fun updateShredderRect() {
+ val shredderHeight = height / 10f
+ currentShredderWidth = baseShredderWidth * shredderWidthFactor
+ val shredderLeft = (width - currentShredderWidth) / 2
+ shredderRect = RectF(shredderLeft, height - shredderHeight, shredderLeft + currentShredderWidth, height.toFloat())
+ invalidate()
+ }
+
+ private fun startGameLoop() {
+ if (gameAnimator?.isRunning == true) return // Already running and not paused
+ isGameRunning = true
+
+ gameAnimator?.cancel()
+ gameAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
+ duration = Long.MAX_VALUE
+ interpolator = LinearInterpolator()
+ addUpdateListener {
+ if(isGameRunning) {
+ updateGame()
+ invalidate()
+ }
+ }
+ start()
+ }
+ lastSpawnTime = System.currentTimeMillis()
+ }
+
+ private fun stopGameLoop() {
+ isGameRunning = false
+ gameAnimator?.cancel()
+ }
+
+ private fun updateGame() {
+ if (!isGameRunning) return
+
+ val currentTime = System.currentTimeMillis()
+
+ if (sessionScore >= 10 && isGameRunning) {
+ isGameRunning = false
+ gameAnimator?.pause()
+ gameOverListener?.onGameOver(sessionScore)
+ return
+ }
+
+ val objectIterator = objects.iterator()
+ while (objectIterator.hasNext()) {
+ val obj = objectIterator.next()
+ if (obj.isShredding) {
+ obj.size -= 5f * shredderSpeedFactor
+ obj.alpha = (obj.alpha - (35 * shredderSpeedFactor).toInt()).coerceAtLeast(0)
+ if (obj.size <= 0f || obj.alpha <= 0) {
+ objectIterator.remove()
+ }
+ continue
+ }
+ obj.y += obj.speed
+ val objectRect = RectF(obj.x - obj.size / 2, obj.y - obj.size / 2, obj.x + obj.size / 2, obj.y + obj.size / 2)
+ if (RectF.intersects(objectRect, shredderRect)) {
+ sessionScore++
+ obj.isShredding = true
+ playSound(shredSoundId)
+ playSound(coinSoundId) // Play coin sound
+ generateParticles(obj.x, shredderRect.top + 5) // Particles from shredder top
+ } else if (obj.y - obj.size / 2 > height) {
+ objectIterator.remove()
+ }
+ }
+
+ if (isGameRunning && currentTime - lastSpawnTime > spawnInterval) {
+ spawnNewObject()
+ lastSpawnTime = currentTime
+ }
+
+ val particleIterator = particles.iterator()
+ while(particleIterator.hasNext()) {
+ val particle = particleIterator.next()
+ particle.x += particle.xSpeed
+ particle.y += particle.ySpeed
+ particle.ySpeed += particleGravity
+ particle.alpha = (particle.alpha - (255 / (particleBaseLifetime / 2) ) ).coerceAtLeast(0) // Faster fade
+ particle.lifetime--
+ if (particle.lifetime <= 0 || particle.alpha <= 0) {
+ particleIterator.remove()
+ }
+ }
+ }
+
+ private fun generateParticles(x: Float, y: Float) {
+ val particleCount = 25 // More particles
+ for (i in 0 until particleCount) {
+ val angle = Random.nextDouble() * Math.PI // Emit upwards in a semicircle
+ val speed = Random.nextFloat() * 6 + 3 // Speed between 3 and 9
+ val xSpeed = (kotlin.math.cos(angle - Math.PI / 2) * speed).toFloat() // Centered upwards
+ val ySpeed = (-kotlin.math.sin(angle) * speed).toFloat() // Ensure initial upward motion
+
+ particles.add(
+ Particle(
+ x = x + (Random.nextFloat() - 0.5f) * 50f, // Spread horizontally a bit
+ y = y,
+ color = Particle.RAINBOW_COLORS.random(),
+ radius = Random.nextFloat() * 6 + 4f, // size 4 to 10
+ xSpeed = xSpeed,
+ ySpeed = ySpeed,
+ lifetime = (particleBaseLifetime + Random.nextInt(-10, 10)) // Vary lifetime a bit
+ )
+ )
+ }
+ }
+
+ private fun spawnNewObject() {
+ val randomType = ObjectType.values().random()
+ val randomX = Random.nextFloat() * width
+ val randomSize = Random.nextFloat() * 50f + 30f
+ val randomSpeed = Random.nextFloat() * 5f + 5f
+
+ objects.add(
+ FallingObject(
+ x = randomX,
+ y = -randomSize,
+ type = randomType,
+ size = randomSize,
+ speed = randomSpeed
+ )
+ )
+ }
+
+ private fun playSound(soundId: Int) {
+ if (soundId > 0) { // Check if soundId is loaded
+ soundPool?.play(soundId, 0.5f, 0.5f, 1, 0, 1f) // Reduced volume slightly
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ canvas.drawColor(Color.parseColor("#FFFDE7")) // Background from colors.xml
+
+ canvas.drawRect(shredderRect, shredderPaint)
+
+ for (obj in objects) {
+ objectPaint.alpha = obj.alpha
+ val halfSize = obj.size / 2
+ when (obj.type) {
+ ObjectType.TOY -> {
+ objectPaint.color = when(currentSelectedSkinId) {
+ "blue_toy" -> Color.CYAN // Example: Blue Toy skin makes toys cyan
+ else -> Color.parseColor("#4FC3F7") // Default Light Blue
+ }
+ val rect = RectF(obj.x - halfSize, obj.y - halfSize, obj.x + halfSize, obj.y + halfSize)
+ canvas.drawRoundRect(rect, obj.size / 4, obj.size / 4, objectPaint)
+ }
+ ObjectType.FRUIT -> {
+ objectPaint.color = Color.parseColor("#AED581") // Default Light Green
+ // Add skin logic for FRUIT if any
+ canvas.drawCircle(obj.x, obj.y, halfSize, objectPaint)
+ }
+ ObjectType.BLOCK -> {
+ objectPaint.color = when(currentSelectedSkinId) {
+ "red_block" -> Color.MAGENTA // Example: Red Block skin makes blocks magenta
+ else -> Color.parseColor("#FF8A80") // Default Light Red
+ }
+ canvas.drawRect(obj.x - halfSize, obj.y - halfSize, obj.x + halfSize, obj.y + halfSize, objectPaint)
+ }
+ }
+ }
+
+ for (particle in particles) {
+ particlePaint.color = particle.color
+ particlePaint.alpha = particle.alpha
+ val particleHalfSize = particle.radius / 2
+ canvas.drawRect( // Draw as small squares for confetti
+ particle.x - particleHalfSize,
+ particle.y - particleHalfSize,
+ particle.x + particleHalfSize,
+ particle.y + particleHalfSize,
+ particlePaint
+ )
+ }
+ }
+}
diff --git a/presentation/ui/MainActivity.kt b/presentation/ui/MainActivity.kt
new file mode 100644
index 0000000..dc13bac
--- /dev/null
+++ b/presentation/ui/MainActivity.kt
@@ -0,0 +1,425 @@
+package com.example.myapp.presentation.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioAttributes
+import android.media.SoundPool
+import android.net.ConnectivityManager
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.View
+import android.widget.Button
+import android.widget.ImageButton
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import com.example.myapp.R
+import com.example.myapp.util.ConnectivityReceiver
+import com.example.myapp.util.NetworkUtils
+
+class MainActivity : AppCompatActivity(), GameView.GameOverListener {
+
+ private lateinit var gameView: GameView
+ private lateinit var scoreTextView: TextView
+ private lateinit var boosterStatusTextView: TextView
+ private lateinit var playPauseButton: ImageButton
+ private lateinit var dailyRewardButton: ImageButton
+ private lateinit var upgradesButton: ImageButton
+ private lateinit var skinsButton: ImageButton
+ private lateinit var testBoosterButton: Button
+ private lateinit var viewModel: MainViewModel
+
+ private var soundPool: SoundPool? = null
+ private var buttonClickSoundId: Int = 0
+ private lateinit var connectivityReceiver: ConnectivityReceiver
+ private var isNetworkDialogShowing = false
+ private var wasGameActiveBeforeNetworkLoss = false
+ private var gameOverCountForInterstitial = 0
+ private var observeViewModelCalledFirstTime = false
+
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val scoreUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (::gameView.isInitialized && ::scoreTextView.isInitialized && ::viewModel.isInitialized) {
+ scoreTextView.text = "Score: ${gameView.getScore()} | Coins: ${viewModel.currentCoins.value ?: 0}"
+ }
+ if (::viewModel.isInitialized && viewModel.boosterActive.value == true) {
+ viewModel.updateBoosterTimeRemaining()
+ }
+ handler.postDelayed(this, 100)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
+
+ gameView = findViewById(R.id.gameView)
+ scoreTextView = findViewById(R.id.scoreTextView)
+ boosterStatusTextView = findViewById(R.id.boosterStatusTextView)
+ playPauseButton = findViewById(R.id.playPauseButton)
+ dailyRewardButton = findViewById(R.id.dailyRewardButton)
+ upgradesButton = findViewById(R.id.upgradesButton)
+ skinsButton = findViewById(R.id.skinsButton)
+ testBoosterButton = findViewById(R.id.testBoosterButton)
+
+ setupSoundPool()
+ loadSounds()
+
+ if (!NetworkUtils.isInternetAvailable(this)) {
+ showInternetRequiredDialog(isOnCreate = true)
+ } else {
+ initializeGameUiAndObservers()
+ }
+ setupConnectivityReceiver()
+ }
+
+ private fun setupConnectivityReceiver() {
+ connectivityReceiver = ConnectivityReceiver()
+ ConnectivityReceiver.networkState.observe(this, Observer { isConnected ->
+ if (isConnected) {
+ if (isNetworkDialogShowing) {
+ isNetworkDialogShowing = false
+ initializeGameUiAndObservers()
+ if (wasGameActiveBeforeNetworkLoss) {
+ gameView.resumeGame()
+ wasGameActiveBeforeNetworkLoss = false
+ }
+ }
+ if (gameView.visibility == View.GONE && !playPauseButton.isEnabled) {
+ initializeGameUiAndObservers()
+ }
+ } else {
+ if (playPauseButton.isEnabled || gameView.visibility == View.VISIBLE) {
+ wasGameActiveBeforeNetworkLoss = gameView.isGameRunning
+ gameView.pauseGame()
+ showInternetLostDialog()
+ } else if (!isNetworkDialogShowing && gameView.visibility == View.GONE) {
+ showInternetRequiredDialog(isOnCreate = true)
+ }
+ }
+ })
+ }
+
+ private fun initializeGameUiAndObservers() {
+ if (!NetworkUtils.isInternetAvailable(this)) {
+ if (!isNetworkDialogShowing) showInternetRequiredDialog(isOnCreate = true)
+ return
+ }
+
+ viewModel.fetchUserCountryAndSetAdStrategy()
+
+ gameView.setGameOverListener(this)
+ gameView.visibility = View.VISIBLE
+ playPauseButton.isEnabled = true
+ dailyRewardButton.isEnabled = true
+ upgradesButton.isEnabled = true
+ skinsButton.isEnabled = true
+ testBoosterButton.isEnabled = true
+
+ if (!observeViewModelCalledFirstTime) {
+ observeViewModel()
+ observeViewModelCalledFirstTime = true
+ }
+ viewModel.checkDailyRewardAvailability()
+ viewModel.checkBoosterStatus()
+
+ playPauseButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ if (!NetworkUtils.isInternetAvailable(this)) {
+ showInternetRequiredDialog(isFromPlayButton = true)
+ } else {
+ if (gameView.isGameRunning) {
+ gameView.pauseGame()
+ // TODO: Update button icon to "Play"
+ } else {
+ viewModel.upgrades.value?.forEach { _, item ->
+ if (item.id == "faster_blades" || item.id == "more_blades" || (item.id == "bubble_shred" && item.isAdUnlocked && item.currentLevel > 0)) {
+ if(::gameView.isInitialized) gameView.setUpgradeEffect(item.id, item.effectValue)
+ }
+ }
+ if(::gameView.isInitialized) gameView.resetGameAndScore()
+ viewModel.updateScore(0)
+ // TODO: Update button icon to "Pause"
+ }
+ }
+ }
+
+ upgradesButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ val intent = Intent(this, UpgradesActivity::class.java)
+ upgradesActivityResultLauncher.launch(intent)
+ }
+
+ skinsButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ val intent = Intent(this, SkinsActivity::class.java)
+ startActivity(intent)
+ }
+
+ dailyRewardButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ if (viewModel.dailyRewardAvailable.value == true) {
+ if (viewModel.claimDailyReward()) {
+ Toast.makeText(this, "You received 500 coins!", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(this, "Daily reward not available yet!", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ testBoosterButton.setOnClickListener {
+ playSound(buttonClickSoundId)
+ viewModel.activateCoinBooster(2.0f, 60000L)
+ Toast.makeText(this, "2x Coin Booster Activated for 1 min!", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private val upgradesActivityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { _ ->
+ if (!viewModel.interstitialShownThisSession) {
+ viewModel.requestLoadInterstitialAd()
+ }
+ }
+
+ private fun showInternetRequiredDialog(isOnCreate: Boolean = false, isFromPlayButton: Boolean = false) {
+ if (isNetworkDialogShowing) return
+ isNetworkDialogShowing = true
+
+ gameView.visibility = View.GONE
+ playPauseButton.isEnabled = false
+ dailyRewardButton.isEnabled = false
+ upgradesButton.isEnabled = false
+ skinsButton.isEnabled = false
+ testBoosterButton.isEnabled = false
+ boosterStatusTextView.visibility = View.GONE
+
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.app_name))
+ .setMessage(getString(R.string.internet_required_message))
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.retry_button_text)) { dialog, _ ->
+ isNetworkDialogShowing = false
+ dialog.dismiss()
+ if (NetworkUtils.isInternetAvailable(this)) {
+ initializeGameUiAndObservers()
+ } else {
+ showInternetRequiredDialog(isOnCreate = isOnCreate, isFromPlayButton = isFromPlayButton)
+ }
+ }
+ .show()
+ }
+
+ private fun showInternetLostDialog() {
+ if (isNetworkDialogShowing) return
+ isNetworkDialogShowing = true
+
+ playPauseButton.isEnabled = false
+
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.app_name))
+ .setMessage(getString(R.string.internet_lost_message))
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.ok_button_text)) { dialog, _ ->
+ isNetworkDialogShowing = false
+ dialog.dismiss()
+ if (NetworkUtils.isInternetAvailable(this)) {
+ initializeGameUiAndObservers()
+ } else {
+ showInternetLostDialog()
+ }
+ }
+ .show()
+ }
+
+ override fun onGameOver(sessionScore: Int) {
+ viewModel.updateScore(sessionScore)
+ gameOverCountForInterstitial++
+
+ AlertDialog.Builder(this)
+ .setTitle("Game Over!")
+ .setMessage("Score: $sessionScore\nWatch an Ad to Double Your Coins?")
+ .setPositiveButton("Yes, Double Coins!") { dialog, _ ->
+ playSound(buttonClickSoundId)
+ dialog.dismiss()
+ viewModel.requestLoadRewardedVideo()
+ }
+ .setNegativeButton("No, Thanks") { dialog, _ ->
+ playSound(buttonClickSoundId)
+ dialog.dismiss()
+ viewModel.addCoins(sessionScore)
+ checkAndShowInterstitialAfterGameOver()
+ }
+ .setCancelable(false)
+ .show()
+ }
+
+ private fun checkAndShowInterstitialAfterGameOver() {
+ if (gameOverCountForInterstitial % 2 == 0) {
+ if (!viewModel.interstitialShownThisSession) {
+ viewModel.requestLoadInterstitialAd()
+ }
+ }
+ }
+
+ private fun observeViewModel() {
+ viewModel.rewardedAdLoaded.observe(this) { isLoaded ->
+ if (isLoaded == true) {
+ viewModel.requestShowRewardedVideo(
+ onRewarded = { _ ->
+ val lastSessionScore = viewModel.score.value ?: 0
+ viewModel.addCoins(lastSessionScore)
+ Toast.makeText(this, "Coins Doubled!", Toast.LENGTH_SHORT).show()
+ checkAndShowInterstitialAfterGameOver()
+ },
+ onClosed = {
+ if(!NetworkUtils.isInternetAvailable(this)){
+ showInternetLostDialog()
+ } else {
+ // Interstitial check is handled by onRewarded or if ad is closed without reward by NegativeButton
+ }
+ }
+ )
+ }
+ })
+
+ viewModel.interstitialAdLoaded.observe(this) { isLoaded ->
+ if (isLoaded == true) {
+ if (!isFinishing && !isChangingConfigurations) {
+ gameView.pauseGame()
+ viewModel.requestShowInterstitialAd {
+ gameView.resumeGame()
+ }
+ }
+ }
+ }
+
+ viewModel.currentCoins.observe(this) { coins ->
+ scoreTextView.text = "Score: ${gameView.getScore()} | Coins: $coins"
+ }
+
+ viewModel.upgrades.observe(this) { upgradesMap ->
+ if (::gameView.isInitialized) {
+ upgradesMap.forEach { _, upgradeItem ->
+ if (upgradeItem.id == "faster_blades" || upgradeItem.id == "more_blades" ||
+ (upgradeItem.id == "bubble_shred" && upgradeItem.isAdUnlocked && upgradeItem.currentLevel > 0)) {
+ gameView.setUpgradeEffect(upgradeItem.id, upgradeItem.effectValue)
+ }
+ }
+ }
+ })
+
+ viewModel.dailyRewardAvailable.observe(this) { available ->
+ if (available) {
+ dailyRewardButton.isEnabled = true
+ dailyRewardButton.alpha = 1.0f
+ // TODO: Add animation to make button prominent
+ } else {
+ dailyRewardButton.isEnabled = false
+ dailyRewardButton.alpha = 0.5f
+ }
+ }
+
+ viewModel.boosterActive.observe(this) { isActive ->
+ if (isActive) {
+ boosterStatusTextView.visibility = View.VISIBLE
+ viewModel.updateBoosterTimeRemaining()
+ } else {
+ boosterStatusTextView.visibility = View.GONE
+ }
+ }
+ viewModel.boosterTimeRemaining.observe(this) { timeString ->
+ if (viewModel.boosterActive.value == true && timeString.isNotBlank()) {
+ val multiplierText = String.format("%.0fx", viewModel.coinMultiplier.value ?: 1.0f)
+ boosterStatusTextView.text = "$multiplierText Coins Active! $timeString"
+ } else {
+ boosterStatusTextView.visibility = View.GONE
+ }
+ }
+
+ viewModel.selectedSkinId.observe(this) { skinId ->
+ Log.d("MainActivity", "Selected skin ID changed to: $skinId")
+ if(::gameView.isInitialized && skinId != null) gameView.setSelectedSkinId(skinId)
+ }
+ }
+
+ private fun setupSoundPool() {
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ soundPool = SoundPool.Builder()
+ .setMaxStreams(3)
+ .setAudioAttributes(audioAttributes)
+ .build()
+ }
+
+ private fun loadSounds() {
+ soundPool?.let {
+ buttonClickSoundId = it.load(this, R.raw.button_click, 1)
+ }
+ }
+
+ private fun playSound(soundId: Int) {
+ if (soundId > 0) {
+ soundPool?.play(soundId, 0.8f, 0.8f, 1, 0, 1f)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ handler.post(scoreUpdateRunnable)
+
+ @Suppress("DEPRECATION")
+ registerReceiver(connectivityReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
+
+ if (!NetworkUtils.isInternetAvailable(this) && !isNetworkDialogShowing) {
+ if(playPauseButton.isEnabled || gameView.visibility == View.VISIBLE){
+ gameView.pauseGame()
+ showInternetLostDialog()
+ } else {
+ showInternetRequiredDialog(true)
+ }
+ } else if (NetworkUtils.isInternetAvailable(this) &&
+ (gameView.visibility == View.GONE && !playPauseButton.isEnabled && !isNetworkDialogShowing)){
+ initializeGameUiAndObservers()
+ } else if (NetworkUtils.isInternetAvailable(this) && ::viewModel.isInitialized) {
+ viewModel.checkDailyRewardAvailability()
+ viewModel.checkBoosterStatus()
+
+ viewModel.upgrades.value?.forEach { _, item ->
+ if (item.id == "faster_blades" || item.id == "more_blades" || (item.id == "bubble_shred" && item.isAdUnlocked && item.currentLevel > 0)) {
+ if(::gameView.isInitialized) gameView.setUpgradeEffect(item.id, item.effectValue)
+ }
+ }
+ if (!observeViewModelCalledFirstTime) {
+ observeViewModel()
+ observeViewModelCalledFirstTime = true
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(scoreUpdateRunnable)
+
+ unregisterReceiver(connectivityReceiver)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ soundPool?.release()
+ soundPool = null
+ }
+}
diff --git a/presentation/ui/MainViewModel.kt b/presentation/ui/MainViewModel.kt
new file mode 100644
index 0000000..63efc1f
--- /dev/null
+++ b/presentation/ui/MainViewModel.kt
@@ -0,0 +1,341 @@
+package com.example.myapp.presentation.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.example.myapp.R // For placeholder iconResId
+import com.example.myapp.data.local.SharedPreferencesManager
+import com.example.myapp.data.network.IpApiServiceImpl
+import com.example.myapp.data.repository.GeoRepositoryImpl
+import com.example.myapp.domain.model.AdNetworkStrategy
+import com.example.myapp.domain.model.Skin
+import com.example.myapp.domain.model.UpgradeItem
+import com.example.myapp.domain.usecase.GetCountryUseCase
+import com.example.myapp.presentation.ads.AdManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.concurrent.TimeUnit
+
+class MainViewModel(application: Application) : AndroidViewModel(application) {
+ private val prefsManager = SharedPreferencesManager(application.applicationContext)
+
+ // Game Score
+ private val _score = MutableLiveData(0) // Current game session score
+ val score: LiveData = _score
+
+ // Player Coins
+ private val _currentCoins = MutableLiveData(prefsManager.getCurrentCoins())
+ val currentCoins: LiveData = _currentCoins
+
+ // Geolocation & Ad Strategy
+ private val _userCountryCode = MutableLiveData()
+ val userCountryCode: LiveData = _userCountryCode
+ private val _adNetworkStrategy = MutableLiveData(AdNetworkStrategy.UNKNOWN)
+ val adNetworkStrategy: LiveData = _adNetworkStrategy
+ private var adManager: AdManager? = null
+
+ // Upgrades
+ private val _upgrades = MutableLiveData