responds-forms 0.4__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.
- cartas/__init__.py +2060 -0
- cartas/origin.py +1196 -0
- pyflutter_ui/__init__.py +30 -0
- pyflutter_ui/app.py +289 -0
- pyflutter_ui/file_picker.py +274 -0
- pyflutter_ui/navigator.py +93 -0
- pyflutter_ui/scheduler.py +154 -0
- pyflutter_ui/theme.py +1058 -0
- pyflutter_ui/ui/__init__.py +27 -0
- pyflutter_ui/ui/buttons.py +219 -0
- pyflutter_ui/ui/container.py +119 -0
- pyflutter_ui/ui/core.py +820 -0
- pyflutter_ui/ui/list_builder.py +396 -0
- pyflutter_ui/ui/progress.py +80 -0
- pyflutter_ui/ui/tables.py +109 -0
- pyflutter_ui/ui/widgets.py +1418 -0
- responds_forms-0.4.dist-info/METADATA +15 -0
- responds_forms-0.4.dist-info/RECORD +38 -0
- responds_forms-0.4.dist-info/WHEEL +4 -0
- responds_ui/__init__.py +1 -0
- responds_ui/__main__.py +25 -0
- responds_ui/assets/forms_config.json +57 -0
- responds_ui/config_app.py +109 -0
- responds_ui/lib_toi.py +864 -0
- responds_ui/page_forms/Diagnorstico.txt +108 -0
- responds_ui/page_forms/__init__.py +5 -0
- responds_ui/page_forms/common.py +192 -0
- responds_ui/page_forms/config.py +68 -0
- responds_ui/page_forms/controller_forms.py +390 -0
- responds_ui/page_forms/diagnose_forms.py +64 -0
- responds_ui/page_forms/master_forms.py +113 -0
- responds_ui/page_forms/model_forms.py +329 -0
- responds_ui/page_forms/service.py +347 -0
- responds_ui/page_forms/view_forms.py +164 -0
- responds_ui/table_widget.py +140 -0
- responds_ui/util.py +74 -0
- responds_ui/version.py +2 -0
- whatsapp_files/__init__.py +1092 -0
pyflutter_ui/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pyflutter_ui.ui import (
|
|
2
|
+
BaseMaterialApp, BuildContext, ChangeNotifier, CommandNotifier,
|
|
3
|
+
Consumer, Element, FlutterWidget, ConsumerState, LayoutData,
|
|
4
|
+
Navigator, Provider, StatefulWidget, StatelessWidget, State,
|
|
5
|
+
ValueNotifier, ValueListenableBuilder, Provider, AlignElement,
|
|
6
|
+
Align, AppBar, BottomNavigationBar, Center, Card, Checkbox,
|
|
7
|
+
CircularProgress, Container, Divider, Drawer, Column, Row,
|
|
8
|
+
DropdownButton, ElevatedButton, Flexible, Expanded,
|
|
9
|
+
FloatingActionButton, Icon, IconButton, LinearProgress, Provider,
|
|
10
|
+
Padding, Image, ListBuilder, MainAxisAlignment, CrossAxisAlignment,
|
|
11
|
+
TableView, TkMaterialApp, Scaffold, Text, SizedBox, Alignment,
|
|
12
|
+
ScrollView,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .theme import (
|
|
16
|
+
ColorScheme, CardTheme, InputDecorationTheme, TextStyle,
|
|
17
|
+
Theme, ThemeData, ThemeMode, ThemeProvider, TextTheme,
|
|
18
|
+
make_themed_app,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .file_picker import (
|
|
22
|
+
FilePickerController, FilePickerResult, FilePickerType, PlatformFile,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from pyflutter_ui.ui.list_builder import (
|
|
26
|
+
ScrollableColumn, ScrollableColumnElement, ScrollableColumnOrProgress
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .navigator import NavigatorApp, PageRoute, Route
|
|
30
|
+
from .app import run_app, SnackBar, SnackBarType
|
pyflutter_ui/app.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# app.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from tkinter import Tk
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
from pyflutter_ui.ui import BuildContext, TkMaterialApp, Expanded
|
|
8
|
+
from pyflutter_ui.theme import ThemeData, ThemeMode, ThemeProvider,ColorScheme
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ============================================================
|
|
12
|
+
# SNACKBAR
|
|
13
|
+
# ============================================================
|
|
14
|
+
|
|
15
|
+
class SnackBarType(Enum):
|
|
16
|
+
INFO = auto()
|
|
17
|
+
SUCCESS = auto()
|
|
18
|
+
WARNING = auto()
|
|
19
|
+
ERROR = auto()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SnackBar:
|
|
23
|
+
"""
|
|
24
|
+
Exibe uma mensagem temporária na parte inferior da janela,
|
|
25
|
+
similar ao SnackBar do Flutter.
|
|
26
|
+
|
|
27
|
+
Uso via contexto (recomendado):
|
|
28
|
+
SnackBar.of(context).show("Arquivo salvo!", type=SnackBarType.SUCCESS)
|
|
29
|
+
|
|
30
|
+
Uso direto com root Tk:
|
|
31
|
+
SnackBar.show_on(root, "Erro ao carregar", type=SnackBarType.ERROR)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# cores por tipo — funcionam tanto em tema claro quanto escuro
|
|
35
|
+
_COLORS = {
|
|
36
|
+
SnackBarType.INFO: {"bg": "#323232", "fg": "#FFFFFF", "accent": "#90CAF9"},
|
|
37
|
+
SnackBarType.SUCCESS: {"bg": "#1B5E20", "fg": "#FFFFFF", "accent": "#A5D6A7"},
|
|
38
|
+
SnackBarType.WARNING: {"bg": "#E65100", "fg": "#FFFFFF", "accent": "#FFE082"},
|
|
39
|
+
SnackBarType.ERROR: {"bg": "#B71C1C", "fg": "#FFFFFF", "accent": "#EF9A9A"},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_ICONS = {
|
|
43
|
+
SnackBarType.INFO: "ℹ",
|
|
44
|
+
SnackBarType.SUCCESS: "✓",
|
|
45
|
+
SnackBarType.WARNING: "⚠",
|
|
46
|
+
SnackBarType.ERROR: "✕",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __init__(self, root: tk.Tk) -> None:
|
|
50
|
+
self._root = root
|
|
51
|
+
self._current: Optional[tk.Toplevel] = None
|
|
52
|
+
self._after_id: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def of(cls, context: BuildContext) -> SnackBar:
|
|
56
|
+
"""Obtém o SnackBar a partir de um BuildContext."""
|
|
57
|
+
root = context.app.root
|
|
58
|
+
# reutiliza instância já anexada ao root
|
|
59
|
+
if not hasattr(root, "_snackbar"):
|
|
60
|
+
root._snackbar = cls(root)
|
|
61
|
+
return root._snackbar
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def show_on(
|
|
65
|
+
cls,
|
|
66
|
+
root: tk.Tk,
|
|
67
|
+
message: str,
|
|
68
|
+
*,
|
|
69
|
+
type: SnackBarType = SnackBarType.INFO,
|
|
70
|
+
duration: int = 3000,
|
|
71
|
+
action_label: Optional[str] = None,
|
|
72
|
+
on_action: Optional[Callable[[], None]] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Atalho estático: não precisa instanciar."""
|
|
75
|
+
if not hasattr(root, "_snackbar"):
|
|
76
|
+
root._snackbar = cls(root)
|
|
77
|
+
root._snackbar.show(
|
|
78
|
+
message,
|
|
79
|
+
type=type,
|
|
80
|
+
duration=duration,
|
|
81
|
+
action_label=action_label,
|
|
82
|
+
on_action=on_action,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def show(
|
|
86
|
+
self,
|
|
87
|
+
message: str,
|
|
88
|
+
*,
|
|
89
|
+
type: SnackBarType = SnackBarType.INFO,
|
|
90
|
+
duration: int = 3000,
|
|
91
|
+
action_label: Optional[str] = None,
|
|
92
|
+
on_action: Optional[Callable[[], None]] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Exibe o SnackBar. Descarta o anterior se ainda estiver visível."""
|
|
95
|
+
self._dismiss()
|
|
96
|
+
|
|
97
|
+
colors = self._COLORS[type]
|
|
98
|
+
icon = self._ICONS[type]
|
|
99
|
+
|
|
100
|
+
# ── janela sem borda flutuando sobre o root ──────────
|
|
101
|
+
popup = tk.Toplevel(self._root)
|
|
102
|
+
popup.title("Aviso")
|
|
103
|
+
popup.geometry('400x50')
|
|
104
|
+
popup.overrideredirect(True) # sem barra de título
|
|
105
|
+
popup.attributes("-topmost", True)
|
|
106
|
+
popup.configure(bg=colors["bg"])
|
|
107
|
+
|
|
108
|
+
self._current = popup
|
|
109
|
+
|
|
110
|
+
# ── frame interno com padding ─────────────────────────
|
|
111
|
+
frame = tk.Frame(popup, bg=colors["bg"], padx=12, pady=10)
|
|
112
|
+
frame.pack(fill="both", expand=True)
|
|
113
|
+
|
|
114
|
+
# ícone
|
|
115
|
+
tk.Label(
|
|
116
|
+
frame,
|
|
117
|
+
text=icon,
|
|
118
|
+
bg=colors["bg"],
|
|
119
|
+
fg=colors["accent"],
|
|
120
|
+
font=("Helvetica", 14, "bold"),
|
|
121
|
+
).pack(side="left", padx=(0, 8))
|
|
122
|
+
|
|
123
|
+
# mensagem
|
|
124
|
+
tk.Label(
|
|
125
|
+
frame,
|
|
126
|
+
text=message,
|
|
127
|
+
bg=colors["bg"],
|
|
128
|
+
fg=colors["fg"],
|
|
129
|
+
font=("Helvetica", 10),
|
|
130
|
+
wraplength=360,
|
|
131
|
+
justify="left",
|
|
132
|
+
anchor="w",
|
|
133
|
+
).pack(side="left", fill="x", expand=True)
|
|
134
|
+
|
|
135
|
+
# botão de ação opcional (ex.: "DESFAZER")
|
|
136
|
+
if action_label and on_action:
|
|
137
|
+
def _action():
|
|
138
|
+
on_action()
|
|
139
|
+
self._dismiss()
|
|
140
|
+
|
|
141
|
+
tk.Button(
|
|
142
|
+
frame,
|
|
143
|
+
text=action_label.upper(),
|
|
144
|
+
bg=colors["bg"],
|
|
145
|
+
fg=colors["accent"],
|
|
146
|
+
font=("Helvetica", 9, "bold"),
|
|
147
|
+
relief="flat",
|
|
148
|
+
cursor="hand2",
|
|
149
|
+
bd=0,
|
|
150
|
+
highlightthickness=0,
|
|
151
|
+
command=_action,
|
|
152
|
+
).pack(side="right", padx=(12, 0))
|
|
153
|
+
|
|
154
|
+
# botão X para fechar manualmente
|
|
155
|
+
tk.Button(
|
|
156
|
+
frame,
|
|
157
|
+
text="✕",
|
|
158
|
+
bg=colors["bg"],
|
|
159
|
+
fg=colors["fg"],
|
|
160
|
+
font=("Helvetica", 8),
|
|
161
|
+
relief="flat",
|
|
162
|
+
cursor="hand2",
|
|
163
|
+
bd=0,
|
|
164
|
+
highlightthickness=0,
|
|
165
|
+
command=self._dismiss,
|
|
166
|
+
).pack(side="right", padx=(8, 0))
|
|
167
|
+
|
|
168
|
+
# ── posicionamento: canto inferior esquerdo do root ──
|
|
169
|
+
popup.update_idletasks()
|
|
170
|
+
self._reposition(popup)
|
|
171
|
+
|
|
172
|
+
# reposiciona se o root for movido ou redimensionado
|
|
173
|
+
self._root.bind("<Configure>", lambda e: self._reposition(popup), add="+")
|
|
174
|
+
|
|
175
|
+
# ── animação de entrada (slide up) ───────────────────
|
|
176
|
+
self._animate_in(popup)
|
|
177
|
+
|
|
178
|
+
# ── auto-dismiss ──────────────────────────────────────
|
|
179
|
+
if duration > 0:
|
|
180
|
+
self._after_id = self._root.after(duration, self._dismiss)
|
|
181
|
+
|
|
182
|
+
def _reposition(self, popup: tk.Toplevel) -> None:
|
|
183
|
+
if popup is None or not popup.winfo_exists():
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
root = self._root
|
|
187
|
+
rx = root.winfo_x()
|
|
188
|
+
ry = root.winfo_y()
|
|
189
|
+
rw = root.winfo_width()
|
|
190
|
+
rh = root.winfo_height()
|
|
191
|
+
|
|
192
|
+
pw = popup.winfo_width()
|
|
193
|
+
ph = popup.winfo_height()
|
|
194
|
+
|
|
195
|
+
margin = 16
|
|
196
|
+
x = rx + margin
|
|
197
|
+
y = ry + rh - ph - margin
|
|
198
|
+
|
|
199
|
+
popup.geometry(f"+{x}+{y}")
|
|
200
|
+
|
|
201
|
+
def _animate_in(self, popup: tk.Toplevel, steps: int = 8) -> None:
|
|
202
|
+
"""Desliza o popup de baixo para cima em `steps` passos."""
|
|
203
|
+
if not popup.winfo_exists():
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
root = self._root
|
|
207
|
+
rh = root.winfo_height()
|
|
208
|
+
ry = root.winfo_y()
|
|
209
|
+
ph = popup.winfo_reqheight()
|
|
210
|
+
margin = 16
|
|
211
|
+
|
|
212
|
+
target_y = ry + rh - ph - margin
|
|
213
|
+
start_y = ry + rh # começa fora da janela
|
|
214
|
+
|
|
215
|
+
def _step(current_y: int, step: int) -> None:
|
|
216
|
+
if not popup.winfo_exists():
|
|
217
|
+
return
|
|
218
|
+
if step >= steps:
|
|
219
|
+
popup.geometry(f"+{popup.winfo_x()}+{target_y}")
|
|
220
|
+
return
|
|
221
|
+
new_y = current_y + (target_y - current_y) // 2
|
|
222
|
+
popup.geometry(f"+{popup.winfo_x()}+{new_y}")
|
|
223
|
+
popup.after(16, lambda: _step(new_y, step + 1))
|
|
224
|
+
|
|
225
|
+
popup.geometry(f"+{popup.winfo_x()}+{start_y}")
|
|
226
|
+
_step(start_y, 0)
|
|
227
|
+
|
|
228
|
+
def _dismiss(self) -> None:
|
|
229
|
+
if self._after_id:
|
|
230
|
+
self._root.after_cancel(self._after_id)
|
|
231
|
+
self._after_id = None
|
|
232
|
+
|
|
233
|
+
if self._current and self._current.winfo_exists():
|
|
234
|
+
self._animate_out(self._current)
|
|
235
|
+
self._current = None
|
|
236
|
+
|
|
237
|
+
def _animate_out(self, popup: tk.Toplevel, steps: int = 6) -> None:
|
|
238
|
+
"""Desliza o popup para baixo antes de destruir."""
|
|
239
|
+
if not popup.winfo_exists():
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
root = self._root
|
|
243
|
+
rh = root.winfo_height()
|
|
244
|
+
ry = root.winfo_y()
|
|
245
|
+
target = ry + rh + 10 # fora da janela
|
|
246
|
+
|
|
247
|
+
def _step(current_y: int, step: int) -> None:
|
|
248
|
+
if not popup.winfo_exists():
|
|
249
|
+
return
|
|
250
|
+
if step >= steps:
|
|
251
|
+
popup.destroy()
|
|
252
|
+
return
|
|
253
|
+
new_y = current_y + (target - current_y) // 2 + 2
|
|
254
|
+
popup.geometry(f"+{popup.winfo_x()}+{new_y}")
|
|
255
|
+
popup.after(16, lambda: _step(new_y, step + 1))
|
|
256
|
+
|
|
257
|
+
_step(popup.winfo_y(), 0)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def create_theme_provider(mode: ThemeMode = ThemeMode.DARK) -> ThemeProvider:
|
|
261
|
+
|
|
262
|
+
return ThemeProvider(
|
|
263
|
+
light_theme=ThemeData.light(color_scheme=ColorScheme.light()),
|
|
264
|
+
dark_theme=ThemeData.dark(color_scheme=ColorScheme.dark()),
|
|
265
|
+
mode=ThemeMode.DARK,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def include_theme_app(app: TkMaterialApp) -> TkMaterialApp:
|
|
270
|
+
|
|
271
|
+
if app.root is None:
|
|
272
|
+
app.root = Tk()
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
notifier: ThemeProvider = app.read_state(ThemeProvider)
|
|
276
|
+
if notifier is not None:
|
|
277
|
+
notifier.attach_root(app.root)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
print(e)
|
|
280
|
+
|
|
281
|
+
app.get_navigator().push(app.home)
|
|
282
|
+
return app
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_app(app: TkMaterialApp):
|
|
286
|
+
theme = create_theme_provider()
|
|
287
|
+
app.register_notifier(theme)
|
|
288
|
+
|
|
289
|
+
include_theme_app(app).run()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# file_picker.py
|
|
2
|
+
# Implementação de FilePicker para PyFlutter no padrão do Flutter real.
|
|
3
|
+
#
|
|
4
|
+
# No Flutter, FilePicker não é um widget — é um controller/serviço que
|
|
5
|
+
# expõe um método pickFiles() e notifica os listeners com o resultado.
|
|
6
|
+
# Este arquivo replica essa arquitetura usando os primitivos do PyFlutter.
|
|
7
|
+
#
|
|
8
|
+
# ─────────────────────────────────────────────────────────────
|
|
9
|
+
# USO BÁSICO
|
|
10
|
+
# ─────────────────────────────────────────────────────────────
|
|
11
|
+
#
|
|
12
|
+
# picker = FilePickerController(
|
|
13
|
+
# type=FilePickerType.image, # filtro de tipo
|
|
14
|
+
# allow_multiple=True, # seleção múltipla
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# # Abrir o diálogo e aguardar o resultado:
|
|
18
|
+
# result: FilePickerResult | None = picker.pick_files()
|
|
19
|
+
# if result:
|
|
20
|
+
# for f in result.files:
|
|
21
|
+
# print(f.name, f.path, f.size)
|
|
22
|
+
#
|
|
23
|
+
# # Ou de forma reativa (integrado ao build do widget):
|
|
24
|
+
# ValueListenableBuilder(
|
|
25
|
+
# notifier=picker.result_notifier,
|
|
26
|
+
# builder=lambda ctx, result: Text(
|
|
27
|
+
# result.files[0].name if result else "Nenhum arquivo"
|
|
28
|
+
# ),
|
|
29
|
+
# )
|
|
30
|
+
# ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
import os
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from tkinter import filedialog
|
|
37
|
+
from typing import Optional
|
|
38
|
+
from pyflutter_ui.ui.core import ValueNotifier
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================
|
|
42
|
+
# TIPOS DE ARQUIVO — espelha o FileType do flutter_file_picker
|
|
43
|
+
# ============================================================
|
|
44
|
+
|
|
45
|
+
class FilePickerType(Enum):
|
|
46
|
+
"""Filtro de tipo de arquivo para o diálogo."""
|
|
47
|
+
any = "any" # sem filtro
|
|
48
|
+
image = "image" # png, jpg, jpeg, gif, bmp, webp, svg
|
|
49
|
+
pdf = "pdf" # pdf
|
|
50
|
+
spreadsheet = "spreadsheet" # xlsx, xls, csv, ods
|
|
51
|
+
video = "video" # mp4, mov, avi, mkv, webm
|
|
52
|
+
audio = "audio" # mp3, wav, ogg, flac, aac
|
|
53
|
+
custom = "custom" # usa allowed_extensions fornecido pelo dev
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Mapeamento interno: tipo → lista de extensões tk-compatíveis
|
|
57
|
+
_TYPE_FILTERS: dict[FilePickerType, list[tuple[str, str]]] = {
|
|
58
|
+
FilePickerType.any: [("Todos os arquivos", "*.*")],
|
|
59
|
+
FilePickerType.image: [
|
|
60
|
+
("Imagens", "*.png *.jpg *.jpeg *.gif *.bmp *.webp *.svg"),
|
|
61
|
+
("Todos os arquivos", "*.*"),
|
|
62
|
+
],
|
|
63
|
+
FilePickerType.pdf: [
|
|
64
|
+
("PDF", "*.pdf"),
|
|
65
|
+
("Todos os arquivos", "*.*"),
|
|
66
|
+
],
|
|
67
|
+
FilePickerType.spreadsheet: [
|
|
68
|
+
("Planilhas", "*.xlsx *.xls *.csv *.ods"),
|
|
69
|
+
("Todos os arquivos", "*.*"),
|
|
70
|
+
],
|
|
71
|
+
FilePickerType.video: [
|
|
72
|
+
("Vídeos", "*.mp4 *.mov *.avi *.mkv *.webm"),
|
|
73
|
+
("Todos os arquivos", "*.*"),
|
|
74
|
+
],
|
|
75
|
+
FilePickerType.audio: [
|
|
76
|
+
("Áudios", "*.mp3 *.wav *.ogg *.flac *.aac"),
|
|
77
|
+
("Todos os arquivos", "*.*"),
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ============================================================
|
|
83
|
+
# MODELO DE RESULTADO — espelha PlatformFile do Flutter
|
|
84
|
+
# ============================================================
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class PlatformFile:
|
|
88
|
+
"""Representa um arquivo selecionado pelo usuário."""
|
|
89
|
+
path: str # caminho absoluto no disco
|
|
90
|
+
name: str = field(init=False) # nome do arquivo (sem diretório)
|
|
91
|
+
extension: str = field(init=False) # extensão sem ponto, ex: "pdf"
|
|
92
|
+
size: int = field(init=False) # tamanho em bytes (0 se inacessível)
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
# dataclass frozen=True exige object.__setattr__ para init tardio
|
|
96
|
+
object.__setattr__(self, "name", os.path.basename(self.path))
|
|
97
|
+
ext = os.path.splitext(self.path)[1].lstrip(".")
|
|
98
|
+
object.__setattr__(self, "extension", ext.lower())
|
|
99
|
+
try:
|
|
100
|
+
object.__setattr__(self, "size", os.path.getsize(self.path))
|
|
101
|
+
except OSError:
|
|
102
|
+
object.__setattr__(self, "size", 0)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def size_readable(self) -> str:
|
|
106
|
+
"""Tamanho formatado (bytes / KB / MB / GB)."""
|
|
107
|
+
n = self.size
|
|
108
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
109
|
+
if n < 1024:
|
|
110
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
|
|
111
|
+
n /= 1024
|
|
112
|
+
return f"{n:.1f} TB"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class FilePickerResult:
|
|
117
|
+
"""Resultado de uma seleção de arquivos. Equivale a FilePickerResult do Flutter."""
|
|
118
|
+
files: list[PlatformFile]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def paths(self) -> list[str]:
|
|
122
|
+
"""Atalho: lista de caminhos absolutos."""
|
|
123
|
+
return [f.path for f in self.files]
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def names(self) -> list[str]:
|
|
127
|
+
"""Atalho: lista de nomes de arquivo."""
|
|
128
|
+
return [f.name for f in self.files]
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_single(self) -> bool:
|
|
132
|
+
return len(self.files) == 1
|
|
133
|
+
|
|
134
|
+
def single(self) -> PlatformFile | None:
|
|
135
|
+
"""Retorna o único arquivo ou None se houver 0 ou mais de 1."""
|
|
136
|
+
return self.files[0] if self.is_single else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ============================================================
|
|
140
|
+
# CONTROLLER — o equivalente ao FilePicker.platform do Flutter
|
|
141
|
+
# ============================================================
|
|
142
|
+
|
|
143
|
+
class FilePickerController:
|
|
144
|
+
"""
|
|
145
|
+
Controller que encapsula a lógica de seleção de arquivos.
|
|
146
|
+
|
|
147
|
+
Não é um widget — vive no State do seu widget pai, exatamente
|
|
148
|
+
como FilePickerController no Flutter.
|
|
149
|
+
|
|
150
|
+
Parâmetros
|
|
151
|
+
----------
|
|
152
|
+
type : FilePickerType
|
|
153
|
+
Filtro de tipo de arquivo. Use FilePickerType.custom junto com
|
|
154
|
+
allowed_extensions para filtros personalizados.
|
|
155
|
+
allow_multiple : bool
|
|
156
|
+
Se True, permite selecionar vários arquivos de uma vez.
|
|
157
|
+
allowed_extensions : list[str] | None
|
|
158
|
+
Usado apenas quando type=FilePickerType.custom.
|
|
159
|
+
Ex.: ["json", "toml", "yaml"]
|
|
160
|
+
dialog_title : str
|
|
161
|
+
Título da janela de diálogo do SO.
|
|
162
|
+
initial_directory : str | None
|
|
163
|
+
Diretório inicial do diálogo. Padrão: pasta home do usuário.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
type: FilePickerType = FilePickerType.any,
|
|
169
|
+
allow_multiple: bool = False,
|
|
170
|
+
allowed_extensions: Optional[list[str]] = None,
|
|
171
|
+
dialog_title: str = "Selecionar arquivo",
|
|
172
|
+
initial_directory: Optional[str] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
|
|
175
|
+
self.type = type
|
|
176
|
+
self.allow_multiple = allow_multiple
|
|
177
|
+
self.allowed_extensions = allowed_extensions
|
|
178
|
+
self.dialog_title = dialog_title
|
|
179
|
+
self.initial_directory = initial_directory #or os.path.expanduser("~")
|
|
180
|
+
|
|
181
|
+
if self.initial_directory is None:
|
|
182
|
+
self.initial_directory = os.path.join(f"{os.path.expanduser("~")}", "Downloads")
|
|
183
|
+
|
|
184
|
+
# Expõe o resultado de forma reativa — use em ValueListenableBuilder
|
|
185
|
+
self.result_notifier: ValueNotifier[FilePickerResult | None] = ValueNotifier(None)
|
|
186
|
+
|
|
187
|
+
# ── API pública ───────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def pick_files(self) -> FilePickerResult | None:
|
|
190
|
+
"""
|
|
191
|
+
Abre o diálogo de seleção de arquivos do SO.
|
|
192
|
+
|
|
193
|
+
Retorna FilePickerResult se o usuário confirmou, None se cancelou.
|
|
194
|
+
Também atualiza result_notifier para notificar widgets ouvintes.
|
|
195
|
+
"""
|
|
196
|
+
filters = self._resolve_filters()
|
|
197
|
+
|
|
198
|
+
if self.allow_multiple:
|
|
199
|
+
paths = filedialog.askopenfilenames(
|
|
200
|
+
title=self.dialog_title,
|
|
201
|
+
filetypes=filters,
|
|
202
|
+
initialdir=self.initial_directory,
|
|
203
|
+
)
|
|
204
|
+
raw = list(paths) if paths else []
|
|
205
|
+
else:
|
|
206
|
+
path = filedialog.askopenfilename(
|
|
207
|
+
title=self.dialog_title,
|
|
208
|
+
filetypes=filters,
|
|
209
|
+
initialdir=self.initial_directory,
|
|
210
|
+
)
|
|
211
|
+
raw = [path] if path else []
|
|
212
|
+
|
|
213
|
+
result = FilePickerResult(files=[PlatformFile(p) for p in raw]) if raw else None
|
|
214
|
+
if raw is not None:
|
|
215
|
+
first = raw[0] if len(raw) > 0 else None
|
|
216
|
+
if first is not None:
|
|
217
|
+
self.initial_directory = os.path.dirname(first)
|
|
218
|
+
print(f'{__class__.__name__} [INITIAL_DIR] ... {self.initial_directory}')
|
|
219
|
+
|
|
220
|
+
self.result_notifier.value = result
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def pick_directory(self) -> str | None:
|
|
224
|
+
"""
|
|
225
|
+
Abre o diálogo para selecionar uma pasta.
|
|
226
|
+
|
|
227
|
+
Retorna o caminho absoluto ou None se cancelado.
|
|
228
|
+
"""
|
|
229
|
+
path = filedialog.askdirectory(
|
|
230
|
+
title=self.dialog_title,
|
|
231
|
+
initialdir=self.initial_directory,
|
|
232
|
+
mustexist=True,
|
|
233
|
+
)
|
|
234
|
+
return path or None
|
|
235
|
+
|
|
236
|
+
def save_file(
|
|
237
|
+
self,
|
|
238
|
+
suggested_name: str = "",
|
|
239
|
+
) -> str | None:
|
|
240
|
+
"""
|
|
241
|
+
Abre o diálogo "Salvar como".
|
|
242
|
+
|
|
243
|
+
Retorna o caminho escolhido ou None se cancelado.
|
|
244
|
+
"""
|
|
245
|
+
filters = self._resolve_filters()
|
|
246
|
+
default_ext = (
|
|
247
|
+
"." + self.allowed_extensions[0]
|
|
248
|
+
if self.allowed_extensions
|
|
249
|
+
else ""
|
|
250
|
+
)
|
|
251
|
+
path = filedialog.asksaveasfilename(
|
|
252
|
+
title=self.dialog_title,
|
|
253
|
+
filetypes=filters,
|
|
254
|
+
initialdir=self.initial_directory,
|
|
255
|
+
initialfile=suggested_name,
|
|
256
|
+
defaultextension=default_ext,
|
|
257
|
+
)
|
|
258
|
+
return path or None
|
|
259
|
+
|
|
260
|
+
def clear_result(self) -> None:
|
|
261
|
+
"""Limpa o resultado atual e notifica os ouvintes."""
|
|
262
|
+
self.result_notifier.value = None
|
|
263
|
+
|
|
264
|
+
# ── privado ───────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def _resolve_filters(self) -> list[tuple[str, str]]:
|
|
267
|
+
if self.type == FilePickerType.custom:
|
|
268
|
+
if not self.allowed_extensions:
|
|
269
|
+
return [("Todos os arquivos", "*.*")]
|
|
270
|
+
pattern = " ".join(f"*.{ext}" for ext in self.allowed_extensions)
|
|
271
|
+
label = ", ".join(self.allowed_extensions).upper()
|
|
272
|
+
return [(label, pattern), ("Todos os arquivos", "*.*")]
|
|
273
|
+
return _TYPE_FILTERS.get(self.type, [("Todos os arquivos", "*.*")])
|
|
274
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# navigator.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Callable
|
|
5
|
+
from pyflutter_ui.ui.core import (
|
|
6
|
+
BuildContext, Element, FlutterWidget, BaseMaterialApp, Navigator
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# NAVIGATOR
|
|
12
|
+
# ============================================================
|
|
13
|
+
class Route(ABC):
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def build(self, context: BuildContext) -> FlutterWidget:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PageRoute(Route):
|
|
24
|
+
|
|
25
|
+
def __init__(self, builder: Callable[
|
|
26
|
+
[BuildContext],
|
|
27
|
+
FlutterWidget
|
|
28
|
+
]) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.builder = builder
|
|
31
|
+
|
|
32
|
+
def build(self, context: BuildContext) -> FlutterWidget:
|
|
33
|
+
return self.builder(context)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NavigatorApp(Navigator):
|
|
37
|
+
|
|
38
|
+
def __init__(self, app: BaseMaterialApp) -> None:
|
|
39
|
+
super().__init__(app)
|
|
40
|
+
self.stack: list[Element] = []
|
|
41
|
+
|
|
42
|
+
def push(self, page: FlutterWidget) -> None:
|
|
43
|
+
|
|
44
|
+
if self.stack:
|
|
45
|
+
current = self.stack[-1]
|
|
46
|
+
|
|
47
|
+
if current.widget_tk is not None:
|
|
48
|
+
current.widget_tk.pack_forget()
|
|
49
|
+
|
|
50
|
+
element = page.create_element()
|
|
51
|
+
context = BuildContext(self.app, parent_widget=self.app.root)
|
|
52
|
+
element.mount(context)
|
|
53
|
+
self.stack.append(element)
|
|
54
|
+
self.current_element = element
|
|
55
|
+
|
|
56
|
+
def pop(self) -> None:
|
|
57
|
+
|
|
58
|
+
if len(self.stack) <= 1:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
current = self.stack.pop()
|
|
62
|
+
|
|
63
|
+
current.unmount()
|
|
64
|
+
|
|
65
|
+
previous = self.stack[-1]
|
|
66
|
+
|
|
67
|
+
if previous.widget_tk is not None:
|
|
68
|
+
previous.widget_tk.pack(fill="both", expand=True)
|
|
69
|
+
|
|
70
|
+
self.current_element = previous
|
|
71
|
+
|
|
72
|
+
def replace(self, page: FlutterWidget) -> None:
|
|
73
|
+
|
|
74
|
+
if self.stack:
|
|
75
|
+
current = self.stack.pop()
|
|
76
|
+
|
|
77
|
+
current.unmount()
|
|
78
|
+
|
|
79
|
+
element = page.create_element()
|
|
80
|
+
|
|
81
|
+
context = BuildContext(
|
|
82
|
+
app=self.app,
|
|
83
|
+
parent_widget=self.app.root
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
element.mount(context)
|
|
87
|
+
|
|
88
|
+
self.stack.append(element)
|
|
89
|
+
|
|
90
|
+
self.current_element = element
|
|
91
|
+
|
|
92
|
+
def render(self) -> None:
|
|
93
|
+
pass
|