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:
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal 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
148
app/build.gradle.kts
Normal 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
34
app/proguard-rules.pro
vendored
Normal 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 *
|
||||
67
app/src/main/AndroidManifest.xml
Normal file
67
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
43
app/src/main/java/com/geozoner/app/GeoZonerApp.kt
Normal file
43
app/src/main/java/com/geozoner/app/GeoZonerApp.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.geozoner.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MessageResponse(
|
||||
val detail: String,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
38
app/src/main/java/com/geozoner/app/di/DatabaseModule.kt
Normal file
38
app/src/main/java/com/geozoner/app/di/DatabaseModule.kt
Normal 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()
|
||||
}
|
||||
101
app/src/main/java/com/geozoner/app/di/NetworkModule.kt
Normal file
101
app/src/main/java/com/geozoner/app/di/NetworkModule.kt
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
92
app/src/main/java/com/geozoner/app/ui/MainActivity.kt
Normal file
92
app/src/main/java/com/geozoner/app/ui/MainActivity.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/geozoner/app/ui/auth/AuthViewModel.kt
Normal file
77
app/src/main/java/com/geozoner/app/ui/auth/AuthViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
115
app/src/main/java/com/geozoner/app/ui/auth/LoginScreen.kt
Normal file
115
app/src/main/java/com/geozoner/app/ui/auth/LoginScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/src/main/java/com/geozoner/app/ui/auth/RegisterScreen.kt
Normal file
127
app/src/main/java/com/geozoner/app/ui/auth/RegisterScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt
Normal file
26
app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
106
app/src/main/java/com/geozoner/app/ui/navigation/NavGraph.kt
Normal file
106
app/src/main/java/com/geozoner/app/ui/navigation/NavGraph.kt
Normal 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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/geozoner/app/ui/navigation/Screen.kt
Normal file
36
app/src/main/java/com/geozoner/app/ui/navigation/Screen.kt
Normal 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"),
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/com/geozoner/app/ui/theme/Color.kt
Normal file
56
app/src/main/java/com/geozoner/app/ui/theme/Color.kt
Normal 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
|
||||
)
|
||||
83
app/src/main/java/com/geozoner/app/ui/theme/Theme.kt
Normal file
83
app/src/main/java/com/geozoner/app/ui/theme/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
70
app/src/main/java/com/geozoner/app/ui/theme/Type.kt
Normal file
70
app/src/main/java/com/geozoner/app/ui/theme/Type.kt
Normal 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,
|
||||
),
|
||||
)
|
||||
27
app/src/main/java/com/geozoner/app/util/Constants.kt
Normal file
27
app/src/main/java/com/geozoner/app/util/Constants.kt
Normal 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
|
||||
}
|
||||
7
app/src/main/res/values/colors.xml
Normal file
7
app/src/main/res/values/colors.xml
Normal 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>
|
||||
52
app/src/main/res/values/strings.xml
Normal file
52
app/src/main/res/values/strings.xml
Normal 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>
|
||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal 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>
|
||||
5
app/src/main/res/xml/backup_rules.xml
Normal file
5
app/src/main/res/xml/backup_rules.xml
Normal 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
9
build.gradle.kts
Normal 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
19
gradle.properties
Normal 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
116
gradle/libs.versions.toml
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
135
gradlew
vendored
Normal 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
90
gradlew.bat
vendored
Normal 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
34
settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user