439 lines
16 KiB
Python
439 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import zipfile
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
OUT_DIR = ROOT / "wireframe_mobile_requested"
|
|
COMBINED = ROOT / "wireframe-mobile-full.png"
|
|
ZIP_PATH = ROOT / "wireframe-mobile-screens.zip"
|
|
|
|
PHONE_W = 430
|
|
PHONE_H = 860
|
|
CANVAS_W = 520
|
|
CANVAS_H = 1010
|
|
MARGIN = 32
|
|
|
|
COLS = 3
|
|
ROWS = 5
|
|
|
|
|
|
def font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.FreeTypeFont:
|
|
names = []
|
|
if bold and italic:
|
|
names = ["arialbi.ttf", "Arial Bold Italic.ttf"]
|
|
elif bold:
|
|
names = ["arialbd.ttf", "Arial Bold.ttf"]
|
|
elif italic:
|
|
names = ["ariali.ttf", "Arial Italic.ttf"]
|
|
else:
|
|
names = ["arial.ttf", "Arial.ttf"]
|
|
for name in names:
|
|
try:
|
|
return ImageFont.truetype(name, size)
|
|
except OSError:
|
|
pass
|
|
return ImageFont.load_default()
|
|
|
|
|
|
F = {
|
|
"title": font(23, True),
|
|
"subtitle": font(16, True),
|
|
"body": font(14),
|
|
"small": font(12),
|
|
"tiny": font(10),
|
|
"button": font(14, True),
|
|
"label": font(13, True),
|
|
}
|
|
|
|
|
|
class Screen:
|
|
def __init__(self, title: str):
|
|
self.title = title
|
|
self.img = Image.new("RGB", (CANVAS_W, CANVAS_H), "white")
|
|
self.d = ImageDraw.Draw(self.img)
|
|
self.phone_x = (CANVAS_W - PHONE_W) // 2
|
|
self.phone_y = 82
|
|
self.d.text((CANVAS_W // 2, 36), title, font=F["title"], fill="#111111", anchor="mm")
|
|
self.rounded(self.phone_x, self.phone_y, PHONE_W, PHONE_H, 36, "#ffffff", "#777777", 3)
|
|
self.rounded(self.phone_x + 155, self.phone_y + 12, 120, 18, 9, "#eeeeee", "#bbbbbb", 1)
|
|
self.x0 = self.phone_x + 28
|
|
self.y0 = self.phone_y + 54
|
|
self.w = PHONE_W - 56
|
|
self.bottom_y = self.phone_y + PHONE_H - 86
|
|
|
|
def rounded(self, x, y, w, h, r=10, fill="#f3f3f3", outline="#9a9a9a", width=2):
|
|
self.d.rounded_rectangle([x, y, x + w, y + h], radius=r, fill=fill, outline=outline, width=width)
|
|
|
|
def rect(self, x, y, w, h, fill="#f3f3f3", outline="#9a9a9a", width=2):
|
|
self.d.rectangle([x, y, x + w, y + h], fill=fill, outline=outline, width=width)
|
|
|
|
def line(self, x1, y1, x2, y2, fill="#9a9a9a", width=2):
|
|
self.d.line([x1, y1, x2, y2], fill=fill, width=width)
|
|
|
|
def text(self, x, y, txt, style="body", fill="#111111", anchor="la", max_width=None):
|
|
txt = str(txt)
|
|
if max_width is None:
|
|
self.d.text((x, y), txt, font=F[style], fill=fill, anchor=anchor)
|
|
return
|
|
words = txt.split()
|
|
lines = []
|
|
cur = ""
|
|
for word in words:
|
|
test = f"{cur} {word}".strip()
|
|
if self.d.textlength(test, font=F[style]) <= max_width:
|
|
cur = test
|
|
else:
|
|
if cur:
|
|
lines.append(cur)
|
|
cur = word
|
|
if cur:
|
|
lines.append(cur)
|
|
for i, line in enumerate(lines[:3]):
|
|
self.d.text((x, y + i * 17), line, font=F[style], fill=fill, anchor=anchor)
|
|
|
|
def header(self, title, sub=None, back=False):
|
|
self.rounded(self.phone_x + 1, self.phone_y + 34, PHONE_W - 2, 92, 22, "#dedede", "#999999", 2)
|
|
label = f"< {title}" if back else title
|
|
self.text(self.x0, self.phone_y + 78, label, "subtitle")
|
|
if sub:
|
|
self.text(self.x0, self.phone_y + 103, sub, "small", "#555555")
|
|
|
|
def input(self, x, y, label, placeholder="", w=None, eye=False):
|
|
w = w or self.w
|
|
self.text(x, y, label, "label")
|
|
self.rounded(x, y + 22, w, 44, 10, "#f7f7f7", "#999999", 2)
|
|
self.text(x + 14, y + 51, placeholder, "small", "#666666", anchor="lm")
|
|
if eye:
|
|
self.text(x + w - 25, y + 51, "eye", "tiny", "#555555", anchor="mm")
|
|
|
|
def button(self, x, y, label, w=None, dark=True):
|
|
w = w or self.w
|
|
fill = "#111111" if dark else "#f7f7f7"
|
|
outline = "#111111" if dark else "#999999"
|
|
text = "#ffffff" if dark else "#111111"
|
|
self.rounded(x, y, w, 46, 12, fill, outline, 2)
|
|
self.text(x + w / 2, y + 24, label, "button", text, anchor="mm")
|
|
|
|
def card(self, x, y, w, h, title=None, sub=None, right=None):
|
|
self.rounded(x, y, w, h, 14, "#f7f7f7", "#9a9a9a", 2)
|
|
if title:
|
|
self.text(x + 14, y + 18, title, "label", max_width=w - 28)
|
|
if sub:
|
|
self.text(x + 14, y + 42, sub, "small", "#555555", max_width=w - 28)
|
|
if right:
|
|
self.text(x + w - 14, y + 24, right, "small", "#111111", anchor="ra")
|
|
|
|
def bottom_nav(self, active="Home"):
|
|
y = self.phone_y + PHONE_H - 62
|
|
labels = ["Home", "Produk", "Stok", "Pred", "Lapor"]
|
|
item_w = self.w / len(labels)
|
|
for i, label in enumerate(labels):
|
|
x = self.x0 + i * item_w
|
|
if label == active:
|
|
self.rounded(x + 3, y, item_w - 6, 38, 8, "#111111", "#111111", 1)
|
|
self.text(x + item_w / 2, y + 22, label, "tiny", "#ffffff", anchor="mm")
|
|
else:
|
|
self.text(x + item_w / 2, y + 22, label, "tiny", "#555555", anchor="mm")
|
|
|
|
def plus(self, x, y):
|
|
self.rounded(x, y, 46, 46, 23, "#111111", "#111111", 2)
|
|
self.text(x + 23, y + 25, "+", "title", "#ffffff", anchor="mm")
|
|
|
|
def save(self, filename: str):
|
|
OUT_DIR.mkdir(exist_ok=True)
|
|
path = OUT_DIR / filename
|
|
self.img.save(path)
|
|
return path
|
|
|
|
|
|
def splash():
|
|
s = Screen("1. Splash Screen")
|
|
x, y = s.x0, s.phone_y + 220
|
|
s.rounded(x + 112, y, 150, 150, 28, "#e0e0e0", "#999999", 2)
|
|
s.text(x + 187, y + 65, "IKON", "label", "#555555", anchor="mm")
|
|
s.text(x + 187, y + 90, "BAHAN KUE", "tiny", "#555555", anchor="mm")
|
|
s.text(x + 187, y + 205, "Prediksi Stok", "title", anchor="mm")
|
|
s.text(x + 187, y + 235, "Bahan Kue", "title", anchor="mm")
|
|
s.text(x + 187, y + 315, "Memuat aplikasi...", "small", "#555555", anchor="mm")
|
|
s.text(x + 187, y + 350, ". . .", "subtitle", "#777777", anchor="mm")
|
|
return s.save("01_splash_screen.png")
|
|
|
|
|
|
def login():
|
|
s = Screen("2. Halaman Login")
|
|
x, y = s.x0, s.y0 + 35
|
|
s.rounded(x + 137, y, 100, 100, 22, "#dedede", "#999999", 2)
|
|
s.text(x + 187, y + 53, "LOGO", "label", "#555555", anchor="mm")
|
|
s.text(x + 187, y + 145, "Prediksi Stok Bahan Kue", "subtitle", anchor="mm")
|
|
s.input(x, y + 190, "Email", "Masukkan email")
|
|
s.input(x, y + 275, "Password", "Masukkan password", eye=True)
|
|
s.button(x, y + 380, "Masuk / Login")
|
|
s.text(x + s.w, y + 450, "Lupa Password", "small", "#555555", anchor="ra")
|
|
s.text(x + 187, y + 500, "Belum punya akun? Daftar", "small", "#111111", anchor="mm")
|
|
return s.save("02_login.png")
|
|
|
|
|
|
def register():
|
|
s = Screen("3. Halaman Daftar / Register")
|
|
s.header("Daftar Akun", "Buat akun baru", back=True)
|
|
x, y = s.x0, s.phone_y + 165
|
|
s.input(x, y, "Nama Pengguna", "Masukkan nama")
|
|
s.input(x, y + 82, "Email", "Masukkan email")
|
|
s.input(x, y + 164, "Password", "Masukkan password")
|
|
s.input(x, y + 246, "Konfirmasi Password", "Ulangi password")
|
|
s.button(x, y + 355, "Daftar")
|
|
s.text(x + 187, y + 425, "Sudah punya akun? Login", "small", "#111111", anchor="mm")
|
|
return s.save("03_register.png")
|
|
|
|
|
|
def dashboard():
|
|
s = Screen("4. Halaman Dashboard")
|
|
s.header("Prediksi Stok Bahan Kue", "Tobaku Sulastri")
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.card(x, y, 176, 76, "Jumlah Bahan", "24 item")
|
|
s.card(x + 198, y, 176, 76, "Stok Kritis", "3 bahan")
|
|
s.card(x, y + 92, 374, 70, "Prediksi Terbaru", "Tepung: siapkan 12 kg", "RF")
|
|
s.text(x, y + 195, "Menu Utama", "label")
|
|
menus = [("Produk/Stok", "Bahan"), ("Stok Masuk", "Tambah"), ("Prediksi", "Kebutuhan"), ("Laporan", "Rekap")]
|
|
for i, (a, b) in enumerate(menus):
|
|
cx = x + (i % 2) * 198
|
|
cy = y + 225 + (i // 2) * 98
|
|
s.card(cx, cy, 176, 78, a, b)
|
|
s.bottom_nav("Home")
|
|
return s.save("04_dashboard.png")
|
|
|
|
|
|
def products():
|
|
s = Screen("5. Data Produk / Stok Bahan")
|
|
s.header("Data Produk / Stok", "Daftar bahan kue", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
items = [
|
|
("Tepung Terigu", "Bahan | Stok 20 kg | Min 5 kg"),
|
|
("Gula Pasir", "Bahan | Stok 2 kg | Min 5 kg"),
|
|
("Telur", "Bahan | Stok 10 kg | Min 3 kg"),
|
|
("Mentega", "Bahan | Stok 4 kg | Min 2 kg"),
|
|
]
|
|
for i, (name, sub) in enumerate(items):
|
|
s.card(x, y + i * 88, s.w, 74, name, sub)
|
|
s.text(x + s.w - 70, y + i * 88 + 52, "Edit", "tiny", "#555555")
|
|
s.text(x + s.w - 30, y + i * 88 + 52, "Hapus", "tiny", "#555555", anchor="ra")
|
|
s.plus(s.phone_x + PHONE_W - 88, s.bottom_y - 16)
|
|
s.bottom_nav("Produk")
|
|
return s.save("05_data_produk_stok.png")
|
|
|
|
|
|
def product_form():
|
|
s = Screen("6. Tambah / Edit Produk")
|
|
s.header("Tambah / Edit Produk", "Input data bahan", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.input(x, y, "Nama Bahan", "Contoh: Tepung")
|
|
s.input(x, y + 82, "Kategori", "Bahan / Barang")
|
|
s.input(x, y + 164, "Jumlah Stok", "0")
|
|
s.input(x, y + 246, "Satuan", "kg / pcs")
|
|
s.input(x, y + 328, "Stok Minimum", "0")
|
|
s.button(x, y + 435, "Simpan")
|
|
return s.save("06_tambah_edit_produk.png")
|
|
|
|
|
|
def stock_in():
|
|
s = Screen("7. Halaman Stok Masuk")
|
|
s.header("Stok Masuk", "Riwayat penambahan stok", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
rows = [
|
|
("Tepung Terigu", "+10 kg | 01/06/2026", "Pembelian"),
|
|
("Gula Pasir", "+5 kg | 01/06/2026", "Restock"),
|
|
("Mentega", "+2 kg | 31/05/2026", "Supplier"),
|
|
("Telur", "+6 kg | 30/05/2026", "Pembelian"),
|
|
]
|
|
for i, (a, b, c) in enumerate(rows):
|
|
s.card(x, y + i * 88, s.w, 74, a, b)
|
|
s.text(x + 14, y + i * 88 + 59, c, "tiny", "#777777")
|
|
s.plus(s.phone_x + PHONE_W - 88, s.bottom_y - 16)
|
|
s.bottom_nav("Stok")
|
|
return s.save("07_stok_masuk.png")
|
|
|
|
|
|
def add_stock_in():
|
|
s = Screen("8. Tambah Stok Masuk")
|
|
s.header("Tambah Stok Masuk", "Stok bertambah otomatis", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.input(x, y, "Pilih Bahan", "Dropdown bahan")
|
|
s.input(x, y + 82, "Jumlah Stok Masuk", "0")
|
|
s.input(x, y + 164, "Tanggal", "Pilih tanggal")
|
|
s.input(x, y + 246, "Keterangan", "Opsional")
|
|
s.card(x, y + 335, s.w, 72, "Catatan", "Setelah disimpan, stok bahan bertambah otomatis.")
|
|
s.button(x, y + 435, "Simpan")
|
|
return s.save("08_tambah_stok_masuk.png")
|
|
|
|
|
|
def prediction():
|
|
s = Screen("9. Prediksi Kebutuhan Bahan")
|
|
s.header("Prediksi Kebutuhan", "Model utama: Random Forest", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.input(x, y, "Pilih Bahan / Produk", "Dropdown produk")
|
|
s.input(x, y + 82, "Periode Prediksi", "Harian / Mingguan / Bulanan")
|
|
s.input(x, y + 164, "Parameter Kebutuhan", "Jumlah produksi / input lain")
|
|
s.card(x, y + 255, s.w, 76, "Model Prediksi", "Random Forest digunakan sebagai model utama.")
|
|
s.button(x, y + 365, "Proses Prediksi")
|
|
s.bottom_nav("Pred")
|
|
return s.save("09_prediksi_kebutuhan.png")
|
|
|
|
|
|
def prediction_result():
|
|
s = Screen("10. Hasil Prediksi")
|
|
s.header("Hasil Prediksi", "Rekomendasi stok", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.card(x, y, s.w, 76, "Nama Bahan", "Tepung Terigu")
|
|
s.card(x, y + 92, s.w, 78, "Hasil Prediksi", "Permintaan: 12 kg")
|
|
s.card(x, y + 184, s.w, 78, "Rekomendasi", "Siapkan stok minimal 15 kg")
|
|
s.card(x, y + 276, s.w, 70, "Status Stok", "Aman / Kritis")
|
|
s.card(x, y + 360, s.w, 88, "Perbandingan Model", "Random Forest: 92% | Linear Regression: 78%")
|
|
s.button(x, y + 475, "Simpan Hasil")
|
|
s.button(x, y + 535, "Gunakan untuk Produksi", dark=False)
|
|
s.text(x, y + 600, "Jika digunakan, stok bahan berkurang otomatis.", "tiny", "#555555")
|
|
return s.save("10_hasil_prediksi.png")
|
|
|
|
|
|
def usage():
|
|
s = Screen("11. Penggunaan Bahan")
|
|
s.header("Penggunaan Bahan", "Pengurangan stok otomatis", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
rows = [
|
|
("Tepung Terigu", "Terpakai 3 kg | Sisa 17 kg"),
|
|
("Gula Pasir", "Terpakai 1 kg | Sisa 4 kg"),
|
|
("Telur", "Terpakai 2 kg | Sisa 8 kg"),
|
|
("Mentega", "Terpakai 0.5 kg | Sisa 3.5 kg"),
|
|
]
|
|
for i, (a, b) in enumerate(rows):
|
|
s.card(x, y + i * 88, s.w, 74, a, b)
|
|
s.text(x + 14, y + i * 88 + 59, "Tanggal: 01/06/2026", "tiny", "#777777")
|
|
s.card(x, y + 370, s.w, 64, "Info", "Stok bahan dikurangi otomatis saat produksi.")
|
|
s.plus(s.phone_x + PHONE_W - 88, s.bottom_y - 16)
|
|
return s.save("11_penggunaan_bahan.png")
|
|
|
|
|
|
def reports():
|
|
s = Screen("12. Halaman Laporan")
|
|
s.header("Laporan", "Rekap data sistem", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
cards = ["Laporan Stok Bahan", "Laporan Stok Masuk", "Laporan Penggunaan", "Laporan Hasil Prediksi"]
|
|
for i, title in enumerate(cards):
|
|
s.card(x, y + i * 76, s.w, 62, title, "Lihat detail")
|
|
s.rounded(x, y + 330, s.w, 150, 14, "#f7f7f7", "#999999", 2)
|
|
s.text(x + s.w / 2, y + 400, "Placeholder Grafik", "label", "#555555", anchor="mm")
|
|
s.line(x + 35, y + 445, x + s.w - 35, y + 355, "#aaaaaa", 2)
|
|
s.button(x, y + 515, "Export Laporan")
|
|
s.bottom_nav("Lapor")
|
|
return s.save("12_laporan.png")
|
|
|
|
|
|
def report_detail():
|
|
s = Screen("13. Detail Laporan")
|
|
s.header("Detail Laporan", "Data laporan terpilih", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
s.card(x, y, s.w, 78, "Ringkasan", "Total bahan: 24 | Kritis: 3")
|
|
s.text(x, y + 110, "Tabel Data", "label")
|
|
s.rounded(x, y + 135, s.w, 260, 12, "#f7f7f7", "#999999", 2)
|
|
headers = ["Nama", "Jumlah", "Status"]
|
|
for i, h in enumerate(headers):
|
|
s.text(x + 18 + i * 118, y + 168, h, "label")
|
|
for r in range(4):
|
|
yy = y + 205 + r * 42
|
|
s.line(x + 12, yy - 18, x + s.w - 12, yy - 18, "#d0d0d0", 1)
|
|
s.text(x + 18, yy, f"Data {r+1}", "small", "#555555")
|
|
s.text(x + 138, yy, "10 kg", "small", "#555555")
|
|
s.text(x + 258, yy, "Aman", "small", "#555555")
|
|
s.button(x, y + 440, "Export / Cetak Laporan")
|
|
return s.save("13_detail_laporan.png")
|
|
|
|
|
|
def settings():
|
|
s = Screen("14. Halaman Pengaturan")
|
|
s.header("Pengaturan", "Menu aplikasi", back=True)
|
|
x, y = s.x0, s.phone_y + 150
|
|
rows = ["Profil Pengguna", "Pengaturan Akun", "Tentang Aplikasi", "Bantuan"]
|
|
for i, row in enumerate(rows):
|
|
s.card(x, y + i * 82, s.w, 64, row, "Buka menu", ">")
|
|
s.button(x, y + 380, "Logout")
|
|
s.bottom_nav("Home")
|
|
return s.save("14_pengaturan.png")
|
|
|
|
|
|
def profile():
|
|
s = Screen("15. Halaman Profil")
|
|
s.header("Profil", "Informasi pengguna", back=True)
|
|
x, y = s.x0, s.phone_y + 155
|
|
s.rounded(x + 137, y, 100, 100, 50, "#dedede", "#999999", 2)
|
|
s.text(x + 187, y + 55, "Avatar", "tiny", "#555555", anchor="mm")
|
|
s.text(x + 187, y + 145, "Admin Sulastri", "subtitle", anchor="mm")
|
|
s.text(x + 187, y + 174, "admin@sulastri.com", "small", "#555555", anchor="mm")
|
|
s.card(x, y + 225, s.w, 72, "Nama Pengguna/Admin", "Admin Sulastri")
|
|
s.card(x, y + 313, s.w, 72, "Email", "admin@sulastri.com")
|
|
s.button(x, y + 420, "Edit Profil")
|
|
s.button(x, y + 480, "Logout", dark=False)
|
|
return s.save("15_profil.png")
|
|
|
|
|
|
BUILDERS = [
|
|
splash,
|
|
login,
|
|
register,
|
|
dashboard,
|
|
products,
|
|
product_form,
|
|
stock_in,
|
|
add_stock_in,
|
|
prediction,
|
|
prediction_result,
|
|
usage,
|
|
reports,
|
|
report_detail,
|
|
settings,
|
|
profile,
|
|
]
|
|
|
|
|
|
def combine(paths: list[Path]):
|
|
sheet_w = COLS * CANVAS_W + (COLS + 1) * MARGIN
|
|
sheet_h = ROWS * CANVAS_H + (ROWS + 1) * MARGIN + 72
|
|
sheet = Image.new("RGB", (sheet_w, sheet_h), "#f2f2f2")
|
|
draw = ImageDraw.Draw(sheet)
|
|
draw.text((sheet_w // 2, 45), "Wireframe Mobile Aplikasi Prediksi Stok Bahan Kue", font=font(30, True), fill="#111111", anchor="mm")
|
|
for idx, path in enumerate(paths):
|
|
img = Image.open(path)
|
|
row = idx // COLS
|
|
col = idx % COLS
|
|
x = MARGIN + col * (CANVAS_W + MARGIN)
|
|
y = 84 + MARGIN + row * (CANVAS_H + MARGIN)
|
|
draw.rounded_rectangle([x - 8, y - 8, x + CANVAS_W + 8, y + CANVAS_H + 8], radius=18, fill="#ffffff", outline="#d0d0d0", width=2)
|
|
sheet.paste(img, (x, y))
|
|
sheet.save(COMBINED)
|
|
|
|
|
|
def zip_outputs(paths: list[Path]):
|
|
if ZIP_PATH.exists():
|
|
ZIP_PATH.unlink()
|
|
with zipfile.ZipFile(ZIP_PATH, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for path in paths:
|
|
z.write(path, path.name)
|
|
z.write(COMBINED, COMBINED.name)
|
|
|
|
|
|
def main():
|
|
OUT_DIR.mkdir(exist_ok=True)
|
|
for old in OUT_DIR.glob("*.png"):
|
|
old.unlink()
|
|
paths = [builder() for builder in BUILDERS]
|
|
combine(paths)
|
|
zip_outputs(paths)
|
|
print(f"Combined wireframe: {COMBINED}")
|
|
print(f"Individual screens: {OUT_DIR}")
|
|
print(f"ZIP: {ZIP_PATH}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|