mirror of
https://github.com/lucasroyerdev/stock-pignon.git
synced 2026-05-10 11:02:26 +00:00
Compare commits
6 Commits
de2915064a
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b53f8407c | |||
| 7bea2fa9cf | |||
| 5e69a13487 | |||
| cca2183e3d | |||
| 049a8d08cb | |||
| 352a6e797a |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Application Android (4.2+) de gestion de stock et d'aide à la vente à prix libre. Conçue pour l'[**Atelier du Pignon**](https://www.atelierdupignon.fr/), atelier d'auto-réparation à Nantes.
|
Application Android (4.2+) de gestion de stock et d'aide à la vente à prix libre. Conçue pour l'[**Atelier du Pignon**](https://www.atelierdupignon.fr/), atelier d'auto-réparation à Nantes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 🛠 Fonctionnalités
|
## 🛠 Fonctionnalités
|
||||||
- **Catalogue visuel** : Permet aux adhérent·es de noter rapidement les pièces qu'iels ont emportées.
|
- **Catalogue visuel** : Permet aux adhérent·es de noter rapidement les pièces qu'iels ont emportées.
|
||||||
- **Prix libre & conscient** : Calculateur de panier affichant une fourchette de prix suggérée.
|
- **Prix libre & conscient** : Calculateur de panier affichant une fourchette de prix suggérée.
|
||||||
@@ -29,3 +31,9 @@ Application Android (4.2+) de gestion de stock et d'aide à la vente à prix lib
|
|||||||
|
|
||||||
Ce projet est sous licence **CC BY-NC-SA 4.0**.
|
Ce projet est sous licence **CC BY-NC-SA 4.0**.
|
||||||
Cela signifie que vous êtes libre de partager et d'adapter le code, tant que vous citez l'auteur original, que vous n'en faites pas un usage commercial, et que vous diffusez vos modifications sous la même licence.
|
Cela signifie que vous êtes libre de partager et d'adapter le code, tant que vous citez l'auteur original, que vous n'en faites pas un usage commercial, et que vous diffusez vos modifications sous la même licence.
|
||||||
|
|
||||||
|
## 👀 Screenshots
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ android {
|
|||||||
applicationId "com.stock.pignon"
|
applicationId "com.stock.pignon"
|
||||||
minSdkVersion 17
|
minSdkVersion 17
|
||||||
targetSdkVersion 36
|
targetSdkVersion 36
|
||||||
versionCode 1
|
versionCode 4
|
||||||
versionName "0.1.0"
|
versionName "0.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@ dependencies {
|
|||||||
// GIF
|
// GIF
|
||||||
implementation 'com.github.bumptech.glide:glide:3.8.0'
|
implementation 'com.github.bumptech.glide:glide:3.8.0'
|
||||||
|
|
||||||
|
// NanoHTTPD
|
||||||
|
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import android.view.View;
|
|||||||
import android.widget.GridLayout;
|
import android.widget.GridLayout;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.ImageView;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.reflect.TypeToken;
|
import com.google.gson.reflect.TypeToken;
|
||||||
@@ -23,11 +22,8 @@ import java.io.FileOutputStream;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
import java.util.HashMap;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action on shopping cart: validation, clearing, and persistence
|
* Action on shopping cart: validation, clearing, and persistence
|
||||||
@@ -36,6 +32,21 @@ public class CartActionHelper {
|
|||||||
|
|
||||||
private static final String TAG = "CartActionHelper";
|
private static final String TAG = "CartActionHelper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data structure for JSON and CSV
|
||||||
|
*/
|
||||||
|
private static class StockEntry {
|
||||||
|
String date;
|
||||||
|
String name;
|
||||||
|
int qty;
|
||||||
|
|
||||||
|
StockEntry(String date, String name, int qty) {
|
||||||
|
this.date = date;
|
||||||
|
this.name = name;
|
||||||
|
this.qty = qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets the cart data and refreshes UI
|
* Resets the cart data and refreshes UI
|
||||||
*/
|
*/
|
||||||
@@ -93,6 +104,74 @@ public class CartActionHelper {
|
|||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges current cart items with the existing stock file (json and csv) on the SD Card.
|
||||||
|
*/
|
||||||
|
private static void saveCartToExternalFile(List<CartItem> cartItems) {
|
||||||
|
File dir = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
|
||||||
|
File stockFile = new File(dir, Config.OUPUT_JSON_NAME);
|
||||||
|
File csvFile = new File(dir, Config.OUPUT_CSV_NAME);
|
||||||
|
|
||||||
|
String today = DateHelper.getTodayIso();
|
||||||
|
Gson gson = new Gson();
|
||||||
|
|
||||||
|
// Load previous list
|
||||||
|
List<StockEntry> history = loadHistory(stockFile, gson);
|
||||||
|
|
||||||
|
// Merge current cart items in previous list
|
||||||
|
for (CartItem cartItem : cartItems) {
|
||||||
|
boolean merged = false;
|
||||||
|
for (StockEntry entry : history) {
|
||||||
|
// Same date same name ? Add it
|
||||||
|
if (entry.date.equals(today) && entry.name.equals(cartItem.getName())) {
|
||||||
|
entry.qty += cartItem.getQuantity();
|
||||||
|
merged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not found on the same date ? Create it
|
||||||
|
if (!merged) {
|
||||||
|
history.add(new StockEntry(today, cartItem.getName(), cartItem.getQuantity()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Save to JSON
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(stockFile);
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) {
|
||||||
|
gson.newBuilder().setPrettyPrinting().create().toJson(history, writer);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error writing JSON", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to CSV, french format with ";"
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(csvFile);
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) {
|
||||||
|
writer.write('\ufeff');
|
||||||
|
writer.write("Date;Article;Quantité\n");
|
||||||
|
for (StockEntry entry : history) {
|
||||||
|
writer.write(entry.date + ";" + entry.name.replace(";", ",") + ";" + entry.qty + "\n");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error writing CSV", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load JSON history
|
||||||
|
*/
|
||||||
|
private static List<StockEntry> loadHistory(File file, Gson gson) {
|
||||||
|
if (!file.exists()) return new ArrayList<>();
|
||||||
|
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), "UTF-8")) {
|
||||||
|
Type type = new TypeToken<List<StockEntry>>(){}.getType();
|
||||||
|
List<StockEntry> result = gson.fromJson(reader, type);
|
||||||
|
return (result != null) ? result : new ArrayList<>();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error reading json history", e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a thank you popup and returns to the main menu after 2 seconds.
|
* Displays a thank you popup and returns to the main menu after 2 seconds.
|
||||||
*/
|
*/
|
||||||
@@ -104,78 +183,21 @@ public class CartActionHelper {
|
|||||||
.create();
|
.create();
|
||||||
merciDialog.show();
|
merciDialog.show();
|
||||||
|
|
||||||
|
|
||||||
new Handler().postDelayed(() -> {
|
new Handler().postDelayed(() -> {
|
||||||
// Close dialog
|
// Close dialog
|
||||||
merciDialog.dismiss();
|
merciDialog.dismiss();
|
||||||
// Go to home if not already
|
|
||||||
if (!(activity instanceof MainActivity)) {
|
if (activity instanceof MainActivity) {
|
||||||
Intent intent = new Intent(activity, MainActivity.class);
|
MainActivity main = (MainActivity) activity;
|
||||||
|
// Go to home if not already
|
||||||
|
main.showHome();
|
||||||
// Clear the backstack so the user can't "go back" to a validated cart
|
// Clear the backstack so the user can't "go back" to a validated cart
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
GridLayout grid = main.findViewById(R.id.gridPieces);
|
||||||
activity.startActivity(intent);
|
refreshGridQuantities(grid);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges current cart items with the existing stock file on the SD Card.
|
|
||||||
*/
|
|
||||||
private static void saveCartToExternalFile(List<CartItem> cartItems) {
|
|
||||||
File stockFile = new File(Environment.getExternalStorageDirectory(), Config.STOCK_FILE_NAME);
|
|
||||||
Gson gson = new Gson();
|
|
||||||
Map<String, Integer> stockMap;
|
|
||||||
|
|
||||||
// Load data into a Map (Key: Item Name, Value: Total Quantity)
|
|
||||||
stockMap = loadExistingStock(stockFile, gson);
|
|
||||||
|
|
||||||
// Merge with new items from current cart
|
|
||||||
for (CartItem item : cartItems) {
|
|
||||||
// Get previous quantity
|
|
||||||
Integer qtyObj = stockMap.get(item.getName());
|
|
||||||
int currentQty = (qtyObj != null) ? qtyObj : 0;
|
|
||||||
// Add
|
|
||||||
stockMap.put(item.getName(), currentQty + item.getQuantity());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite the file with updated data
|
|
||||||
// Needs WRITE_EXTERNAL_STORAGE permission
|
|
||||||
try (FileOutputStream fos = new FileOutputStream(stockFile);
|
|
||||||
@SuppressWarnings("CharsetObjectCanBeUsed")
|
|
||||||
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) {
|
|
||||||
|
|
||||||
// Writing directly to the stream
|
|
||||||
gson.toJson(stockMap, writer);
|
|
||||||
Log.i(TAG, "Stock updated successfully at: " + stockFile.getAbsolutePath());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Failed to write stock file", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the current stock file. If the file is missing or corrupted, returns an empty map.
|
|
||||||
*/
|
|
||||||
private static Map<String, Integer> loadExistingStock(File stockFile, Gson gson) {
|
|
||||||
if (!stockFile.exists()) return new HashMap<>();
|
|
||||||
|
|
||||||
try (FileInputStream fis = new FileInputStream(stockFile);
|
|
||||||
@SuppressWarnings("CharsetObjectCanBeUsed")
|
|
||||||
InputStreamReader reader = new InputStreamReader(fis, "UTF-8")) {
|
|
||||||
|
|
||||||
// Type definition for Map required by GSON : <String, Integer>
|
|
||||||
Type type = new TypeToken<Map<String, Integer>>(){}.getType();
|
|
||||||
// Read and fill map
|
|
||||||
Map<String, Integer> result = gson.fromJson(reader, type);
|
|
||||||
|
|
||||||
return (result != null) ? result : new HashMap<>();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error reading existing stock, starting fresh", e);
|
|
||||||
return new HashMap<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the UI grid to reflect quantities.
|
* Updates the UI grid to reflect quantities.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ public class Config {
|
|||||||
public static final String IMAGES_SUBDIR_NAME = "images";
|
public static final String IMAGES_SUBDIR_NAME = "images";
|
||||||
|
|
||||||
// Input json
|
// Input json
|
||||||
public static final String PIECES_FILE_NAME = "pieces.json";
|
public static final String INPUT_JSON_NAME = "pieces.json";
|
||||||
|
|
||||||
// Output json
|
// Output json
|
||||||
public static final String STOCK_FILE_NAME = "stock.json";
|
public static final String OUPUT_JSON_NAME = "stock.json";
|
||||||
|
|
||||||
|
// Output json
|
||||||
|
public static final String OUPUT_CSV_NAME = "stock.csv";
|
||||||
}
|
}
|
||||||
254
app/src/main/java/com/stock/pignon/ControlServer.java
Normal file
254
app/src/main/java/com/stock/pignon/ControlServer.java
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// ControlServer.java
|
||||||
|
package com.stock.pignon;
|
||||||
|
|
||||||
|
import android.os.Environment;
|
||||||
|
|
||||||
|
import fi.iki.elonen.NanoHTTPD;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a web server to remote management of app assets.
|
||||||
|
*/
|
||||||
|
// Inherit what is needed to build a web server
|
||||||
|
public class ControlServer extends NanoHTTPD {
|
||||||
|
|
||||||
|
// Assets folders
|
||||||
|
private final File storageRoot = Environment.getExternalStorageDirectory();
|
||||||
|
private final File baseDir = new File(storageRoot, Config.EXTERNAL_DIR_NAME);
|
||||||
|
private final File imagesDir = new File(baseDir, Config.IMAGES_SUBDIR_NAME);
|
||||||
|
|
||||||
|
public ControlServer(int port) {
|
||||||
|
super(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main method which receive remote request
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Response serve(IHTTPSession session) {
|
||||||
|
// Get requested address
|
||||||
|
String uri = session.getUri();
|
||||||
|
|
||||||
|
switch (uri) {
|
||||||
|
case "/download_input":
|
||||||
|
return downloadFile(Config.INPUT_JSON_NAME);
|
||||||
|
|
||||||
|
case "/output_json":
|
||||||
|
return viewFile(Config.OUPUT_JSON_NAME);
|
||||||
|
|
||||||
|
case "/download_output_json":
|
||||||
|
return downloadFile(Config.OUPUT_JSON_NAME);
|
||||||
|
|
||||||
|
case "/output_csv":
|
||||||
|
return viewFile(Config.OUPUT_CSV_NAME);
|
||||||
|
|
||||||
|
case "/download_output_csv":
|
||||||
|
return downloadFile(Config.OUPUT_CSV_NAME);
|
||||||
|
|
||||||
|
case "/":
|
||||||
|
case "/index.html":
|
||||||
|
return newFixedLengthResponse(getHtmlResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload management
|
||||||
|
if (session.getMethod() == Method.POST) {
|
||||||
|
if (uri.equals("/upload_json")) {
|
||||||
|
return handleJsonUpload(session);
|
||||||
|
} else if (uri.equals("/upload_images")) {
|
||||||
|
return handleImagesUpload(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "❌ Page non trouvée");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML UI for users
|
||||||
|
*/
|
||||||
|
private String getHtmlResponse() {
|
||||||
|
return "<html>" +
|
||||||
|
"<head>" +
|
||||||
|
"<meta charset='UTF-8'>" +
|
||||||
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>" +
|
||||||
|
"<style>" +
|
||||||
|
"body { font-family: sans-serif; line-height: 1.6; padding: 20px; color: #333; max-width: 800px; margin: auto; }" +
|
||||||
|
"h1 { color: #0049AF; border-bottom: 2px solid #0049AF; }" +
|
||||||
|
"h2 { color: #555; margin-top: 30px; }" +
|
||||||
|
".card { background: #f4f4f4; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #ddd; }" +
|
||||||
|
".btn { display: inline-block; width: 280px; height: 45px; line-height: 45px; text-align: center; background: #0049AF; color: white; text-decoration: none; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; box-sizing: border-box; padding: 0; -webkit-appearance: none; font-family: inherit; font-size: 14px; font-style: normal; letter-spacing: normal; margin: 10px 5px; vertical-align: middle;}" +
|
||||||
|
".btn-download { background: #2E7D32; }" +
|
||||||
|
"input[type='file'] { margin: 10px 0; }" +
|
||||||
|
".status { font-weight: bold; color: green; }" +
|
||||||
|
"</style>" +
|
||||||
|
"</head>" +
|
||||||
|
"<body>" +
|
||||||
|
"<h1>Application : stock-pignon</h1>" +
|
||||||
|
|
||||||
|
"<h2>Gestion des stocks</h2>" +
|
||||||
|
"<div class='card'>" +
|
||||||
|
"<h3>Récupérer les sacoches des adhérent·es</h3>" +
|
||||||
|
"<p>Télécharger le décompte des pièces sorties de l'atelier.</p>" +
|
||||||
|
"<a href='/output_json' class='btn'>Voir stock.json</a>" +
|
||||||
|
"<a href='/output_csv' class='btn'>Voir stock.csv</a>" +
|
||||||
|
"<a href='/download_output_json' class='btn'>Télécharger stock.json</a>" +
|
||||||
|
"<a href='/download_output_csv' class='btn'>Télécharger stock.csv</a>" +
|
||||||
|
"</div>" +
|
||||||
|
|
||||||
|
"<h2>Modifier le catalogue</h2>" +
|
||||||
|
"<div class='card'>" +
|
||||||
|
"<h3>Gestion du fichier JSON</h3>" +
|
||||||
|
"<p>Catalogue actuel : <a href='/download_input' class='btn'>Télécharger pieces.json</a></p>" +
|
||||||
|
"<hr>" +
|
||||||
|
"<form action='/upload_json' method='post' enctype='multipart/form-data'>" +
|
||||||
|
"<p><strong>Envoyer un nouveau catalogue :</strong></p>" +
|
||||||
|
"<input type='file' name='json_file' accept='.json'><br>" +
|
||||||
|
"<input type='submit' value='Remplacer pieces.json' class='btn'>" +
|
||||||
|
"</form>" +
|
||||||
|
"</div>" +
|
||||||
|
|
||||||
|
"<div class='card'>" +
|
||||||
|
"<h3>Gestion des images</h3>" +
|
||||||
|
"<form action='/upload_images' method='post' enctype='multipart/form-data'>" +
|
||||||
|
"<p><strong>Ajouter/Modifier des images (PNG/JPG) :</strong></p>" +
|
||||||
|
"<input type='file' name='images' accept='image/*' multiple><br>" +
|
||||||
|
"<input type='submit' value='Envoyer les images' class='btn'>" +
|
||||||
|
"</form>" +
|
||||||
|
"</div>" +
|
||||||
|
|
||||||
|
"</body>" +
|
||||||
|
"</html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage JSON sent by remote.
|
||||||
|
*/
|
||||||
|
private Response handleJsonUpload(IHTTPSession session) {
|
||||||
|
Map<String, String> tmpFiles = new HashMap<>();
|
||||||
|
try {
|
||||||
|
session.parseBody(tmpFiles);
|
||||||
|
// NanoHTTPD stocke le fichier avec le nom du champ HTML (json_file)
|
||||||
|
String tmpPath = tmpFiles.get("json_file");
|
||||||
|
if (tmpPath == null) return newFixedLengthResponse("❌ Aucun fichier reçu.");
|
||||||
|
|
||||||
|
File src = new File(tmpPath);
|
||||||
|
File dest = new File(baseDir, Config.INPUT_JSON_NAME);
|
||||||
|
|
||||||
|
copyFile(src, dest);
|
||||||
|
return newFixedLengthResponse("✅ Catalogue mis à jour ! <a href='/'>Retour</a>");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return newFixedLengthResponse("❌ Erreur JSON : " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage multiple images sent by remote.
|
||||||
|
*/
|
||||||
|
private Response handleImagesUpload(IHTTPSession session) {
|
||||||
|
Map<String, String> tmpFiles = new HashMap<>();
|
||||||
|
try {
|
||||||
|
session.parseBody(tmpFiles);
|
||||||
|
|
||||||
|
if (!imagesDir.exists() && !imagesDir.mkdirs()) {
|
||||||
|
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT,
|
||||||
|
"❌ Erreur : Impossible de créer le dossier des images sur la tablette.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
// On parcourt les fichiers temporaires créés par NanoHTTPD
|
||||||
|
for (Map.Entry<String, String> entry : tmpFiles.entrySet()) {
|
||||||
|
// NanoHTTPD indexe les envois multiples (images, images1, images2...)
|
||||||
|
if (entry.getKey().startsWith("images")) {
|
||||||
|
String tmpPath = entry.getValue();
|
||||||
|
|
||||||
|
// Récupération sécurisée des paramètres pour obtenir le nom original
|
||||||
|
List<String> params = session.getParameters().get(entry.getKey());
|
||||||
|
|
||||||
|
if (params != null && !params.isEmpty()) {
|
||||||
|
// Nettoyage du nom de fichier (pour ne garder que "image.jpg" sans le chemin PC)
|
||||||
|
String originalName = new File(params.get(0)).getName();
|
||||||
|
|
||||||
|
File src = new File(tmpPath);
|
||||||
|
File dest = new File(imagesDir, originalName);
|
||||||
|
|
||||||
|
copyFile(src, dest);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFixedLengthResponse("✅ " + count + " images enregistrées ! <a href='/'>Retour</a>");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT,
|
||||||
|
"❌ Erreur serveur : " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare file and offer as download
|
||||||
|
*/
|
||||||
|
private Response downloadFile(String filename) {
|
||||||
|
File file = new File(baseDir, filename);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "❌ Fichier non trouvé.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
InputStream is = new FileInputStream(file);
|
||||||
|
|
||||||
|
// Force download
|
||||||
|
Response res = newChunkedResponse(Response.Status.OK, "application/octet-stream", is);
|
||||||
|
|
||||||
|
res.addHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
|
||||||
|
return res;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return newFixedLengthResponse("❌ Erreur de lecture.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare file and print it in browser
|
||||||
|
*/
|
||||||
|
private Response viewFile(String filename) {
|
||||||
|
File file = new File(baseDir, filename);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "❌ Fichier non trouvé.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
InputStream is = new FileInputStream(file);
|
||||||
|
|
||||||
|
// Force Mime type to print file inside browser
|
||||||
|
String mimeType = "text/plain";
|
||||||
|
|
||||||
|
Response res = newChunkedResponse(Response.Status.OK, mimeType + "; charset=utf-8", is);
|
||||||
|
|
||||||
|
res.addHeader("Content-Disposition", "inline");
|
||||||
|
return res;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return newFixedLengthResponse("❌ Erreur de lecture.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to copy file byte per byte.
|
||||||
|
* Not always possible to simply move a file on Android.
|
||||||
|
*/
|
||||||
|
private void copyFile(File src, File dst) throws IOException {
|
||||||
|
try (InputStream in = new FileInputStream(src);
|
||||||
|
OutputStream out = new FileOutputStream(dst)) {
|
||||||
|
byte[] buf = new byte[1024];
|
||||||
|
int len;
|
||||||
|
while ((len = in.read(buf)) > 0) {
|
||||||
|
out.write(buf, 0, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import java.util.List;
|
|||||||
public class DataLoader {
|
public class DataLoader {
|
||||||
private static final String TAG = "DataLoader"; // For readable logs
|
private static final String TAG = "DataLoader"; // For readable logs
|
||||||
private static final String EXTERNAL_DIR = Config.EXTERNAL_DIR_NAME;
|
private static final String EXTERNAL_DIR = Config.EXTERNAL_DIR_NAME;
|
||||||
private static final String PIECES_FILE = Config.PIECES_FILE_NAME;
|
private static final String PIECES_FILE = Config.INPUT_JSON_NAME;
|
||||||
|
|
||||||
// Raw data
|
// Raw data
|
||||||
private static List<Category> cachedCategories = new ArrayList<>();
|
private static List<Category> cachedCategories = new ArrayList<>();
|
||||||
|
|||||||
15
app/src/main/java/com/stock/pignon/DateHelper.java
Normal file
15
app/src/main/java/com/stock/pignon/DateHelper.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// DateHelper.java
|
||||||
|
package com.stock.pignon;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class DateHelper {
|
||||||
|
/**
|
||||||
|
* Return ISO format date (AAAA-MM-JJ)
|
||||||
|
*/
|
||||||
|
public static String getTodayIso() {
|
||||||
|
return new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(new Date());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// MainActivity.java
|
||||||
package com.stock.pignon;
|
package com.stock.pignon;
|
||||||
|
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
@@ -31,7 +32,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private static final String TAG = "MainActivity";
|
private static final String TAG = "MainActivity";
|
||||||
|
|
||||||
// UI Components
|
// UI components
|
||||||
private LinearLayout cartList;
|
private LinearLayout cartList;
|
||||||
private LinearLayout homeLayout;
|
private LinearLayout homeLayout;
|
||||||
private LinearLayout categoryItemsLayout;
|
private LinearLayout categoryItemsLayout;
|
||||||
@@ -39,6 +40,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private GridLayout gridPieces;
|
private GridLayout gridPieces;
|
||||||
private ImageView mainImage;
|
private ImageView mainImage;
|
||||||
|
|
||||||
|
// Server component
|
||||||
|
private ControlServer server;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -74,6 +78,53 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
// Initial cart visual sync
|
// Initial cart visual sync
|
||||||
CartViewHelper.updateCartView(cartList, this);
|
CartViewHelper.updateCartView(cartList, this);
|
||||||
|
|
||||||
|
// Action bar
|
||||||
|
if (getSupportActionBar() != null) {
|
||||||
|
getSupportActionBar().setDisplayShowHomeEnabled(true);
|
||||||
|
getSupportActionBar().setTitle(" 🚲 Atelier du Pignon - Gestion du stock à prix libre");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get app version
|
||||||
|
String versionName = "";
|
||||||
|
try {
|
||||||
|
versionName = "App v" + getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
||||||
|
} catch (Exception e) {
|
||||||
|
versionName = ""; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch server
|
||||||
|
server = new ControlServer(8080);
|
||||||
|
try {
|
||||||
|
server.start();
|
||||||
|
String url = "http://" + getDeviceIP() + ":8080";
|
||||||
|
|
||||||
|
// On met l'URL directement dans le sous-titre de l'ActionBar
|
||||||
|
if (getSupportActionBar() != null) {
|
||||||
|
getSupportActionBar().setSubtitle("Serveur en ligne : " + url + " - " + versionName);
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Server started on : " + url);
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (getSupportActionBar() != null) {
|
||||||
|
getSupportActionBar().setSubtitle("Erreur serveur" + " - " + versionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
// Ensure cart is up to date if returning to the activity
|
||||||
|
CartViewHelper.updateCartView(cartList, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
if (server != null) {
|
||||||
|
server.stop();
|
||||||
|
Log.i(TAG, "Server stopped.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,7 +266,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
CartViewHelper.updateCartView(cartList, this);
|
CartViewHelper.updateCartView(cartList, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showHome() {
|
public void showHome() {
|
||||||
categoryItemsLayout.setVisibility(View.GONE);
|
categoryItemsLayout.setVisibility(View.GONE);
|
||||||
homeLayout.setVisibility(View.VISIBLE);
|
homeLayout.setVisibility(View.VISIBLE);
|
||||||
}
|
}
|
||||||
@@ -225,8 +276,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
ImageLoader.loadImage(mainImage, "_velo", 800,800);
|
ImageLoader.loadImage(mainImage, "_velo", 800,800);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Button Actions (linked via android:onClick in XML) ---
|
|
||||||
|
|
||||||
public void emptyCart(View view) {
|
public void emptyCart(View view) {
|
||||||
CartActionHelper.emptyCart(cartList, this);
|
CartActionHelper.emptyCart(cartList, this);
|
||||||
}
|
}
|
||||||
@@ -239,32 +288,66 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
|
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void copyAssetsIfEmpty() {
|
||||||
protected void onResume() {
|
File rootDir = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
|
||||||
super.onResume();
|
|
||||||
// Ensure cart is up to date if returning to the activity
|
// Create root folder if not found
|
||||||
CartViewHelper.updateCartView(cartList, this);
|
if (!rootDir.exists()) {
|
||||||
|
if (!rootDir.mkdirs()) {
|
||||||
|
Log.e("MainActivity", "Can't create root dir." + rootDir.getAbsolutePath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pieces.json
|
||||||
|
File inputJson = new File(rootDir, Config.INPUT_JSON_NAME);
|
||||||
|
if (!inputJson.exists()) {
|
||||||
|
Log.i("MainActivity", "pieces.json not found, copying it...");
|
||||||
|
copyFileFromAssets(Config.INPUT_JSON_NAME, inputJson);
|
||||||
|
} else {
|
||||||
|
Log.d("MainActivity", "Keep existing pieces.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stock.json and stock.csv output files to avoid control server error
|
||||||
|
checkOrCreateEmptyFile(new File(rootDir, Config.OUPUT_JSON_NAME), "[]");
|
||||||
|
checkOrCreateEmptyFile(new File(rootDir, Config.OUPUT_CSV_NAME), "");
|
||||||
|
|
||||||
|
// Check images folder
|
||||||
|
File imgDir = new File(rootDir, Config.IMAGES_SUBDIR_NAME);
|
||||||
|
if (!imgDir.exists()) {
|
||||||
|
if (!imgDir.mkdirs()) {
|
||||||
|
Log.e("MainActivity", "Can't create images dir.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy images only if not found
|
||||||
|
String[] filesInImgDir = imgDir.list();
|
||||||
|
if (filesInImgDir == null || filesInImgDir.length == 0) {
|
||||||
|
Log.i("MainActivity", "Images folder empty. Installing images...");
|
||||||
|
copyFolderFromAssets(Config.IMAGES_SUBDIR_NAME, imgDir);
|
||||||
|
} else {
|
||||||
|
Log.d("MainActivity", "Keep existing images.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void copyAssetsIfEmpty() {
|
/**
|
||||||
File folder = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
|
* Create a file with default content if not found
|
||||||
|
*/
|
||||||
// First safety : is folder already in sdcard ?
|
private void checkOrCreateEmptyFile(File file, String defaultContent) {
|
||||||
if (!folder.exists()) {
|
if (!file.exists()) {
|
||||||
// Second safety : are we able to create folder ?
|
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
if (folder.mkdirs()) {
|
fos.write(defaultContent.getBytes());
|
||||||
// Copy JSON file
|
Log.d(TAG, "Initialisation de : " + file.getName());
|
||||||
copyFileFromAssets(Config.PIECES_FILE_NAME, new File(folder, Config.PIECES_FILE_NAME));
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "Erreur lors de l'initialisation de " + file.getName(), e);
|
||||||
// Copy images subfolder
|
|
||||||
File imgFolder = new File(folder, Config.IMAGES_SUBDIR_NAME);
|
|
||||||
if (imgFolder.mkdirs()) {
|
|
||||||
copyFolderFromAssets(Config.IMAGES_SUBDIR_NAME, imgFolder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy folder from assets
|
||||||
|
*/
|
||||||
private void copyFolderFromAssets(String assetDirName, File destDir) {
|
private void copyFolderFromAssets(String assetDirName, File destDir) {
|
||||||
try {
|
try {
|
||||||
String[] files = getAssets().list(assetDirName);
|
String[] files = getAssets().list(assetDirName);
|
||||||
@@ -280,25 +363,50 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e("MainActivity", "Erreur listing assets: " + assetDirName, e);
|
Log.e("MainActivity", "Listing assets error: " + assetDirName, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy file from assets
|
||||||
|
*/
|
||||||
private void copyFileFromAssets(String assetName, File destFile) {
|
private void copyFileFromAssets(String assetName, File destFile) {
|
||||||
// Optimized read
|
// Optimized read
|
||||||
// Try-with-resources ensures streams are automatically closed, avoid memory leaks
|
// Try-with-resources ensures streams are automatically closed, avoid memory leaks
|
||||||
try (InputStream in = getAssets().open(assetName);
|
try (InputStream in = getAssets().open(assetName);
|
||||||
OutputStream out = new FileOutputStream(destFile)) {
|
OutputStream out = new FileOutputStream(destFile)) {
|
||||||
|
|
||||||
byte[] buffer = new byte[1024];
|
byte[] buffer = new byte[8192];
|
||||||
int read;
|
int read;
|
||||||
while ((read = in.read(buffer)) != -1) {
|
while ((read = in.read(buffer)) != -1) {
|
||||||
out.write(buffer, 0, read);
|
out.write(buffer, 0, read);
|
||||||
}
|
}
|
||||||
Log.d("MainActivity", "Succès : " + assetName + " copié.");
|
Log.d("MainActivity", "Success : " + assetName + " copied.");
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e("MainActivity", "Erreur copie asset: " + assetName, e);
|
Log.e("MainActivity", "Can't copy asset: " + assetName, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device IP to print it to users for easy remote control
|
||||||
|
*/
|
||||||
|
private String getDeviceIP() {
|
||||||
|
try {
|
||||||
|
java.util.Enumeration<java.net.NetworkInterface> interfaces = java.net.NetworkInterface.getNetworkInterfaces();
|
||||||
|
while (interfaces.hasMoreElements()) {
|
||||||
|
java.net.NetworkInterface iface = interfaces.nextElement();
|
||||||
|
java.util.Enumeration<java.net.InetAddress> addresses = iface.getInetAddresses();
|
||||||
|
while (addresses.hasMoreElements()) {
|
||||||
|
java.net.InetAddress addr = addresses.nextElement();
|
||||||
|
if (!addr.isLoopbackAddress() && addr instanceof java.net.Inet4Address) {
|
||||||
|
return addr.getHostAddress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "IP error", e);
|
||||||
|
}
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
<item name="colorPrimary">@color/pignon_blue</item>
|
||||||
<item name="colorPrimaryDark">@color/purple_700</item>
|
<item name="colorPrimaryDark">@color/pignon_blue_dark</item>
|
||||||
<item name="colorAccent">@color/teal_200</item>
|
<item name="colorAccent">@color/pignon_blue</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="purple_500">#FF6200EE</color>
|
<color name="pignon_blue">#0049AF</color>
|
||||||
<color name="purple_700">#FF3700B3</color>
|
<color name="pignon_blue_dark">#003580</color>
|
||||||
<color name="teal_200">#FF03DAC5</color>
|
<color name="white">#FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<string name="cart_name">Mes sacoches</string>
|
<string name="cart_name">Mes sacoches</string>
|
||||||
<string name="cart_validate_btn">Valider</string>
|
<string name="cart_validate_btn">Valider</string>
|
||||||
<string name="cart_empty_btn">Vider</string>
|
<string name="cart_empty_btn">Vider</string>
|
||||||
<string name="cart_empty">Sacoches vides</string>
|
<string name="cart_empty">C\'est vide !</string>
|
||||||
<string name="cart_item">Quantité : %1$d (%2$d - %3$d €)</string>
|
<string name="cart_item">Quantité : %1$d (%2$d - %3$d €)</string>
|
||||||
|
|
||||||
<string name="currency">€</string>
|
<string name="currency">€</string>
|
||||||
@@ -13,6 +13,6 @@
|
|||||||
<string name="popup_name">Mes sacoches</string>
|
<string name="popup_name">Mes sacoches</string>
|
||||||
<string name="popup_item"><![CDATA[<b>%1$d x %2$s</b> : %3$d - %4$d €<br>]]></string>
|
<string name="popup_item"><![CDATA[<b>%1$d x %2$s</b> : %3$d - %4$d €<br>]]></string>
|
||||||
<string name="popup_total"><![CDATA[<br><b>Contribution consciente dans la petite boîte : %1$d - %2$d €</b>]]></string>
|
<string name="popup_total"><![CDATA[<br><b>Contribution consciente dans la petite boîte : %1$d - %2$d €</b>]]></string>
|
||||||
<string name="popup_end">Sacoches sauvegardées, merci !</string>
|
<string name="popup_end">Sacoches sauvegardées, merci ! ❤️</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,11 +2,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
<item name="colorPrimary">@color/pignon_blue</item>
|
||||||
<item name="colorPrimaryDark">@color/purple_700</item>
|
<item name="colorPrimaryDark">@color/pignon_blue_dark</item>
|
||||||
<item name="colorAccent">@color/teal_200</item>
|
<item name="colorAccent">@color/pignon_blue</item>
|
||||||
|
|
||||||
<item name="actionBarSize">40dp</item>
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user