Initial commit: GeoZoner Android app scaffolding

- Full Android project with Gradle wrapper
- Hilt DI, Room DB, Retrofit networking
- Auth flow with JWT token management
- Bottom navigation with 4 tabs
- Material 3 theme (light/dark)
- Stub screens ready for implementation
- Ready for Mapbox integration
This commit is contained in:
Redsandy
2026-03-14 21:40:43 +03:00
parent dcf63fae58
commit 4cc43a410b
56 changed files with 2654 additions and 0 deletions

59
.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
gradle-app.setting
# Local configuration file (sdk path, etc)
local.properties
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# Keystore files
*.jks
*.keystore
# Google Services (API keys)
google-services.json
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Secrets
secrets.properties
MAPBOX_PUBLIC_TOKEN
MAPBOX_DOWNLOADS_TOKEN
# OS specific files
.DS_Store
Thumbs.db
# Uncomment this if your app uses Mapbox
# app/src/main/res/values/mapbox_access_token.xml

148
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,148 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
// Load local.properties for Mapbox token
val localProperties = Properties().apply {
val localPropsFile = rootProject.file("local.properties")
if (localPropsFile.exists()) {
load(localPropsFile.inputStream())
}
}
android {
namespace = "com.geozoner.app"
compileSdk = 35
defaultConfig {
applicationId = "com.geozoner.app"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Inject Mapbox public token as a BuildConfig field
buildConfigField(
"String",
"MAPBOX_PUBLIC_TOKEN",
"\"${localProperties.getProperty("MAPBOX_PUBLIC_TOKEN", "")}\""
)
// Backend base URL
buildConfigField(
"String",
"BASE_URL",
"\"http://10.0.2.2:8000\""
)
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(
"String",
"BASE_URL",
"\"https://api.geozoner.com\""
)
}
debug {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// AndroidX Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.security.crypto)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
ksp(libs.hilt.work.compiler)
// Networking
implementation(libs.retrofit)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization)
// Mapbox (uncomment after setting MAPBOX_DOWNLOADS_TOKEN in local.properties)
// implementation(libs.mapbox.maps)
// Firebase (uncomment when google-services.json is added)
// implementation(platform(libs.firebase.bom))
// implementation(libs.firebase.messaging)
// implementation(libs.firebase.analytics)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
// Image loading
implementation(libs.coil.compose)
// Health Connect (Phase 2)
// implementation(libs.androidx.health.connect)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.junit.ext)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test.junit4)
}

34
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,34 @@
# Add project specific ProGuard rules here.
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.geozoner.app.**$$serializer { *; }
-keepclassmembers class com.geozoner.app.** {
*** Companion;
}
-keepclasseswithmembers class com.geozoner.app.** {
kotlinx.serialization.KSerializer serializer(...);
}
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground Service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Activity Recognition (for Health Connect) -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- Boot receiver for WorkManager -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".GeoZonerApp"
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.GeoZoner"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.GeoZoner">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- GPS Tracking Foreground Service -->
<service
android:name=".service.LocationTrackingService"
android:exported="false"
android:foregroundServiceType="location" />
<!-- WorkManager initializer (for Hilt) -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,43 @@
package com.geozoner.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class GeoZonerApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
val manager = getSystemService(NotificationManager::class.java)
val trackingChannel = NotificationChannel(
CHANNEL_TRACKING,
getString(R.string.tracking_notification_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows notification while tracking activity"
setShowBadge(false)
}
val socialChannel = NotificationChannel(
CHANNEL_SOCIAL,
"Social",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Zone captures, friend updates, leaderboard changes"
}
manager.createNotificationChannels(listOf(trackingChannel, socialChannel))
}
companion object {
const val CHANNEL_TRACKING = "tracking"
const val CHANNEL_SOCIAL = "social"
}
}

View File

@@ -0,0 +1,52 @@
package com.geozoner.app.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")
@Singleton
class TokenStorage @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")
private val userIdKey = stringPreferencesKey("user_id")
val accessToken: Flow<String?> = context.dataStore.data.map { it[accessTokenKey] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[refreshTokenKey] }
val userId: Flow<String?> = context.dataStore.data.map { it[userIdKey] }
val isLoggedIn: Flow<Boolean> = accessToken.map { it != null }
suspend fun saveTokens(accessToken: String, refreshToken: String) {
context.dataStore.edit { prefs ->
prefs[accessTokenKey] = accessToken
prefs[refreshTokenKey] = refreshToken
}
}
suspend fun saveUserId(userId: String) {
context.dataStore.edit { prefs ->
prefs[userIdKey] = userId
}
}
suspend fun getAccessTokenOnce(): String? = accessToken.first()
suspend fun getRefreshTokenOnce(): String? = refreshToken.first()
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
}

View File

@@ -0,0 +1,30 @@
package com.geozoner.app.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geozoner.app.data.local.entity.CachedZoneEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedZoneDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(zones: List<CachedZoneEntity>)
@Query("SELECT * FROM cached_zones WHERE isOwn = 1")
fun getOwnZones(): Flow<List<CachedZoneEntity>>
@Query("SELECT * FROM cached_zones WHERE isOwn = 0")
fun getFriendZones(): Flow<List<CachedZoneEntity>>
@Query("SELECT * FROM cached_zones")
fun getAllZones(): Flow<List<CachedZoneEntity>>
@Query("DELETE FROM cached_zones WHERE isOwn = :isOwn")
suspend fun deleteByOwnership(isOwn: Boolean)
@Query("DELETE FROM cached_zones")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,27 @@
package com.geozoner.app.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geozoner.app.data.local.entity.PendingActivityEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PendingActivityDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(activity: PendingActivityEntity): Long
@Query("SELECT * FROM pending_activities ORDER BY createdAt ASC")
fun getAll(): Flow<List<PendingActivityEntity>>
@Query("SELECT * FROM pending_activities ORDER BY createdAt ASC")
suspend fun getAllOnce(): List<PendingActivityEntity>
@Query("DELETE FROM pending_activities WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT COUNT(*) FROM pending_activities")
fun count(): Flow<Int>
}

View File

@@ -0,0 +1,21 @@
package com.geozoner.app.data.local.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.geozoner.app.data.local.dao.CachedZoneDao
import com.geozoner.app.data.local.dao.PendingActivityDao
import com.geozoner.app.data.local.entity.CachedZoneEntity
import com.geozoner.app.data.local.entity.PendingActivityEntity
@Database(
entities = [
PendingActivityEntity::class,
CachedZoneEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class GeoZonerDatabase : RoomDatabase() {
abstract fun pendingActivityDao(): PendingActivityDao
abstract fun cachedZoneDao(): CachedZoneDao
}

View File

@@ -0,0 +1,18 @@
package com.geozoner.app.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Local cache of zone data for offline map rendering.
*/
@Entity(tableName = "cached_zones")
data class CachedZoneEntity(
@PrimaryKey val id: String,
val ownerId: String,
val polygonGeojson: String, // JSON string of GeoJSON polygon
val areaM2: Double,
val defenseLevel: Int,
val isOwn: Boolean, // true = current user's zone, false = friend's zone
val lastUpdated: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,18 @@
package com.geozoner.app.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Stores activity data locally when offline.
* Will be uploaded to server when connectivity is restored.
*/
@Entity(tableName = "pending_activities")
data class PendingActivityEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val type: String,
val startedAt: String,
val endedAt: String,
val gpsTrackJson: String, // JSON array of GpsPoints
val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,40 @@
package com.geozoner.app.data.remote
import com.geozoner.app.data.local.TokenStorage
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Adds Authorization header with Bearer token to all requests
* except auth endpoints (login, register, refresh).
*/
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenStorage: TokenStorage,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// Skip auth endpoints
val path = request.url.encodedPath
if (path.contains("/auth/")) {
return chain.proceed(request)
}
val token = runBlocking { tokenStorage.getAccessTokenOnce() }
val authenticatedRequest = if (token != null) {
request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
request
}
return chain.proceed(authenticatedRequest)
}
}

View File

@@ -0,0 +1,54 @@
package com.geozoner.app.data.remote
import com.geozoner.app.data.local.TokenStorage
import com.geozoner.app.data.remote.api.AuthApi
import com.geozoner.app.data.remote.dto.RefreshRequest
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles 401 responses by refreshing the JWT token.
* If refresh fails, clears tokens (forcing re-login).
*/
@Singleton
class TokenAuthenticator @Inject constructor(
private val tokenStorage: TokenStorage,
private val authApiProvider: dagger.Lazy<AuthApi>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// Prevent infinite refresh loops
if (response.request.header("X-Retry-Auth") != null) {
runBlocking { tokenStorage.clear() }
return null
}
val refreshToken = runBlocking { tokenStorage.getRefreshTokenOnce() } ?: return null
return try {
val tokenResponse = runBlocking {
authApiProvider.get().refresh(RefreshRequest(refreshToken))
}
runBlocking {
tokenStorage.saveTokens(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
)
}
response.request.newBuilder()
.header("Authorization", "Bearer ${tokenResponse.accessToken}")
.header("X-Retry-Auth", "true")
.build()
} catch (e: Exception) {
runBlocking { tokenStorage.clear() }
null
}
}
}

View File

@@ -0,0 +1,25 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.ActivityCreateRequest
import com.geozoner.app.data.remote.dto.ActivityDetailResponse
import com.geozoner.app.data.remote.dto.ActivityResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface ActivityApi {
@POST("activities")
suspend fun createActivity(@Body request: ActivityCreateRequest): ActivityDetailResponse
@GET("activities")
suspend fun getActivities(
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0,
): List<ActivityResponse>
@GET("activities/{activityId}")
suspend fun getActivity(@Path("activityId") activityId: String): ActivityDetailResponse
}

View File

@@ -0,0 +1,21 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.LoginRequest
import com.geozoner.app.data.remote.dto.RefreshRequest
import com.geozoner.app.data.remote.dto.RegisterRequest
import com.geozoner.app.data.remote.dto.TokenResponse
import com.geozoner.app.data.remote.dto.UserResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface AuthApi {
@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): UserResponse
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): TokenResponse
@POST("auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): TokenResponse
}

View File

@@ -0,0 +1,21 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.MessageResponse
import com.geozoner.app.data.remote.dto.UserResponse
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface FriendApi {
@POST("friends")
suspend fun addFriend(@Query("username") username: String): MessageResponse
@GET("friends")
suspend fun getFriends(): List<UserResponse>
@DELETE("friends/{friendId}")
suspend fun removeFriend(@Path("friendId") friendId: String)
}

View File

@@ -0,0 +1,10 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.LeaderboardEntryResponse
import retrofit2.http.GET
interface LeaderboardApi {
@GET("leaderboard")
suspend fun getLeaderboard(): List<LeaderboardEntryResponse>
}

View File

@@ -0,0 +1,21 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.UserResponse
import com.geozoner.app.data.remote.dto.UserStatsResponse
import com.geozoner.app.data.remote.dto.UserUpdateRequest
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path
interface UserApi {
@GET("users/me")
suspend fun getMe(): UserResponse
@PATCH("users/me")
suspend fun updateMe(@Body request: UserUpdateRequest): UserResponse
@GET("users/{userId}/stats")
suspend fun getUserStats(@Path("userId") userId: String): UserStatsResponse
}

View File

@@ -0,0 +1,18 @@
package com.geozoner.app.data.remote.api
import com.geozoner.app.data.remote.dto.ZoneBriefResponse
import com.geozoner.app.data.remote.dto.ZoneDetailResponse
import retrofit2.http.GET
import retrofit2.http.Path
interface ZoneApi {
@GET("zones")
suspend fun getMyZones(): List<ZoneBriefResponse>
@GET("zones/friends")
suspend fun getFriendZones(): List<ZoneBriefResponse>
@GET("zones/{zoneId}")
suspend fun getZone(@Path("zoneId") zoneId: String): ZoneDetailResponse
}

View File

@@ -0,0 +1,47 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GpsPointDto(
val lat: Double,
val lon: Double,
val timestamp: String? = null,
val altitude: Double? = null,
val hdop: Double? = null,
)
@Serializable
data class ActivityCreateRequest(
val type: String,
@SerialName("started_at") val startedAt: String,
@SerialName("ended_at") val endedAt: String,
@SerialName("gps_track") val gpsTrack: List<GpsPointDto>,
)
@Serializable
data class ActivityResponse(
val id: String,
@SerialName("user_id") val userId: String,
val type: String,
@SerialName("started_at") val startedAt: String? = null,
@SerialName("ended_at") val endedAt: String? = null,
@SerialName("distance_m") val distanceM: Double? = null,
val status: String,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class ActivityDetailResponse(
val id: String,
@SerialName("user_id") val userId: String,
val type: String,
@SerialName("started_at") val startedAt: String? = null,
@SerialName("ended_at") val endedAt: String? = null,
@SerialName("distance_m") val distanceM: Double? = null,
val status: String,
@SerialName("created_at") val createdAt: String,
@SerialName("zone_id") val zoneId: String? = null,
@SerialName("area_m2") val areaM2: Double? = null,
)

View File

@@ -0,0 +1,29 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val username: String,
val email: String,
val password: String,
)
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
)
@Serializable
data class TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String,
)

View File

@@ -0,0 +1,8 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MessageResponse(
val detail: String,
)

View File

@@ -0,0 +1,15 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LeaderboardEntryResponse(
@SerialName("user_id") val userId: String,
val username: String,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("total_pts") val totalPts: Int,
@SerialName("total_area_m2") val totalAreaM2: Double,
@SerialName("zone_count") val zoneCount: Int,
val rank: Int,
)

View File

@@ -0,0 +1,31 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserResponse(
val id: String,
val username: String,
val email: String,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class UserUpdateRequest(
val username: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("fcm_token") val fcmToken: String? = null,
)
@Serializable
data class UserStatsResponse(
val id: String,
val username: String,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("total_area_m2") val totalAreaM2: Double,
@SerialName("total_points") val totalPoints: Int,
@SerialName("zone_count") val zoneCount: Int,
@SerialName("activity_count") val activityCount: Int,
)

View File

@@ -0,0 +1,27 @@
package com.geozoner.app.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class ZoneBriefResponse(
val id: String,
@SerialName("owner_id") val ownerId: String,
@SerialName("polygon_geojson") val polygonGeojson: JsonObject,
@SerialName("area_m2") val areaM2: Double,
@SerialName("defense_level") val defenseLevel: Int,
)
@Serializable
data class ZoneDetailResponse(
val id: String,
@SerialName("owner_id") val ownerId: String,
@SerialName("activity_id") val activityId: String,
@SerialName("polygon_geojson") val polygonGeojson: JsonObject,
@SerialName("area_m2") val areaM2: Double,
@SerialName("defense_level") val defenseLevel: Int,
@SerialName("defense_runs") val defenseRuns: Int,
@SerialName("created_at") val createdAt: String,
@SerialName("last_reinforced_at") val lastReinforcedAt: String? = null,
)

View File

@@ -0,0 +1,38 @@
package com.geozoner.app.di
import android.content.Context
import androidx.room.Room
import com.geozoner.app.data.local.dao.CachedZoneDao
import com.geozoner.app.data.local.dao.PendingActivityDao
import com.geozoner.app.data.local.db.GeoZonerDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): GeoZonerDatabase {
return Room.databaseBuilder(
context,
GeoZonerDatabase::class.java,
"geozoner.db",
)
.fallbackToDestructiveMigration()
.build()
}
@Provides
fun providePendingActivityDao(db: GeoZonerDatabase): PendingActivityDao =
db.pendingActivityDao()
@Provides
fun provideCachedZoneDao(db: GeoZonerDatabase): CachedZoneDao =
db.cachedZoneDao()
}

View File

@@ -0,0 +1,101 @@
package com.geozoner.app.di
import com.geozoner.app.BuildConfig
import com.geozoner.app.data.remote.AuthInterceptor
import com.geozoner.app.data.remote.TokenAuthenticator
import com.geozoner.app.data.remote.api.ActivityApi
import com.geozoner.app.data.remote.api.AuthApi
import com.geozoner.app.data.remote.api.FriendApi
import com.geozoner.app.data.remote.api.LeaderboardApi
import com.geozoner.app.data.remote.api.UserApi
import com.geozoner.app.data.remote.api.ZoneApi
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = true
}
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val builder = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
builder.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
}
return builder.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL.trimEnd('/') + "/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi =
retrofit.create(AuthApi::class.java)
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi =
retrofit.create(UserApi::class.java)
@Provides
@Singleton
fun provideActivityApi(retrofit: Retrofit): ActivityApi =
retrofit.create(ActivityApi::class.java)
@Provides
@Singleton
fun provideZoneApi(retrofit: Retrofit): ZoneApi =
retrofit.create(ZoneApi::class.java)
@Provides
@Singleton
fun provideFriendApi(retrofit: Retrofit): FriendApi =
retrofit.create(FriendApi::class.java)
@Provides
@Singleton
fun provideLeaderboardApi(retrofit: Retrofit): LeaderboardApi =
retrofit.create(LeaderboardApi::class.java)
}

View File

@@ -0,0 +1,66 @@
package com.geozoner.app.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.geozoner.app.GeoZonerApp
import com.geozoner.app.R
import com.geozoner.app.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
/**
* Foreground service for GPS tracking during activity recording.
* TODO: Implement FusedLocationProviderClient, GPS point collection,
* loop detection, and broadcasting location updates to UI.
*/
@AndroidEntryPoint
class LocationTrackingService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startTracking()
ACTION_STOP -> stopTracking()
}
return START_STICKY
}
private fun startTracking() {
val notification = createNotification()
startForeground(NOTIFICATION_ID, notification)
// TODO: Start GPS location updates via FusedLocationProviderClient
}
private fun stopTracking() {
// TODO: Stop GPS location updates, collect final track
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun createNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, GeoZonerApp.CHANNEL_TRACKING)
.setContentTitle(getString(R.string.tracking_notification_title))
.setContentText(getString(R.string.tracking_notification_text))
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_START = "ACTION_START_TRACKING"
const val ACTION_STOP = "ACTION_STOP_TRACKING"
private const val NOTIFICATION_ID = 1001
}
}

View File

@@ -0,0 +1,92 @@
package com.geozoner.app.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.geozoner.app.data.local.TokenStorage
import com.geozoner.app.ui.navigation.BottomNavItem
import com.geozoner.app.ui.navigation.NavGraph
import com.geozoner.app.ui.navigation.Screen
import com.geozoner.app.ui.theme.GeoZonerTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var tokenStorage: TokenStorage
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
GeoZonerTheme {
val isLoggedIn by tokenStorage.isLoggedIn.collectAsState(initial = false)
GeoZonerApp(isLoggedIn = isLoggedIn)
}
}
}
}
@Composable
fun GeoZonerApp(isLoggedIn: Boolean) {
val navController = rememberNavController()
val startDestination = if (isLoggedIn) Screen.Map.route else Screen.Login.route
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// Show bottom nav only on main screens
val showBottomBar = currentDestination?.hierarchy?.any { dest ->
BottomNavItem.entries.any { it.screen.route == dest.route }
} == true
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
BottomNavItem.entries.forEach { item ->
val selected = currentDestination?.hierarchy?.any {
it.route == item.screen.route
} == true
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = selected,
onClick = {
navController.navigate(item.screen.route) {
popUpTo(Screen.Map.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
},
) { innerPadding ->
NavGraph(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding),
)
}
}

View File

@@ -0,0 +1,53 @@
package com.geozoner.app.ui.activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geozoner.app.R
/**
* Activity tracking screen — will show real-time GPS track on map,
* distance/time counters, and start/stop controls.
* TODO: GPS foreground service, live map polyline, loop detection.
*/
@Composable
fun TrackActivityScreen(
onActivityFinished: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Track Activity",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "GPS tracking will be implemented here.\nSelect activity type, start recording, see live route.",
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onActivityFinished) {
Text(stringResource(R.string.start_activity))
}
}
}

View File

@@ -0,0 +1,77 @@
package com.geozoner.app.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geozoner.app.data.local.TokenStorage
import com.geozoner.app.data.remote.api.AuthApi
import com.geozoner.app.data.remote.api.UserApi
import com.geozoner.app.data.remote.dto.LoginRequest
import com.geozoner.app.data.remote.dto.RegisterRequest
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val isSuccess: Boolean = false,
)
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authApi: AuthApi,
private val userApi: UserApi,
private val tokenStorage: TokenStorage,
) : ViewModel() {
private val _loginState = MutableStateFlow(AuthUiState())
val loginState: StateFlow<AuthUiState> = _loginState.asStateFlow()
private val _registerState = MutableStateFlow(AuthUiState())
val registerState: StateFlow<AuthUiState> = _registerState.asStateFlow()
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = AuthUiState(isLoading = true)
try {
val tokens = authApi.login(LoginRequest(username, password))
tokenStorage.saveTokens(tokens.accessToken, tokens.refreshToken)
// Fetch user info to store user ID
val user = userApi.getMe()
tokenStorage.saveUserId(user.id)
_loginState.value = AuthUiState(isSuccess = true)
} catch (e: Exception) {
_loginState.value = AuthUiState(
error = e.message ?: "Login failed"
)
}
}
}
fun register(username: String, email: String, password: String) {
viewModelScope.launch {
_registerState.value = AuthUiState(isLoading = true)
try {
authApi.register(RegisterRequest(username, email, password))
_registerState.value = AuthUiState(isSuccess = true)
} catch (e: Exception) {
_registerState.value = AuthUiState(
error = e.message ?: "Registration failed"
)
}
}
}
fun clearLoginError() {
_loginState.value = _loginState.value.copy(error = null)
}
fun clearRegisterError() {
_registerState.value = _registerState.value.copy(error = null)
}
}

View File

@@ -0,0 +1,115 @@
package com.geozoner.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.geozoner.app.R
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit,
viewModel: AuthViewModel = hiltViewModel(),
) {
val state by viewModel.loginState.collectAsState()
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
LaunchedEffect(state.isSuccess) {
if (state.isSuccess) onLoginSuccess()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.login_title),
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password_hint)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth(),
)
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { viewModel.login(username.trim(), password) },
enabled = username.isNotBlank() && password.isNotBlank() && !state.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.login_button))
}
}
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = onNavigateToRegister) {
Text(stringResource(R.string.switch_to_register))
}
}
}

View File

@@ -0,0 +1,127 @@
package com.geozoner.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.geozoner.app.R
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onNavigateToLogin: () -> Unit,
viewModel: AuthViewModel = hiltViewModel(),
) {
val state by viewModel.registerState.collectAsState()
var username by rememberSaveable { mutableStateOf("") }
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
LaunchedEffect(state.isSuccess) {
if (state.isSuccess) onRegisterSuccess()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.register_title),
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.email_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password_hint)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth(),
)
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { viewModel.register(username.trim(), email.trim(), password) },
enabled = username.isNotBlank() && email.isNotBlank() && password.isNotBlank() && !state.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.register_button))
}
}
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = onNavigateToLogin) {
Text(stringResource(R.string.switch_to_login))
}
}
}

View File

@@ -0,0 +1,52 @@
package com.geozoner.app.ui.friends
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geozoner.app.R
/**
* Friends screen — list friends, add by username, remove.
* TODO: Fetch from GET /friends, POST /friends, DELETE /friends/{id}.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FriendsScreen(
onNavigateBack: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(stringResource(R.string.friends_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Friends list will appear here",
style = MaterialTheme.typography.bodyLarge,
)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.geozoner.app.ui.history
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Activity history screen — list past activities with stats.
* TODO: Fetch from GET /activities, show distance/duration/zone area.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActivityHistoryScreen(
onNavigateBack: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Activity History") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Activity history will appear here",
style = MaterialTheme.typography.bodyLarge,
)
}
}
}

View File

@@ -0,0 +1,46 @@
package com.geozoner.app.ui.leaderboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geozoner.app.R
/**
* Leaderboard screen — shows friend ranking by total points.
* TODO: Fetch from GET /leaderboard, display ranked list with stats.
*/
@Composable
fun LeaderboardScreen(
onNavigateToFriends: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.leaderboard_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Friend leaderboard will appear here",
style = MaterialTheme.typography.bodyLarge,
)
TextButton(onClick = onNavigateToFriends) {
Text(stringResource(R.string.add_friend))
}
}
}

View File

@@ -0,0 +1,26 @@
package com.geozoner.app.ui.map
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* Map screen — will display Mapbox map with zone polygons.
* TODO: Integrate Mapbox SDK, render own + friend zones, live GPS track.
*/
@Composable
fun MapScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "Map Screen\n(Mapbox integration pending)",
style = MaterialTheme.typography.headlineMedium,
)
}
}

View File

@@ -0,0 +1,106 @@
package com.geozoner.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.geozoner.app.ui.auth.LoginScreen
import com.geozoner.app.ui.auth.RegisterScreen
import com.geozoner.app.ui.map.MapScreen
import com.geozoner.app.ui.activity.TrackActivityScreen
import com.geozoner.app.ui.leaderboard.LeaderboardScreen
import com.geozoner.app.ui.profile.ProfileScreen
import com.geozoner.app.ui.friends.FriendsScreen
import com.geozoner.app.ui.history.ActivityHistoryScreen
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
// Auth flow
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Screen.Map.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
},
onNavigateToRegister = {
navController.navigate(Screen.Register.route)
},
)
}
composable(Screen.Register.route) {
RegisterScreen(
onRegisterSuccess = {
navController.navigate(Screen.Login.route) {
popUpTo(Screen.Register.route) { inclusive = true }
}
},
onNavigateToLogin = {
navController.popBackStack()
},
)
}
// Main flow
composable(Screen.Map.route) {
MapScreen()
}
composable(Screen.TrackActivity.route) {
TrackActivityScreen(
onActivityFinished = {
navController.navigate(Screen.Map.route) {
popUpTo(Screen.Map.route) { inclusive = true }
}
},
)
}
composable(Screen.Leaderboard.route) {
LeaderboardScreen(
onNavigateToFriends = {
navController.navigate(Screen.Friends.route)
},
)
}
composable(Screen.Profile.route) {
ProfileScreen(
onLogout = {
navController.navigate(Screen.Login.route) {
popUpTo(0) { inclusive = true }
}
},
onNavigateToHistory = {
navController.navigate(Screen.ActivityHistory.route)
},
onNavigateToFriends = {
navController.navigate(Screen.Friends.route)
},
)
}
composable(Screen.Friends.route) {
FriendsScreen(
onNavigateBack = { navController.popBackStack() },
)
}
composable(Screen.ActivityHistory.route) {
ActivityHistoryScreen(
onNavigateBack = { navController.popBackStack() },
)
}
}
}

View File

@@ -0,0 +1,36 @@
package com.geozoner.app.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.ui.graphics.vector.ImageVector
sealed class Screen(val route: String) {
// Auth
data object Login : Screen("login")
data object Register : Screen("register")
// Main
data object Map : Screen("map")
data object TrackActivity : Screen("track_activity")
data object Leaderboard : Screen("leaderboard")
data object Profile : Screen("profile")
data object Friends : Screen("friends")
data object ActivityHistory : Screen("activity_history")
data object ActivityDetail : Screen("activity_detail/{activityId}") {
fun createRoute(activityId: String) = "activity_detail/$activityId"
}
}
enum class BottomNavItem(
val screen: Screen,
val icon: ImageVector,
val label: String,
) {
Map(Screen.Map, Icons.Default.Map, "Map"),
Activity(Screen.TrackActivity, Icons.AutoMirrored.Filled.DirectionsRun, "Activity"),
Leaderboard(Screen.Leaderboard, Icons.Default.EmojiEvents, "Leaderboard"),
Profile(Screen.Profile, Icons.Default.Person, "Profile"),
}

View File

@@ -0,0 +1,68 @@
package com.geozoner.app.ui.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geozoner.app.R
/**
* Profile screen — shows user stats, avatar, links to friends and history.
* TODO: Fetch from GET /users/me and GET /users/{id}/stats.
*/
@Composable
fun ProfileScreen(
onLogout: () -> Unit,
onNavigateToHistory: () -> Unit,
onNavigateToFriends: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.profile_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "User stats will appear here",
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedButton(onClick = onNavigateToHistory) {
Text("Activity History")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onNavigateToFriends) {
Text(stringResource(R.string.friends_title))
}
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onLogout) {
Text(stringResource(R.string.logout))
}
}
}

View File

@@ -0,0 +1,56 @@
package com.geozoner.app.ui.theme
import androidx.compose.ui.graphics.Color
// Primary — Green (territory / nature)
val Green10 = Color(0xFF0A3A0A)
val Green20 = Color(0xFF1B5E20)
val Green30 = Color(0xFF2E7D32)
val Green40 = Color(0xFF388E3C)
val Green80 = Color(0xFFA5D6A7)
val Green90 = Color(0xFFC8E6C9)
// Secondary — Cyan (water / exploration)
val Cyan10 = Color(0xFF003F47)
val Cyan20 = Color(0xFF006064)
val Cyan30 = Color(0xFF00838F)
val Cyan40 = Color(0xFF00ACC1)
val Cyan80 = Color(0xFF80DEEA)
val Cyan90 = Color(0xFFB2EBF2)
// Tertiary — Amber (achievements / energy)
val Amber10 = Color(0xFF3E2700)
val Amber20 = Color(0xFF5D3F00)
val Amber30 = Color(0xFF7C5800)
val Amber40 = Color(0xFFFFA000)
val Amber80 = Color(0xFFFFE082)
val Amber90 = Color(0xFFFFF8E1)
// Error
val Red10 = Color(0xFF410002)
val Red20 = Color(0xFF690005)
val Red30 = Color(0xFF93000A)
val Red40 = Color(0xFFB3261E)
val Red80 = Color(0xFFF2B8B5)
val Red90 = Color(0xFFF9DEDC)
// Neutral
val Grey10 = Color(0xFF1C1B1F)
val Grey20 = Color(0xFF313033)
val Grey90 = Color(0xFFE6E1E5)
val Grey95 = Color(0xFFF4EFF4)
val Grey99 = Color(0xFFFFFBFE)
// Zone colors — one per player on the map
val ZoneColors = listOf(
Color(0xFF4CAF50), // Green
Color(0xFF2196F3), // Blue
Color(0xFFFF9800), // Orange
Color(0xFF9C27B0), // Purple
Color(0xFFF44336), // Red
Color(0xFF009688), // Teal
Color(0xFFFF5722), // Deep Orange
Color(0xFF3F51B5), // Indigo
Color(0xFFCDDC39), // Lime
Color(0xFFE91E63), // Pink
)

View File

@@ -0,0 +1,83 @@
package com.geozoner.app.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = Green40,
onPrimary = Grey99,
primaryContainer = Green90,
onPrimaryContainer = Green10,
secondary = Cyan40,
onSecondary = Grey99,
secondaryContainer = Cyan90,
onSecondaryContainer = Cyan10,
tertiary = Amber40,
onTertiary = Grey99,
tertiaryContainer = Amber90,
onTertiaryContainer = Amber10,
error = Red40,
onError = Grey99,
errorContainer = Red90,
onErrorContainer = Red10,
background = Grey99,
onBackground = Grey10,
surface = Grey99,
onSurface = Grey10,
surfaceVariant = Grey95,
onSurfaceVariant = Grey20,
)
private val DarkColorScheme = darkColorScheme(
primary = Green80,
onPrimary = Green20,
primaryContainer = Green30,
onPrimaryContainer = Green90,
secondary = Cyan80,
onSecondary = Cyan20,
secondaryContainer = Cyan30,
onSecondaryContainer = Cyan90,
tertiary = Amber80,
onTertiary = Amber20,
tertiaryContainer = Amber30,
onTertiaryContainer = Amber90,
error = Red80,
onError = Red20,
errorContainer = Red30,
onErrorContainer = Red90,
background = Grey10,
onBackground = Grey90,
surface = Grey10,
onSurface = Grey90,
surfaceVariant = Grey20,
onSurfaceVariant = Grey90,
)
@Composable
fun GeoZonerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}

View File

@@ -0,0 +1,70 @@
package com.geozoner.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)

View File

@@ -0,0 +1,27 @@
package com.geozoner.app.util
/**
* App-wide constants derived from the GeoZoner PRD.
*/
object Constants {
// GPS settings
const val GPS_INTERVAL_FOREGROUND_MS = 3_000L
const val GPS_INTERVAL_BACKGROUND_MS = 5_000L
const val MAX_HDOP = 4.0
const val MAX_GPS_ACCURACY_M = 20f
// Zone rules
const val LOOP_CLOSURE_RADIUS_M = 50.0
const val MIN_ZONE_AREA_M2 = 5_000.0
// Anti-cheat
const val MAX_RUN_SPEED_KMH = 60.0
// Scoring
const val POINTS_PER_1000_M2 = 1
const val CAPTURE_BONUS_PTS = 50
const val ACTIVITY_BONUS_PTS_PER_KM = 10
const val DEFENSE_BONUS_MULTIPLIER = 0.20
const val STREAK_MULTIPLIER_PER_DAY = 0.05
const val MAX_STREAK_MULTIPLIER = 2.0
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="geozoner_primary">#FF2E7D32</color>
<color name="geozoner_primary_dark">#FF1B5E20</color>
<color name="geozoner_secondary">#FF00ACC1</color>
<color name="geozoner_background">#FFFAFAFA</color>
</resources>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GeoZoner</string>
<!-- Navigation -->
<string name="nav_map">Map</string>
<string name="nav_activity">Activity</string>
<string name="nav_leaderboard">Leaderboard</string>
<string name="nav_profile">Profile</string>
<!-- Auth -->
<string name="login_title">Welcome back</string>
<string name="register_title">Create account</string>
<string name="username_hint">Username</string>
<string name="email_hint">Email</string>
<string name="password_hint">Password</string>
<string name="login_button">Log in</string>
<string name="register_button">Sign up</string>
<string name="switch_to_register">Don\'t have an account? Sign up</string>
<string name="switch_to_login">Already have an account? Log in</string>
<!-- Activity Tracking -->
<string name="start_activity">Start Activity</string>
<string name="finish_activity">Finish</string>
<string name="loop_detected">Loop detected ✓</string>
<string name="select_activity_type">Select activity type</string>
<string name="activity_run">Run</string>
<string name="activity_cycle">Cycle</string>
<string name="activity_walk">Walk</string>
<string name="activity_hike">Hike</string>
<!-- Tracking notification -->
<string name="tracking_notification_channel">Activity Tracking</string>
<string name="tracking_notification_title">Recording activity</string>
<string name="tracking_notification_text">GeoZoner is tracking your route</string>
<!-- Friends -->
<string name="friends_title">Friends</string>
<string name="add_friend">Add friend</string>
<string name="add_friend_hint">Enter username</string>
<!-- Leaderboard -->
<string name="leaderboard_title">Leaderboard</string>
<!-- Profile -->
<string name="profile_title">Profile</string>
<string name="total_area">Total Area</string>
<string name="total_points">Total Points</string>
<string name="zone_count">Zones</string>
<string name="activity_count">Activities</string>
<string name="logout">Log out</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GeoZoner" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
</full-backup-content>

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

19
gradle.properties Normal file
View File

@@ -0,0 +1,19 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.configuration-cache=true
# Use Android Studio's bundled JDK (JBR 21) to run Gradle
org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr
# AndroidX
android.useAndroidX=true
# Kotlin
kotlin.code.style=official
# Non-transitive R classes
android.nonTransitiveRClass=true
# Mapbox token (set in local.properties or CI env)
# MAPBOX_DOWNLOADS_TOKEN=sk.xxx

116
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,116 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
# AndroidX
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
navigationCompose = "2.8.5"
datastore = "1.1.1"
workRuntime = "2.10.0"
room = "2.6.1"
securityCrypto = "1.1.0-alpha06"
healthConnect = "1.1.0-alpha11"
# Compose
composeBom = "2024.12.01"
# Hilt
hilt = "2.53.1"
hiltNavigationCompose = "1.2.0"
# Networking
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
retrofitKotlinxSerialization = "1.0.0"
# Mapbox
mapbox = "11.8.2"
# Firebase
firebaseBom = "33.7.0"
# Coroutines
coroutines = "1.9.0"
# Image loading
coil = "2.7.0"
# Testing
junit = "4.13.2"
junitExt = "1.2.1"
espresso = "3.6.1"
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
# Compose BOM
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerialization" }
# Mapbox
mapbox-maps = { group = "com.mapbox.maps", name = "android", version.ref = "mapbox" }
# Firebase
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
# Image loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
junit-ext = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

135
gradlew vendored Normal file
View File

@@ -0,0 +1,135 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld -- "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NonStop* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1 ; then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks://gradle.org/badging
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# temporary variables to preserve quoting and provide a way for the user to
# override the default options.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" is unset, for security reasons
unset GREP_OPTIONS
exec "$JAVACMD" "$@"

90
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,90 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%\bin\java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %OS%==Windows_NT endlocal
:omega
exit /b %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%

34
settings.gradle.kts Normal file
View File

@@ -0,0 +1,34 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
credentials.username = "mapbox"
credentials.password = providers.gradleProperty("MAPBOX_DOWNLOADS_TOKEN")
.orElse(providers.environmentVariable("MAPBOX_DOWNLOADS_TOKEN"))
.getOrElse("")
authentication {
create<BasicAuthentication>("basic")
}
}
}
}
rootProject.name = "GeoZoner"
include(":app")