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 from kivy.uix.floatlayout import FloatLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.textinput import TextInput from kivy.uix.button import Button from kivy.uix.scrollview import ScrollView from kivy.uix.popup import Popup from kivy.clock import Clock from kivy.core.window import Window from database_manager import DatabaseManager 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 first so Window.height reflects the screen Window.fullscreen = 'auto' # ------------------------------------------------------------------ # Responsive sizing: derive all dimensions from the actual screen height # so the layout fits on any display (800p, 900p, 1080p, etc.) # ------------------------------------------------------------------ wh = Window.height # actual screen height after fullscreen # Numpad occupies 29% of screen height (fixed proportion) h_numpad_wr = int(wh * 0.29) # Main layout outer padding and spacing (scaled) s = max(0.65, min(1.0, wh / 1080.0)) pad_v = max(8, int(20 * s)) # top / bottom padding pad_h = max(8, int(30 * s)) # left / right padding m_spacing = max(4, int(10 * s)) # gap between content and numpad # Space available for the 6 content rows (after numpad + padding + gap) avail_total = wh - h_numpad_wr - 2 * pad_v - m_spacing c_spacing = max(4, int(12 * s)) # gap between content rows avail_items = avail_total - c_spacing * 5 # 6 rows → 5 gaps # Distribute height proportionally among rows # Reference weights: title=50, search=100, mode=38, buttons=65, update=187, status=40 _w = [50, 100, 38, 65, 187, 40] _t = sum(_w) def _h(weight): return max(24, int(avail_items * weight / _t)) h_title = _h(_w[0]) h_search = _h(_w[1]) h_row = max(20, h_search // 2) h_mode = _h(_w[2]) h_buttons = _h(_w[3]) h_update = _h(_w[4]) h_status = _h(_w[5]) # Update-frame internal heights upd_pad = max(4, int(8 * s)) upd_spc = max(4, int(8 * s)) h_upd_title = max(20, int(h_update * 0.20)) h_upd_row = max(20, int(h_update * 0.24)) h_upd_inputs = h_upd_row * 2 + upd_spc h_upd_btns = max(28, h_update - h_upd_title - h_upd_inputs - 2*upd_pad - 2*upd_spc) # Numpad internal heights np_pad_v = max(3, int(6 * s)) np_spc = max(3, int(6 * s)) h_enter_btn = max(34, int(h_numpad_wr * 0.24)) h_numpad_gr = h_numpad_wr - h_enter_btn - 2 * np_pad_v - np_spc # Font sizes (scaled) f_title = max(14, int(26 * s)) f_normal = max(11, int(18 * s)) f_btn = max(12, int(20 * s)) f_numpad = max(15, int(26 * s)) f_enter = max(14, int(24 * s)) f_mode = max(10, int(16 * s)) f_override = max(10, int(14 * s)) f_status = max(12, int(20 * s)) sp = max(6, int(10 * s)) # generic widget spacing # ------------------------------------------------------------------ # Build UI # ------------------------------------------------------------------ root = FloatLayout() main_layout = BoxLayout( orientation='vertical', padding=[pad_h, pad_v, pad_h, pad_v], spacing=m_spacing, size_hint=(1, 1), pos_hint={'x': 0, 'y': 0} ) # --- Content container (fills remaining space above numpad) --- content_layout = BoxLayout(orientation='vertical', spacing=c_spacing, size_hint_y=None) content_layout.bind(minimum_height=content_layout.setter('height')) # Title row: title + Exit button title_row = BoxLayout(orientation='horizontal', size_hint_y=None, height=h_title, spacing=sp) title = Label(text='Database Search & Update', font_size=f_title, bold=True) title_row.add_widget(title) exit_btn = Button( text='Exit', font_size=max(11, int(16 * s)), bold=True, background_color=(0.85, 0.1, 0.1, 1), color=(1, 1, 1, 1), size_hint_x=None, width=max(70, int(100 * s)) ) exit_btn.bind(on_press=lambda x: self.stop()) title_row.add_widget(exit_btn) content_layout.add_widget(title_row) # Search section (ID + Mass) search_layout = GridLayout( cols=2, size_hint_y=None, height=h_search, spacing=sp, row_force_default=True, row_default_height=h_row ) search_layout.add_widget(Label(text='ID:', size_hint_x=0.25, font_size=f_normal, bold=True)) self.id_input = TextInput( multiline=False, size_hint_x=0.75, hint_text='Enter ID (max 20 chars)', readonly=False, font_size=f_normal, padding=[7, 7] ) 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=f_normal, bold=True)) self.mass_input = TextInput( multiline=False, size_hint_x=0.75, hint_text='Mass (read-only)', readonly=True, font_size=f_normal, padding=[7, 7] ) search_layout.add_widget(self.mass_input) content_layout.add_widget(search_layout) # Mode indicator row self.manual_override = None mode_row = BoxLayout(orientation='horizontal', size_hint_y=None, height=h_mode, spacing=sp) self.mode_label = Label( text='Article type detected: PRODUCT', size_hint_x=0.75, font_size=f_mode, 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=f_override, 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) # Action buttons (Add/Update, Reset, Settings) button_layout = GridLayout(cols=3, size_hint_y=None, height=h_buttons, spacing=sp) add_update_btn = Button(text='Add/Update', font_size=f_btn, bold=True) add_update_btn.bind(on_press=self.show_update_frame) button_layout.add_widget(add_update_btn) reset_btn = Button(text='Reset Values', font_size=f_btn, bold=True) reset_btn.bind(on_press=self.reset_values) button_layout.add_widget(reset_btn) settings_btn = Button(text='Settings', font_size=f_btn, bold=True) settings_btn.bind(on_press=self.show_settings) button_layout.add_widget(settings_btn) content_layout.add_widget(button_layout) # Update frame self.update_frame = BoxLayout( orientation='vertical', padding=upd_pad, spacing=upd_spc, size_hint_y=None, height=h_update ) self.update_frame_label = Label( text='Update Values', size_hint_y=None, height=h_upd_title, font_size=f_btn, bold=True ) self.update_frame.add_widget(self.update_frame_label) update_inputs = GridLayout( cols=2, spacing=sp, size_hint_y=None, height=h_upd_inputs, row_force_default=True, row_default_height=h_upd_row ) update_inputs.add_widget(Label(text='ID:', size_hint_x=0.25, font_size=f_normal, bold=True)) self.update_id_input = TextInput( multiline=False, size_hint_x=0.75, readonly=True, font_size=f_normal, padding=[7, 7] ) update_inputs.add_widget(self.update_id_input) update_inputs.add_widget(Label(text='Mass:', size_hint_x=0.25, font_size=f_normal, bold=True)) self.update_mass_input = TextInput( multiline=False, size_hint_x=0.75, readonly=True, font_size=f_normal, padding=[7, 7] ) 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) update_buttons = GridLayout(cols=2, size_hint_y=None, height=h_upd_btns, spacing=sp) self.update_confirm_btn = Button(text='Confirm Add/Update', disabled=True, font_size=f_btn, bold=True) self.update_confirm_btn.bind(on_press=self.add_update_record) update_buttons.add_widget(self.update_confirm_btn) self.delete_btn = Button(text='Delete', disabled=True, font_size=f_btn, bold=True) self.delete_btn.bind(on_press=self.delete_record) update_buttons.add_widget(self.delete_btn) self.update_frame.add_widget(update_buttons) content_layout.add_widget(self.update_frame) self.set_update_frame_enabled(False) # Status label self.status_label = Label( text='Ready', size_hint_y=None, height=h_status, color=(0, 0.8, 0, 1), font_size=f_status, bold=True ) content_layout.add_widget(self.status_label) # Wrap content in an AnchorLayout that fills all space above the numpad # so the content block is vertically centred regardless of screen size content_anchor = AnchorLayout(anchor_x='center', anchor_y='center', size_hint_y=1) content_anchor.add_widget(content_layout) main_layout.add_widget(content_anchor) # --- Numeric keypad --- numpad_wrapper = BoxLayout( orientation='vertical', size_hint_y=None, height=h_numpad_wr, spacing=np_spc, padding=[pad_h, np_pad_v, pad_h, np_pad_v] ) numpad = GridLayout(cols=3, size_hint_y=None, height=h_numpad_gr, spacing=max(3, int(6 * s))) for digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: btn = Button(text=digit, font_size=f_numpad, bold=True) btn.bind(on_press=self.numpad_press) numpad.add_widget(btn) dot_btn = Button(text='.', font_size=f_numpad, 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=f_numpad, bold=True) zero_btn.bind(on_press=self.numpad_press) numpad.add_widget(zero_btn) back_btn = Button(text='⌫', font_size=f_numpad, 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=f_enter, bold=True, background_color=(0.1, 0.55, 0.1, 1), size_hint_y=None, height=h_enter_btn ) 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) # Init DB and load data in background so the UI appears immediately def _init_db(dt): def _do(): self.db_manager.init_database() Clock.schedule_once(lambda dt2: self.refresh_data(None)) threading.Thread(target=_do, daemon=True).start() 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() mass_text = self.mass_input.text.strip() self.set_update_frame_enabled(True) # If mass field is empty, just clear update frame if not record_id: self.update_id_input.text = '' self.update_mass_input.text = '' return self.update_id_input.text = record_id self.update_mass_input.text = mass_text def search_record(self, instance): record_id = self.id_input.text.strip() if not record_id: self.show_status("Please enter an ID to search", error=True) return if len(record_id) > 20: self.show_status("ID must be 20 characters or less", error=True) return self.show_status("Searching...", error=False) resolved_id = self._resolve_id(record_id) def _do(): try: 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(resolved_id) else: self.show_status(f"ID '{record_id}' not found in database", error=True) self.mass_input.text = "" Clock.schedule_once(_update) except Exception as e: err = str(e) Clock.schedule_once(lambda dt: self.show_status(f"Search error: {err}", error=True)) threading.Thread(target=_do, daemon=True).start() def add_update_record(self, instance): """Add or update a record from the update frame.""" record_id = self.update_id_input.text.strip() mass_text = self.update_mass_input.text.strip() if not record_id or not mass_text: self.show_status("Please enter both ID and mass in update frame", error=True) return if len(record_id) > 20: self.show_status("ID must be 20 characters or less", error=True) return try: mass = float(mass_text) except ValueError: self.show_status("Mass must be a valid number", error=True) return self.show_status("Saving...", error=False) def _do(): try: success = self.db_manager.add_or_update_record(record_id, mass) def _update(dt): if success: self.show_status(f"Successfully added/updated: {record_id} = {mass}") self.update_id_input.text = "" self.update_mass_input.text = "" self.set_update_frame_enabled(False) self.refresh_data(None) else: self.show_status("Failed to add/update record", error=True) Clock.schedule_once(_update) except Exception as e: err = str(e) Clock.schedule_once(lambda dt: self.show_status(f"Save error: {err}", error=True)) threading.Thread(target=_do, daemon=True).start() def delete_record(self, instance): """Delete a record using the update frame fields.""" record_id = self.update_id_input.text.strip() if not record_id: self.show_status("Please enter an ID in the update fields to delete", error=True) return # Confirm deletion self.show_confirmation_popup( f"Are you sure you want to delete ID '{record_id}'?", lambda: self.confirm_delete(record_id) ) def confirm_delete(self, record_id): """Confirm and execute deletion.""" self.show_status("Deleting...", error=False) def _do(): try: success = self.db_manager.delete_record(record_id) def _update(dt): if success: self.show_status(f"Successfully deleted: {record_id}") self.clear_fields() self.update_id_input.text = "" self.update_mass_input.text = "" self.set_update_frame_enabled(False) self.refresh_data(None) else: self.show_status(f"Failed to delete ID '{record_id}' (not found)", error=True) Clock.schedule_once(_update) except Exception as e: err = str(e) Clock.schedule_once(lambda dt: self.show_status(f"Delete error: {err}", error=True)) threading.Thread(target=_do, daemon=True).start() def clear_fields(self): """Clear the ID and mass fields.""" self.id_input.text = "" self.mass_input.text = "" def reset_values(self, instance): """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) def refresh_data(self, instance): """Refresh the data display (runs in a background thread).""" def _do(): try: records = self.db_manager.read_all_data() count = len(records) Clock.schedule_once(lambda dt: self.show_status(f"Data refreshed - {count} records found")) except Exception as e: err = str(e) Clock.schedule_once(lambda dt: self.show_status(f"Error refreshing data: {err}", error=True)) threading.Thread(target=_do, daemon=True).start() def highlight_record(self, record_id): """Highlight a specific record in the display.""" # Since we removed the data display, just show a status message pass def show_status(self, message, error=False): """Show status message.""" self.status_label.text = message if error: self.status_label.color = (1, 0.2, 0.2, 1) # Red for errors else: self.status_label.color = (0, 0.8, 0, 1) # Green for success # Clear status after 5 seconds Clock.schedule_once(lambda dt: self.clear_status(), 5) def clear_status(self): """Clear the status message.""" self.status_label.text = "Ready" self.status_label.color = (0, 0.8, 0, 1) def show_settings(self, instance): """Show settings popup for database configuration.""" content = BoxLayout(orientation='vertical', spacing=15, padding=20) # Title title_label = Label(text='Database Server Settings', font_size=24, bold=True, size_hint_y=None, height=40) content.add_widget(title_label) # IP address input ip_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=60, spacing=10) ip_layout.add_widget(Label(text='Server IP Address:', font_size=20, bold=True, size_hint_x=0.4)) ip_input = TextInput( text=self.db_manager.host, multiline=False, font_size=20, size_hint_x=0.6, padding=[10, 10] ) ip_layout.add_widget(ip_input) content.add_widget(ip_layout) # Info label info_label = Label( text='Enter the IP address or hostname of the database server.\nOther connection settings remain unchanged.', font_size=16, size_hint_y=None, height=60 ) content.add_widget(info_label) # Test connection button + result label test_btn = Button(text='Test Connection', font_size=20, bold=True, size_hint_y=None, height=55) content.add_widget(test_btn) test_result_label = Label( text='', font_size=17, size_hint_y=None, height=40, bold=True ) content.add_widget(test_result_label) # Buttons buttons = BoxLayout(size_hint_y=None, height=60, spacing=15) save_btn = Button(text='Save', font_size=20, bold=True) cancel_btn = Button(text='Cancel', font_size=20, bold=True) buttons.add_widget(save_btn) buttons.add_widget(cancel_btn) content.add_widget(buttons) popup = Popup( title='Settings', content=content, size_hint=(0.6, 0.65) ) def save_settings(): new_host = ip_input.text.strip() if new_host: self.db_manager.host = new_host self.db_manager.save_host(new_host) # Reconnect with new settings if self.db_manager.connection: try: self.db_manager.connection.close() except: pass self.db_manager.connection = None self.show_status(f"Database server updated to: {new_host}") popup.dismiss() else: self.show_status("Please enter a valid IP address", error=True) def test_connection(instance): host = ip_input.text.strip() if not host: test_result_label.text = 'Please enter a host first.' test_result_label.color = (1, 0.6, 0, 1) return test_result_label.text = 'Testing...' test_result_label.color = (1, 1, 1, 1) def _do(): success, message = self.db_manager.test_connection(host) def _update(dt): if success: test_result_label.text = f'OK: {message}' test_result_label.color = (0, 0.85, 0, 1) else: test_result_label.text = f'Failed: {message}' test_result_label.color = (1, 0.2, 0.2, 1) Clock.schedule_once(_update) threading.Thread(target=_do, daemon=True).start() test_btn.bind(on_press=test_connection) save_btn.bind(on_press=lambda x: save_settings()) cancel_btn.bind(on_press=popup.dismiss) popup.open() def show_confirmation_popup(self, message, confirm_callback): """Show a confirmation popup.""" content = BoxLayout(orientation='vertical', spacing=10, padding=10) label = Label(text=message, text_size=(300, None), halign='center') content.add_widget(label) buttons = BoxLayout(size_hint_y=None, height=50, spacing=10) yes_btn = Button(text='Yes') no_btn = Button(text='No') buttons.add_widget(yes_btn) buttons.add_widget(no_btn) content.add_widget(buttons) popup = Popup( title='Confirm Action', content=content, size_hint=(0.8, 0.4) ) yes_btn.bind(on_press=lambda x: (confirm_callback(), popup.dismiss())) no_btn.bind(on_press=popup.dismiss) popup.open() if __name__ == '__main__': DatabaseApp().run()