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:
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>
|
||||
Reference in New Issue
Block a user