diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1175110..b75758c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "com.example.maposition" minSdk = 21 targetSdk = 36 - versionCode = 4 - versionName = "1.3" + versionCode = 5 + versionName = "1.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/example/maposition/MainActivity.kt b/app/src/main/java/com/example/maposition/MainActivity.kt index 5b398a5..7394e13 100644 --- a/app/src/main/java/com/example/maposition/MainActivity.kt +++ b/app/src/main/java/com/example/maposition/MainActivity.kt @@ -17,7 +17,6 @@ import android.os.Looper import android.os.SystemClock import android.telephony.SmsManager import android.view.View -import android.widget.ImageButton import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat @@ -27,6 +26,8 @@ import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup import com.google.android.material.progressindicator.CircularProgressIndicator import java.util.concurrent.TimeUnit @@ -52,6 +53,7 @@ class MainActivity : AppCompatActivity() { private lateinit var statusText: TextView private lateinit var progress: CircularProgressIndicator private lateinit var recipientsText: TextView + private lateinit var groupChips: ChipGroup // État d'un envoi en cours private var busy = false @@ -83,6 +85,7 @@ class MainActivity : AppCompatActivity() { statusText = findViewById(R.id.statusText) progress = findViewById(R.id.progress) recipientsText = findViewById(R.id.recipientsText) + groupChips = findViewById(R.id.groupChips) ContextCompat.registerReceiver( this, smsSentReceiver, IntentFilter(ACTION_SMS_SENT), @@ -90,7 +93,6 @@ class MainActivity : AppCompatActivity() { ) sendButton.setOnClickListener { startSendFlow() } - findViewById(R.id.settingsButton).setOnClickListener { openSettings() } findViewById(R.id.recipientsCard).setOnClickListener { openSettings() } } @@ -109,28 +111,41 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, SettingsActivity::class.java)) } - private fun getSavedSettings(): Pair> { - val prefs = getSharedPreferences("MaPositionPrefs", MODE_PRIVATE) - val name = prefs.getString("name", "") ?: "" - val phones = prefs.getString("phones", "") ?: "" - val phoneList = phones.split(";").map { it.trim() }.filter { it.isNotEmpty() } - return Pair(name, phoneList) - } + // --- Carte des groupes de destinataires --- private fun refreshRecipientsCard() { - val (name, phones) = getSavedSettings() - recipientsText.text = if (phones.isEmpty()) { - getString(R.string.recipients_none) - } else { - val who = if (name.isBlank()) "" else "$name → " - who + phones.joinToString(", ") + val groups = Prefs.getGroups(this) + groupChips.removeAllViews() + + if (groups.isEmpty()) { + groupChips.visibility = View.GONE + recipientsText.text = getString(R.string.recipients_none) + return } + + groupChips.visibility = View.VISIBLE + val active = Prefs.getActiveGroup(this)!! + groups.forEach { g -> + val chip = Chip(this).apply { + text = g.name + isCheckable = true + isChecked = g.name == active.name + setOnClickListener { + Prefs.setActiveGroupName(this@MainActivity, g.name) + refreshRecipientsCard() + } + } + groupChips.addView(chip) + } + recipientsText.text = active.phones.joinToString(", ") } + // --- Déclenchement de l'envoi --- + private fun startSendFlow() { if (busy) return - val (_, phones) = getSavedSettings() - if (phones.isEmpty()) { + val active = Prefs.getActiveGroup(this) + if (active == null || active.phones.isEmpty()) { setStatus(getString(R.string.status_no_recipients)) openSettings() return @@ -220,7 +235,12 @@ class MainActivity : AppCompatActivity() { } private fun sendSmsWithLocation(location: Location, stale: Boolean) { - val (name, phones) = getSavedSettings() + val name = Prefs.getName(this) + val phones = Prefs.getActiveGroup(this)?.phones ?: emptyList() + if (phones.isEmpty()) { + abort(getString(R.string.status_no_recipients)) + return + } val link = "https://maps.google.com/?q=${location.latitude},${location.longitude}" val ageMin = TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis() - location.time) diff --git a/app/src/main/java/com/example/maposition/Prefs.kt b/app/src/main/java/com/example/maposition/Prefs.kt new file mode 100644 index 0000000..293cd33 --- /dev/null +++ b/app/src/main/java/com/example/maposition/Prefs.kt @@ -0,0 +1,64 @@ +package com.example.maposition + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +data class Group(val name: String, val phones: List) + +object Prefs { + private const val FILE = "MaPositionPrefs" + + private fun sp(ctx: Context) = ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + fun getName(ctx: Context): String = sp(ctx).getString("name", "") ?: "" + + fun setName(ctx: Context, name: String) { + sp(ctx).edit().putString("name", name.trim()).apply() + } + + fun getGroups(ctx: Context): MutableList { + val raw = sp(ctx).getString("groups", null) + val groups = mutableListOf() + if (raw != null) { + val arr = JSONArray(raw) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val phones = mutableListOf() + val pa = o.getJSONArray("phones") + for (j in 0 until pa.length()) phones.add(pa.getString(j)) + groups.add(Group(o.getString("name"), phones)) + } + } else { + // Migration depuis l'ancien format : liste unique de numéros + val legacy = sp(ctx).getString("phones", "") ?: "" + val phones = legacy.split(";").map { it.trim() }.filter { it.isNotEmpty() } + if (phones.isNotEmpty()) { + groups.add(Group(ctx.getString(R.string.default_group_name), phones)) + saveGroups(ctx, groups) + } + } + return groups + } + + fun saveGroups(ctx: Context, groups: List) { + val arr = JSONArray() + groups.forEach { g -> + arr.put(JSONObject().put("name", g.name).put("phones", JSONArray(g.phones))) + } + sp(ctx).edit().putString("groups", arr.toString()).apply() + } + + fun getActiveGroupName(ctx: Context): String = sp(ctx).getString("activeGroup", "") ?: "" + + fun setActiveGroupName(ctx: Context, name: String) { + sp(ctx).edit().putString("activeGroup", name).apply() + } + + /** Groupe actif, ou le premier groupe si l'actif n'existe plus. */ + fun getActiveGroup(ctx: Context): Group? { + val groups = getGroups(ctx) + if (groups.isEmpty()) return null + return groups.find { it.name == getActiveGroupName(ctx) } ?: groups.first() + } +} diff --git a/app/src/main/java/com/example/maposition/SettingsActivity.kt b/app/src/main/java/com/example/maposition/SettingsActivity.kt index 664a151..24395b7 100644 --- a/app/src/main/java/com/example/maposition/SettingsActivity.kt +++ b/app/src/main/java/com/example/maposition/SettingsActivity.kt @@ -1,57 +1,138 @@ package com.example.maposition -import android.content.Context import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View import android.widget.ImageButton -import android.widget.Toast +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout class SettingsActivity : AppCompatActivity() { private val phoneRegex = Regex("""^\+?[0-9][0-9 .\-]{5,16}$""") + private lateinit var groupsList: LinearLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) - val prefs = getSharedPreferences("MaPositionPrefs", Context.MODE_PRIVATE) + groupsList = findViewById(R.id.groupsList) val nameInput = findViewById(R.id.nameInput) - val phonesLayout = findViewById(R.id.phonesLayout) - val phonesInput = findViewById(R.id.phonesInput) - - nameInput.setText(prefs.getString("name", "")) - phonesInput.setText(prefs.getString("phones", "")) + nameInput.setText(Prefs.getName(this)) + nameInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} + override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} + override fun afterTextChanged(s: Editable?) { + Prefs.setName(this@SettingsActivity, s?.toString() ?: "") + } + }) findViewById(R.id.backButton).setOnClickListener { finish() } + findViewById(R.id.addGroupButton).setOnClickListener { showGroupDialog(null) } - findViewById(R.id.saveButton).setOnClickListener { - val rawPhones = phonesInput.text.toString() - val phones = rawPhones.split(";").map { it.trim() }.filter { it.isNotEmpty() } + renderGroups() + } - when { - phones.isEmpty() -> { - phonesLayout.error = getString(R.string.error_no_phone) - return@setOnClickListener - } - phones.any { !phoneRegex.matches(it) } -> { - val bad = phones.first { !phoneRegex.matches(it) } - phonesLayout.error = getString(R.string.error_bad_phone, bad) - return@setOnClickListener - } + private fun renderGroups() { + groupsList.removeAllViews() + val groups = Prefs.getGroups(this) + + if (groups.isEmpty()) { + val empty = TextView(this).apply { + text = getString(R.string.no_groups) + setPadding(8, 24, 8, 24) } - phonesLayout.error = null + groupsList.addView(empty) + return + } - prefs.edit() - .putString("name", nameInput.text.toString().trim()) - .putString("phones", phones.joinToString(";")) - .apply() - - Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show() - finish() + val activeName = Prefs.getActiveGroup(this)?.name + groups.forEach { g -> + val item = layoutInflater.inflate(R.layout.item_group, groupsList, false) + val title = if (g.name == activeName) { + getString(R.string.group_active_suffix, g.name) + } else { + g.name + } + item.findViewById(R.id.groupName).text = title + item.findViewById(R.id.groupPhones).text = g.phones.joinToString(", ") + item.setOnClickListener { showGroupDialog(g) } + groupsList.addView(item) } } + + private fun showGroupDialog(existing: Group?) { + val view = layoutInflater.inflate(R.layout.dialog_group, null) + val nameLayout = view.findViewById(R.id.groupNameLayout) + val nameInput = view.findViewById(R.id.groupNameInput) + val phonesLayout = view.findViewById(R.id.groupPhonesLayout) + val phonesInput = view.findViewById(R.id.groupPhonesInput) + + if (existing != null) { + nameInput.setText(existing.name) + phonesInput.setText(existing.phones.joinToString(";")) + } + + val builder = MaterialAlertDialogBuilder(this) + .setTitle(if (existing == null) R.string.group_new else R.string.group_edit) + .setView(view) + .setPositiveButton(R.string.save, null) + .setNegativeButton(android.R.string.cancel, null) + if (existing != null) builder.setNeutralButton(R.string.delete, null) + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val name = nameInput.text.toString().trim() + val phones = phonesInput.text.toString() + .split(";").map { it.trim() }.filter { it.isNotEmpty() } + nameLayout.error = null + phonesLayout.error = null + + val groups = Prefs.getGroups(this) + when { + name.isEmpty() -> + nameLayout.error = getString(R.string.error_no_group_name) + groups.any { it.name == name && it.name != existing?.name } -> + nameLayout.error = getString(R.string.error_dup_group) + phones.isEmpty() -> + phonesLayout.error = getString(R.string.error_no_phone) + phones.any { !phoneRegex.matches(it) } -> + phonesLayout.error = getString( + R.string.error_bad_phone, + phones.first { !phoneRegex.matches(it) } + ) + else -> { + val idx = groups.indexOfFirst { it.name == existing?.name } + if (idx >= 0) groups[idx] = Group(name, phones) + else groups.add(Group(name, phones)) + Prefs.saveGroups(this, groups) + if (existing != null && Prefs.getActiveGroupName(this) == existing.name) { + Prefs.setActiveGroupName(this, name) + } + renderGroups() + dialog.dismiss() + } + } + } + if (existing != null) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + val groups = Prefs.getGroups(this) + groups.removeAll { it.name == existing.name } + Prefs.saveGroups(this, groups) + renderGroups() + dialog.dismiss() + } + } + } + dialog.show() + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 8c08d1f..d0caca3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -23,10 +23,11 @@ android:layout_height="wrap_content" android:layout_marginStart="24dp" android:layout_marginTop="24dp" + android:layout_marginEnd="24dp" android:text="@string/app_name" android:textSize="28sp" android:textStyle="bold" - app:layout_constraintEnd_toStartOf="@id/settingsButton" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -38,23 +39,10 @@ android:text="@string/subtitle" android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" - app:layout_constraintEnd_toStartOf="@id/settingsButton" + app:layout_constraintEnd_toEndOf="@id/titleText" app:layout_constraintStart_toStartOf="@id/titleText" app:layout_constraintTop_toBottomOf="@id/titleText" /> - - - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + - - - + android:layout_height="match_parent"> - - - - - - - - - + android:orientation="vertical" + android:padding="24dp"> - + - - + - + + - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_group.xml b/app/src/main/res/layout/dialog_group.xml new file mode 100644 index 0000000..ba885af --- /dev/null +++ b/app/src/main/res/layout/dialog_group.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_group.xml b/app/src/main/res/layout/item_group.xml new file mode 100644 index 0000000..fc69de0 --- /dev/null +++ b/app/src/main/res/layout/item_group.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 509d19e..fbdde1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,14 +6,28 @@ ENVOYER\nMA POSITION - Destinataires — toucher pour modifier - Aucun destinataire configuré.\nTouchez ici pour en ajouter. + Groupe de destinataires — toucher pour gérer + Aucun groupe de destinataires.\nTouchez ici pour en créer un. + + + Groupes de destinataires + Créez des groupes (famille, amis, collègues…) et choisissez le groupe actif sur l\'écran principal. + Mes contacts + %1$s ✓ (groupe actif) + Nouveau groupe + Modifier le groupe + Nom du groupe + + Ajouter un groupe + Aucun groupe pour l\'instant. + Supprimer + Donnez un nom au groupe + Un groupe porte déjà ce nom Recherche de votre position… GPS lent — utilisation de la dernière position connue… ❌ Position introuvable. Vérifiez que la localisation est activée, puis réessayez. - Ajoutez d\'abord un destinataire. + Créez d\'abord un groupe de destinataires. ❌ Les autorisations SMS et localisation sont nécessaires pour envoyer votre position. Envoi des SMS… (%1$d/%2$d) ✅ Position envoyée à %1$d destinataire(s)