3 Commits

11 changed files with 377 additions and 45 deletions

View File

@@ -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.
![Pignon1](https://github.com/user-attachments/assets/1e43e8f2-872c-41c2-9e12-405e41db5ca1)
## 🛠 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
![Pignon2](https://github.com/user-attachments/assets/78439dff-cde4-4d75-8a8f-f6ee38a69abf)
![Pignon3](https://github.com/user-attachments/assets/51694675-ec5f-4a59-95a9-7b7e0dd1caf3)
![Pignon4](https://github.com/user-attachments/assets/f402250c-045c-4b3d-bbdc-11ae22bd62b2)

View File

@@ -10,8 +10,8 @@ android {
applicationId "com.stock.pignon" applicationId "com.stock.pignon"
minSdkVersion 17 minSdkVersion 17
targetSdkVersion 36 targetSdkVersion 36
versionCode 1 versionCode 3
versionName "0.1.0" versionName "0.3.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'

View File

@@ -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"

View File

@@ -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;
@@ -27,8 +26,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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
*/ */
@@ -119,38 +116,47 @@ public class CartActionHelper {
} }
/** /**
* Merges current cart items with the existing stock file on the SD Card. * Merges current cart items with the existing stock file (json and csv) on the SD Card.
*/ */
private static void saveCartToExternalFile(List<CartItem> cartItems) { private static void saveCartToExternalFile(List<CartItem> cartItems) {
File stockFile = new File(Environment.getExternalStorageDirectory(), Config.STOCK_FILE_NAME); 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);
Gson gson = new Gson(); Gson gson = new Gson();
Map<String, Integer> stockMap; // Load previous list
Map<String, Integer> stockMap = loadExistingStock(stockFile, gson);
// Load data into a Map (Key: Item Name, Value: Total Quantity) // Add current cart item
stockMap = loadExistingStock(stockFile, gson);
// Merge with new items from current cart
for (CartItem item : cartItems) { for (CartItem item : cartItems) {
// Get previous quantity
Integer qtyObj = stockMap.get(item.getName()); Integer qtyObj = stockMap.get(item.getName());
int currentQty = (qtyObj != null) ? qtyObj : 0; int currentQty = (qtyObj != null) ? qtyObj : 0;
// Add
stockMap.put(item.getName(), currentQty + item.getQuantity()); stockMap.put(item.getName(), currentQty + item.getQuantity());
} }
// Overwrite the file with updated data // Save to JSON
// Needs WRITE_EXTERNAL_STORAGE permission
try (FileOutputStream fos = new FileOutputStream(stockFile); try (FileOutputStream fos = new FileOutputStream(stockFile);
@SuppressWarnings("CharsetObjectCanBeUsed")
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) { OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) {
// Writing directly to the stream
gson.toJson(stockMap, writer); gson.toJson(stockMap, writer);
Log.i(TAG, "Stock updated successfully at: " + stockFile.getAbsolutePath());
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Failed to write stock file", e); Log.e(TAG, "Failed to write stock file", e);
} }
// Save to CSV, french format with ";"
try (FileOutputStream fos = new FileOutputStream(csvFile);
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8")) {
// UTF-8 BOM and columns headers
writer.write('\ufeff');
writer.write("Article;Quantité\n");
for (Map.Entry<String, Integer> entry : stockMap.entrySet()) {
writer.write(entry.getKey().replace(";", ",") + ";" + entry.getValue() + "\n");
}
Log.i(TAG, "CSV Export updated successfully");
} catch (Exception e) {
Log.e(TAG, "Failed to write CSV file", e);
}
} }
/** /**

View File

@@ -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";
} }

View 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);
}
}
}
}

View File

@@ -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<>();

View File

@@ -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,45 @@ 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");
}
// 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 actif : " + url);
}
Log.i(TAG, "Serveur démarré sur : " + url);
} catch (IOException e) {
if (getSupportActionBar() != null) {
getSupportActionBar().setSubtitle("🔴 Erreur serveur : Port 8080 occupé");
}
}
}
@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, "Serveur arrêté.");
}
} }
/** /**
@@ -239,13 +282,6 @@ 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
protected void onResume() {
super.onResume();
// Ensure cart is up to date if returning to the activity
CartViewHelper.updateCartView(cartList, this);
}
private void copyAssetsIfEmpty() { private void copyAssetsIfEmpty() {
File folder = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME); File folder = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
@@ -254,7 +290,7 @@ public class MainActivity extends AppCompatActivity {
// Second safety : are we able to create folder ? // Second safety : are we able to create folder ?
if (folder.mkdirs()) { if (folder.mkdirs()) {
// Copy JSON file // Copy JSON file
copyFileFromAssets(Config.PIECES_FILE_NAME, new File(folder, Config.PIECES_FILE_NAME)); copyFileFromAssets(Config.INPUT_JSON_NAME, new File(folder, Config.INPUT_JSON_NAME));
// Copy images subfolder // Copy images subfolder
File imgFolder = new File(folder, Config.IMAGES_SUBDIR_NAME); File imgFolder = new File(folder, Config.IMAGES_SUBDIR_NAME);
@@ -301,4 +337,26 @@ public class MainActivity extends AppCompatActivity {
Log.e("MainActivity", "Erreur copie asset: " + assetName, e); Log.e("MainActivity", "Erreur copie 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, "Erreur IP", e);
}
return "127.0.0.1";
}
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>