mirror of
https://github.com/lucasroyerdev/stock-pignon.git
synced 2026-05-10 11:02:26 +00:00
Compare commits
3 Commits
develop
...
de2915064a
| Author | SHA1 | Date | |
|---|---|---|---|
| de2915064a | |||
| 48e66a539e | |||
| 432dd2a439 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 🛠 Fonctionnalités
|
||||
- **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.
|
||||
@@ -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**.
|
||||
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"
|
||||
minSdkVersion 17
|
||||
targetSdkVersion 36
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
versionCode 2
|
||||
versionName "0.2.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -41,6 +41,9 @@ dependencies {
|
||||
// GIF
|
||||
implementation 'com.github.bumptech.glide:glide:3.8.0'
|
||||
|
||||
// NanoHTTPD
|
||||
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||
|
||||
// Tests
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
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.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.view.View;
|
||||
import android.widget.GridLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
@@ -27,8 +26,6 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
/**
|
||||
* Action on shopping cart: validation, clearing, and persistence
|
||||
*/
|
||||
@@ -122,7 +119,9 @@ public class CartActionHelper {
|
||||
* 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);
|
||||
File dir = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
|
||||
File stockFile = new File(dir, Config.STOCK_FILE_NAME);
|
||||
|
||||
Gson gson = new Gson();
|
||||
Map<String, Integer> stockMap;
|
||||
|
||||
|
||||
216
app/src/main/java/com/stock/pignon/ControlServer.java
Normal file
216
app/src/main/java/com/stock/pignon/ControlServer.java
Normal file
@@ -0,0 +1,216 @@
|
||||
// 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();
|
||||
|
||||
// Adapt URL to find the file : /stock.json become stock.json
|
||||
if (uri.equals("/download_orders")) {
|
||||
return downloadFile(Config.STOCK_FILE_NAME);
|
||||
}
|
||||
if (uri.equals("/download_json")) {
|
||||
return downloadFile(Config.PIECES_FILE_NAME);
|
||||
}
|
||||
|
||||
// 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 UI
|
||||
return newFixedLengthResponse(getHtmlResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }" +
|
||||
".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='/download_orders' class='btn'>Télécharger stock.json</a>" +
|
||||
"</div>" +
|
||||
|
||||
"<h2>Modifier le catalogue</h2>" +
|
||||
"<div class='card'>" +
|
||||
"<h3>Gestion du fichier JSON</h3>" +
|
||||
"<p>Catalogue actuel : <a href='/download_json' 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.PIECES_FILE_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 local file to send it to remote.
|
||||
*/
|
||||
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é : " + filename);
|
||||
}
|
||||
try {
|
||||
InputStream is = new FileInputStream(file);
|
||||
Response res = newChunkedResponse(Response.Status.OK, "application/octet-stream", is);
|
||||
|
||||
// Force file download name
|
||||
res.addHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// MainActivity.java
|
||||
package com.stock.pignon;
|
||||
|
||||
import android.graphics.Color;
|
||||
@@ -31,7 +32,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
// UI Components
|
||||
// UI components
|
||||
private LinearLayout cartList;
|
||||
private LinearLayout homeLayout;
|
||||
private LinearLayout categoryItemsLayout;
|
||||
@@ -39,6 +40,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
private GridLayout gridPieces;
|
||||
private ImageView mainImage;
|
||||
|
||||
// Server component
|
||||
private ControlServer server;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -74,6 +78,45 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Initial cart visual sync
|
||||
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());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
// Ensure cart is up to date if returning to the activity
|
||||
CartViewHelper.updateCartView(cartList, this);
|
||||
}
|
||||
|
||||
private void copyAssetsIfEmpty() {
|
||||
File folder = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME);
|
||||
|
||||
@@ -301,4 +337,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryDark">@color/purple_700</item>
|
||||
<item name="colorAccent">@color/teal_200</item>
|
||||
<item name="colorPrimary">@color/pignon_blue</item>
|
||||
<item name="colorPrimaryDark">@color/pignon_blue_dark</item>
|
||||
<item name="colorAccent">@color/pignon_blue</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="pignon_blue">#0049AF</color>
|
||||
<color name="pignon_blue_dark">#003580</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -2,11 +2,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.Pignon" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryDark">@color/purple_700</item>
|
||||
<item name="colorAccent">@color/teal_200</item>
|
||||
|
||||
<item name="actionBarSize">40dp</item>
|
||||
<item name="colorPrimary">@color/pignon_blue</item>
|
||||
<item name="colorPrimaryDark">@color/pignon_blue_dark</item>
|
||||
<item name="colorAccent">@color/pignon_blue</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user