-
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathapp.py
More file actions
386 lines (337 loc) · 13 KB
/
app.py
File metadata and controls
386 lines (337 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
from typing import Callable, Optional
from flet import (
AlertDialog,
FilePicker,
FilePickerUploadFile,
Page,
SnackBar,
TemplateRoute,
ThemeMode,
View,
run,
)
from loguru import logger
from pandas import DataFrame
from tuttle.app.auth.view import ProfileScreen, SplashScreen
from tuttle.app.core.abstractions import TView, TViewParams
from tuttle.app.core.client_storage_impl import ClientStorageImpl
from tuttle.app.core.database_storage_impl import DatabaseStorageImpl
from tuttle.app.core.models import RouteView
from tuttle.app.core.utils import AlertDialogControls
from tuttle.app.core.views import THeading
from tuttle.app.error_views.page_not_found_screen import Error404Screen
from tuttle.app.home.view import HomeScreen
from tuttle.app.preferences.intent import PreferencesIntent
from tuttle.app.preferences.model import PreferencesStorageKeys
from tuttle.app.preferences.view import PreferencesScreen
from tuttle.app.res.colors import (
accent,
bg,
bg_surface,
danger,
text_inverse,
text_primary,
# backward-compat aliases still used elsewhere
BLACK_COLOR_ALT,
ERROR_COLOR,
PRIMARY_COLOR,
WHITE_COLOR,
)
from tuttle.app.res.dimens import MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH
from tuttle.app.res.fonts import APP_FONTS, HEADLINE_4_SIZE, HEADLINE_FONT
from tuttle.app.res.res_utils import (
HOME_SCREEN_ROUTE,
PREFERENCES_SCREEN_ROUTE,
PROFILE_SCREEN_ROUTE,
SPLASH_SCREEN_ROUTE,
)
from tuttle.app.res.theme import APP_THEME, THEME_MODES, get_theme_mode_from_value
from tuttle.app.timetracking.intent import TimeTrackingIntent
class TuttleApp:
"""The main application class"""
def __init__(
self,
page: Page,
debug_mode: bool = False,
):
""" """
self.debug_mode = debug_mode
self.page = page
self.page.title = "Tuttle"
self.page.fonts = APP_FONTS
self.page.theme = APP_THEME
self.page.theme_mode = ThemeMode.DARK
self.page.window.bgcolor = bg
self.client_storage = ClientStorageImpl(page=self.page)
self.db = DatabaseStorageImpl(
store_demo_timetracking_dataframe=self.store_demo_timetracking_dataframe,
debug_mode=self.debug_mode,
)
self.page.window.min_width = MIN_WINDOW_WIDTH
self.page.window.min_height = MIN_WINDOW_HEIGHT
self.page.window.width = MIN_WINDOW_WIDTH + 400
self.page.window.height = MIN_WINDOW_HEIGHT + 200
self.file_picker = FilePicker()
"""holds the RouteView object associated with a route
used in on route change"""
self.route_to_route_view_cache = {}
self.page.on_route_change = self.on_route_change
self.page.on_view_pop = self.on_view_pop
self.route_parser = TuttleRoutes(self)
self.current_route_view: Optional[RouteView] = None
self.page.on_resize = self.page_resize
def page_resize(self, e):
if self.current_route_view:
self.current_route_view.on_window_resized(e.width, e.height)
def pick_file_callback(
self,
on_file_picker_result,
allowed_extensions,
dialog_title,
file_type,
):
from types import SimpleNamespace
from flet import FilePickerFileType
file_type_map = {
"any": FilePickerFileType.ANY,
"custom": FilePickerFileType.CUSTOM,
"image": FilePickerFileType.IMAGE,
"media": FilePickerFileType.MEDIA,
"video": FilePickerFileType.VIDEO,
"audio": FilePickerFileType.AUDIO,
}
ft_file_type = (
file_type_map.get(file_type, FilePickerFileType.ANY)
if isinstance(file_type, str)
else file_type
)
import sys
if sys.platform == "darwin" and ft_file_type == FilePickerFileType.CUSTOM:
ft_file_type = FilePickerFileType.ANY
async def _pick_files():
pick_kwargs = dict(
allow_multiple=False,
dialog_title=dialog_title,
file_type=ft_file_type,
)
if ft_file_type == FilePickerFileType.CUSTOM and allowed_extensions:
pick_kwargs["allowed_extensions"] = allowed_extensions
files = await self.file_picker.pick_files(**pick_kwargs)
if files and allowed_extensions:
files = [
f
for f in files
if any(
f.name.lower().endswith(f".{ext.lower()}")
for ext in allowed_extensions
)
]
result = SimpleNamespace(files=files)
on_file_picker_result(result)
self.page.run_task(_pick_files)
def on_theme_mode_changed(self, selected_theme: str):
"""callback function used by views for changing app theme mode"""
mode = get_theme_mode_from_value(selected_theme)
self.page.theme_mode = ThemeMode.DARK
self.page.update()
def show_snack(
self,
message: str,
is_error: bool = False,
action_label: Optional[str] = None,
action_callback: Optional[Callable] = None,
):
"""callback function used by views to display a snack bar message"""
from flet import SnackBarAction
action = None
if action_label:
action = SnackBarAction(
label=action_label,
text_color=accent,
on_click=action_callback,
)
snack = SnackBar(
content=THeading(
title=message,
size=HEADLINE_4_SIZE,
color=danger if is_error else text_primary,
),
bgcolor=bg_surface,
action=action,
open=True,
)
def on_snack_dismiss(e):
if snack in self.page.overlay:
self.page.overlay.remove(snack)
snack.on_dismiss = on_snack_dismiss
self.page.show_dialog(snack)
def control_alert_dialog(
self,
dialog: Optional[AlertDialog] = None,
control: AlertDialogControls = AlertDialogControls.CLOSE,
):
"""handles adding, opening and closing of page alert dialogs"""
if control.value == AlertDialogControls.ADD_AND_OPEN.value:
if dialog:
dialog.open = True
self.page.show_dialog(dialog)
if control.value == AlertDialogControls.CLOSE.value:
if dialog:
dialog.open = False
self.page.pop_dialog()
def change_route(self, to_route: str, data: Optional[any] = None):
"""navigates to a new route"""
newRoute = to_route if data is None else f"{to_route}/{data}"
self.page.run_task(self.page.push_route, newRoute)
def on_view_pop(self, e=None):
"""invoked on back pressed"""
if len(self.page.views) == 1:
return
if e is not None and hasattr(e, "view") and e.view is not None:
self.page.views.remove(e.view)
else:
self.page.views.pop()
current_page_view: View = self.page.views[-1]
self.page.run_task(self.page.push_route, current_page_view.route)
if current_page_view.controls:
try:
tuttle_view: TView = current_page_view.controls[0]
tuttle_view.on_resume_after_back_pressed()
except Exception as ex:
logger.error(
f"Exception raised @TuttleApp.on_view_pop {ex.__class__.__name__}"
)
logger.exception(ex)
def on_route_change(self, e=None):
"""auto invoked when the route changes
parses the new destination route
then appends the new page to page views
"""
current_route = self.page.route
if current_route in self.route_to_route_view_cache:
# route already visited: reuse cached view
self.current_route_view = self.route_to_route_view_cache[current_route]
if not self.current_route_view.keep_back_stack:
self.route_to_route_view_cache.clear()
self.route_to_route_view_cache[current_route] = self.current_route_view
self.page.views.clear()
if self.current_route_view.view not in self.page.views:
self.page.views.append(self.current_route_view.view)
self.page.update()
self.current_route_view.on_window_resized(
self.page.window.width, self.page.window.height
)
return
# build a new view for this route
route_view_wrapper = self.route_parser.parse_route(pageRoute=current_route)
if not route_view_wrapper.keep_back_stack:
self.route_to_route_view_cache.clear()
self.page.views.clear()
self.route_to_route_view_cache[current_route] = route_view_wrapper
self.page.views.append(route_view_wrapper.view)
self.current_route_view = route_view_wrapper
self.page.update()
self.current_route_view.on_window_resized(
self.page.window.width, self.page.window.height
)
def store_demo_timetracking_dataframe(self, time_tracking_data: DataFrame):
"""Caches the time tracking dataframe created from a demo installation"""
self.timetracking_intent = TimeTrackingIntent(
client_storage=self.client_storage
)
self.timetracking_intent.set_timetracking_data(data=time_tracking_data)
def build(self):
self.on_route_change()
def close(self):
"""Closes the application."""
self.page.window.close()
def reset_and_quit(self):
"""Resets the application and quits."""
self.db.reset_database()
self.close()
class TuttleRoutes:
"""Utility class for parsing of routes to destination views"""
def __init__(self, app: TuttleApp):
# init callbacks for some views
self.on_theme_changed = app.on_theme_mode_changed
self.on_reset_and_quit = app.reset_and_quit
self.on_install_demo_data = app.db.install_demo_data
self.file_picker = app.file_picker
# init common params for views
self.tuttle_view_params = TViewParams(
navigate_to_route=app.change_route,
show_snack=app.show_snack,
dialog_controller=app.control_alert_dialog,
on_navigate_back=app.on_view_pop,
client_storage=app.client_storage,
pick_file_callback=app.pick_file_callback,
)
def get_page_route_view(
self,
routeName: str,
view: TView,
) -> RouteView:
"""Constructs the view with a given route"""
view_container = View(
padding=0,
spacing=0,
route=routeName,
scroll=view.page_scroll_type,
controls=[view],
vertical_alignment=view.vertical_alignment_in_parent,
horizontal_alignment=view.horizontal_alignment_in_parent,
bgcolor=bg,
services=[self.file_picker],
)
return RouteView(
view=view_container,
on_window_resized=view.on_window_resized_listener,
keep_back_stack=view.keep_back_stack,
)
def parse_route(self, pageRoute: str):
"""parses a given route path and returns it's view"""
routePath = TemplateRoute(pageRoute)
screen = None
if routePath.match(SPLASH_SCREEN_ROUTE):
screen = SplashScreen(
params=self.tuttle_view_params,
on_install_demo_data=self.on_install_demo_data,
)
elif routePath.match(HOME_SCREEN_ROUTE):
screen = HomeScreen(
params=self.tuttle_view_params,
)
elif routePath.match(PROFILE_SCREEN_ROUTE):
screen = ProfileScreen(
params=self.tuttle_view_params,
)
elif routePath.match(PREFERENCES_SCREEN_ROUTE):
screen = PreferencesScreen(
params=self.tuttle_view_params,
on_theme_changed_callback=self.on_theme_changed,
on_reset_app_callback=self.on_reset_and_quit,
)
else:
screen = Error404Screen(params=self.tuttle_view_params)
return self.get_page_route_view(routePath.route, view=screen)
def get_assets_uploads_url(with_parent_dir: bool = False):
uploads_parent_dir = "assets"
uploads_dir = "uploads"
if with_parent_dir:
return f"{uploads_parent_dir}/{uploads_dir}"
return uploads_dir
async def main(page: Page):
"""Entry point of the app"""
app = TuttleApp(page)
# if database does not exist, create it
app.db.ensure_database()
# pre-load shared preferences cache (async in Flet 0.80+)
await app.client_storage.load_cache()
app.build()
if __name__ == "__main__":
run(
name="Tuttle",
main=main,
assets_dir="assets",
upload_dir=get_assets_uploads_url(with_parent_dir=True),
)