SaaS-FrameWork 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. components/__init__.py +119 -0
  2. components/buttons/DEx_BtnFAB.py +37 -0
  3. components/buttons/DEx_BtnFilled.py +20 -0
  4. components/buttons/DEx_BtnGhost.py +20 -0
  5. components/buttons/DEx_BtnIcon.py +18 -0
  6. components/buttons/DEx_BtnPrimary.py +21 -0
  7. components/buttons/DEx_BtnSegmented.py +45 -0
  8. components/buttons/DEx_BtnText.py +19 -0
  9. components/buttons/DEx_BtnTonal.py +20 -0
  10. components/buttons/DEx_MenuBar.py +61 -0
  11. components/buttons/DEx_PopupMenu.py +43 -0
  12. components/buttons/__init__.py +0 -0
  13. components/cards/DEx_Card.py +23 -0
  14. components/cards/DEx_ContactCard.py +52 -0
  15. components/cards/DEx_MetricCard.py +36 -0
  16. components/cards/__init__.py +0 -0
  17. components/data/DEx_DataTable.py +82 -0
  18. components/data/DEx_ExpansionPanel.py +39 -0
  19. components/data/DEx_ExpansionTile.py +29 -0
  20. components/data/DEx_GridView.py +65 -0
  21. components/data/DEx_ListTile.py +27 -0
  22. components/data/DEx_ListView.py +65 -0
  23. components/data/DEx_PipelineColumn.py +74 -0
  24. components/data/DEx_TreeView.py +536 -0
  25. components/data/__init__.py +0 -0
  26. components/feedback/DEx_AlertDialog.py +26 -0
  27. components/feedback/DEx_Banner.py +19 -0
  28. components/feedback/DEx_BottomSheet.py +37 -0
  29. components/feedback/DEx_Notify.py +15 -0
  30. components/feedback/DEx_ProgressBar.py +34 -0
  31. components/feedback/DEx_ProgressRing.py +52 -0
  32. components/feedback/DEx_Shimmer.py +84 -0
  33. components/feedback/DEx_Tooltip.py +24 -0
  34. components/feedback/__init__.py +0 -0
  35. components/inputs/DEx_AutoComplete.py +29 -0
  36. components/inputs/DEx_Checkbox.py +27 -0
  37. components/inputs/DEx_Chip.py +33 -0
  38. components/inputs/DEx_DatePicker.py +72 -0
  39. components/inputs/DEx_Dropdown.py +25 -0
  40. components/inputs/DEx_FilePicker.py +70 -0
  41. components/inputs/DEx_Radio.py +37 -0
  42. components/inputs/DEx_SearchBar.py +37 -0
  43. components/inputs/DEx_SearchField.py +9 -0
  44. components/inputs/DEx_Slider.py +49 -0
  45. components/inputs/DEx_Switch.py +22 -0
  46. components/inputs/DEx_TextField.py +29 -0
  47. components/inputs/DEx_TimePicker.py +71 -0
  48. components/inputs/__init__.py +0 -0
  49. components/layout/DEx_AnimatedSwitcher.py +43 -0
  50. components/layout/DEx_AppBar.py +48 -0
  51. components/layout/DEx_AppShell.py +80 -0
  52. components/layout/DEx_Draggable.py +67 -0
  53. components/layout/DEx_GestureDetector.py +52 -0
  54. components/layout/DEx_Header.py +57 -0
  55. components/layout/DEx_NavItem.py +61 -0
  56. components/layout/DEx_NavigationBar.py +30 -0
  57. components/layout/DEx_NavigationDrawer.py +60 -0
  58. components/layout/DEx_NavigationRail.py +42 -0
  59. components/layout/DEx_ResponsiveRow.py +42 -0
  60. components/layout/DEx_Sidebar.py +131 -0
  61. components/layout/DEx_Stack.py +73 -0
  62. components/layout/DEx_Tabs.py +110 -0
  63. components/layout/__init__.py +0 -0
  64. components/metadata.py +98 -0
  65. components/primitives/DEx_Avatar.py +87 -0
  66. components/primitives/DEx_Badge.py +18 -0
  67. components/primitives/DEx_Divider.py +13 -0
  68. components/primitives/DEx_Icon.py +46 -0
  69. components/primitives/DEx_Image.py +55 -0
  70. components/primitives/DEx_Markdown.py +21 -0
  71. components/primitives/DEx_SectionEyebrow.py +22 -0
  72. components/primitives/DEx_StatusDot.py +8 -0
  73. components/primitives/__init__.py +0 -0
  74. components/project.py +23 -0
  75. components/theme/DEx_ColorThemes.py +375 -0
  76. components/theme/DEx_Theme.py +136 -0
  77. components/theme/__init__.py +4 -0
  78. components/versioning.py +103 -0
  79. saas_framework-0.1.1.dist-info/METADATA +450 -0
  80. saas_framework-0.1.1.dist-info/RECORD +82 -0
  81. saas_framework-0.1.1.dist-info/WHEEL +5 -0
  82. saas_framework-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,536 @@
1
+ """Hierarchical tree view with search, selection, and expand/collapse controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import flet as ft
11
+
12
+ from components.theme.DEx_Theme import Colors, Spacing
13
+
14
+
15
+ COMPONENT_VERSION = "0.0.1"
16
+
17
+
18
+ class DEx_TreeView(ft.Column):
19
+ """Versioned hierarchical tree with recursive search and single-node selection.
20
+
21
+ `DEx_TreeView` accepts data as a Python list, a dict with a `menu` key, a JSON
22
+ string, or a JSON file path. Each node supports `label`, `type`, `icon`, and
23
+ `children`. Labels using the format `(ID) - Text` render the ID separately so
24
+ menus and module trees stay scannable.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ menu_path: str | Path | None = None,
30
+ menu_data: dict | list | str | None = None,
31
+ *,
32
+ on_select: Callable[["_DEx_TreeNode"], None] | None = None,
33
+ on_insert: Callable[["DEx_TreeView"], None] | None = None,
34
+ show_icon: bool = True,
35
+ indent_step: int = 15,
36
+ toggle_size: int = 18,
37
+ dex_id: str = "",
38
+ on_dex_ready: Callable[["DEx_TreeView"], None] | None = None,
39
+ **kwargs: Any,
40
+ ) -> None:
41
+ super().__init__(**kwargs)
42
+ self.menu_path = Path(menu_path) if menu_path else None
43
+ self._menu_data_provided = menu_data is not None
44
+ self.menu_data = [] if menu_data is None else menu_data
45
+ self.external_on_select = on_select
46
+ self.external_on_insert = on_insert
47
+ self.show_icon = show_icon
48
+ self.indent_step = indent_step
49
+ self.toggle_size = toggle_size
50
+ self.dex_id = dex_id
51
+ self._on_dex_ready = on_dex_ready
52
+
53
+ self.selected_node: dict[str, _DEx_TreeNode | None] = {"node": None}
54
+ self.root_nodes: list[_DEx_TreeNode] = []
55
+ self.tree_col = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
56
+ self.tree_view = ft.Row([self.tree_col], scroll=ft.ScrollMode.AUTO, expand=True)
57
+
58
+ self.search_field = self._build_search_field()
59
+ self._build_ui()
60
+ self.reload()
61
+
62
+ def reload(self) -> None:
63
+ """Reload tree data from `menu_data` or `menu_path` and rebuild root nodes."""
64
+ data = self._load_data()
65
+ menu_items = self._extract_menu_list(data)
66
+ self.root_nodes = self._build_tree(menu_items, level=0)
67
+ self.tree_col.controls = [node.control for node in self.root_nodes]
68
+ self.selected_node["node"] = None
69
+ self._update_tree()
70
+
71
+ def collapse_all(self, e: ft.ControlEvent | None = None) -> None:
72
+ """Collapse every tree node."""
73
+ for node in self.root_nodes:
74
+ _set_expanded_node(node, False)
75
+ self._update_tree()
76
+
77
+ def expand_all(self, e: ft.ControlEvent | None = None) -> None:
78
+ """Expand every tree node."""
79
+ for node in self.root_nodes:
80
+ _set_expanded_node(node, True)
81
+ self._update_tree()
82
+
83
+ def collapse_selected(self, e: ft.ControlEvent | None = None) -> None:
84
+ """Collapse the currently selected node."""
85
+ node = self.selected_node["node"]
86
+ if node is None:
87
+ return
88
+ node.expanded = False
89
+ node.refresh()
90
+ self._update_tree()
91
+
92
+ def expand_selected(self, e: ft.ControlEvent | None = None) -> None:
93
+ """Expand the currently selected node."""
94
+ node = self.selected_node["node"]
95
+ if node is None:
96
+ return
97
+ node.expanded = True
98
+ node.refresh()
99
+ self._update_tree()
100
+
101
+ def refresh_selected(self, e: ft.ControlEvent | None = None) -> None:
102
+ """Refresh visual state for the currently selected node."""
103
+ node = self.selected_node["node"]
104
+ if node is None:
105
+ return
106
+ node.refresh()
107
+ self._update_tree()
108
+
109
+ def insert(self, e: ft.ControlEvent | None = None) -> None:
110
+ """Run the optional insert callback for the host application."""
111
+ if self.external_on_insert:
112
+ self.external_on_insert(self)
113
+
114
+ def on_keyboard_event(self, e: ft.KeyboardEvent) -> None:
115
+ """Move selection with arrow up/down and page up/down keys."""
116
+ key = (e.key or "").lower()
117
+ if key in ("arrow up", "up"):
118
+ self._move_selection(-1)
119
+ elif key in ("arrow down", "down"):
120
+ self._move_selection(1)
121
+ elif key in ("page up", "pageup"):
122
+ self._move_selection(-10)
123
+ elif key in ("page down", "pagedown"):
124
+ self._move_selection(10)
125
+
126
+ def did_mount(self) -> None:
127
+ """Notify the host when Flet mounts the component."""
128
+ super().did_mount()
129
+ if self._on_dex_ready:
130
+ self._on_dex_ready(self)
131
+
132
+ def _build_ui(self) -> None:
133
+ self.controls = [
134
+ ft.Container(
135
+ content=ft.Row(
136
+ [
137
+ self.search_field,
138
+ self._icon_button(ft.Icons.CLEAR_ROUNDED, "Limpar busca", self._clear_search),
139
+ ],
140
+ spacing=Spacing.SM,
141
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
142
+ ),
143
+ padding=ft.Padding(Spacing.SM, Spacing.SM, Spacing.SM, Spacing.XS),
144
+ ),
145
+ ft.Container(height=1, bgcolor=Colors.BORDER),
146
+ ft.Container(
147
+ content=self.tree_view,
148
+ alignment=ft.Alignment(-1, -1),
149
+ bgcolor=Colors.BG_DEEP,
150
+ expand=True,
151
+ ),
152
+ ft.Container(height=1, bgcolor=Colors.BORDER),
153
+ ft.Container(
154
+ content=ft.Row(
155
+ [
156
+ self._icon_button(ft.Icons.UNFOLD_LESS_ROUNDED, "Contrair tudo", self.collapse_all),
157
+ self._icon_button(ft.Icons.KEYBOARD_ARROW_UP_ROUNDED, "Contrair selecionado", self.collapse_selected),
158
+ self._icon_button(ft.Icons.REFRESH_ROUNDED, "Atualizar selecionado", self.refresh_selected),
159
+ self._icon_button(ft.Icons.KEYBOARD_ARROW_DOWN_ROUNDED, "Expandir selecionado", self.expand_selected),
160
+ self._icon_button(ft.Icons.UNFOLD_MORE_ROUNDED, "Expandir tudo", self.expand_all),
161
+ ft.Container(expand=True),
162
+ self._icon_button(ft.Icons.ADD_ROUNDED, "Novo registro", self.insert, size=24, box_size=40),
163
+ ],
164
+ spacing=Spacing.SM,
165
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
166
+ ),
167
+ padding=ft.Padding(Spacing.SM, Spacing.XS, Spacing.SM, Spacing.SM),
168
+ ),
169
+ ]
170
+ self.spacing = 0
171
+ self.expand = True
172
+
173
+ def _build_search_field(self) -> ft.TextField:
174
+ return ft.TextField(
175
+ hint_text="Faca uma pesquisa...",
176
+ height=34,
177
+ text_size=12,
178
+ text_style=ft.TextStyle(color=Colors.TEXT_PRIMARY, size=12),
179
+ hint_style=ft.TextStyle(color=Colors.TEXT_MUTED, size=12),
180
+ content_padding=ft.Padding(8, 0, 8, 0),
181
+ prefix_icon=ft.Icons.SEARCH_ROUNDED,
182
+ border_color=Colors.BORDER,
183
+ focused_border_color=Colors.ACCENT,
184
+ bgcolor=Colors.BG_ELEVATED,
185
+ border_radius=8,
186
+ on_change=lambda e: self._apply_search(),
187
+ expand=True,
188
+ )
189
+
190
+ def _icon_button(
191
+ self,
192
+ icon: ft.IconData,
193
+ tooltip: str,
194
+ on_click: Callable | None,
195
+ *,
196
+ size: int = 16,
197
+ box_size: int = 28,
198
+ ) -> ft.Container:
199
+ return ft.Container(
200
+ width=box_size,
201
+ height=box_size,
202
+ content=ft.IconButton(
203
+ icon=icon,
204
+ icon_size=size,
205
+ icon_color=Colors.TEXT_SECONDARY,
206
+ tooltip=tooltip,
207
+ on_click=on_click,
208
+ style=ft.ButtonStyle(padding=0),
209
+ ),
210
+ )
211
+
212
+ def _load_data(self) -> dict | list:
213
+ if self._menu_data_provided:
214
+ return self._normalize_menu_data(self.menu_data)
215
+ if self.menu_path:
216
+ return json.loads(self.menu_path.read_text(encoding="utf-8-sig"))
217
+ return []
218
+
219
+ def _normalize_menu_data(self, menu_data: dict | list | str) -> dict | list:
220
+ if isinstance(menu_data, str):
221
+ try:
222
+ return json.loads(menu_data)
223
+ except json.JSONDecodeError as exc:
224
+ raise ValueError("menu_data must be valid JSON.") from exc
225
+ if isinstance(menu_data, (dict, list)):
226
+ return menu_data
227
+ raise TypeError("menu_data must be a dict, list, or JSON string.")
228
+
229
+ def _extract_menu_list(self, data: dict | list) -> list:
230
+ if isinstance(data, list):
231
+ return data
232
+ if isinstance(data, dict):
233
+ return data.get("menu", [])
234
+ raise TypeError("Tree data must be a list or a dict with a menu key.")
235
+
236
+ def _build_tree(self, data: list, level: int = 0) -> list["_DEx_TreeNode"]:
237
+ nodes: list[_DEx_TreeNode] = []
238
+ for item in data:
239
+ children = self._build_tree(item.get("children", []), level + 1)
240
+ nodes.append(
241
+ _DEx_TreeNode(
242
+ label=item["label"],
243
+ node_type=item.get("type", "file"),
244
+ icon=item.get("icon"),
245
+ children=children,
246
+ level=level,
247
+ on_select=self._on_select,
248
+ show_icon=self.show_icon,
249
+ indent_step=self.indent_step,
250
+ toggle_size=self.toggle_size,
251
+ )
252
+ )
253
+ return nodes
254
+
255
+ def _get_visible_nodes(self) -> list["_DEx_TreeNode"]:
256
+ nodes: list[_DEx_TreeNode] = []
257
+
258
+ def walk(items: list[_DEx_TreeNode]) -> None:
259
+ for node in items:
260
+ nodes.append(node)
261
+ if node.expanded and node.children:
262
+ walk(node.children)
263
+
264
+ walk(self.root_nodes)
265
+ return nodes
266
+
267
+ def _on_select(self, node: "_DEx_TreeNode") -> None:
268
+ current = self.selected_node["node"]
269
+ if current is not None and current is not node:
270
+ current.is_selected = False
271
+ current.refresh()
272
+ node.is_selected = True
273
+ self.selected_node["node"] = node
274
+ node.refresh()
275
+ if self.external_on_select:
276
+ self.external_on_select(node)
277
+
278
+ def _move_selection(self, delta: int) -> None:
279
+ if self.search_field.value:
280
+ return
281
+ visible = self._get_visible_nodes()
282
+ if not visible:
283
+ return
284
+ current = self.selected_node["node"]
285
+ index = visible.index(current) if current in visible else 0
286
+ next_index = max(0, min(len(visible) - 1, index + delta))
287
+ self._on_select(visible[next_index])
288
+ self._update_tree()
289
+
290
+ def _apply_search(self) -> None:
291
+ self.tree_col.controls = _filter_tree(self.root_nodes, self.search_field.value)
292
+ self._update_tree()
293
+
294
+ def _clear_search(self, e: ft.ControlEvent | None = None) -> None:
295
+ self.search_field.value = ""
296
+ self._apply_search()
297
+ if _is_mounted(self.search_field):
298
+ self.search_field.update()
299
+
300
+ def _update_tree(self) -> None:
301
+ if _is_mounted(self.tree_col):
302
+ self.tree_col.update()
303
+
304
+
305
+ class _DEx_TreeNode:
306
+ """Internal node model used by `DEx_TreeView`."""
307
+
308
+ def __init__(
309
+ self,
310
+ label: str,
311
+ node_type: str,
312
+ icon: str | ft.IconData | None = None,
313
+ children: list["_DEx_TreeNode"] | None = None,
314
+ level: int = 0,
315
+ on_select: Callable[["_DEx_TreeNode"], None] | None = None,
316
+ show_icon: bool = True,
317
+ indent_step: int = 15,
318
+ toggle_size: int = 18,
319
+ ) -> None:
320
+ self.label = label
321
+ self.node_type = node_type
322
+ self.icon = icon
323
+ self.children = children or []
324
+ self.level = level
325
+ self.on_select = on_select
326
+ self.expanded = level == 0
327
+ self.is_selected = False
328
+ self.show_icon = show_icon
329
+ self.indent_step = indent_step
330
+ self.toggle_size = toggle_size
331
+
332
+ self._children_column = ft.Column(spacing=0)
333
+ self._toggle_icon: ft.Icon | None = None
334
+ self._item_icon: ft.Icon | None = None
335
+ self._label_text_controls: list[tuple[ft.Text, str]] = []
336
+ self.control = self._build()
337
+
338
+ def toggle(self, e: ft.ControlEvent | None = None) -> None:
339
+ self.expanded = not self.expanded
340
+ self.refresh()
341
+
342
+ def select(self, e: ft.ControlEvent | None = None) -> None:
343
+ if self.on_select:
344
+ self.on_select(self)
345
+
346
+ def refresh(self, update: bool = True) -> None:
347
+ self._children_column.controls = [child.control for child in self.children] if self.expanded else []
348
+ self._sync_visuals()
349
+ if update and _is_mounted(self.control):
350
+ self.control.update()
351
+
352
+ def _build(self) -> ft.Column:
353
+ header = ft.Container(
354
+ height=28,
355
+ content=ft.Row(
356
+ self._build_row_controls(),
357
+ spacing=3,
358
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
359
+ ),
360
+ bgcolor=_selected_node_bgcolor() if self.is_selected else None,
361
+ border_radius=6,
362
+ padding=ft.Padding(0, 0, 6, 0),
363
+ on_click=self.select,
364
+ ink=True,
365
+ )
366
+ self._children_column.controls = [child.control for child in self.children] if self.expanded else []
367
+ return ft.Column([header, self._children_column], spacing=0)
368
+
369
+ def _build_row_controls(self) -> list[ft.Control]:
370
+ has_children = bool(self.children)
371
+ controls: list[ft.Control] = [ft.Container(width=self.indent_step * self.level)]
372
+
373
+ if has_children:
374
+ self._toggle_icon = ft.Icon(self._toggle_icon_name(), size=15, color=Colors.TEXT_SECONDARY)
375
+ controls.append(
376
+ ft.Container(
377
+ width=self.toggle_size,
378
+ height=self.toggle_size,
379
+ alignment=ft.Alignment(0, 0),
380
+ content=self._toggle_icon,
381
+ on_click=self.toggle,
382
+ )
383
+ )
384
+ else:
385
+ controls.append(ft.Container(width=self.toggle_size, height=self.toggle_size))
386
+
387
+ if self.show_icon:
388
+ self._item_icon = ft.Icon(self._resolve_item_icon(has_children), size=15, color=Colors.DATA)
389
+ controls.append(self._item_icon)
390
+
391
+ controls.append(self._build_label_control())
392
+ return controls
393
+
394
+ def _build_label_control(self) -> ft.Control:
395
+ match = re.match(r"^\(([^)]+)\)\s*-\s*(.*)$", self.label)
396
+ if match:
397
+ id_text = ft.Text(f"({match.group(1)})", size=11, color=Colors.DATA, font_family="monospace")
398
+ label_text = ft.Text(match.group(2), size=13, color=Colors.TEXT_PRIMARY)
399
+ self._label_text_controls = [(id_text, "id"), (label_text, "label")]
400
+ return ft.Row(
401
+ [id_text, label_text],
402
+ spacing=5,
403
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
404
+ )
405
+ label_text = ft.Text(self.label, size=13, color=Colors.TEXT_PRIMARY)
406
+ self._label_text_controls = [(label_text, "label")]
407
+ return label_text
408
+
409
+ def _toggle_icon_name(self) -> ft.IconData:
410
+ return ft.Icons.KEYBOARD_ARROW_DOWN_ROUNDED if self.expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED
411
+
412
+ def _resolve_item_icon(self, has_children: bool) -> ft.IconData:
413
+ if self.icon:
414
+ if isinstance(self.icon, str):
415
+ icon_key = self.icon.strip().lower()
416
+ if icon_key == "folder":
417
+ return ft.Icons.FOLDER_OPEN_OUTLINED if self.expanded else ft.Icons.FOLDER_OUTLINED
418
+ if icon_key == "docs":
419
+ return ft.Icons.DESCRIPTION_OUTLINED
420
+
421
+ resolved = getattr(ft.Icons, self.icon.strip().upper(), None)
422
+ if resolved is not None:
423
+ return resolved
424
+ return self.icon
425
+
426
+ if has_children:
427
+ return ft.Icons.FOLDER_OPEN_OUTLINED if self.expanded else ft.Icons.FOLDER_OUTLINED
428
+ return ft.Icons.DESCRIPTION_OUTLINED
429
+
430
+ def _sync_visuals(self) -> None:
431
+ has_children = bool(self.children)
432
+ selected_bgcolor = _selected_node_bgcolor()
433
+ selected_color = _readable_text_color(selected_bgcolor)
434
+ if self._toggle_icon is not None:
435
+ self._toggle_icon.name = self._toggle_icon_name()
436
+ self._toggle_icon.color = selected_color if self.is_selected else Colors.TEXT_SECONDARY
437
+ if self._item_icon is not None:
438
+ self._item_icon.name = self._resolve_item_icon(has_children)
439
+ self._item_icon.color = selected_color if self.is_selected else Colors.DATA
440
+
441
+ header = self.control.controls[0]
442
+ header.bgcolor = selected_bgcolor if self.is_selected else None
443
+ header.border = ft.Border.all(1, Colors.ACCENT) if self.is_selected else None
444
+
445
+ for text_control, role in self._label_text_controls:
446
+ if self.is_selected:
447
+ text_control.color = selected_color
448
+ elif role == "id":
449
+ text_control.color = Colors.DATA
450
+ else:
451
+ text_control.color = Colors.TEXT_PRIMARY
452
+
453
+
454
+ def _is_mounted(control: ft.Control) -> bool:
455
+ return getattr(control, "_Control__uid", None) is not None
456
+
457
+
458
+ def _selected_node_bgcolor() -> str:
459
+ return Colors.ACCENT
460
+
461
+
462
+ def _readable_text_color(bgcolor: str) -> str:
463
+ black_ratio = _contrast_ratio(bgcolor, "#000000")
464
+ white_ratio = _contrast_ratio(bgcolor, "#FFFFFF")
465
+ return "#000000" if black_ratio >= white_ratio else "#FFFFFF"
466
+
467
+
468
+ def _contrast_ratio(color_a: str, color_b: str) -> float:
469
+ lum_a = _relative_luminance(color_a)
470
+ lum_b = _relative_luminance(color_b)
471
+ lighter = max(lum_a, lum_b)
472
+ darker = min(lum_a, lum_b)
473
+ return (lighter + 0.05) / (darker + 0.05)
474
+
475
+
476
+ def _relative_luminance(hex_color: str) -> float:
477
+ red, green, blue = _hex_to_rgb(hex_color)
478
+ values = []
479
+ for channel in (red, green, blue):
480
+ value = channel / 255
481
+ values.append(value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4)
482
+ return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]
483
+
484
+
485
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
486
+ value = hex_color.strip().lstrip("#")[:6]
487
+ if len(value) != 6:
488
+ return (0, 0, 0)
489
+ return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
490
+
491
+
492
+ def _set_expanded_node(node: _DEx_TreeNode, expanded: bool) -> None:
493
+ node.expanded = expanded
494
+ for child in node.children:
495
+ _set_expanded_node(child, expanded)
496
+ node.refresh(update=False)
497
+
498
+
499
+ def _filter_tree(nodes: list[_DEx_TreeNode], query: str | None) -> list[ft.Control]:
500
+ query = (query or "").strip().lower()
501
+ if not query:
502
+ for node in nodes:
503
+ _reset_node(node)
504
+ return [node.control for node in nodes]
505
+
506
+ matches: list[ft.Control] = []
507
+ for node in nodes:
508
+ if _apply_filter(node, query):
509
+ matches.append(node.control)
510
+ return matches
511
+
512
+
513
+ def _reset_node(node: _DEx_TreeNode) -> None:
514
+ for child in node.children:
515
+ _reset_node(child)
516
+ node._children_column.controls = [child.control for child in node.children] if node.expanded else []
517
+ node._sync_visuals()
518
+
519
+
520
+ def _apply_filter(node: _DEx_TreeNode, query: str) -> bool:
521
+ matched_children = [child for child in node.children if _apply_filter(child, query)]
522
+ is_match = query in node.label.lower()
523
+
524
+ if matched_children:
525
+ node.expanded = True
526
+ node._children_column.controls = [child.control for child in matched_children]
527
+ node._sync_visuals()
528
+ return True
529
+
530
+ if is_match:
531
+ node.expanded = False
532
+ node._children_column.controls = []
533
+ node._sync_visuals()
534
+ return True
535
+
536
+ return False
File without changes
@@ -0,0 +1,26 @@
1
+ import flet as ft
2
+ from components.theme.DEx_Theme import Colors, Spacing
3
+
4
+ COMPONENT_VERSION = "0.0.1"
5
+
6
+
7
+ def DEx_AlertDialog(
8
+ title: str,
9
+ content: ft.Control | str | None = None,
10
+ actions: list[ft.Control] | None = None,
11
+ on_dismiss=None,
12
+ ) -> ft.AlertDialog:
13
+ """Modal dialog for confirmations, alerts and short forms."""
14
+ body = (
15
+ ft.Text(content, size=13, color=Colors.TEXT_SECONDARY)
16
+ if isinstance(content, str)
17
+ else content
18
+ )
19
+ return ft.AlertDialog(
20
+ title=ft.Text(title, size=16, weight=ft.FontWeight.W_600, color=Colors.TEXT_PRIMARY),
21
+ content=body,
22
+ actions=actions or [],
23
+ on_dismiss=on_dismiss,
24
+ bgcolor=Colors.BG_ELEVATED,
25
+ shape=ft.RoundedRectangleBorder(radius=Spacing.CARD_BORDER_RADIUS),
26
+ )
@@ -0,0 +1,19 @@
1
+ import flet as ft
2
+ from components.theme.DEx_Theme import Colors, Spacing
3
+
4
+ COMPONENT_VERSION = "0.0.1"
5
+
6
+
7
+ def DEx_Banner(
8
+ message: str,
9
+ icon: str = ft.Icons.INFO_OUTLINE_ROUNDED,
10
+ actions: list[ft.Control] | None = None,
11
+ bgcolor: str | None = None,
12
+ ) -> ft.Banner:
13
+ """Persistent informational banner displayed below the AppBar."""
14
+ return ft.Banner(
15
+ content=ft.Text(message, size=13, color=Colors.TEXT_PRIMARY),
16
+ leading=ft.Icon(icon, color=Colors.ACCENT, size=20),
17
+ actions=actions or [],
18
+ bgcolor=bgcolor or Colors.BG_ELEVATED,
19
+ )
@@ -0,0 +1,37 @@
1
+ import flet as ft
2
+ from components.theme.DEx_Theme import Colors, Spacing
3
+
4
+ COMPONENT_VERSION = "0.0.2"
5
+
6
+
7
+ def DEx_BottomSheet(
8
+ content: ft.Control,
9
+ on_dismiss=None,
10
+ ) -> ft.BottomSheet:
11
+ """Bottom sheet that slides up from the bottom edge of the screen."""
12
+ return ft.BottomSheet(
13
+ content=ft.Container(
14
+ content=content,
15
+ bgcolor=Colors.BG_ELEVATED,
16
+ border_radius=ft.BorderRadius(
17
+ top_left=Spacing.CARD_BORDER_RADIUS,
18
+ top_right=Spacing.CARD_BORDER_RADIUS,
19
+ bottom_left=0,
20
+ bottom_right=0,
21
+ ),
22
+ padding=ft.Padding.symmetric(horizontal=Spacing.LG, vertical=Spacing.MD),
23
+ ),
24
+ bgcolor=Colors.BG_ELEVATED,
25
+ dismissible=True,
26
+ draggable=True,
27
+ show_drag_handle=True,
28
+ scrollable=True,
29
+ clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
30
+ shape=ft.RoundedRectangleBorder(
31
+ radius=ft.BorderRadius(
32
+ top_left=Spacing.CARD_BORDER_RADIUS,
33
+ top_right=Spacing.CARD_BORDER_RADIUS,
34
+ )
35
+ ),
36
+ on_dismiss=on_dismiss,
37
+ )
@@ -0,0 +1,15 @@
1
+ import flet as ft
2
+ from components.theme.DEx_Theme import Colors
3
+
4
+ COMPONENT_VERSION = "0.0.1"
5
+
6
+
7
+ def DEx_Notify(page: ft.Page, message: str, success: bool = True) -> None:
8
+ """Show the versioned DEx snackbar notification on the current page."""
9
+ page.snack_bar = ft.SnackBar(
10
+ content=ft.Text(message, color=Colors.TEXT_PRIMARY, size=13),
11
+ bgcolor=Colors.SUCCESS if success else Colors.DANGER,
12
+ duration=3000,
13
+ )
14
+ page.snack_bar.open = True
15
+ page.update()
@@ -0,0 +1,34 @@
1
+ import flet as ft
2
+ from components.theme.DEx_Theme import Colors, Spacing
3
+
4
+ COMPONENT_VERSION = "0.0.1"
5
+
6
+
7
+ def DEx_ProgressBar(
8
+ value: float | None = None,
9
+ label: str = "",
10
+ show_percent: bool = False,
11
+ color: str | None = None,
12
+ bgcolor: str | None = None,
13
+ height: int = 6,
14
+ ) -> ft.Column:
15
+ """Horizontal progress bar with optional label and percentage."""
16
+ bar = ft.ProgressBar(
17
+ value=value,
18
+ color=color or Colors.ACCENT,
19
+ bgcolor=bgcolor or Colors.BG_ELEVATED,
20
+ height=height,
21
+ border_radius=height,
22
+ )
23
+ if not label and not show_percent:
24
+ return ft.Column([bar], spacing=0, tight=True)
25
+
26
+ pct_text = f"{int((value or 0) * 100)}%" if show_percent and value is not None else ""
27
+ header = ft.Row(
28
+ [
29
+ ft.Text(label, size=12, color=Colors.TEXT_SECONDARY) if label else ft.Container(),
30
+ ft.Text(pct_text, size=12, color=Colors.TEXT_MUTED) if pct_text else ft.Container(),
31
+ ],
32
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
33
+ )
34
+ return ft.Column([header, bar], spacing=Spacing.XS, tight=True)