Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Gradle
.gradle/
build/
**/build/

# Local configuration
local.properties

# IntelliJ/Android Studio
.idea/
*.iml

# Logs
*.log

# OS
.DS_Store
Thumbs.db

# Keystores
*.jks
*.keystore

# APKs
*.apk
*.ap_*

# NDK
.obj/
.externalNativeBuild/
.cxx/

# Generated
captures/
outputs/

# Gradle Wrapper JAR (optional, usually checked in)
gradle/wrapper/gradle-wrapper.jar
120 changes: 120 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}

android {
namespace = "com.example.voiceshopper"
compileSdk = 35

defaultConfig {
applicationId = "com.example.voiceshopper"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}

buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}

buildFeatures {
compose = true
buildConfig = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-Xcontext-receivers"
)
}

packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}

flavorDimensions += "features"
productFlavors {
create("baseline") {
dimension = "features"
buildConfigField("boolean", "FEATURE_GEOFENCE", "false")
}
create("geofence") {
dimension = "features"
buildConfigField("boolean", "FEATURE_GEOFENCE", "true")
}
}
}

val composeBom = platform("androidx.compose:compose-bom:2024.09.02")

dependencies {
implementation(composeBom)
androidTestImplementation(composeBom)

// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation("androidx.navigation:navigation-compose:2.8.0")

// Room + KSP
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")

// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")

// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

// Retrofit (stub)
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")

// Play services for location (geofencing)
implementation("com.google.android.gms:play-services-location:21.3.0")

// WorkManager (optional for reminders)
implementation("androidx.work:work-runtime-ktx:2.9.1")

// Accompanist (optional permissions helper)
implementation("com.google.accompanist:accompanist-permissions:0.36.0")

// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
5 changes: 5 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-dontwarn kotlinx.coroutines.**
-keep class kotlinx.coroutines.** { *; }
-keepclassmembers class ** {
@android.webkit.JavascriptInterface <methods>;
}
42 changes: 42 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<application
android:name=".VoiceShoppingApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.VoiceShopping">

<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.VoiceShopping">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".feature.geofencing.GeofenceTransitionsService"
android:exported="false"
android:foregroundServiceType="location" />

<receiver
android:name=".feature.geofencing.GeofenceBroadcastReceiver"
android:exported="false" />

<receiver
android:name=".util.notifications.BudgetWarningReceiver"
android:exported="false" />

</application>
</manifest>
32 changes: 32 additions & 0 deletions app/src/main/java/com/example/voiceshopper/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.voiceshopper

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.core.view.WindowCompat
import com.example.voiceshopper.ui.AppNavHost
import com.example.voiceshopper.ui.theme.VoiceShoppingTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
VoiceShoppingTheme {
Surface(color = MaterialTheme.colorScheme.background) {
AppNav()
}
}
}
}
}

@Composable
private fun AppNav() {
AppNavHost()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.voiceshopper

import android.app.Application

class VoiceShoppingApp : Application()
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.example.voiceshopper.data

import android.content.Context
import com.example.voiceshopper.data.db.AppDatabase
import com.example.voiceshopper.data.db.ItemEntity
import com.example.voiceshopper.data.db.ShoppingListEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

class ShoppingRepository private constructor(private val db: AppDatabase) {

private val itemDao = db.itemDao()
private val listDao = db.shoppingListDao()

suspend fun getOrCreateCurrentList(): ShoppingListEntity {
val latest = listDao.getLatestList()
if (latest != null) return latest
val createdId = listDao.insert(
ShoppingListEntity(
name = "Today",
createdAt = System.currentTimeMillis(),
total = 0.0
)
)
return listDao.getLatestList()!!
}

fun observeHistory(): Flow<List<ShoppingListEntity>> = listDao.getHistory()

suspend fun createNewList(name: String = "Today"): ShoppingListEntity {
val id = listDao.insert(
ShoppingListEntity(
name = name,
createdAt = System.currentTimeMillis(),
total = 0.0
)
)
return listDao.getLatestList()!!
}

fun observeItems(listId: Long): Flow<List<ItemEntity>> = itemDao.getItemsForList(listId)

fun observePendingItems(listId: Long): Flow<List<ItemEntity>> = itemDao.getPendingItemsForList(listId)

fun observePendingTotal(listId: Long): Flow<Double> = itemDao.getPendingTotalForList(listId)

suspend fun addItem(
listId: Long,
name: String,
quantity: Double?,
unit: String?,
price: Double?,
owner: String?
): Long {
val entity = ItemEntity(
name = name,
quantity = quantity,
unit = unit,
price = price,
owner = owner,
createdAt = System.currentTimeMillis(),
listId = listId,
bought = false
)
return itemDao.insert(entity)
}

suspend fun updateItem(item: ItemEntity) = itemDao.update(item)

suspend fun deleteItem(id: Long) = itemDao.deleteById(id)

suspend fun setBought(id: Long, bought: Boolean) = itemDao.setBought(id, bought)

companion object {
@Volatile private var INSTANCE: ShoppingRepository? = null
fun get(context: Context): ShoppingRepository = INSTANCE ?: synchronized(this) {
val db = AppDatabase.get(context)
INSTANCE ?: ShoppingRepository(db).also { INSTANCE = it }
}
}
}
39 changes: 39 additions & 0 deletions app/src/main/java/com/example/voiceshopper/data/db/AppDatabase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.voiceshopper.data.db

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Database(
entities = [ItemEntity::class, ShoppingListEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
abstract fun shoppingListDao(): ShoppingListDao

companion object {
@Volatile private var INSTANCE: AppDatabase? = null

fun get(context: Context): AppDatabase = INSTANCE ?: synchronized(this) {
INSTANCE ?: build(context).also { INSTANCE = it }
}

private fun build(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "voice_shopping.db")
.fallbackToDestructiveMigration()
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Pre-populate a default shopping list named Today
db.execSQL("INSERT INTO shopping_lists(name, createdAt, total) VALUES('Today', strftime('%s','now')*1000, 0.0)")
}
})
.build()
}
}
}
Loading