feat: add box/product auto-detection, numpad UI, touch screen support

- Auto-detect article type: 8-digit strings = BOX (strip leading zeros),
  anything else = PRODUCT
- Live mode indicator label with manual override toggle button
- On-screen numeric keypad (digits, decimal, backspace, Enter)
- Active field routing: numpad writes to whichever field is focused
- Scanner compatibility: Config keyboard_mode=system, refocus after
  each numpad press so HID scanner always has a target
- Reset Values now also redirects numpad/scanner focus to ID field
- Suppress OS virtual keyboard on touch for ID and mass fields
This commit is contained in:
2026-04-07 12:56:32 +03:00
parent d053aa5816
commit 68b241c3cb

136
main.py
View File

@@ -1,4 +1,6 @@
import threading
from kivy.config import Config
Config.set('kivy', 'keyboard_mode', 'system')
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.anchorlayout import AnchorLayout
@@ -17,6 +19,7 @@ class DatabaseApp(App):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.db_manager = DatabaseManager()
self.active_numpad_input = None
def build(self):
# Set window to fullscreen
@@ -29,8 +32,8 @@ class DatabaseApp(App):
main_layout = BoxLayout(orientation='vertical', padding=40, spacing=20,
size_hint=(1, 1), pos_hint={'x': 0, 'y': 0})
# Top spacer for vertical centering
main_layout.add_widget(Label(size_hint_y=0.15))
# Top spacer (reduced)
main_layout.add_widget(Label(size_hint_y=0.03))
# Content container - centered
content_layout = BoxLayout(orientation='vertical', spacing=30, size_hint_y=None)
@@ -52,6 +55,8 @@ class DatabaseApp(App):
padding=[10, 10]
)
self.id_input.bind(on_text_validate=self.search_record)
self.id_input.bind(on_text=self.update_mode_indicator)
self.id_input.bind(focus=self.on_id_input_focus)
search_layout.add_widget(self.id_input)
search_layout.add_widget(Label(text='Mass:', size_hint_x=0.25, font_size=20, bold=True))
self.mass_input = TextInput(
@@ -65,6 +70,28 @@ class DatabaseApp(App):
search_layout.add_widget(self.mass_input)
content_layout.add_widget(search_layout)
# Mode indicator row: label + override button
self.manual_override = None # None = auto, 'BOX' or 'PRODUCT' = manual
mode_row = BoxLayout(orientation='horizontal', size_hint_y=None, height=40, spacing=15)
self.mode_label = Label(
text='Article type detected: PRODUCT',
size_hint_x=0.75,
font_size=18,
bold=True,
color=(0.4, 0.8, 1, 1)
)
mode_row.add_widget(self.mode_label)
self.override_btn = Button(
text='Override type',
size_hint_x=0.25,
font_size=16,
bold=True,
background_color=(0.3, 0.3, 0.3, 1)
)
self.override_btn.bind(on_press=self.toggle_override)
mode_row.add_widget(self.override_btn)
content_layout.add_widget(mode_row)
# Button section - larger buttons (3 columns now)
button_layout = GridLayout(cols=3, size_hint_y=None, height=70, spacing=15)
add_update_btn = Button(text='Add/Update', font_size=22, bold=True)
@@ -91,6 +118,7 @@ class DatabaseApp(App):
update_inputs.add_widget(self.update_id_input)
update_inputs.add_widget(Label(text='Mass:', size_hint_x=0.25, font_size=20, bold=True))
self.update_mass_input = TextInput(multiline=False, size_hint_x=0.75, readonly=True, font_size=20, padding=[10, 10])
self.update_mass_input.bind(focus=self.on_mass_input_focus)
update_inputs.add_widget(self.update_mass_input)
self.update_frame.add_widget(update_inputs)
# Add update and delete buttons in same row
@@ -119,9 +147,29 @@ class DatabaseApp(App):
content_layout.add_widget(self.status_label)
main_layout.add_widget(content_layout)
# Bottom spacer for vertical centering
main_layout.add_widget(Label(size_hint_y=0.15))
# Numeric keypad — digits + decimal, backspace, enter
numpad_wrapper = BoxLayout(orientation='vertical', size_hint_y=None, height=320, spacing=8, padding=[40, 8, 40, 8])
numpad = GridLayout(cols=3, size_hint_y=None, height=240, spacing=8)
for digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9']:
btn = Button(text=digit, font_size=28, bold=True)
btn.bind(on_press=self.numpad_press)
numpad.add_widget(btn)
dot_btn = Button(text='.', font_size=28, bold=True, background_color=(0.25, 0.25, 0.45, 1))
dot_btn.bind(on_press=self.numpad_press)
numpad.add_widget(dot_btn)
zero_btn = Button(text='0', font_size=28, bold=True)
zero_btn.bind(on_press=self.numpad_press)
numpad.add_widget(zero_btn)
back_btn = Button(text='', font_size=28, bold=True, background_color=(0.5, 0.3, 0.1, 1))
back_btn.bind(on_press=self.numpad_backspace)
numpad.add_widget(back_btn)
numpad_wrapper.add_widget(numpad)
enter_btn = Button(text='Enter', font_size=26, bold=True,
background_color=(0.1, 0.55, 0.1, 1), size_hint_y=None, height=64)
enter_btn.bind(on_press=self.numpad_enter)
numpad_wrapper.add_widget(enter_btn)
main_layout.add_widget(numpad_wrapper)
root.add_widget(main_layout)
@@ -148,12 +196,84 @@ class DatabaseApp(App):
Clock.schedule_once(_init_db, 0.1)
return root
def on_id_input_focus(self, instance, focused):
"""Track active numpad target when ID field gains focus."""
if focused:
self.active_numpad_input = self.id_input
def on_mass_input_focus(self, instance, focused):
"""Track active numpad target when mass field gains focus."""
if focused:
self.active_numpad_input = self.update_mass_input
def _refocus_active(self):
"""Return keyboard focus to the active field so scanner input keeps working."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
Clock.schedule_once(lambda dt: setattr(target, 'focus', True), 0.05)
def numpad_enter(self, instance):
"""Enter key: trigger search if ID field is active, then refocus."""
if self.active_numpad_input is self.id_input or self.active_numpad_input is None:
self.search_record(instance)
self._refocus_active()
def numpad_press(self, instance):
"""Append a digit/dot to the active input field, then refocus for scanner."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
target.text += instance.text
self._refocus_active()
def numpad_backspace(self, instance):
"""Remove the last character from the active input field, then refocus for scanner."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
target.text = target.text[:-1]
self._refocus_active()
def set_update_frame_enabled(self, enabled):
self.update_id_input.readonly = not enabled
self.update_mass_input.readonly = not enabled
self.update_confirm_btn.disabled = not enabled
self.delete_btn.disabled = not enabled
def _resolve_id(self, raw_id: str) -> str:
"""Resolve ID using manual override if set, otherwise auto-detect."""
mode = self.manual_override if self.manual_override else (
'BOX' if (len(raw_id) == 8 and raw_id.isdigit()) else 'PRODUCT'
)
if mode == 'BOX':
return raw_id.lstrip('0') or '0'
return raw_id
def toggle_override(self, instance):
"""Manually flip the detected type between BOX and PRODUCT."""
current_auto = 'BOX' if (len(self.id_input.text.strip()) == 8 and self.id_input.text.strip().isdigit()) else 'PRODUCT'
# Determine current effective mode
effective = self.manual_override if self.manual_override else current_auto
# Flip it
self.manual_override = 'PRODUCT' if effective == 'BOX' else 'BOX'
self._apply_mode_label(self.manual_override, is_override=True)
def update_mode_indicator(self, instance, value):
"""Auto-detect mode on each keystroke; resets any manual override."""
self.manual_override = None
text = value.strip()
mode = 'BOX' if (len(text) == 8 and text.isdigit()) else 'PRODUCT'
self._apply_mode_label(mode, is_override=False)
def _apply_mode_label(self, mode, is_override):
"""Update the mode label and override button appearance."""
prefix = '[Manual] ' if is_override else ''
if mode == 'BOX':
self.mode_label.text = f'{prefix}Article type detected: BOX'
self.mode_label.color = (1, 0.75, 0, 1)
else:
self.mode_label.text = f'{prefix}Article type detected: PRODUCT'
self.mode_label.color = (0.4, 0.8, 1, 1)
self.override_btn.background_color = (0.6, 0.2, 0.6, 1) if is_override else (0.3, 0.3, 0.3, 1)
def show_update_frame(self, instance):
# If no value in search, copy from search fields
record_id = self.id_input.text.strip()
@@ -179,14 +299,15 @@ class DatabaseApp(App):
self.show_status("Searching...", error=False)
resolved_id = self._resolve_id(record_id)
def _do():
try:
record = self.db_manager.search_by_id(record_id)
record = self.db_manager.search_by_id(resolved_id)
def _update(dt):
if record:
self.mass_input.text = str(record[1])
self.show_status(f"Found: {record[0]} = {record[1]}")
self.highlight_record(record_id)
self.highlight_record(resolved_id)
else:
self.show_status(f"ID '{record_id}' not found in database", error=True)
self.mass_input.text = ""
@@ -276,6 +397,7 @@ class DatabaseApp(App):
"""Reset/clear the first ID and mass fields and set focus on ID field."""
self.id_input.text = ""
self.mass_input.text = ""
self.active_numpad_input = self.id_input
self.id_input.focus = True
self.show_status("Fields cleared", error=False)