test #7
@@ -18,7 +18,6 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
// Incrémentation automatique du versionCode basé sur le temps
|
|
||||||
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
||||||
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
|
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
// Chargement des propriétés depuis local.properties
|
|
||||||
val keystoreProperties = Properties()
|
val keystoreProperties = Properties()
|
||||||
val keystorePropertiesFile = rootProject.file("local.properties")
|
val keystorePropertiesFile = rootProject.file("local.properties")
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
@@ -35,9 +33,9 @@ android {
|
|||||||
|
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
|
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
|
||||||
getByName("debug").storePassword = "android"
|
storePassword = "android"
|
||||||
getByName("debug").keyAlias = "androiddebugkey"
|
keyAlias = "androiddebugkey"
|
||||||
getByName("debug").keyPassword = "android"
|
keyPassword = "android"
|
||||||
}
|
}
|
||||||
|
|
||||||
create("release") {
|
create("release") {
|
||||||
@@ -53,7 +51,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true // Activer l'offuscation
|
isMinifyEnabled = true
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
@@ -111,9 +109,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.compose.material.icons.extended)
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
// SDK Firebase App Distribution COMPLET (API + Implémentation)
|
|
||||||
implementation(libs.firebase.appdistribution)
|
implementation(libs.firebase.appdistribution)
|
||||||
|
|
||||||
implementation(libs.google.generativeai)
|
implementation(libs.google.generativeai)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
@@ -133,17 +129,13 @@ dependencies {
|
|||||||
implementation(libs.firebase.firestore)
|
implementation(libs.firebase.firestore)
|
||||||
implementation(libs.firebase.appcheck.playintegrity)
|
implementation(libs.firebase.appcheck.playintegrity)
|
||||||
|
|
||||||
// Barcode Scanning & Camera
|
|
||||||
implementation(libs.mlkit.barcode.scanning)
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
implementation(libs.androidx.camera.core)
|
implementation(libs.androidx.camera.core)
|
||||||
implementation(libs.androidx.camera.camera2)
|
implementation(libs.androidx.camera.camera2)
|
||||||
implementation(libs.androidx.camera.lifecycle)
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
implementation(libs.androidx.camera.view)
|
implementation(libs.androidx.camera.view)
|
||||||
|
|
||||||
// PDF generation
|
|
||||||
implementation(libs.itext7.core)
|
implementation(libs.itext7.core)
|
||||||
|
|
||||||
// Security
|
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
|||||||
@@ -100,24 +100,28 @@ fun AuthWrapper(dao: AppDao) {
|
|||||||
if (email != null && email.isNotEmpty()) {
|
if (email != null && email.isNotEmpty()) {
|
||||||
Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'")
|
Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'")
|
||||||
try {
|
try {
|
||||||
// On spécifie explicitement la base de données "scan-wich"
|
|
||||||
val db = FirebaseFirestore.getInstance("scan-wich")
|
val db = FirebaseFirestore.getInstance("scan-wich")
|
||||||
val docRef = db.collection("authorized_users").document(email)
|
val docRef = db.collection("authorized_users").document(email)
|
||||||
val document = docRef.get().await()
|
val document = docRef.get().await()
|
||||||
|
|
||||||
if (document.exists()) {
|
if (document.exists()) {
|
||||||
Log.d("Auth", "Accès AUTORISÉ pour '$email'. Document trouvé.")
|
Log.d("Auth", "Accès AUTORISÉ pour '$email'.")
|
||||||
|
|
||||||
|
// --- NOUVEAU : Rapatriement des données ---
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
FirebaseUtils.fetchAllDataFromFirestore(dao)
|
||||||
|
}
|
||||||
|
|
||||||
isAuthorized = true
|
isAuthorized = true
|
||||||
} else {
|
} else {
|
||||||
Log.w("Auth", "Accès REFUSÉ pour '$email'. Document NON trouvé.")
|
Log.w("Auth", "Accès REFUSÉ pour '$email'.")
|
||||||
isAuthorized = false
|
isAuthorized = false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Auth", "Erreur critique Firestore. Vérifiez les règles de sécurité.", e)
|
Log.e("Auth", "Erreur critique Firestore.", e)
|
||||||
isAuthorized = false
|
isAuthorized = false
|
||||||
}
|
}
|
||||||
} else if (firebaseUser != null) {
|
} else if (firebaseUser != null) {
|
||||||
Log.w("Auth", "L'utilisateur est connecté mais son email est vide.")
|
|
||||||
isAuthorized = false
|
isAuthorized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,7 @@ fun AuthWrapper(dao: AppDao) {
|
|||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text("Vérification de l'accès...")
|
Text("Vérification et synchronisation...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,21 +51,29 @@ data class FavoriteMeal(
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface AppDao {
|
interface AppDao {
|
||||||
@Insert suspend fun insertMeal(meal: Meal): Long
|
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMeal(meal: Meal): Long
|
||||||
|
|
||||||
@Delete suspend fun deleteMeal(meal: Meal)
|
@Delete suspend fun deleteMeal(meal: Meal)
|
||||||
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||||
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
|
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
|
||||||
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||||
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
|
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
|
||||||
|
|
||||||
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
|
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertGlycemia(glycemia: Glycemia): Long
|
||||||
|
|
||||||
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
|
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
|
||||||
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||||
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
|
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
|
||||||
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||||
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
|
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSports(sports: List<SportActivity>)
|
||||||
|
|
||||||
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
|
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
|
||||||
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||||
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
|
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import android.util.Log
|
|||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
|
||||||
object FirebaseUtils {
|
object FirebaseUtils {
|
||||||
private fun getDb(): FirebaseFirestore {
|
private fun getDb(): FirebaseFirestore {
|
||||||
return try {
|
return try {
|
||||||
// Tente d'utiliser la base de données nommée "scan-wich"
|
|
||||||
FirebaseFirestore.getInstance("scan-wich")
|
FirebaseFirestore.getInstance("scan-wich")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Repli sur la base par défaut si "scan-wich" n'est pas configurée comme base secondaire
|
|
||||||
FirebaseFirestore.getInstance()
|
FirebaseFirestore.getInstance()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,4 +47,34 @@ object FirebaseUtils {
|
|||||||
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
|
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOUVELLE FONCTION : Rapatrie toutes les données depuis le Cloud
|
||||||
|
suspend fun fetchAllDataFromFirestore(dao: AppDao) {
|
||||||
|
val user = FirebaseAuth.getInstance().currentUser ?: return
|
||||||
|
val db = getDb()
|
||||||
|
val userDoc = db.collection("users").document(user.uid)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer les repas
|
||||||
|
val mealsSnapshot = userDoc.collection("meals").get().await()
|
||||||
|
val meals = mealsSnapshot.toObjects(Meal::class.java)
|
||||||
|
meals.forEach { dao.insertMeal(it) }
|
||||||
|
Log.d("FirestoreSync", "${meals.size} repas récupérés")
|
||||||
|
|
||||||
|
// 2. Récupérer la glycémie
|
||||||
|
val glycemiaSnapshot = userDoc.collection("glycemia").get().await()
|
||||||
|
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
|
||||||
|
glycemia.forEach { dao.insertGlycemia(it) }
|
||||||
|
Log.d("FirestoreSync", "${glycemia.size} glycémies récupérées")
|
||||||
|
|
||||||
|
// 3. Récupérer le sport
|
||||||
|
val sportsSnapshot = userDoc.collection("sports").get().await()
|
||||||
|
val sports = sportsSnapshot.toObjects(SportActivity::class.java)
|
||||||
|
dao.insertSports(sports)
|
||||||
|
Log.d("FirestoreSync", "${sports.size} activités sportives récupérées")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FirestoreSync", "Erreur lors du rapatriement des données: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.google.firebase.Firebase
|
|||||||
import com.google.firebase.appcheck.appCheck
|
import com.google.firebase.appcheck.appCheck
|
||||||
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
|
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
|
||||||
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
|
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
|
||||||
|
import com.google.firebase.functions.functions
|
||||||
import com.google.firebase.initialize
|
import com.google.firebase.initialize
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -58,20 +59,34 @@ class MainActivity : ComponentActivity() {
|
|||||||
val code = data.getQueryParameter("code")
|
val code = data.getQueryParameter("code")
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
val prefs = ApiClient.getEncryptedPrefs(this)
|
val prefs = ApiClient.getEncryptedPrefs(this)
|
||||||
val clientId = prefs.getString("strava_client_id", "") ?: ""
|
val functions = Firebase.functions
|
||||||
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
|
|
||||||
if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
|
val requestData = hashMapOf(
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
"code" to code
|
||||||
try {
|
)
|
||||||
val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
|
|
||||||
prefs.edit {
|
functions.getHttpsCallable("exchangeStravaToken")
|
||||||
putString("strava_token", response.accessToken)
|
.call(requestData)
|
||||||
putString("strava_refresh_token", response.refreshToken)
|
.addOnSuccessListener { result ->
|
||||||
putLong("strava_expires_at", response.expiresAt)
|
val res = result.data as? Map<*, *>
|
||||||
|
if (res != null) {
|
||||||
|
val accessToken = res["access_token"] as? String
|
||||||
|
val refreshToken = res["refresh_token"] as? String
|
||||||
|
val expiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
|
||||||
|
|
||||||
|
if (accessToken != null && refreshToken != null) {
|
||||||
|
prefs.edit {
|
||||||
|
putString("strava_token", accessToken)
|
||||||
|
putString("strava_refresh_token", refreshToken)
|
||||||
|
putLong("strava_expires_at", expiresAt)
|
||||||
|
}
|
||||||
|
Log.d("StravaAuth", "Token exchange successful")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { Log.e("StravaAuth", "Exchange failed: ${e.message}") }
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
Log.e("StravaAuth", "Cloud Function exchange failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package com.example.scanwich
|
package com.example.scanwich
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.google.firebase.Firebase
|
||||||
|
import com.google.firebase.functions.functions
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -13,6 +19,7 @@ import retrofit2.http.*
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
|
||||||
// --- OPEN FOOD FACTS API ---
|
// --- OPEN FOOD FACTS API ---
|
||||||
data class OffProductResponse(val status: Int, val product: OffProduct?)
|
data class OffProductResponse(val status: Int, val product: OffProduct?)
|
||||||
@@ -64,12 +71,15 @@ object ApiClient {
|
|||||||
.create(StravaApi::class.java)
|
.create(StravaApi::class.java)
|
||||||
|
|
||||||
val offApi: OffApi = Retrofit.Builder()
|
val offApi: OffApi = Retrofit.Builder()
|
||||||
.baseUrl("https://world.openfoodfacts.org/api/v2/")
|
// On force l'utilisation de l'API française pour les codes-barres
|
||||||
|
.baseUrl("https://fr.openfoodfacts.org/api/v2/")
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
.create(OffApi::class.java)
|
.create(OffApi::class.java)
|
||||||
|
|
||||||
|
private const val STRAVA_CLIENT_ID = "203805"
|
||||||
|
|
||||||
fun getEncryptedPrefs(context: Context): SharedPreferences {
|
fun getEncryptedPrefs(context: Context): SharedPreferences {
|
||||||
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||||
return EncryptedSharedPreferences.create(
|
return EncryptedSharedPreferences.create(
|
||||||
@@ -84,19 +94,38 @@ object ApiClient {
|
|||||||
suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
|
suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
|
||||||
val stravaToken = prefs.getString("strava_token", null) ?: return null
|
val stravaToken = prefs.getString("strava_token", null) ?: return null
|
||||||
val expiresAt = prefs.getLong("strava_expires_at", 0)
|
val expiresAt = prefs.getLong("strava_expires_at", 0)
|
||||||
if (System.currentTimeMillis() / 1000 >= expiresAt) {
|
|
||||||
|
if (System.currentTimeMillis() / 1000 >= (expiresAt - 300)) {
|
||||||
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
|
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
|
||||||
val clientId = prefs.getString("strava_client_id", "") ?: ""
|
|
||||||
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
|
|
||||||
return try {
|
return try {
|
||||||
val res = stravaApi.refreshToken(clientId, clientSecret, refreshToken)
|
val functions = Firebase.functions
|
||||||
prefs.edit {
|
val data = hashMapOf("refreshToken" to refreshToken)
|
||||||
putString("strava_token", res.accessToken)
|
|
||||||
putString("strava_refresh_token", res.refreshToken)
|
val result = functions.getHttpsCallable("refreshStravaToken")
|
||||||
putLong("strava_expires_at", res.expiresAt)
|
.call(data)
|
||||||
|
.await()
|
||||||
|
|
||||||
|
val res = result.data as? Map<*, *>
|
||||||
|
if (res != null) {
|
||||||
|
val newAccessToken = res["access_token"] as? String
|
||||||
|
val newRefreshToken = res["refresh_token"] as? String
|
||||||
|
val newExpiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
|
||||||
|
|
||||||
|
if (newAccessToken != null && newRefreshToken != null) {
|
||||||
|
prefs.edit {
|
||||||
|
putString("strava_token", newAccessToken)
|
||||||
|
putString("strava_refresh_token", newRefreshToken)
|
||||||
|
putLong("strava_expires_at", newExpiresAt)
|
||||||
|
}
|
||||||
|
return newAccessToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.accessToken
|
null
|
||||||
} catch (e: Exception) { null }
|
} catch (e: Exception) {
|
||||||
|
Log.e("StravaAuth", "Refresh failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return stravaToken
|
return stravaToken
|
||||||
}
|
}
|
||||||
@@ -125,4 +154,18 @@ object ApiClient {
|
|||||||
val durationHours = activity.movingTime / 3600.0
|
val durationHours = activity.movingTime / 3600.0
|
||||||
return met * weightKg * durationHours
|
return met * weightKg * durationHours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun launchStravaAuth(context: Context) {
|
||||||
|
val intentUri = Uri.parse("https://www.strava.com/oauth/mobile/authorize")
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("client_id", STRAVA_CLIENT_ID)
|
||||||
|
.appendQueryParameter("redirect_uri", "coloricam://localhost")
|
||||||
|
.appendQueryParameter("response_type", "code")
|
||||||
|
.appendQueryParameter("approval_prompt", "auto")
|
||||||
|
.appendQueryParameter("scope", "activity:read_all")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, intentUri)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.example.scanwich
|
package com.example.scanwich
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -16,16 +14,27 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
|
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
|
||||||
var isEditing by remember { mutableStateOf(false) }
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") }
|
// État réactif pour la connexion Strava
|
||||||
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") }
|
var isStravaConnected by remember { mutableStateOf(prefs.contains("strava_token")) }
|
||||||
val isStravaConnected = prefs.contains("strava_token")
|
|
||||||
|
// Écouteur pour mettre à jour l'état si le token change
|
||||||
|
DisposableEffect(prefs) {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
|
||||||
|
if (key == "strava_token") {
|
||||||
|
isStravaConnected = p.contains("strava_token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
onDispose {
|
||||||
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
SetupScreen(prefs) {
|
SetupScreen(prefs) {
|
||||||
@@ -48,51 +57,25 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
|
|||||||
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
|
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Text("Configuration Strava", style = MaterialTheme.typography.titleMedium)
|
Text("Intégrations", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
if (isStravaConnected) {
|
||||||
value = stravaClientId,
|
|
||||||
onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } },
|
|
||||||
label = { Text("Strava Client ID") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = stravaClientSecret,
|
|
||||||
onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } },
|
|
||||||
label = { Text("Strava Client Secret") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
if (!isStravaConnected) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
|
|
||||||
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri())
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Connecter Strava")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
remove("strava_token")
|
remove("strava_token")
|
||||||
remove("strava_refresh_token")
|
remove("strava_refresh_token")
|
||||||
}
|
}
|
||||||
|
// L'écouteur mettra `isStravaConnected` à jour automatiquement
|
||||||
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("Déconnecter Strava (Connecté)")
|
Text("Déconnecter Strava (Connecté)")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text("Aucune intégration active. Allez dans l'onglet 'Sport' pour connecter Strava.", style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.scanwich
|
package com.example.scanwich
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -8,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -23,21 +25,51 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
|
fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
||||||
val sports by dao.getAllSports().collectAsState(initial = emptyList())
|
val sports by dao.getAllSports().collectAsState(initial = emptyList())
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// État réactif pour la connexion Strava
|
||||||
|
var isConnectedToStrava by remember {
|
||||||
|
mutableStateOf(prefs.getString("strava_token", null) != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écouteur de changements pour les SharedPreferences
|
||||||
|
DisposableEffect(prefs) {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
|
||||||
|
if (key == "strava_token") {
|
||||||
|
isConnectedToStrava = p.getString("strava_token", null) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
onDispose {
|
||||||
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
|
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
Button(
|
if (!isConnectedToStrava) {
|
||||||
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
onClick = { ApiClient.launchStravaAuth(context) },
|
||||||
) {
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
Icon(Icons.Default.Refresh, null)
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFC6100)) // Strava orange
|
||||||
Spacer(Modifier.width(8.dp))
|
) {
|
||||||
Text("Synchroniser Strava")
|
Icon(Icons.Default.Sync, null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Se connecter à Strava")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Synchroniser les activités")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -50,7 +82,8 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
|
|||||||
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||||
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
|
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Text("${activity.type} - ${(activity.distance / 1000).format(2)} km")
|
val distanceInKm = activity.distance / 1000
|
||||||
|
Text("${activity.type} - ${String.format(Locale.getDefault(), "%.2f", distanceInKm)} km")
|
||||||
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,11 +92,11 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPreferences, scope: CoroutineScope, context: Context) {
|
private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val token = ApiClient.getValidStravaToken(prefs)
|
val token = ApiClient.getValidStravaToken(prefs)
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Erreur de connexion Strava", Toast.LENGTH_LONG).show()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +116,7 @@ private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPrefe
|
|||||||
).toFloat(),
|
).toFloat(),
|
||||||
date = ApiClient.parseStravaDate(it.startDate)
|
date = ApiClient.parseStravaDate(it.startDate)
|
||||||
)
|
)
|
||||||
syncSportToFirestore(activity) // Firestore Sync
|
syncSportToFirestore(activity)
|
||||||
activity
|
activity
|
||||||
}
|
}
|
||||||
dao.insertSports(sportActivities)
|
dao.insertSports(sportActivities)
|
||||||
|
|||||||
@@ -2,48 +2,49 @@
|
|||||||
|
|
||||||
**Changements majeurs de la version actuelle :**
|
**Changements majeurs de la version actuelle :**
|
||||||
|
|
||||||
🎨 **Améliorations de l'Interface Utilisateur :**
|
🇫🇷 **Expérience 100% en Français :**
|
||||||
- **Correction du Thème :** Résolution d'un problème de contraste sur l'écran de configuration du profil qui rendait le texte illisible (texte jaune sur fond blanc). L'écran respecte désormais correctement le thème de l'application.
|
- **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français grâce à des instructions renforcées côté serveur.
|
||||||
|
- **Scanner Localisé :** Utilisation forcée de la base de données française d'Open Food Facts pour le scan de codes-barres, garantissant des noms de produits familiers.
|
||||||
|
|
||||||
🛡️ **Refonte de l'Architecture de Sécurité et de Données :**
|
🤖 **Analyse IA plus Robuste :**
|
||||||
- **Gestion des accès centralisée :** L'ancien système d'utilisateurs autorisés codé en dur dans l'application a été supprimé. L'accès est désormais contrôlé de manière sécurisée et dynamique via une liste d'autorisation sur le serveur Firebase Firestore. Cela ouvre la voie à la gestion d'abonnements.
|
- **Correctif d'analyse :** Résolution du bug "Erreur IA" lié au formatage des réponses du modèle.
|
||||||
- **Profils Utilisateurs dans le Cloud :** Les données de profil (poids, objectifs, etc.) sont maintenant synchronisées avec le compte Firebase de l'utilisateur, permettant une expérience cohérente sur plusieurs appareils.
|
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur (Cloud Functions).
|
||||||
- **Correctif Critique de Connexion :** Résolution d'un bug majeur qui empêchait l'application de se connecter correctement à la base de données Firestore, causant des accès refusés inattendus pour les utilisateurs légitimes. L'application est désormais compatible avec les bases de données Firestore nommées.
|
|
||||||
|
🚀 **Connexion Strava 100% Automatique :**
|
||||||
|
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
|
||||||
|
- **Sécurité Maximale :** Utilisation de Google Cloud Secret Manager pour la protection des clés API Strava.
|
||||||
|
|
||||||
|
🎨 **Améliorations UI/UX :**
|
||||||
|
- **Interface épurée :** Suppression des réglages superflus.
|
||||||
|
- **Correction du Thème :** Lisibilité parfaite sur l'écran de profil.
|
||||||
|
|
||||||
|
🛡️ **Architecture Cloud :**
|
||||||
|
- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
|
||||||
|
- **Profils Cloud :** Synchronisation automatique de vos données personnelles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Mises à jour précédentes :**
|
**Mises à jour précédentes :**
|
||||||
|
|
||||||
🛠️ **Correctifs et Améliorations Strava :**
|
🛠️ **Correctifs et Améliorations Strava :**
|
||||||
- Résolution d'un problème de compilation bloquant sur l'écran des sports.
|
- Résolution de bugs de compilation et amélioration du parsing des dates.
|
||||||
- Intégration d'un nouvel algorithme d'estimation des calories basé sur les MET (Metabolic Equivalent of Task) pour une précision accrue.
|
- Nouvel algorithme d'estimation des calories basé sur les MET.
|
||||||
- Amélioration de la fiabilité du parsing des dates d'activités Strava.
|
|
||||||
|
|
||||||
🛡️ **Sécurité renforcée :**
|
🛡️ **Sécurité renforcée :**
|
||||||
- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés.
|
- Intégration de Firebase App Check (Play Integrity).
|
||||||
- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source.
|
- Migration des clés vers Secret Manager.
|
||||||
|
|
||||||
⚡ **Analyse Ultra-Rapide :**
|
⚡ **Analyse Ultra-Rapide :**
|
||||||
- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA.
|
- Nouveau moteur de compression d'image intelligent.
|
||||||
|
|
||||||
🤖 **IA Sécurisée :**
|
|
||||||
- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés.
|
|
||||||
|
|
||||||
📄 **Export PDF Professionnel :**
|
📄 **Export PDF Professionnel :**
|
||||||
- Nouvelle fonctionnalité d'exportation de l'historique. Générez un rapport PDF complet incluant vos repas, activités sportives et suivis de glycémie.
|
- Exportation de l'historique complet (repas, sport, glycémie).
|
||||||
|
|
||||||
🩸 **Suivi Diabétique complet :**
|
🩸 **Suivi Diabétique :**
|
||||||
- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique.
|
- Visualisation de la glycémie directement dans l'historique.
|
||||||
|
|
||||||
🚴 **Synchronisation Strava :**
|
|
||||||
- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées.
|
|
||||||
|
|
||||||
⭐ **Gestion des Favoris :**
|
⭐ **Gestion des Favoris :**
|
||||||
- Refonte de l'interface des favoris avec une nouvelle fenêtre modale pour un ajout rapide de vos repas récurrents.
|
- Nouvelle interface d'ajout rapide.
|
||||||
|
|
||||||
🔍 **Scanner de Code-barres :**
|
🔧 **Stabilité :**
|
||||||
- Intégration d'Open Food Facts pour identifier instantanément les produits industriels via leur code-barres.
|
- Optimisation pour Android 36 et corrections diverses.
|
||||||
|
|
||||||
🔧 **Stabilité et Modernisation :**
|
|
||||||
- Optimisation pour Android 36.
|
|
||||||
- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user