Compare commits

...

191 Commits

Author SHA1 Message Date
Savanni D'Gerinel a5990a2a30 Ensure that the territory property is accepted 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel bd6d5b62e3 Reduce the recursion amount of parser Node to GameNode 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel 82c1765513 Write the more semantic Game interpreter 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel de54ec676f Make sure the settings get saved 2024-03-21 17:01:40 -04:00
Savanni D'Gerinel 5612c89a61 Thread signals through all of the settings dialog 2024-03-21 08:43:08 -04:00
Savanni D'Gerinel c3c144e035 Set up the preferences UI with the path to the games library 2024-03-19 10:08:43 -04:00
Savanni D'Gerinel 05a6dcf3af Set up the initial settings screen 2024-03-15 14:07:55 -04:00
Savanni D'Gerinel b98e0bdcea Strip kifu down even further and revamp the app_window structure 2024-03-15 10:49:25 -04:00
Savanni D'Gerinel 3d4a298dc1 Write some documentation for the LocalObserver 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 2d7fbb9a4b Add the placeholders for game review and settings 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 3a5cb17e09 Extract notification receiving into an observer 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel f6c82cbcb0 Ensure the game_view_model handler aborts when the view_model gets dropped 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 74b00d94b1 Set up the notifications pattern for view models 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel ddf83b3018 GameViewModel now subscribes directly to the Core 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel a1f41a440f Remove typeshare from kifu 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel e838f601ca Set up a placeholder for the GameViewModel 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 1b0a90a332 Create the placeholder for the database view model 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 4dc6e3151b Remove everything related to the kifu typescript code 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 79cec6e21d Switch to async channels and disable most of the existing runtime api 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 32cc74edfa Set up gio settings 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel d3d3260091 Create a flake that can run the Kifu app 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel a1441f7bb1 Bump versions to 0.6.0 2024-02-19 18:47:48 -05:00
Savanni D'Gerinel 9e7350b087 Add an about page that calls out gtk-rs and the GUI development book 2024-02-19 18:41:38 -05:00
Savanni D'Gerinel 0032f16422 Resolve the duplicate widget insertion warning 2024-02-19 18:04:45 -05:00
Savanni D'Gerinel a5d51dab70 Hugely improve the historical view formatting 2024-02-19 17:56:48 -05:00
Savanni D'Gerinel c24a5f515f Set up CSS styles around the date range picker 2024-02-19 17:27:02 -05:00
Savanni D'Gerinel d70ca08db2 Correctly set the date range picker when initializing the historical view 2024-02-19 16:47:10 -05:00
Savanni D'Gerinel 55b6327d42 Open the welcome screen if the database is not available 2024-02-19 16:27:11 -05:00
Savanni D'Gerinel 1c2f40c868 Apply clippy suggestions 2024-02-19 16:14:08 -05:00
Savanni D'Gerinel 1527942f9c Add support for DurationWorkout and SetRep, finishing the legacy importer 2024-02-19 14:24:52 -05:00
Savanni D'Gerinel 7ba758b325 Build an application that can read the legacy record data structure 2024-02-19 13:27:19 -05:00
Savanni D'Gerinel aed4735209 Add some documentation around the quit action 2024-02-19 12:05:21 -05:00
Savanni D'Gerinel c14b20b79e Set up an application quit action group
This ties together a menu item, closing the main window, and Ctrl-Q into
the same action, which is to quit the application.
2024-02-19 11:58:01 -05:00
Savanni D'Gerinel 56ff5527ba Quit the application when the user clicks the header bar X 2024-02-19 09:49:20 -05:00
Savanni D'Gerinel f7f55d74fd Bump fitnesstrax to v0.5.0 2024-02-18 17:58:52 -05:00
Savanni D'Gerinel 843924afef Wrap the list view within a scrolled window 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 86d7ca0b01 Change TextEntry to a builder pattern 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel d137ee2481 Apply clippy 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 74dbb58ed9 Extract the date range picker and enable the quick picker 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 9a18496c95 Apply the value of a date field to the historical view
Date fields can now be updated and their values retrieved. So now I have added a button that retrieves the range of the date field and updates the historical view in response to them.
2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 01776a534e Start setting up date range widgets 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel b33d17c256 Start building an internally editable date field 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel ab59eedef5 Fitnesstrax v0.4.1 2024-02-12 10:14:55 -05:00
Savanni D'Gerinel 4dd6afeae7 Add the desktop shortcut and the gsettings schema to the fitnesstrax installation 2024-02-12 09:15:47 -05:00
Savanni D'Gerinel cb2bec4287 First pass at an installer override 2024-02-10 12:28:12 -05:00
Savanni D'Gerinel 5a93c4fdcd Bump the fitnesstrax version to 0.4.0 2024-02-09 22:52:34 -05:00
Savanni D'Gerinel 6e5cbc0930 Ensure there is a button for every workout type 2024-02-09 22:40:29 -05:00
Savanni D'Gerinel 73a5ab89a3 Rename BikeRide to Biking
This just generally feels more comfortable, especially when put in conjuction with the other activity types
2024-02-09 08:24:01 -05:00
Savanni D'Gerinel 291dc32fe5 Show summaries of all time-distance workouts 2024-02-09 08:22:12 -05:00
Savanni D'Gerinel b5c42e3ac3 Add the activity type selector to the time-distance widget 2024-02-09 08:17:10 -05:00
Savanni D'Gerinel 6394d89331 Create new records with the date of the view model 2024-02-08 22:11:46 -05:00
Savanni D'Gerinel 38b1e62b60 Add the ability to edit the time on a time_distance record 2024-02-08 21:58:22 -05:00
Savanni D'Gerinel 4acf034b8d Clean up warnings and remove printlns 2024-02-08 19:01:56 -05:00
Savanni D'Gerinel 1aff203afc Reload data when the user saves on the DayEdit panel
This required some big overhauls. The view model no longer takes records. It only takes the date that it is responsible for, and it will ask the database for records pertaining to that date. This means that once the view model has saved all of its records, it can simply reload those records from the database. This has the effect that as soon as the user moves from DayEdit back to DayDetail, all of the interesting information has been repopulated.
2024-02-08 19:01:56 -05:00
Savanni D'Gerinel 9fc9d2b758 Create Duration and Distance structures to handle rendering
These structures handle parsing and rendering of a Duration and a Distance, allowing that knowledge to be centralized and reused. Then I'm using those structures in a variety of places in order to ensure that the information gets rendered consistently.
2024-02-08 10:30:14 -05:00
Savanni D'Gerinel 76f4b31466 Show existing time/distance workout rows in day detail and editor 2024-02-08 10:23:47 -05:00
Savanni D'Gerinel 73052a0694 Save new time/distance records
This sets up a bunch of callbacks. We're starting to get into Callback Hell, where there are things that need knowledge that I really don't want them to have.

However, edit fields for TimeDistanceEdit now propogate data back into the view model, which is then able to save the results.
2024-02-08 10:20:57 -05:00
Savanni D'Gerinel 2c42c35dfe Build the facilities to add a new time/distance workout
This adds the code to show the new records in the UI, plus it adds them to the view model. Some of the representation changed in order to facilitate linking UI elements to particular records. There are now some buttons to create workouts of various types, clicking on a button adds a new row to the UI, and it also adds a new record to the view model. Saving the view model writes the records to the database.
2024-02-08 09:44:58 -05:00
Savanni D'Gerinel afe693fe10 Make emseries::Record copyable 2024-02-08 09:18:55 -05:00
Savanni D'Gerinel af1422d523 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-02-08 09:18:55 -05:00
Savanni D'Gerinel 792e20d44b Add buttons with icons to represent workouts 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 8016188b29 Add a test program for gnome icons 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 74df2880bb Implement the Edit Cancel button 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 96c4201680 Render time distance details in the day detail view 2024-02-08 09:02:09 -05:00
Savanni D'Gerinel ecdd38ebbc Show a summary of the day's biking stats when there is one 2024-02-08 08:57:17 -05:00
Savanni D'Gerinel 2fb8728856 Invert the TraxRecord
This simplifies, though not as much as I was hoping, the patterns for accessing data along strict type patterns. I may see better results once I'm getting the Time/Distance views working.
2024-02-08 00:20:40 -05:00
Savanni D'Gerinel a7d43ef184 Update Cargo.nix 2024-02-08 00:16:27 -05:00
Savanni D'Gerinel 9727d35116 Resolve clippy warnings
Warnings were mounting up. It was time to resolve them before attempting a massive rebase.
2024-02-07 23:36:03 -05:00
Savanni D'Gerinel 1d6155d9e5 Finish the update and delete view model functions 2024-02-07 09:29:08 -05:00
Savanni D'Gerinel a8bf540517 Remove test that steps and weights are honored correctly
Step and weight records may be presented in any order. Any test that
tries to enforce that one gets presented before teh other can't cannot
succeed. So, I've removed that test and instead put in a warning that
will appear when the view model gets loaded.
2024-02-07 08:28:38 -05:00
Savanni D'Gerinel 3db870d790 Set up time distance operations and tests 2024-02-03 15:28:33 -05:00
Savanni D'Gerinel 24276d172b Introduce the RecordProvider interface
DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App.
2024-02-01 10:12:35 -05:00
Savanni D'Gerinel 96317f5692 Finish removing the previous record grouping
Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens.
2024-02-01 10:08:18 -05:00
Savanni D'Gerinel c1e797f3ae DayDetailViewModel now ignores records and directly retrieves data from App
This is preparatory work. Having the view model directly retrieve data both adds a degree of symmetry (it both gets data from and sends data to the app) and makes it possible for the view model to refresh itself when needing to revert data or after saving data.
2024-02-01 09:27:40 -05:00
Savanni D'Gerinel 304008c674 The view model can no longer be initialized without an app 2024-01-31 09:51:17 -05:00
Savanni D'Gerinel 772188b470 Set up text entry fields for all of the common metrics 2024-01-31 08:56:54 -05:00
Savanni D'Gerinel bc31522c95 Add the on_update callback to TextEntry, and test the component 2024-01-31 08:44:46 -05:00
Savanni D'Gerinel 6c68564a77 Create a function which safely initializes GTK once
This is only available in test code, and it allows GUI component tests to run without having to worry about double-initializing GTK
2024-01-31 08:40:55 -05:00
Savanni D'Gerinel 55c1a6372f Rename the formatters 2024-01-31 08:38:17 -05:00
Savanni D'Gerinel 2cbd539bf4 Add a type for managing Time of Day values 2024-01-31 08:27:42 -05:00
Savanni D'Gerinel 7d14308def Create automated testing for weight, duration, and distance formatters 2024-01-31 08:10:46 -05:00
Savanni D'Gerinel dcd6301bb9 Introduce structures for formatting and parsing Duration and Distance values
These aren't in use yet.
2024-01-30 10:11:30 -05:00
Savanni D'Gerinel 69567db486 Introduce a structure for formatting and parsing Weight values 2024-01-30 10:08:10 -05:00
Savanni D'Gerinel f8d66bbb69 Update build tools for dashboard and fitnesstrax 2024-01-25 22:55:45 -05:00
Savanni D'Gerinel dce11dde2b Set up flake-based builds 2024-01-25 22:48:41 -05:00
Savanni D'Gerinel 3f9a7072eb Fitnesstrax, version 0.3.0 2024-01-21 10:14:50 -05:00
Savanni D'Gerinel 7ec48ded5d Make the day summary use the view model 2024-01-20 17:04:20 -05:00
Savanni D'Gerinel 9461c387fe Simplify the weight editor 2024-01-20 16:05:33 -05:00
Savanni D'Gerinel d4c48c4443 Add a step count editor field 2024-01-20 15:59:03 -05:00
Savanni D'Gerinel 9bedb7a76c Tons of linting and get tests running again 2024-01-20 15:04:46 -05:00
Savanni D'Gerinel 1fe318068b Set up a view model for the day detail view 2024-01-20 11:16:31 -05:00
Savanni D'Gerinel 18e7e4fe2f Start setting up the day detail view model
I've created the view model and added a getter function for the weight.
I'm passing the view model now to the DayDetailView, DayDetail, and
DayEdit.

I'm starting to set up the Save function for the view model, draining
all of the updated records and saving them.

None of the components yet save any updates to the view model, so
updated_records is always going to be empty until I figure that out.
2024-01-18 09:00:08 -05:00
Savanni D'Gerinel 1c2c4982a1 Update the record in the detail view on save 2024-01-18 07:43:18 -05:00
Savanni D'Gerinel c075b7ed6e Just barely get the data saveable again
Starting to see some pretty serious limitations already.
2024-01-17 22:59:20 -05:00
Savanni D'Gerinel 56d0a53666 Fix how DayEdit deals with the weight field 2024-01-17 22:35:13 -05:00
Savanni D'Gerinel b00acc64a3 Set up the ActionGroup component 2024-01-17 22:13:55 -05:00
Savanni D'Gerinel 104760c754 Be able to switch into edit mode 2024-01-15 23:27:55 -05:00
Savanni D'Gerinel 1e6555ef61 Create a day detail view
DayDetail, the component, I used to use as a view. Now I'm swapping
things out so that DayDetailView handles the view itself. The DayDetail
component will still show the details of the day, but I'll create a
DayEditComponent which is dedicated to showing the edit interface for
everything in a day.

The swapping will now happen in DayDetailView, not in DayDetail or an
even deeper component.
2024-01-15 15:53:01 -05:00
Savanni D'Gerinel 2e2ff6b47e Create a Singleton component and use it to simplify the weight view 2024-01-15 13:20:23 -05:00
Savanni D'Gerinel 2d22397382 Bump the dashboard version 2024-01-09 08:10:58 -05:00
Savanni D'Gerinel 2c7666304a Fix the year in the current date view 2024-01-09 08:10:02 -05:00
Savanni D'Gerinel 0007522b26 Extract the Weight and Time Distance widgets 2024-01-01 23:58:55 -05:00
Savanni D'Gerinel b7b9b1b29f Create an EditView "component"
This is a super tiny data structure that covers an edit mode, a view
mode, and an unconfigured mode. It's mostly a container so that views
don't have to preserve everything directly.
2024-01-01 23:49:31 -05:00
Savanni D'Gerinel 2e3d5fc5a4 Clean up the parameters to TextEntry and populate the field 2024-01-01 22:57:59 -05:00
Savanni D'Gerinel a25b76d230 Create a validated text entry widget
I move the weight edit view into the validated text entry widget, and I
work on some of the unfortunate logic in the weight blur function. I've
left behind a lot of breadcrumbs for things that still need to be done.
2023-12-31 11:18:15 -05:00
Savanni D'Gerinel 9970161c30 Set up a lot of the files necessary for a desktop app 2023-12-29 13:13:22 -05:00
Savanni D'Gerinel 7bd4885b09 Save new information to the day detail view and to the historical view 2023-12-29 09:24:37 -05:00
Savanni D'Gerinel b5dcee3737 Update the historical view when a change happens in the db 2023-12-28 22:47:47 -05:00
Savanni D'Gerinel 0c3ae062c8 Save real data to the database. Load data on app start. 2023-12-28 22:46:44 -05:00
Savanni D'Gerinel f422e233a1 Record data to the database
This isn't recording real data. It's basically discarding all
information from the weight edit field. But it is creating a record.
2023-12-28 22:43:56 -05:00
Savanni D'Gerinel 7a6e902fdd Create placeholders in the historical view for days that are unpopulated. 2023-12-28 22:36:44 -05:00
Savanni D'Gerinel 6d9e2ea382 Switch to the updated emseries record type 2023-12-28 22:36:40 -05:00
Savanni D'Gerinel 04a48574d3 Develop a pattern to detect clicking outside of a focused child
Be able to respond to blur events and potentially be able to record weight.
2023-12-28 22:34:09 -05:00
Savanni D'Gerinel e13e7cf4c3 Create a widget that can show the weight view and edit modes 2023-12-28 22:31:11 -05:00
Savanni D'Gerinel 383f809191 Open and style the day detail view and add it to the navigation stack 2023-12-28 22:31:07 -05:00
Savanni D'Gerinel d269924827 Refactorings and dead code removal 2023-12-28 22:20:30 -05:00
Savanni D'Gerinel 8049859816 Clean up showing the welcome and historical screens
Swapping is now done in dedicated functions instead of a big pattern
match.

After selecting a database, the app window will apply the configuration
by opening the database, saving the path to configuration, and switching
to the historical view.
2023-12-28 21:45:55 -05:00
Savanni D'Gerinel ac343a2af6 Switch from channel-based communication to async calls into the core 2023-12-28 19:09:12 -05:00
Savanni D'Gerinel 5cd0e822c6 Update to adwaita 1.4, and add a navigation page stack 2023-12-28 13:21:42 -05:00
Savanni D'Gerinel fe5e4ed044 Save the views as their original widgets
This allows me to directly reference functions that occur on those
widgets without losing them behind a gtk::Widget upcast or needing to
later downcast them.
2023-12-28 12:59:29 -05:00
Savanni D'Gerinel e30668ca8e Drop DateTimeTz from fitnesstrax 2023-12-28 12:51:50 -05:00
Savanni D'Gerinel 149587f0bd Bind the ID to the record instead of keeping them separate 2023-12-27 16:13:47 -05:00
Savanni D'Gerinel d2f4ec97c0 Rename UniqueId to RecordId 2023-12-26 15:39:17 -05:00
Savanni D'Gerinel c94b7db484 Stop using DateTimeTz 2023-12-26 15:37:53 -05:00
Savanni D'Gerinel 85e2494c3b Add a test application that demonstrates chrono and timezone processing 2023-12-26 13:33:18 -05:00
Savanni D'Gerinel af8f9b0244 Generate some random data and feed it into hte historical view 2023-12-24 19:13:49 -05:00
Savanni D'Gerinel 1b3ca7439d Add styling to the day summary 2023-12-24 12:00:12 -05:00
Savanni D'Gerinel 3dc8be0d26 Render a weight record 2023-12-22 18:53:29 -05:00
Savanni D'Gerinel 43cd408e2c Start elaborating upon the HistoricalView
I've created the DaySummary structure and set up a list view to go into
the historical view. One hard-coded date is visible as a placeholder to
start filling things into the day summary.
2023-12-22 17:32:45 -05:00
Savanni D'Gerinel 3a728a51b4 Extract the application loop from the main file 2023-12-22 15:17:22 -05:00
Savanni D'Gerinel f19090311b Extract all of the UI components into dedicated files 2023-12-22 15:17:22 -05:00
Savanni D'Gerinel dedcc76df0 Mild cleanups 2023-12-22 15:16:03 -05:00
Savanni D'Gerinel 6678ab9852 Documentation 2023-12-22 14:28:23 -05:00
Savanni D'Gerinel 9c200f555c Set up app invocation and response handling 2023-12-22 14:08:16 -05:00
Savanni D'Gerinel 3ca8bf64cc Set up message passing between app window and an app thread 2023-12-19 18:05:22 -05:00
Savanni D'Gerinel 87994012fa Save the database path to settings and attempt to open the database on start 2023-12-19 10:59:33 -05:00
Savanni D'Gerinel 50268ffadc Actually be able to open the database 2023-12-19 10:46:53 -05:00
Savanni D'Gerinel beedeba8dc Style the welcome screen 2023-12-19 10:10:02 -05:00
Savanni D'Gerinel db188ea75a Allow the user to create a new file 2023-12-19 00:37:51 -05:00
Savanni D'Gerinel 104ffc5782 Set up callbacks to make the save button sensitive to the file selection 2023-12-19 00:31:36 -05:00
Savanni D'Gerinel 38db3d6780 Elaborate upon and format the welcome dialog 2023-12-18 21:14:08 -05:00
Savanni D'Gerinel 0dd0a5f7cc Set up some of the content of the welcome view 2023-12-18 20:04:55 -05:00
Savanni D'Gerinel acdf9ec150 Add the window header bar 2023-12-18 19:08:32 -05:00
Savanni D'Gerinel 0ebdcd7c2a Add some commentary 2023-12-18 18:36:22 -05:00
Savanni D'Gerinel baf652173c Set up the main views for the window, as well as the redraw policy
Whenever we change views, we need to call the redraw function. That
function will handle dropping the old view and populating the new one.
2023-12-18 18:30:41 -05:00
Savanni D'Gerinel c4befcc6de Add the CSS style context to the main window 2023-12-18 11:59:56 -05:00
Savanni D'Gerinel a7d6d82ec2 Set up an environment variable to toggle between dev and production schemas 2023-12-07 09:56:10 -05:00
Savanni D'Gerinel f3a453d151 Set up a development gsettings schema 2023-12-07 09:45:56 -05:00
Savanni D'Gerinel b9aa434278 Remove types that are not implemented yet
I've gone *years* without these types, mostly because I wasn't doing
these workouts. I can go longer.
2023-12-06 23:55:12 -05:00
Savanni D'Gerinel 83a4839b1d Implement the timestamp function 2023-12-06 23:52:46 -05:00
Savanni D'Gerinel 0e0d67a9ac Split Fitnesstrax into two crates 2023-12-06 23:52:33 -05:00
Savanni D'Gerinel e5fb605816 Create a test that verifies that a series can be made for a TraxRecord 2023-12-06 23:52:33 -05:00
Savanni D'Gerinel f9db002464 Make the series open function accept anything that can be a path reference 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel 0ac9bb74a6 Set up the bare minimum of a GUI app, opening only a single window 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel f034dfcb8b Set up the basic data structures of a new fitnesstrax app. 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel 7abb33c4fe Work out how the session filter and the handlers can function 2023-11-21 09:57:35 -05:00
Savanni D'Gerinel 581979fc54 Make some test endpoints and prototype an authentication filter 2023-11-20 23:30:10 -05:00
Savanni D'Gerinel bf93625225 Create a placeholder for the Visions server 2023-11-20 00:03:16 -05:00
Savanni D'Gerinel 778da0b651 Start working out designs and build tools for the visions vtt 2023-11-20 00:03:16 -05:00
Savanni D'Gerinel 8b53114d0d Have the file-service depend on the new authdb library 2023-11-19 23:55:43 -05:00
Savanni D'Gerinel 42e931d780 Move the cli app into authdb 2023-11-19 23:54:02 -05:00
Savanni D'Gerinel 532210db03 Extract the authentication DB from the file service 2023-11-19 23:43:33 -05:00
Savanni D'Gerinel 37f6334c9f Update the gtk dependencies for all packages
This breaks the hex-grid application. set_source_pixbuf got removed and
I have not figured out a replacement for drawing a pixbuf to a context.
2023-11-14 10:05:56 -05:00
Savanni D'Gerinel 3310c460ba Cleanups 2023-11-14 08:27:13 -05:00
Savanni D'Gerinel 6d14cdbe2a Build a color test pattern. 2023-11-14 08:04:31 -05:00
Savanni D'Gerinel c46ab1b389 Tweak the bit-banging code to get the protocol right 2023-11-13 18:21:07 -05:00
Savanni D'Gerinel 168ba6eb40 Try controlling dotstars through SPI and through bit-banging 2023-11-09 22:24:37 -05:00
Savanni D'Gerinel 7e3ee9a5b7 Set up a blink application for the raspberry pi pico 2023-11-05 15:54:33 -05:00
Savanni D'Gerinel 86a6d386d2 Set up raspberry pi cross-compile tools 2023-11-05 15:53:43 -05:00
Savanni D'Gerinel e461cb9908 Import the new level-one parser
This is the parser that does a raw parse of the SGF file, interpreting components but not enforcing node types.
2023-10-30 01:57:00 +00:00
Savanni D'Gerinel 942e91009e Disable sgf::go and provide a shim for a game 2023-10-30 01:57:00 +00:00
Savanni D'Gerinel 48113d6ccb Bump version to 0.2.0 2023-10-26 00:26:52 -04:00
Savanni D'Gerinel d878f4e82c Resolve more linting issues 2023-10-26 00:19:13 -04:00
Savanni D'Gerinel 7949033857 Add the handler to delete a file 2023-10-26 00:14:10 -04:00
Savanni D'Gerinel ce874e1d30 Fix the form to string conversion and set up the Delete form 2023-10-26 00:12:45 -04:00
Savanni D'Gerinel 07b8bb7bfe Style the authentication page for mobile 2023-10-26 00:03:49 -04:00
Savanni D'Gerinel a403c1b1b3 Hugely refactor the HTML 2023-10-26 00:03:39 -04:00
Savanni D'Gerinel 9a014af75a Remove my custom Image struct 2023-10-25 23:24:41 -04:00
Savanni D'Gerinel 448231739b Remove my custom Unordered List 2023-10-25 23:08:02 -04:00
Savanni D'Gerinel b0027032a4 Rename the password field to be compatible with 1Password 2023-10-25 23:05:06 -04:00
Savanni D'Gerinel 41bbfa14f3 Bump file-service tag to 0.1.2 2023-10-25 10:38:11 -04:00
Savanni D'Gerinel 66876e41c0 Clean up broken tests and clippy warnings 2023-10-25 10:35:24 -04:00
Savanni D'Gerinel ee348c29cb Render the name and the uploaded date for each file in the gallery 2023-10-25 10:20:14 -04:00
Savanni D'Gerinel e96b8087e2 Add filenames to FileInfo and then set those filenames when creating the file 2023-10-25 10:17:17 -04:00
Savanni D'Gerinel 12df1f4b9b Create an UnorderedList HTML container 2023-10-25 09:47:27 -04:00
Savanni D'Gerinel c2e34db79c Map on the data within the node instead of the node itself 2023-10-24 23:05:02 -04:00
Savanni D'Gerinel 0fbfb4f1ad Add a tree map operation 2023-10-20 23:43:47 -04:00
Savanni D'Gerinel c2e78d7c54 Clean up some unnecessary references 2023-10-20 20:28:36 -04:00
Savanni D'Gerinel 2ceccbf38d Remove the Clone constraint from T 2023-10-20 20:17:33 -04:00
Savanni D'Gerinel fbf6a9e76e Move the refcell to inside of the Node 2023-10-20 19:49:31 -04:00
Savanni D'Gerinel 52f814e663 Build a basic tree and experiment with traversals 2023-10-20 18:32:43 -04:00
176 changed files with 27805 additions and 6527 deletions

6
.gitignore vendored
View File

@ -5,7 +5,7 @@ dist
result
*.tgz
*.tar.gz
file-service/*.sqlite
file-service/*.sqlite-shm
file-service/*.sqlite-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
file-service/var

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"rust-analyzer.showUnlinkedFileNotification": false
}

1755
Cargo.lock generated

File diff suppressed because it is too large Load Diff

16202
Cargo.nix Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
[workspace]
resolver = "2"
members = [
"authdb",
"changeset",
"config",
"config-derive",
@ -8,10 +10,13 @@ members = [
"dashboard",
"emseries",
"file-service",
"fitnesstrax/core",
"fitnesstrax/app",
"fluent-ergonomics",
"geo-types",
"gm-control-panel",
"hex-grid",
"icon-test",
"ifc",
"kifu/core",
"kifu/gtk",
@ -20,4 +25,7 @@ members = [
"result-extended",
"screenplay",
"sgf",
"timezone-testing",
"tree",
"visions/server",
]

View File

@ -1,30 +0,0 @@
all: test bin
test: kifu-core/test-oneshot sgf/test-oneshot
bin: kifu-gtk
kifu-core/dev:
cd kifu/core && make test
kifu-core/test:
cd kifu/core && make test
kifu-core/test-oneshot:
cd kifu/core && make test-oneshot
kifu-gtk:
cd kifu/gtk && make release
kifu-gtk/dev:
cd kifu/gtk && make dev
kifu-pwa:
cd kifu/pwa && make release
kifu-pwa/dev:
pushd kifu/pwa && make dev
kifu-pwa/server:
pushd kifu/pwa && make server

27
authdb/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "authdb"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "authdb"
path = "src/lib.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
[dependencies]
base64ct = { version = "1", features = [ "alloc" ] }
clap = { version = "4", features = [ "derive" ] }
serde = { version = "1.0", features = ["derive"] }
sha2 = { version = "0.10" }
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
[dev-dependencies]
cool_asserts = "*"

View File

@ -1,5 +1,5 @@
use authdb::{AuthDB, Username};
use clap::{Parser, Subcommand};
use file_service::{AuthDB, Username};
use std::path::PathBuf;
#[derive(Subcommand, Debug)]

302
authdb/src/lib.rs Normal file
View File

@ -0,0 +1,302 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::ops::Deref;
use std::path::PathBuf;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("database failed")]
SqlError(sqlx::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.is_empty() {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.is_empty() {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use cool_asserts::assert_matches;
use std::collections::HashSet;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.authenticate(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.authenticate(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.authenticate(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.validate_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RUST_ALL_TARGETS=(
"changeset"
"config"
"config-derive"
"coordinates"
"cyberpunk-splash"
"dashboard"
"emseries"
"file-service"
"fluent-ergonomics"
"geo-types"
"gm-control-panel"
"hex-grid"
"ifc"
"kifu-core"
"kifu-gtk"
"memorycache"
"nom-training"
"result-extended"
"screenplay"
"sgf"
)
build_rust_targets() {
local CMD=$1
local TARGETS=${@/$CMD}
for target in $TARGETS; do
MODULE=$target CMD=$CMD ./builders/rust.sh
done
}
build_dist() {
local TARGETS=${@/$CMD}
for target in $TARGETS; do
if [ -f $target/dist.sh ]; then
build_rust_targets release ${TARGETS[*]}
cd $target && ./dist.sh
fi
done
}
export CARGO=`which cargo`
if [ -z "${TARGET-}" ]; then
TARGET="all"
fi
if [ -z "${CMD-}" ]; then
CMD="test release"
fi
if [ "${CMD}" == "clean" ]; then
cargo clean
exit 0
fi
for cmd in $CMD; do
if [ "${CMD}" == "dist" ]; then
build_dist $TARGET
elif [ "${TARGET}" == "all" ]; then
build_rust_targets $cmd ${RUST_ALL_TARGETS[*]}
else
build_rust_targets $cmd $TARGET
fi
done

View File

@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ ! -z "$MODULE" ]; then
MODULE="-p $MODULE"
fi
if [ -z "${PARAMS-}" ]; then
PARAMS=""
fi
case $CMD in
build)
$CARGO build $MODULE $PARAMS
;;
lint)
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
;;
test)
$CARGO test $MODULE $PARAMS
;;
run)
$CARGO run $MODULE $PARAMS
;;
release)
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
$CARGO build --release $MODULE $PARAMS
$CARGO test --release $MODULE $PARAMS
;;
clean)
$CARGO clean $MODULE
;;
"")
echo "No command specified. Use build | lint | test | run | release | clean"
;;
*)
echo "$CMD is unknown. Use build | lint | test | run | release | clean"
;;
esac

View File

@ -7,7 +7,7 @@ license = "GPL-3.0-only"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cairo-rs = { version = "0.17" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gtk = { version = "0.6", package = "gtk4" }
cairo-rs = { version = "0.18" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4" }

View File

@ -703,7 +703,7 @@ fn main() {
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<State>(gtk::glib::PRIORITY_DEFAULT);
gtk::glib::MainContext::channel::<State>(gtk::glib::Priority::DEFAULT);
let window = gtk::ApplicationWindow::new(app);
window.present();
@ -736,7 +736,7 @@ fn main() {
gtk_rx.attach(None, move |state| {
splash.set_state(state);
Continue(true)
glib::ControlFlow::Continue
});
std::thread::spawn({

View File

@ -1,22 +1,22 @@
[package]
name = "dashboard"
version = "0.1.1"
version = "0.1.2"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.17" }
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.18" }
chrono = { version = "0.4", features = ["serde"] }
fluent-ergonomics = { path = "../fluent-ergonomics/" }
fluent = { version = "0.16" }
futures = { version = "0.3" }
geo-types = { path = "../geo-types/" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gdk = { version = "0.6", package = "gdk4" }
gtk = { version = "0.6", package = "gtk4" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4" }
ifc = { path = "../ifc/" }
lazy_static = { version = "1.4" }
memorycache = { path = "../memorycache/" }
@ -28,5 +28,5 @@ tokio = { version = "1", features = ["full"] }
unic-langid = { version = "0.9" }
[build-dependencies]
glib-build-tools = "0.16"
glib-build-tools = "0.18"

View File

@ -1,7 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
"resources/gresources.xml",
&["resources"],
"gresources.xml",
"com.luminescent-dreams.dashboard.gresource",
);
}

View File

@ -11,10 +11,11 @@ pub struct DatePrivate {
impl Default for DatePrivate {
fn default() -> Self {
let date = chrono::Local::now().date_naive();
let year = date.year();
let date = date.with_year(year + 10000).unwrap();
Self {
date: Rc::new(RefCell::new(IFC::from(
chrono::Local::now().date_naive().with_year(12023).unwrap(),
))),
date: Rc::new(RefCell::new(IFC::from(date))),
label: Rc::new(RefCell::new(gtk::Label::new(None))),
}
}

View File

@ -102,7 +102,7 @@ pub fn main() {
let now = Local::now();
let state = State {
date: IFC::from(now.date_naive().with_year(12023).unwrap()),
date: IFC::from(now.date_naive().with_year(now.year() + 10000).unwrap()),
next_event: EVENTS.next_event(now.with_timezone(&Utc)).unwrap(),
events: EVENTS.yearly_events(now.year()).unwrap(),
transit: Some(transit),
@ -120,7 +120,7 @@ pub fn main() {
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
gtk::glib::MainContext::channel::<Message>(gtk::glib::Priority::DEFAULT);
*core.tx.write().unwrap() = Some(gtk_tx);
@ -133,7 +133,7 @@ pub fn main() {
let Message::Refresh(state) = msg;
ApplicationWindow::update_state(&window, state);
Continue(true)
glib::ControlFlow::Continue
}
});
});

View File

@ -1,198 +0,0 @@
/*
Copyright 2020-2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of the Luminescent Dreams Tools.
Luminescent Dreams Tools is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Luminescent Dreams Tools is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate chrono;
extern crate chrono_tz;
use chrono::SecondsFormat;
use chrono_tz::Etc::UTC;
use serde::de::{self, Deserialize, Deserializer, Visitor};
use serde::ser::{Serialize, Serializer};
use std::{fmt, str::FromStr};
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
/// string.
///
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
/// and the named time zone allows future parsers to know the exact interpretation of the time in
/// the frame of reference of the original recording.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
impl fmt::Display for DateTimeTz {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
if self.0.timezone() == UTC {
write!(f, "{}", self.0.to_rfc3339_opts(SecondsFormat::Secs, true))
} else {
write!(
f,
"{} {}",
self.0
.with_timezone(&chrono_tz::Etc::UTC)
.to_rfc3339_opts(SecondsFormat::Secs, true,),
self.0.timezone().name()
)
}
}
}
impl DateTimeTz {
pub fn map<F>(&self, f: F) -> DateTimeTz
where
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
{
DateTimeTz(f(self.0))
}
}
impl std::str::FromStr for DateTimeTz {
type Err = chrono::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: Vec<&str> = s.split_terminator(' ').collect();
if v.len() == 2 {
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
} else {
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
}
}
}
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
DateTimeTz(dt)
}
}
struct DateTimeTzVisitor;
impl<'de> Visitor<'de> for DateTimeTzVisitor {
type Value = DateTimeTz;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string date time representation that can be parsed")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
DateTimeTz::from_str(s).or(Err(E::custom(
"string is not a parsable datetime representation".to_owned(),
)))
}
}
impl Serialize for DateTimeTz {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for DateTimeTz {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(DateTimeTzVisitor)
}
}
#[cfg(test)]
mod test {
extern crate serde_json;
use super::*;
use chrono::TimeZone;
use chrono_tz::America::Phoenix;
use chrono_tz::Etc::UTC;
use chrono_tz::US::{Arizona, Central};
use std::str::FromStr;
#[test]
fn it_creates_timestamp_with_z() {
let t = DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap());
assert_eq!(t.to_string(), "2019-05-15T12:00:00Z");
}
#[test]
fn it_parses_utc_rfc3339_z() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00Z").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap())
);
}
#[test]
fn it_parses_rfc3339_with_offset() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00-06:00").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 18, 0, 0).unwrap())
);
}
#[test]
fn it_parses_rfc3339_with_tz() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z US/Arizona").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 6, 15, 19, 0, 0).unwrap())
);
assert_eq!(
t,
DateTimeTz(Arizona.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
);
assert_eq!(
t,
DateTimeTz(Central.with_ymd_and_hms(2019, 6, 15, 14, 0, 0).unwrap())
);
assert_eq!(t.to_string(), "2019-06-15T19:00:00Z US/Arizona");
}
#[derive(Serialize)]
struct DemoStruct {
id: String,
dt: DateTimeTz,
}
// I used Arizona here specifically because large parts of Arizona do not honor DST, and so
// that adds in more ambiguity of the -0700 offset with Pacific time.
#[test]
fn it_json_serializes() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z America/Phoenix").unwrap();
assert_eq!(
serde_json::to_string(&t).unwrap(),
"\"2019-06-15T19:00:00Z America/Phoenix\""
);
let demo = DemoStruct {
id: String::from("abcdefg"),
dt: t,
};
assert_eq!(
serde_json::to_string(&demo).unwrap(),
"{\"id\":\"abcdefg\",\"dt\":\"2019-06-15T19:00:00Z America/Phoenix\"}"
);
}
#[test]
fn it_json_parses() {
let t =
serde_json::from_str::<DateTimeTz>("\"2019-06-15T19:00:00Z America/Phoenix\"").unwrap();
assert_eq!(
t,
DateTimeTz(Phoenix.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
);
}
}

View File

@ -71,11 +71,9 @@ extern crate thiserror;
extern crate uuid;
mod criteria;
mod date_time_tz;
mod series;
mod types;
pub use criteria::*;
pub use date_time_tz::DateTimeTz;
pub use series::Series;
pub use types::{EmseriesReadError, EmseriesWriteError, Recordable, Timestamp, UniqueId};
pub use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable, Timestamp};

View File

@ -18,13 +18,51 @@ use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, LineWriter, Write};
use std::iter::Iterator;
use criteria::Criteria;
use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId};
use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable};
// A RecordOnDisk, a private data structure, is useful for handling all of the on-disk
// representations of a record. Unlike [Record], this one can accept an empty data value to
// represent that the data may have been deleted. This is not made public because, so far as the
// user is concerned, any record in the system must have data associated with it.
#[derive(Clone, Deserialize, Serialize)]
struct RecordOnDisk<T: Clone + Recordable> {
id: RecordId,
data: Option<T>,
}
/*
impl<T> FromStr for RecordOnDisk<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
}
}
*/
impl<T: Clone + Recordable> TryFrom<RecordOnDisk<T>> for Record<T> {
type Error = EmseriesReadError;
fn try_from(disk_record: RecordOnDisk<T>) -> Result<Self, Self::Error> {
match disk_record.data {
Some(data) => Ok(Record {
id: disk_record.id,
data,
}),
None => Err(Self::Error::RecordDeleted(disk_record.id)),
}
}
}
/// An open time series database.
///
@ -33,7 +71,7 @@ use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId}
pub struct Series<T: Clone + Recordable + DeserializeOwned + Serialize> {
//path: String,
writer: LineWriter<File>,
records: HashMap<UniqueId, T>,
records: HashMap<RecordId, Record<T>>,
}
impl<T> Series<T>
@ -42,7 +80,7 @@ where
{
/// Open a time series database at the specified path. `path` is the full path and filename for
/// the database.
pub fn open(path: &str) -> Result<Series<T>, EmseriesReadError> {
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Series<T>, EmseriesReadError> {
let f = OpenOptions::new()
.read(true)
.append(true)
@ -62,20 +100,18 @@ where
}
/// Load a file and return all of the records in it.
fn load_file(f: &File) -> Result<HashMap<UniqueId, T>, EmseriesReadError> {
let mut records: HashMap<UniqueId, T> = HashMap::new();
fn load_file(f: &File) -> Result<HashMap<RecordId, Record<T>>, EmseriesReadError> {
let mut records: HashMap<RecordId, Record<T>> = HashMap::new();
let reader = BufReader::new(f);
for line in reader.lines() {
match line {
Ok(line_) => {
/* Can't create a JSONParseError because I can't actually create the underlying error.
fail_point!("parse-line", Err(Error::JSONParseError()))
*/
match line_.parse::<Record<T>>() {
Ok(record) => match record.data {
Some(val) => records.insert(record.id.clone(), val),
None => records.remove(&record.id.clone()),
},
match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref())
.map_err(EmseriesReadError::JSONParseError)
.and_then(Record::try_from)
{
Ok(record) => records.insert(record.id, record.clone()),
Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),
Err(err) => return Err(err),
};
}
@ -87,18 +123,20 @@ where
/// Put a new record into the database. A unique id will be assigned to the record and
/// returned.
pub fn put(&mut self, entry: T) -> Result<UniqueId, EmseriesWriteError> {
let uuid = UniqueId::default();
self.update(uuid.clone(), entry).map(|_| uuid)
pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> {
let id = RecordId::default();
let record = Record { id, data: entry };
self.update(record)?;
Ok(id)
}
/// Update an existing record. The `UniqueId` of the record passed into this function must match
/// the `UniqueId` of a record already in the database.
pub fn update(&mut self, uuid: UniqueId, entry: T) -> Result<(), EmseriesWriteError> {
self.records.insert(uuid.clone(), entry.clone());
let write_res = match serde_json::to_string(&Record {
id: uuid,
data: Some(entry),
/// Update an existing record. The [RecordId] of the record passed into this function must match
/// the [RecordId] of a record already in the database.
pub fn update(&mut self, record: Record<T>) -> Result<(), EmseriesWriteError> {
self.records.insert(record.id, record.clone());
let write_res = match serde_json::to_string(&RecordOnDisk {
id: record.id,
data: Some(record.data),
}) {
Ok(rec_str) => self
.writer
@ -118,14 +156,14 @@ where
/// Future note: while this deletes a record from the view, it only adds an entry to the
/// database that indicates `data: null`. If record histories ever become important, the record
/// and its entire history (including this delete) will still be available.
pub fn delete(&mut self, uuid: &UniqueId) -> Result<(), EmseriesWriteError> {
pub fn delete(&mut self, uuid: &RecordId) -> Result<(), EmseriesWriteError> {
if !self.records.contains_key(uuid) {
return Ok(());
};
self.records.remove(uuid);
let rec: Record<T> = Record {
id: uuid.clone(),
let rec: RecordOnDisk<T> = RecordOnDisk {
id: *uuid,
data: None,
};
match serde_json::to_string(&rec) {
@ -138,8 +176,8 @@ where
}
/// Get all of the records in the database.
pub fn records(&self) -> impl Iterator<Item = (&UniqueId, &T)> {
self.records.iter()
pub fn records(&self) -> impl Iterator<Item = &Record<T>> {
self.records.values()
}
/* The point of having Search is so that a lot of internal optimizations can happen once the
@ -148,29 +186,29 @@ where
pub fn search<'s>(
&'s self,
criteria: impl Criteria + 's,
) -> impl Iterator<Item = (&'s UniqueId, &'s T)> + 's {
self.records().filter(move |&tr| criteria.apply(tr.1))
) -> impl Iterator<Item = &'s Record<T>> + 's {
self.records().filter(move |&tr| criteria.apply(&tr.data))
}
/// Perform a search and sort the resulting records based on the comparison.
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<(&UniqueId, &T)>
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<&'s Record<T>>
where
C: Criteria + 's,
CMP: FnMut(&(&UniqueId, &T), &(&UniqueId, &T)) -> Ordering,
CMP: FnMut(&&Record<T>, &&Record<T>) -> Ordering,
{
let search_iter = self.search(criteria);
let mut records: Vec<(&UniqueId, &T)> = search_iter.collect();
let mut records: Vec<&Record<T>> = search_iter.collect();
records.sort_by(compare);
records
}
/// Get an exact record from the database based on unique id.
pub fn get(&self, uuid: &UniqueId) -> Option<T> {
pub fn get(&self, uuid: &RecordId) -> Option<Record<T>> {
self.records.get(uuid).cloned()
}
/*
pub fn remove(&self, uuid: UniqueId) -> Result<(), EmseriesError> {
pub fn remove(&self, uuid: RecordId) -> Result<(), EmseriesError> {
unimplemented!()
}
*/

View File

@ -10,10 +10,7 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use chrono::NaiveDate;
use date_time_tz::DateTimeTz;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use chrono::{DateTime, FixedOffset, NaiveDate};
use std::{cmp::Ordering, fmt, io, str};
use thiserror::Error;
use uuid::Uuid;
@ -28,6 +25,9 @@ pub enum EmseriesReadError {
#[error("Error parsing JSON: {0}")]
JSONParseError(serde_json::error::Error),
#[error("Record was deleted")]
RecordDeleted(RecordId),
/// Indicates a general IO error
#[error("IO Error: {0}")]
IOError(io::Error),
@ -44,17 +44,47 @@ pub enum EmseriesWriteError {
JSONWriteError(serde_json::error::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[derive(Debug, Clone, PartialEq, Eq)]
/// A Timestamp, stored with reference to human reckoning. This could be either a Naive Date or a
/// date and a time with a timezone. The idea of the "human reckoning" is that, no matter what
/// timezone the record was created in, we want to group things based on the date that the human
/// was perceiving at the time it was recorded.
pub enum Timestamp {
DateTime(DateTimeTz),
DateTime(DateTime<FixedOffset>),
Date(NaiveDate),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TimestampJS {
DateTime(String),
Date(String),
}
impl From<Timestamp> for TimestampJS {
fn from(s: Timestamp) -> TimestampJS {
match s {
Timestamp::DateTime(ts) => TimestampJS::DateTime(ts.to_rfc3339()),
Timestamp::Date(ts) => TimestampJS::Date(ts.to_string()),
}
}
}
impl From<TimestampJS> for Timestamp {
fn from(s: TimestampJS) -> Timestamp {
match s {
TimestampJS::DateTime(ts) => {
Timestamp::DateTime(DateTime::parse_from_rfc3339(&ts).unwrap())
}
TimestampJS::Date(ts) => Timestamp::Date(ts.parse::<NaiveDate>().unwrap()),
}
}
}
impl str::FromStr for Timestamp {
type Err = chrono::ParseError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
DateTimeTz::from_str(line)
DateTime::parse_from_rfc3339(line)
.map(Timestamp::DateTime)
.or(NaiveDate::from_str(line).map(Timestamp::Date))
}
@ -70,25 +100,13 @@ impl Ord for Timestamp {
fn cmp(&self, other: &Timestamp) -> Ordering {
match (self, other) {
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(dt2),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.0.date_naive().cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.0.date_naive()),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive().cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.date_naive()),
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => dt1.cmp(dt2),
}
}
}
impl From<DateTimeTz> for Timestamp {
fn from(d: DateTimeTz) -> Self {
Self::DateTime(d)
}
}
impl From<NaiveDate> for Timestamp {
fn from(d: NaiveDate) -> Self {
Self::Date(d)
}
}
/// Any element to be put into the database needs to be Recordable. This is the common API that
/// will aid in searching and later in indexing records.
pub trait Recordable {
@ -102,75 +120,88 @@ pub trait Recordable {
/// Uniquely identifies a record.
///
/// This is a wrapper around a basic uuid with some extra convenience methods.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct UniqueId(Uuid);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct RecordId(Uuid);
impl Default for UniqueId {
impl Default for RecordId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
impl str::FromStr for UniqueId {
impl str::FromStr for RecordId {
type Err = EmseriesReadError;
/// Parse a UniqueId from a string. Raise UUIDParseError if the parsing fails.
/// Parse a RecordId from a string. Raise UUIDParseError if the parsing fails.
fn from_str(val: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(val)
.map(UniqueId)
.map(RecordId)
.map_err(EmseriesReadError::UUIDParseError)
}
}
impl fmt::Display for UniqueId {
impl fmt::Display for RecordId {
/// Convert to a hyphenated string
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.0.to_hyphenated())
}
}
/// Every record contains a unique ID and then the primary data, which itself must implementd the
/// Recordable trait.
#[derive(Clone, Deserialize, Serialize)]
/// A record represents data that actually exists in the database. Users cannot make the record
/// directly, as the database will create them.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Record<T: Clone + Recordable> {
pub id: UniqueId,
pub data: Option<T>,
pub id: RecordId,
pub data: T,
}
impl<T> str::FromStr for Record<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
impl<T: Clone + Recordable> Record<T> {
pub fn date(&self) -> NaiveDate {
match self.data.timestamp() {
Timestamp::DateTime(dt) => dt.date_naive(),
Timestamp::Date(dt) => dt,
}
}
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
pub fn timestamp(&self) -> Timestamp {
self.data.timestamp()
}
pub fn map<Map, U>(self, map: Map) -> Record<U>
where
Map: Fn(T) -> U,
U: Clone + Recordable,
{
Record {
id: self.id,
data: map(self.data),
}
}
}
#[cfg(test)]
mod test {
extern crate dimensioned;
extern crate serde_json;
use self::dimensioned::si::{Kilogram, KG};
use super::*;
use chrono::TimeZone;
use chrono_tz::{Etc::UTC, US::Central};
use date_time_tz::DateTimeTz;
use chrono_tz::Etc::UTC;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Weight(Kilogram<f64>);
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct WeightRecord {
pub date: Timestamp,
pub date: NaiveDate,
pub weight: Weight,
}
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.clone()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
@ -179,12 +210,14 @@ mod test {
}
#[test]
fn timestamp_parses_datetimetz_without_timezone() {
fn timestamp_parses_utc_time() {
assert_eq!(
"2003-11-10T06:00:00Z".parse::<Timestamp>().unwrap(),
Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
)),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
),
);
}
@ -196,9 +229,10 @@ mod test {
);
}
#[test]
/*
#[ignore]
fn v_alpha_serialization() {
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109,\"date\":\"2003-11-10T06:00:00.000000000000Z\"},\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109},\"date\":\"2003-11-10\",\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
let rec: Record<WeightRecord> = WEIGHT_ENTRY
.parse()
@ -209,66 +243,64 @@ mod test {
);
assert_eq!(
rec.data,
Some(WeightRecord {
date: Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
)),
WeightRecord {
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.79109 * KG),
})
}
);
}
*/
#[test]
fn serialization_output() {
let rec = WeightRecord {
date: Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
)),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
let rec2 = WeightRecord {
date: Timestamp::DateTime(
Central
.with_ymd_and_hms(2003, 11, 10, 0, 0, 0)
.unwrap()
.into(),
),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec2).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z US/Central\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
}
#[test]
fn two_datetimes_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
));
let time2 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0).unwrap(),
));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
assert!(time1 < time2);
}
#[test]
fn two_dates_can_be_compared() {
let time1 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
let time1: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
let time2: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2);
}
#[test]
fn datetime_and_date_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2)
}

View File

@ -20,7 +20,7 @@ extern crate emseries;
#[cfg(test)]
mod test {
use chrono::prelude::*;
use chrono::{prelude::*};
use chrono_tz::Etc::UTC;
use dimensioned::si::{Kilogram, Meter, Second, M, S};
@ -34,7 +34,7 @@ mod test {
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
struct BikeTrip {
datetime: DateTimeTz,
datetime: DateTime<FixedOffset>,
distance: Distance,
duration: Duration,
comments: String,
@ -42,7 +42,7 @@ mod test {
impl Recordable for BikeTrip {
fn timestamp(&self) -> Timestamp {
self.datetime.clone().into()
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
Vec::new()
@ -52,31 +52,46 @@ mod test {
fn mk_trips() -> [BikeTrip; 5] {
[
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(58741.055 * M),
duration: Duration(11040.0 * S),
comments: String::from("long time ago"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(17702.0 * M),
duration: Duration(2880.0 * S),
comments: String::from("day 2"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(41842.945 * M),
duration: Duration(7020.0 * S),
comments: String::from("Do Some Distance!"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(34600.895 * M),
duration: Duration(5580.0 * S),
comments: String::from("I did a lot of distance back then"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(6437.376 * M),
duration: Duration(960.0 * S),
comments: String::from("day 5"),
@ -84,7 +99,7 @@ mod test {
]
}
fn run_test<T>(test: T) -> ()
fn run_test<T>(test: T)
where
T: FnOnce(tempfile::TempPath),
{
@ -93,14 +108,14 @@ mod test {
test(tmp_path);
}
fn run<T>(test: T) -> ()
fn run<T>(test: T)
where
T: FnOnce(Series<BikeTrip>),
{
let tmp_file = tempfile::NamedTempFile::new().expect("temporary path created");
let tmp_path = tmp_file.into_temp_path();
let ts: Series<BikeTrip> = Series::open(&tmp_path.to_string_lossy())
.expect("the time series should open correctly");
let ts: Series<BikeTrip> =
Series::open(&tmp_path).expect("the time series should open correctly");
test(ts);
}
@ -122,11 +137,15 @@ mod test {
Some(tr) => {
assert_eq!(
tr.timestamp(),
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()).into()
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
)
);
assert_eq!(tr.duration, Duration(11040.0 * S));
assert_eq!(tr.comments, String::from("long time ago"));
assert_eq!(tr, trips[0]);
assert_eq!(tr.data.duration, Duration(11040.0 * S));
assert_eq!(tr.data.comments, String::from("long time ago"));
assert_eq!(tr.data, trips[0]);
}
}
})
@ -136,20 +155,22 @@ mod test {
pub fn can_search_for_an_entry_with_exact_time() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
))
let v: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(v.len(), 1);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(v[0].data, trips[1]);
})
}
@ -157,26 +178,34 @@ mod test {
pub fn can_get_entries_in_time_range() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
})
}
@ -186,8 +215,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
@ -195,21 +224,29 @@ mod test {
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
}
})
}
@ -220,8 +257,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=2] {
ts.put(trip.clone()).expect("expect a successful put");
@ -229,41 +266,57 @@ mod test {
}
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 2);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
ts.put(trips[3].clone()).expect("expect a successful put");
ts.put(trips[4].clone()).expect("expect a successful put");
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 4);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(*v[3].1, trips[4]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
assert_eq!(v[3].data, trips[4]);
}
})
}
@ -273,8 +326,8 @@ mod test {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -283,9 +336,8 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id.clone(), trip)
.expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
@ -293,12 +345,12 @@ mod test {
None => assert!(false, "record not found"),
Some(trip) => {
assert_eq!(
trip.datetime,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
trip.data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()
);
assert_eq!(trip.distance, Distance(50000.0 * M));
assert_eq!(trip.duration, Duration(7020.0 * S));
assert_eq!(trip.comments, String::from("Do Some Distance!"));
assert_eq!(trip.data.distance, Distance(50000.0 * M));
assert_eq!(trip.data.duration, Duration(7020.0 * S));
assert_eq!(trip.data.comments, String::from("Do Some Distance!"));
}
}
})
@ -310,8 +362,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -320,32 +372,36 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id, trip).expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trips: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let trips: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(trips.len(), 3);
let trips: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()).into(),
))
let trips: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(trips.len(), 1);
assert_eq!(
trips[0].1.datetime,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
trips[0].data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
);
assert_eq!(trips[0].1.distance, Distance(50000.0 * M));
assert_eq!(trips[0].1.duration, Duration(7020.0 * S));
assert_eq!(trips[0].1.comments, String::from("Do Some Distance!"));
assert_eq!(trips[0].data.distance, Distance(50000.0 * M));
assert_eq!(trips[0].data.duration, Duration(7020.0 * S));
assert_eq!(trips[0].data.comments, String::from("Do Some Distance!"));
}
})
}
@ -356,21 +412,21 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trip_id = ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
ts.put(trips[2].clone()).expect("expect a successful put");
ts.delete(&trip_id).expect("successful delete");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
})
@ -387,7 +443,7 @@ mod test {
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.into()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {

View File

@ -1,6 +1,6 @@
[package]
name = "file-service"
version = "0.1.1"
version = "0.2.0"
authors = ["savanni@luminescent-dreams.com"]
edition = "2018"
@ -14,13 +14,10 @@ path = "src/lib.rs"
name = "file-service"
path = "src/main.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
[target.auth-cli.dependencies]
[dependencies]
authdb = { path = "../authdb/" }
base64ct = { version = "1", features = [ "alloc" ] }
build_html = { version = "2" }
bytes = { version = "1" }
@ -38,9 +35,8 @@ mime_guess = "2.0.3"
pretty_env_logger = { version = "0.5" }
serde_json = "*"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
thiserror = "1.0.20"
sha2 = { version = "0.10" }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
warp = { version = "0.3" }

View File

@ -87,7 +87,7 @@ pub async fn handle_auth(
app: App,
form: HashMap<String, String>,
) -> Result<http::Response<String>, Error> {
match form.get("token") {
match form.get("password") {
Some(token) => match app.authenticate(AuthToken::from(token.clone())).await {
Ok(Some(session_token)) => Response::builder()
.header("location", "/")
@ -134,6 +134,25 @@ pub async fn handle_upload(
}
}
pub async fn handle_delete(
app: App,
token: SessionToken,
id: FileId,
) -> Result<http::Response<String>, Error> {
match app.validate_session(token).await {
Ok(Some(_)) => match app.delete_file(id).await {
Ok(_) => Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Err(_) => unimplemented!(),
},
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("".to_owned()),
}
}
fn serve_file<F>(
info: FileInfo,
file: F,

View File

@ -74,7 +74,7 @@ impl Html for Form {
None => "".to_owned(),
};
format!(
"<form action=\"{path}\" method=\"{method}\" {encoding}\n{elements}\n</form>\n",
"<form action=\"{path}\" method=\"{method}\" {encoding}>\n{elements}\n</form>\n",
path = self.path,
method = self.method,
encoding = encoding,
@ -137,11 +137,6 @@ impl Input {
self
}
pub fn with_value(mut self, val: &str) -> Self {
self.value = Some(val.to_owned());
self
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
@ -156,31 +151,6 @@ impl Input {
}
}
#[derive(Clone, Debug)]
pub struct Label {
target: String,
text: String,
}
impl Label {
pub fn new(target: &str, text: &str) -> Self {
Self {
target: target.to_owned(),
text: text.to_owned(),
}
}
}
impl Html for Label {
fn to_html_string(&self) -> String {
format!(
"<label for=\"{target}\">{text}</label>",
target = self.target,
text = self.text
)
}
}
#[derive(Clone, Debug)]
pub struct Button {
ty: Option<String>,
@ -236,41 +206,3 @@ impl Html for Button {
)
}
}
#[derive(Clone, Debug)]
pub struct Image {
path: String,
attributes: Attributes,
}
impl Image {
pub fn new(path: &str) -> Self {
Self {
path: path.to_owned(),
attributes: Attributes::default(),
}
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
self.attributes = Attributes(
values
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
.collect::<Vec<(String, String)>>(),
);
self
}
}
impl Html for Image {
fn to_html_string(&self) -> String {
format!(
"<img src={path} {attrs} />",
path = self.path,
attrs = self.attributes.to_string()
)
}
}

View File

@ -1,6 +1,5 @@
mod store;
pub use store::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};

View File

@ -1,7 +1,7 @@
extern crate log;
use cookie::Cookie;
use handlers::{file, handle_auth, handle_css, handle_upload, thumbnail};
use handlers::{file, handle_auth, handle_css, handle_delete, handle_upload, thumbnail};
use std::{
collections::{HashMap, HashSet},
convert::Infallible,
@ -18,9 +18,10 @@ mod pages;
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
pub use file_service::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
use authdb::{AuthDB, AuthError, AuthToken, SessionToken, Username};
use file_service::{
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};
pub use handlers::handle_index;
@ -64,6 +65,11 @@ impl App {
) -> Result<FileHandle, WriteFileError> {
self.store.write().await.add_file(filename, content)
}
pub async fn delete_file(&self, id: FileId) -> Result<(), DeleteFileError> {
self.store.write().await.delete_file(&id)?;
Ok(())
}
}
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
@ -134,6 +140,12 @@ pub async fn main() {
.and(warp::multipart::form().max_length(MAX_UPLOAD))
.then(handle_upload);
let delete_via_form = warp::path!("delete" / String)
.and(warp::post())
.and(with_app(app.clone()))
.and(with_session())
.then(|id, app, token| handle_delete(app, token, FileId::from(id)));
let thumbnail = warp::path!(String / "tn")
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
@ -150,6 +162,7 @@ pub async fn main() {
root.or(styles)
.or(auth)
.or(upload_via_form)
.or(delete_via_form)
.or(thumbnail)
.or(file)
.with(log),

View File

@ -1,35 +1,38 @@
use crate::html::*;
use build_html::{self, Container, ContainerType, Html, HtmlContainer};
use file_service::{FileHandle, FileId, ReadFileError};
use file_service::{FileHandle, FileInfo, ReadFileError};
pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
build_html::HtmlPage::new()
.with_title("Authentication")
.with_title("Sign In")
.with_stylesheet("/css")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-page")])
.with_container(auth_form()),
)
}
fn auth_form() -> Container {
Container::default()
.with_attributes([("class", "card authentication-form")])
.with_html(
Form::new()
.with_path("/auth")
.with_method("post")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "card authentication-form")])
.with_html(
Form::new()
.with_path("/auth")
.with_method("post")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-form__label")])
.with_html(Label::new("for-token-input", "Authentication")),
)
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-form__input")])
.with_html(
Input::new("token", "token")
.with_id("for-token-input")
.with_attributes([("size", "50")]),
),
),
Input::new("password", "password")
.with_id("for-token-input")
.with_attributes([
("size", "50"),
("class", "authentication-form__input"),
]),
)
.with_html(
Button::new("Sign In")
.with_attributes([("class", "authentication-form__button")]),
),
),
)
@ -49,14 +52,7 @@ pub fn gallery(handles: Vec<Result<FileHandle, ReadFileError>>) -> build_html::H
let mut gallery = Container::new(ContainerType::Div).with_attributes([("class", "gallery")]);
for handle in handles {
let container = match handle {
Ok(ref handle) => thumbnail(&handle.id).with_html(
Form::new()
.with_path(&format!("/{}", *handle.id))
.with_method("post")
.with_html(Input::new("hidden", "_method").with_value("delete"))
.with_html(Button::new("Delete")),
),
Ok(ref handle) => thumbnail(&handle.info),
Err(err) => Container::new(ContainerType::Div)
.with_attributes(vec![("class", "file")])
.with_paragraph(format!("{:?}", err)),
@ -88,15 +84,31 @@ pub fn upload_form() -> Form {
)
}
pub fn thumbnail(id: &FileId) -> Container {
pub fn thumbnail(info: &FileInfo) -> Container {
Container::new(ContainerType::Div)
.with_attributes(vec![("class", "card thumbnail")])
.with_html(
Container::new(ContainerType::Div).with_link(
format!("/{}", **id),
Image::new(&format!("{}/tn", **id))
.with_attributes([("class", "thumbnail__image")])
format!("/{}", *info.id),
Container::default()
.with_attributes([("class", "thumbnail")])
.with_image(format!("{}/tn", *info.id), "test data")
.to_html_string(),
),
)
.with_html(
Container::new(ContainerType::Div)
.with_html(
Container::new(ContainerType::UnorderedList)
.with_attributes(vec![("class", "thumbnail__metadata")])
.with_html(info.name.clone())
.with_html(format!("{}", info.created.format("%Y-%m-%d"))),
)
.with_html(
Form::new()
.with_path(&format!("/delete/{}", *info.id))
.with_method("post")
.with_html(Button::new("Delete")),
),
)
}

View File

@ -120,8 +120,13 @@ impl FileHandle {
/// Create a new entry in the database
pub fn new(filename: String, root: PathBuf) -> Result<Self, WriteFileError> {
let id = FileId::from(Uuid::new_v4().hyphenated().to_string());
let path = PathBuf::from(filename);
let extension = PathBuf::from(filename)
let name = path
.file_stem()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(WriteFileError::InvalidPath)?;
let extension = path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(WriteFileError::InvalidPath)?;
@ -138,6 +143,7 @@ impl FileHandle {
let info = FileInfo {
id: id.clone(),
name,
size: 0,
created: Utc::now(),
file_type,
@ -233,6 +239,17 @@ mod test {
);
}
#[test]
fn it_creates_file_info() {
let tmp = TempDir::new("var").unwrap();
let handle =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
assert_eq!(handle.info.name, "rawr");
assert_eq!(handle.info.size, 0);
assert_eq!(handle.info.file_type, "image/png");
assert_eq!(handle.info.extension, "png");
}
#[test]
fn it_opens_a_file() {
let tmp = TempDir::new("var").unwrap();

View File

@ -11,6 +11,12 @@ use std::{
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileInfo {
pub id: FileId,
// Early versions of the application didn't support a name field, so it is possible that
// metadata won't contain the name. We can just default to an empty string when loading the
// metadata, as all future versions will require a filename when the file gets uploaded.
#[serde(default)]
pub name: String,
pub size: usize,
pub created: DateTime<Utc>,
pub file_type: String,
@ -50,6 +56,7 @@ mod test {
let info = FileInfo {
id: FileId("temp-id".to_owned()),
name: "test-image".to_owned(),
size: 23777,
created,
file_type: "image/png".to_owned(),

View File

@ -1,13 +1,6 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::{collections::HashSet, ops::Deref, path::PathBuf};
use thiserror::Error;
use uuid::Uuid;
mod filehandle;
mod fileinfo;
@ -53,9 +46,6 @@ pub enum ReadFileError {
#[error("permission denied")]
PermissionDenied,
#[error("invalid path")]
InvalidPath,
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
@ -64,132 +54,32 @@ pub enum ReadFileError {
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
pub enum DeleteFileError {
#[error("file not found")]
FileNotFound(PathBuf),
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("metadata path is not a file")]
NotAFile,
#[error("database failed")]
SqlError(sqlx::Error),
#[error("cannot read metadata")]
PermissionDenied,
#[error("invalid metadata path")]
MetadataParseError(serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
impl From<ReadFileError> for DeleteFileError {
fn from(err: ReadFileError) -> Self {
match err {
ReadFileError::FileNotFound(path) => DeleteFileError::FileNotFound(path),
ReadFileError::NotAFile => DeleteFileError::NotAFile,
ReadFileError::PermissionDenied => DeleteFileError::PermissionDenied,
ReadFileError::JSONError(err) => DeleteFileError::MetadataParseError(err),
ReadFileError::IOError(err) => DeleteFileError::IOError(err),
}
}
}
@ -240,95 +130,6 @@ impl FileRoot for Context {
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.is_empty() {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.is_empty() {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
pub struct Store {
files_root: PathBuf,
}
@ -369,7 +170,7 @@ impl Store {
FileHandle::load(id, &self.files_root)
}
pub fn delete_file(&mut self, id: &FileId) -> Result<(), WriteFileError> {
pub fn delete_file(&mut self, id: &FileId) -> Result<(), DeleteFileError> {
let handle = FileHandle::load(id, &self.files_root)?;
handle.delete();
Ok(())
@ -466,74 +267,3 @@ mod test {
});
}
}
#[cfg(test)]
mod authdb_test {
use super::*;
use cool_asserts::assert_matches;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.authenticate(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.authenticate(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.authenticate(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.validate_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -29,6 +29,7 @@ body {
justify-content: center;
align-items: center;
height: 200px;
margin: 8px;
}
.authentication-form {
@ -77,6 +78,10 @@ body {
border: none;
}
.thumbnail__metadata {
list-style: none;
}
/*
[type="submit"] {
border-radius: 1em;
@ -129,6 +134,16 @@ body {
.authentication-form {
width: 100%;
display: flex;
flex-direction: column;
}
.authentication-form__input {
font-size: x-large;
}
.authentication-form__button {
font-size: x-large;
}
.upload-form__selector {

View File

@ -0,0 +1,26 @@
[package]
name = "fitnesstrax"
version = "0.6.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2.1" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" }
chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] }
emseries = { path = "../../emseries" }
ft-core = { path = "../core" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
thiserror = { version = "1.0" }
tokio = { version = "1.34", features = [ "full" ] }
[build-dependencies]
glib-build-tools = "0.18"

7
fitnesstrax/app/build.rs Normal file
View File

@ -0,0 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
&["resources"],
"gresources.xml",
"com.luminescent-dreams.fitnesstrax.gresource",
);
}

12
fitnesstrax/app/dist.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
mkdir -p dist
cp ../../target/release/fitnesstrax dist
cp resources/com.luminescent-dreams.fitnesstrax.gschema.xml resources/fitnesstrax.desktop dist
strip dist/fitnesstrax
tar -czf fitnesstrax-${VERSION}.tgz dist/

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/">
<file>style.css</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">cycling-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">running-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions/">
<file preprocess="xml-stripblanks">walking-symbolic.svg</file>
</gresource>
</gresources>

View File

@ -0,0 +1,14 @@
{ gtkNativeInputs }:
attrs: {
nativeBuildInputs = gtkNativeInputs;
postInstall = ''
install -Dt $out/share/applications resources/fitnesstrax.desktop
install -Dt $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas resources/com.luminescent-dreams.fitnesstrax.gschema.xml
glib-compile-schemas $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas
'';
preFixup = ''
gappsWrapperArgs+=(
--prefix XDG_DATA_DIRS : $out/gsettings-schemas/${attrs.crateName}-${attrs.version}
)
'';
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax.dev" path="/com/luminescent-dreams/fitnesstrax/dev/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax" path="/com/luminescent-dreams/fitnesstrax/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 2 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m 0 0"/><path d="m 4.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><path d="m 8.992188 13.007812 v -3.003906 c 0 -0.359375 -0.1875 -0.6875 -0.5 -0.867187 l -2.558594 -1.476563 l 0.363281 1.363282 l 1.671875 -2.890626 l -1.367188 0.363282 l 0.910157 0.527344 l -0.40625 -0.4375 c 0.773437 1.621093 1.96875 1.933593 1.96875 1.933593 s 0.578125 0.242188 1.9375 0.429688 c 0.546875 0.074219 1.050781 -0.304688 1.128906 -0.851563 c 0.074219 -0.550781 -0.308594 -1.054687 -0.855469 -1.128906 c -1.179687 -0.164062 -1.601562 -0.355469 -1.601562 -0.355469 s -0.425782 -0.164062 -0.769532 -0.886719 c -0.089843 -0.183593 -0.226562 -0.335937 -0.402343 -0.4375 l -0.910157 -0.523437 c -0.476562 -0.277344 -1.089843 -0.113281 -1.363281 0.367187 l -1.671875 2.890626 c -0.277344 0.480468 -0.113281 1.089843 0.367188 1.367187 l 2.558594 1.480469 l -0.5 -0.867188 v 3.003906 c 0 0.550782 0.449218 1 1 1 c 0.554687 0 1 -0.449218 1 -1 z m 0 0"/><path d="m 14.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,5 @@
[Desktop Entry]
Version=0.2
Type=Application
Name=FitnessTrax
Exec=fitnesstrax

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 8.5 0 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m -2.5 4 c -0.117188 0 -0.230469 0.027344 -0.335938 0.082031 l -2 1 c -0.144531 0.070313 -0.261718 0.1875 -0.332031 0.332031 l -1 2 c -0.1875 0.371094 -0.039062 0.820313 0.332031 1.007813 c 0.371094 0.183594 0.820313 0.035156 1.003907 -0.335937 l 0.890625 -1.777344 l 1.5625 -0.773438 c -0.042969 0.074219 -0.726563 2.835938 -0.726563 2.835938 c -0.230469 0.949218 0.398438 1.523437 0.398438 1.523437 l 3.351562 2.703125 l 0.90625 2.71875 c 0.175781 0.523438 0.742188 0.808594 1.265625 0.632813 c 0.523438 -0.175781 0.808594 -0.742188 0.632813 -1.265625 l -1 -3 c -0.0625 -0.183594 -0.171875 -0.34375 -0.324219 -0.464844 l -2 -1.597656 l 0.679688 -2.714844 l 0.25 0.625 c 0.085937 0.222656 0.28125 0.390625 0.515624 0.449219 l 2 0.5 c 0.402344 0.097656 0.808594 -0.144531 0.910157 -0.546875 c 0.097656 -0.40625 -0.144531 -0.8125 -0.546875 -0.910156 l -1.628906 -0.40625 l -0.855469 -2.144532 c -0.117188 -0.285156 -0.390625 -0.472656 -0.699219 -0.472656 z m -1.164062 6.328125 l -0.710938 2.128906 l -1.832031 1.835938 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 2 -2 c 0.109375 -0.109375 0.191407 -0.242187 0.242188 -0.390625 l 0.542969 -1.628906 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,85 @@
.welcome {
margin: 64px;
}
.welcome__title {
font-size: x-large;
padding: 8px;
}
.welcome__content {
padding: 8px;
}
.historical {
margin: 32px;
border-radius: 8px;
}
.date-range-picker {
margin-bottom: 16px;
}
/*
.date-range-picker > box:not(:last-child) {
margin-bottom: 8px;
}
*/
.date-range-picker__date-field {
margin: 8px;
}
.date-range-picker__search-button {
margin: 8px;
}
.date-range-picker__range-button {
margin: 8px;
}
.date-field__year {
margin: 0px 4px 0px 0px;
}
.date-field__month {
margin: 0px 4px 0px 4px;
}
.date-field__day {
margin: 0px 0px 0px 4px;
}
.day-summary {
padding: 8px;
}
.day-summary > *:not(:last-child) {
margin-bottom: 8px;
}
.day-summary__date {
font-size: x-large;
}
.day-summary__weight {
margin: 4px;
}
.weight-view {
padding: 8px;
margin: 8px;
}
.step-view {
padding: 8px;
margin: 8px;
}
.about__content {
padding: 32px;
}
.about label {
margin-bottom: 16px;
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 1.5 c 0 0.828125 -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 s 1.5 0.671875 1.5 1.5 z m 0 0"/><path d="m 7 4 c -0.550781 0 -1 0.449219 -1 1 v 4 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 l 0.445312 0.449219 l -2.59375 4.328125 c -0.285156 0.476563 -0.132812 1.089844 0.34375 1.375 c 0.472657 0.28125 1.085938 0.128906 1.367188 -0.34375 l 2.34375 -3.902344 l 0.925781 0.929688 l 0.925781 2.773437 c 0.082031 0.25 0.265625 0.460938 0.5 0.578125 c 0.238281 0.121094 0.515625 0.140625 0.765625 0.054688 c 0.25 -0.082031 0.460938 -0.265625 0.578125 -0.5 c 0.121094 -0.238281 0.140625 -0.515625 0.054688 -0.765625 l -1 -3 c -0.050781 -0.148438 -0.132813 -0.28125 -0.242188 -0.390625 l -1.707031 -1.707031 v -4.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 4 c -0.101562 0 -0.207031 0.019531 -0.300781 0.0625 c 0 0 -2.113281 0.847656 -2.199219 2.90625 v 0.03125 v 2.25 c 0 0.414062 0.335938 0.75 0.75 0.75 s 0.75 -0.335938 0.75 -0.75 v -2.21875 c 0.039062 -0.894531 1.050781 -1.449219 1.207031 -1.53125 h 2.332031 l 1.042969 2.085938 c 0.097657 0.195312 0.273438 0.339843 0.488281 0.394531 l 2 0.5 c 0.191407 0.046875 0.394532 0.015625 0.566407 -0.085938 c 0.171875 -0.101562 0.292969 -0.269531 0.34375 -0.460937 c 0.046875 -0.195313 0.015625 -0.398438 -0.085938 -0.570313 c -0.101562 -0.171875 -0.269531 -0.292969 -0.464843 -0.34375 l -1.664063 -0.414062 l -1.097656 -2.191407 c -0.125 -0.253906 -0.382813 -0.414062 -0.667969 -0.414062 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,80 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct AboutWindowPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for AboutWindowPrivate {
const NAME: &'static str = "AboutWindow";
type Type = AboutWindow;
type ParentType = gtk::Window;
}
impl ObjectImpl for AboutWindowPrivate {}
impl WidgetImpl for AboutWindowPrivate {}
impl WindowImpl for AboutWindowPrivate {}
glib::wrapper! {
pub struct AboutWindow(ObjectSubclass<AboutWindowPrivate>) @extends gtk::Window, gtk::Widget;
}
impl Default for AboutWindow {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(600);
s.set_height_request(700);
s.add_css_class("about");
s.set_title(Some("About Fitnesstrax"));
let copyright = gtk::Label::builder()
.label("Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>")
.halign(gtk::Align::Start)
.build();
let gtk_rs_thanks = gtk::Label::builder()
.label("I owe a huge debt of gratitude to the GTK-RS project (https://gtk-rs.org/), which makes it possible for me to write this application to begin with. Further, I owe a particular debt to Julian Hofer and his book, GUI development with Rust and GTK 4 (https://gtk-rs.org/gtk4-rs/stable/latest/book/). Without this book, I would have continued to stumble around writing bad user interfaces with even worse code.")
.halign(gtk::Align::Start).wrap(true)
.build();
let dependencies = gtk::Label::builder()
.label("This application depends on many libraries, most of which are licensed under the BSD-3 or GPL-3 licenses.")
.halign(gtk::Align::Start).wrap(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.css_classes(["about__content"])
.build();
content.append(&copyright);
content.append(&gtk_rs_thanks);
content.append(&dependencies);
let scroller = gtk::ScrolledWindow::builder()
.child(&content)
.hexpand(true)
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
s.set_child(Some(&scroller));
s
}
}

167
fitnesstrax/app/src/app.rs Normal file
View File

@ -0,0 +1,167 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use async_trait::async_trait;
use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord;
use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use thiserror::Error;
use tokio::runtime::Runtime;
#[derive(Debug, Error)]
pub enum AppError {
#[error("no database loaded")]
NoDatabase,
#[error("failed to open the database")]
FailedToOpenDatabase,
#[error("unhandled error")]
Unhandled,
}
#[derive(Debug, Error)]
pub enum ReadError {
#[error("no database loaded")]
NoDatabase,
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("no database loaded")]
NoDatabase,
#[error("unhandled error")]
Unhandled,
}
#[async_trait]
pub trait RecordProvider: Send + Sync {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
}
/// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)]
pub struct App {
runtime: Arc<Runtime>,
database: Arc<RwLock<Option<Series<TraxRecord>>>>,
}
impl App {
pub fn new(db_path: Option<PathBuf>) -> Self {
let database = db_path.map(|path| Series::open(path).unwrap());
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap(),
);
Self {
runtime,
database: Arc::new(RwLock::new(database)),
}
}
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
let db_ref = self.database.clone();
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
}
pub fn database_is_open(&self) -> bool {
self.database.read().unwrap().is_some()
}
}
#[async_trait]
impl RecordProvider for App {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref db) = *db.read().unwrap() {
let records = db
.search(time_range(
Timestamp::Date(start),
true,
Timestamp::Date(end),
true,
))
.cloned()
.collect::<Vec<Record<TraxRecord>>>();
Ok(records)
} else {
Err(ReadError::NoDatabase)
}
})
.await
.unwrap()
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
let id = db.put(record).unwrap();
Ok(id)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
db.update(record).map_err(|_| AppError::Unhandled)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn delete_record(&self, _id: RecordId) -> Result<(), WriteError> {
unimplemented!()
}
}

View File

@ -0,0 +1,215 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::App,
types::DayInterval,
view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
};
use adw::prelude::*;
use chrono::{Duration, Local};
use gio::resources_lookup_data;
use gtk::STYLE_PROVIDER_PRIORITY_USER;
use std::{cell::RefCell, path::PathBuf, rc::Rc};
/// The application window, or the main window, is the main user interface for the app. Almost
/// everything occurs here.
#[derive(Clone)]
pub struct AppWindow {
app: App,
layout: gtk::Box,
current_view: Rc<RefCell<View>>,
settings: gio::Settings,
navigation: adw::NavigationView,
}
impl AppWindow {
/// Construct a new App Window.
///
/// adw_app is an Adwaita application. Application windows need to have access to this, but
/// otherwise we don't use this.
///
/// app is a core [crate::app::App] object which encapsulates all of the basic logic.
pub fn new(
app_id: &str,
resource_path: &str,
adw_app: &adw::Application,
ft_app: App,
) -> AppWindow {
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.width_request(800)
.height_request(746)
.build();
window.connect_destroy(|s| {
let _ = gtk::prelude::WidgetExt::activate_action(s, "app.quit", None);
});
let stylesheet = String::from_utf8(
resources_lookup_data(
&format!("{}style.css", resource_path),
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet must be available in the resources")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
#[allow(deprecated)]
let context = window.style_context();
#[allow(deprecated)]
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let navigation = adw::NavigationView::new();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let initial_view = View::Placeholder(PlaceholderView::default().upcast());
let header_bar = adw::HeaderBar::new();
let main_menu = gio::Menu::new();
main_menu.append(Some("About"), Some("app.about"));
main_menu.append(Some("Quit"), Some("app.quit"));
let main_menu_button = gtk::MenuButton::builder()
.icon_name("open-menu")
.direction(gtk::ArrowType::Down)
.halign(gtk::Align::End)
.menu_model(&main_menu)
.build();
header_bar.pack_end(&main_menu_button);
layout.append(&initial_view.widget());
let nav_layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
nav_layout.append(&header_bar);
nav_layout.append(&layout);
navigation.push(
&adw::NavigationPage::builder()
.can_pop(false)
.title("FitnessTrax")
.child(&nav_layout)
.build(),
);
window.set_content(Some(&navigation));
window.present();
let s = Self {
app: ft_app,
layout,
current_view: Rc::new(RefCell::new(initial_view)),
settings: gio::Settings::new(app_id),
navigation,
};
s.load_records();
s.navigation.connect_popped({
let s = s.clone();
move |_, _| {
if let View::Historical(_) = *s.current_view.borrow() {
s.load_records();
}
}
});
s
}
fn show_welcome_view(&self) {
let view = View::Welcome(WelcomeView::new({
let s = self.clone();
move |path| s.on_apply_config(path)
}));
self.swap_main(view);
}
fn show_historical_view(&self, interval: DayInterval) {
let on_select_day = {
let s = self.clone();
move |date| {
let s = s.clone();
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&adw::HeaderBar::new());
// layout.append(&DayDetailView::new(date, records, s.app.clone()));
layout.append(&DayDetailView::new(view_model));
let page = &adw::NavigationPage::builder()
.title(date.format("%Y-%m-%d").to_string())
.child(&layout)
.build();
s.navigation.push(page);
});
}
};
let view = View::Historical(HistoricalView::new(
self.app.clone(),
interval,
Rc::new(on_select_day),
));
self.swap_main(view);
}
fn load_records(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.database_is_open() {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.show_historical_view(DayInterval { start, end });
} else {
s.show_welcome_view();
}
}
});
}
// Switch views.
//
// This function only replaces the old view with the one which matches the current view state.
// It is responsible for ensuring that the new view goes into the layout in the correct
// position.
fn swap_main(&self, view: View) {
let mut current_widget = self.current_view.borrow_mut();
self.layout.remove(&current_widget.widget());
*current_widget = view;
self.layout.append(&current_widget.widget());
}
#[allow(unused)]
fn on_apply_config(&self, path: PathBuf) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.open_db(path.clone()).await.is_ok() {
let _ = s.settings.set("series-path", path.to_str().unwrap());
s.load_records();
}
}
});
}
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! ActionGroup and related structures
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct ActionGroupPrivate;
#[glib::object_subclass]
impl ObjectSubclass for ActionGroupPrivate {
const NAME: &'static str = "ActionGroup";
type Type = ActionGroup;
type ParentType = gtk::Box;
}
impl ObjectImpl for ActionGroupPrivate {}
impl WidgetImpl for ActionGroupPrivate {}
impl BoxImpl for ActionGroupPrivate {}
glib::wrapper! {
pub struct ActionGroup(ObjectSubclass<ActionGroupPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl ActionGroup {
fn new(builder: ActionGroupBuilder) -> Self {
let s: Self = Object::builder().build();
s.set_orientation(builder.orientation);
let primary_button = builder.primary_action.button();
let secondary_button = builder.secondary_action.map(|action| action.button());
let tertiary_button = builder.tertiary_action.map(|action| action.button());
if let Some(button) = tertiary_button {
s.append(&button);
}
s.set_halign(gtk::Align::End);
if let Some(button) = secondary_button {
s.append(&button);
}
s.append(&primary_button);
s
}
pub fn builder() -> ActionGroupBuilder {
ActionGroupBuilder {
orientation: gtk::Orientation::Horizontal,
primary_action: Action {
label: "Ok".to_owned(),
action: Box::new(|| {}),
},
secondary_action: None,
tertiary_action: None,
}
}
}
struct Action {
label: String,
action: Box<dyn Fn()>,
}
impl Action {
fn button(self) -> gtk::Button {
let button = gtk::Button::builder().label(self.label).build();
button.connect_clicked(move |_| (self.action)());
button
}
}
pub struct ActionGroupBuilder {
orientation: gtk::Orientation,
primary_action: Action,
secondary_action: Option<Action>,
tertiary_action: Option<Action>,
}
impl ActionGroupBuilder {
pub fn orientation(mut self, orientation: gtk::Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn primary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.primary_action = Action {
label: label.to_owned(),
action: Box::new(action),
};
self
}
pub fn secondary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.secondary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn tertiary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.tertiary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn build(self) -> ActionGroup {
ActionGroup::new(self)
}
}

View File

@ -0,0 +1,175 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::{i32_field_builder, TextEntry, month_field_builder}, types::ParseError};
use chrono::{Datelike, Local};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub struct DateFieldPrivate {
date: Rc<RefCell<chrono::NaiveDate>>,
year: TextEntry<i32>,
month: TextEntry<u32>,
day: TextEntry<u32>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateFieldPrivate {
const NAME: &'static str = "DateField";
type Type = DateField;
type ParentType = gtk::Box;
fn new() -> Self {
let date = Rc::new(RefCell::new(Local::now().date_naive()));
let year = i32_field_builder()
.with_value(date.borrow().year())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(year) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_year(year) {
*date = new_date;
}
}
}
})
.with_length(4)
.with_css_classes(vec!["date-field__year".to_owned()]).build();
let month = month_field_builder()
.with_value(date.borrow().month())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(month) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_month(month) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__month".to_owned()])
.build();
/* Modify this so that it enforces the number of days per month */
let day = TextEntry::builder()
.with_placeholder("day".to_owned())
.with_value(date.borrow().day())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let date = date.clone();
move |value| {
if let Some(day) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_day(day) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__day".to_owned()])
.build();
Self {
date,
year,
month,
day,
}
}
}
impl ObjectImpl for DateFieldPrivate {}
impl WidgetImpl for DateFieldPrivate {}
impl BoxImpl for DateFieldPrivate {}
glib::wrapper! {
pub struct DateField(ObjectSubclass<DateFieldPrivate>) @extends gtk::Box, gtk::Widget;
}
/* Render a date in the format 2006 Jan 01. The entire date is editable. When the user moves to one part of the date, it will be erased and replaced with a grey placeholder.
*/
impl DateField {
pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build();
s.add_css_class("date-field");
s.append(&s.imp().year.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().month.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().day.widget());
s.set_date(date);
s
}
pub fn set_date(&self, date: chrono::NaiveDate) {
self.imp().year.set_value(Some(date.year()));
self.imp().month.set_value(Some(date.month()));
self.imp().day.set_value(Some(date.day()));
*self.imp().date.borrow_mut() = date;
}
pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow()
}
/*
pub fn is_valid(&self) -> bool {
false
}
*/
}
#[cfg(test)]
mod test {
use super::*;
// use crate::gtk_init::gtk_init;
// Enabling this test pushes tests on the TextField into an infinite loop. That likely indicates a bad interaction within the TextField itself, and that is going to need to be fixed.
#[test]
#[ignore]
fn it_allows_valid_dates() {
let reference = chrono::NaiveDate::from_ymd_opt(2006, 01, 02).unwrap();
let field = DateField::new(reference);
field.imp().year.set_value(Some(2023));
field.imp().month.set_value(Some(10));
field.imp().day.set_value(Some(13));
// assert!(field.is_valid());
}
#[test]
#[ignore]
fn it_disallows_out_of_range_months() {
unimplemented!()
}
#[test]
#[ignore]
fn it_allows_days_within_range_for_month() {
unimplemented!()
}
}

View File

@ -0,0 +1,189 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::DateField, types::DayInterval};
use chrono::{Duration, Local, Months};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
type OnSearch = dyn Fn(DayInterval) + 'static;
pub struct DateRangePickerPrivate {
start: DateField,
end: DateField,
on_search: RefCell<Box<OnSearch>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateRangePickerPrivate {
const NAME: &'static str = "DateRangePicker";
type Type = DateRangePicker;
type ParentType = gtk::Box;
fn new() -> Self {
let default_date = Local::now().date_naive();
let start = DateField::new(default_date);
start.add_css_class("date-range-picker__date-field");
let end = DateField::new(default_date);
end.add_css_class("date-range-picker__date-field");
Self {
start,
end,
on_search: RefCell::new(Box::new(|_| {})),
}
}
}
impl ObjectImpl for DateRangePickerPrivate {}
impl WidgetImpl for DateRangePickerPrivate {}
impl BoxImpl for DateRangePickerPrivate {}
glib::wrapper! {
pub struct DateRangePicker(ObjectSubclass<DateRangePickerPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DateRangePicker {
pub fn connect_on_search<OnSearch>(&self, f: OnSearch)
where
OnSearch: Fn(DayInterval) + 'static,
{
*self.imp().on_search.borrow_mut() = Box::new(f);
}
pub fn set_interval(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) {
self.imp().start.set_date(start);
self.imp().end.set_date(end);
}
pub fn interval(&self) -> DayInterval {
DayInterval {
start: self.imp().start.date(),
end: self.imp().end.date(),
}
}
}
impl Default for DateRangePicker {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.add_css_class("date-range-picker");
let search_button = gtk::Button::builder()
.css_classes(["date-range-picker__search-button"])
.label("Search")
.build();
search_button.connect_clicked({
let s = s.clone();
move |_| (s.imp().on_search.borrow())(s.interval())
});
let last_week_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("week")
.build();
last_week_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let two_weeks_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("two weeks")
.build();
two_weeks_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(14);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_month_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("month")
.build();
last_month_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(1);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let six_months_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("six months")
.build();
six_months_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(6);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_year_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("year")
.build();
last_year_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(12);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let date_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
date_row.append(&s.imp().start);
date_row.append(&gtk::Label::new(Some("to")));
date_row.append(&s.imp().end);
date_row.append(&search_button);
let quick_picker = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
quick_picker.append(&last_week_button);
quick_picker.append(&two_weeks_button);
quick_picker.append(&last_month_button);
quick_picker.append(&six_months_button);
quick_picker.append(&last_year_button);
s.append(&date_row);
s.append(&quick_picker);
s
}
}

View File

@ -0,0 +1,400 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
// use chrono::NaiveDate;
// use ft_core::TraxRecord;
use crate::{
components::{
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
},
types::{DistanceFormatter, DurationFormatter, WeightFormatter},
view_models::DayDetailViewModel,
};
use emseries::{Record, RecordId};
use ft_core::{TimeDistanceActivity, TraxRecord, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
use super::{time_distance::TimeDistanceEdit, time_distance_detail};
pub struct DaySummaryPrivate {
date: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for DaySummaryPrivate {
const NAME: &'static str = "DaySummary";
type Type = DaySummary;
type ParentType = gtk::Box;
fn new() -> Self {
let date = gtk::Label::builder()
.css_classes(["day-summary__date"])
.halign(gtk::Align::Start)
.build();
Self { date }
}
}
impl ObjectImpl for DaySummaryPrivate {}
impl WidgetImpl for DaySummaryPrivate {}
impl BoxImpl for DaySummaryPrivate {}
glib::wrapper! {
/// The DaySummary displays one day's activities in a narrative style. This is meant to give
/// an overall feel of everything that happened during the day without going into details.
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for DaySummary {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["day-summary"]);
s.append(&s.imp().date);
s
}
}
impl DaySummary {
pub fn new() -> Self {
Self::default()
}
pub fn set_data(&self, view_model: DayDetailViewModel) {
self.imp()
.date
.set_text(&view_model.date.format("%Y-%m-%d").to_string());
let row = gtk::Box::builder().build();
let weight_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__weight"])
.build();
if let Some(w) = view_model.weight() {
weight_label.set_label(&w.to_string())
}
let steps_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__steps"])
.build();
if let Some(s) = view_model.steps() {
steps_label.set_label(&format!("{} steps", s));
}
row.append(&weight_label);
row.append(&steps_label);
self.append(&row);
for activity in TIME_DISTANCE_ACTIVITIES {
let summary = view_model.time_distance_summary(activity);
if let Some(label) = time_distance_summary(
activity,
DistanceFormatter::from(summary.0),
DurationFormatter::from(summary.1),
) {
self.append(&label);
}
}
}
}
#[derive(Default)]
pub struct DayDetailPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailPrivate {
const NAME: &'static str = "DayDetail";
type Type = DayDetail;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailPrivate {}
impl WidgetImpl for DayDetailPrivate {}
impl BoxImpl for DayDetailPrivate {}
glib::wrapper! {
pub struct DayDetail(ObjectSubclass<DayDetailPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayDetail {
pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
where
OnEdit: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.append(
&ActionGroup::builder()
.primary_action("Edit", Box::new(on_edit))
.build(),
);
let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from));
top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps());
top_row.append(&steps_view.widget());
s.append(&top_row);
let records = view_model.time_distance_records();
for emseries::Record { data, .. } in records {
s.append(&time_distance_detail(data));
}
s
}
}
pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>,
#[allow(unused)]
workout_rows: RefCell<gtk::Box>,
view_model: RefCell<Option<DayDetailViewModel>>,
}
impl Default for DayEditPrivate {
fn default() -> Self {
Self {
on_finished: RefCell::new(Box::new(|| {})),
workout_rows: RefCell::new(
gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build(),
),
view_model: RefCell::new(None),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for DayEditPrivate {
const NAME: &'static str = "DayEdit";
type Type = DayEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayEditPrivate {}
impl WidgetImpl for DayEditPrivate {}
impl BoxImpl for DayEditPrivate {}
glib::wrapper! {
pub struct DayEdit(ObjectSubclass<DayEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayEdit {
pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
where
OnFinished: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
*s.imp().view_model.borrow_mut() = Some(view_model.clone());
let workout_buttons = workout_buttons(view_model.clone(), {
let s = s.clone();
move |workout| s.add_row(workout)
});
view_model
.records()
.into_iter()
.filter_map({
let s = s.clone();
move |record| match record.data {
TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, {
let s = s.clone();
move |data| {
s.update_workout(record.id, data);
}
})),
_ => None,
}
})
.for_each(|row| s.imp().workout_rows.borrow().append(&row));
s.append(&control_buttons(&s, &view_model));
s.append(&weight_and_steps_row(&view_model));
s.append(&*s.imp().workout_rows.borrow());
s.append(&workout_buttons);
s
}
fn finish(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
let view_model = {
let view_model = s.imp().view_model.borrow();
view_model
.as_ref()
.expect("DayEdit has not been initialized with the view model")
.clone()
};
let _ = view_model.async_save().await;
(s.imp().on_finished.borrow())()
}
});
}
fn add_row(&self, workout: Record<TraxRecord>) {
let workout_rows = self.imp().workout_rows.borrow();
#[allow(clippy::single_match)]
match workout.data {
TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, {
let s = self.clone();
move |data| {
println!("update workout callback on workout: {:?}", workout.id);
s.update_workout(workout.id, data)
}
})),
_ => {}
}
}
fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) {
if let Some(ref view_model) = *self.imp().view_model.borrow() {
let record = Record {
id,
data: TraxRecord::TimeDistance(data),
};
view_model.update_record(record);
}
}
}
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
ActionGroup::builder()
.primary_action("Save", {
let s = s.clone();
move || s.finish()
})
.secondary_action("Cancel", {
let s = s.clone();
let view_model = view_model.clone();
move || {
let s = s.clone();
let view_model = view_model.clone();
glib::spawn_future_local(async move {
view_model.revert().await;
s.finish();
});
}
})
.build()
}
fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
row.append(
&weight_field(view_model.weight().map(WeightFormatter::from), {
let view_model = view_model.clone();
move |w| match w {
Some(w) => view_model.set_weight(*w),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row.append(
&steps_editor(view_model.steps(), {
let view_model = view_model.clone();
move |s| match s {
Some(s) => view_model.set_steps(s),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row
}
fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let add_row = Rc::new(add_row);
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
for (activity, icon, label) in [
(
TimeDistanceActivity::Biking,
"cycling-symbolic",
"Bike Ride",
),
(TimeDistanceActivity::Rowing, "rowing-symbolic", "Rowing"),
(TimeDistanceActivity::Running, "running-symbolic", "Run"),
(TimeDistanceActivity::Swimming, "swimming-symbolic", "Swim"),
(TimeDistanceActivity::Walking, "walking-symbolic", "Walk"),
] {
let button = workout_button(activity, icon, label, view_model.clone(), {
let add_row = add_row.clone();
move |record| add_row(record)
});
layout.append(&button);
}
layout
}
fn workout_button<AddRow>(
activity: TimeDistanceActivity,
_icon: &str,
label: &str,
view_model: DayDetailViewModel,
add_row: AddRow,
) -> gtk::Button
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let button = gtk::Button::builder()
.label(label)
.width_request(64)
.height_request(64)
.build();
button.connect_clicked({
let view_model = view_model.clone();
move |_| {
let workout = view_model.new_time_distance(activity);
add_row(workout.map(TraxRecord::TimeDistance));
}
});
button
}

View File

@ -0,0 +1,155 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not,
see <https://www.gnu.org/licenses/>.
*/
mod action_group;
pub use action_group::ActionGroup;
mod day;
pub use day::{DayDetail, DayEdit, DaySummary};
mod date_field;
pub use date_field::DateField;
mod date_range;
pub use date_range::DateRangePicker;
mod singleton;
pub use singleton::{Singleton, SingletonImpl};
mod steps;
pub use steps::{steps_editor, Steps};
mod text_entry;
pub use text_entry::{distance_field, duration_field, time_field, weight_field, i32_field_builder, month_field_builder, TextEntry};
mod time_distance;
pub use time_distance::{time_distance_detail, time_distance_summary};
mod weight;
pub use weight::WeightLabel;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, path::PathBuf, rc::Rc};
pub struct FileChooserRowPrivate {
path: RefCell<Option<PathBuf>>,
label: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for FileChooserRowPrivate {
const NAME: &'static str = "FileChooser";
type Type = FileChooserRow;
type ParentType = gtk::Box;
fn new() -> Self {
Self {
path: RefCell::new(None),
label: gtk::Label::builder().hexpand(true).build(),
}
}
}
impl ObjectImpl for FileChooserRowPrivate {}
impl WidgetImpl for FileChooserRowPrivate {}
impl BoxImpl for FileChooserRowPrivate {}
glib::wrapper! {
pub struct FileChooserRow(ObjectSubclass<FileChooserRowPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl FileChooserRow {
pub fn new<F>(on_selected: F) -> Self
where
F: Fn(PathBuf) + 'static,
{
let s: Self = Object::builder().build();
s.set_css_classes(&["dialog-row", "card"]);
s.set_orientation(gtk::Orientation::Horizontal);
s.set_spacing(8);
// The database selection row should be a box that shows a default database path, along with a
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
// updated to reflect the chosen path.
s.imp().label.set_text("No database selected");
let on_selected = Rc::new(Box::new(on_selected));
let import_button = gtk::Button::builder().label("Import a Database").build();
let handle_file_selection = Rc::new(Box::new({
let s = s.clone();
let on_selected = on_selected.clone();
move |file_id: Result<gio::File, glib::Error>| match file_id {
Ok(file_id) => match file_id.path() {
Some(path) => {
s.imp().label.set_text(path.to_str().unwrap());
on_selected(path.clone());
*s.imp().path.borrow_mut() = Some(path);
}
None => {
*s.imp().path.borrow_mut() = None;
s.imp().label.set_text("No database selected");
}
},
Err(err) => println!("file opening failed: {}", err),
}
}));
import_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().open(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
let new_button = gtk::Button::builder().label("Create Database").build();
new_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().save(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
s.imp().label.set_halign(gtk::Align::Start);
s.append(&s.imp().label);
s.append(&import_button);
s.append(&new_button);
s
}
pub fn path(&self) -> Option<PathBuf> {
self.imp().path.borrow().clone()
}
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! A Widget container for a single components
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
pub struct SingletonPrivate {
widget: RefCell<gtk::Widget>,
}
impl Default for SingletonPrivate {
fn default() -> Self {
Self {
widget: RefCell::new(gtk::Label::new(None).upcast()),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for SingletonPrivate {
const NAME: &'static str = "Singleton";
type Type = Singleton;
type ParentType = gtk::Box;
}
impl ObjectImpl for SingletonPrivate {}
impl WidgetImpl for SingletonPrivate {}
impl BoxImpl for SingletonPrivate {}
glib::wrapper! {
/// The Singleton component contains exactly one child widget. The swap function makes it easy
/// to handle the job of swapping that child out for a different one.
pub struct Singleton(ObjectSubclass<SingletonPrivate>) @extends gtk::Box, gtk::Widget;
}
impl Default for Singleton {
fn default() -> Self {
let s: Self = Object::builder().build();
s.append(&*s.imp().widget.borrow());
s
}
}
impl Singleton {
pub fn swap(&self, new_widget: &impl IsA<gtk::Widget>) {
let new_widget = new_widget.clone().upcast();
self.remove(&*self.imp().widget.borrow());
self.append(&new_widget);
*self.imp().widget.borrow_mut() = new_widget;
}
}
pub trait SingletonImpl: WidgetImpl + BoxImpl {}
unsafe impl<T: SingletonImpl> IsSubclassable<T> for Singleton {}

View File

@ -0,0 +1,60 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::TextEntry, types::ParseError};
use gtk::prelude::*;
#[derive(Default)]
pub struct Steps {
label: gtk::Label,
}
impl Steps {
pub fn new(steps: Option<u32>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "step-view"])
.can_focus(true)
.build();
match steps {
Some(s) => label.set_text(&format!("{}", s)),
None => label.set_text("No steps recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
where
OnUpdate: Fn(Option<u32>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder( "0".to_owned())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}.build()
}

View File

@ -0,0 +1,373 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{
DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter,
};
use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
pub type OnUpdate<T> = dyn Fn(Option<T>);
// TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget.
#[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry,
renderer: Rc<dyn Fn(&T) -> String>,
parser: Rc<Parser<T>>,
on_update: Rc<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
f,
"{{ value: {:?}, widget: {:?} }}",
self.value, self.widget
)
}
}
// I do not understand why the data should be 'static.
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
fn from_builder(builder: TextEntryBuilder<T>) -> TextEntry<T> {
let widget = gtk::Entry::builder()
.placeholder_text(builder.placeholder)
.build();
if let Some(ref v) = builder.value {
widget.set_text(&(builder.renderer)(v))
}
let s = Self {
value: Rc::new(RefCell::new(builder.value)),
widget,
renderer: Rc::new(builder.renderer),
parser: Rc::new(builder.parser),
on_update: Rc::new(builder.on_update),
};
s.widget.buffer().connect_text_notify({
let s = s.clone();
move |buffer| s.handle_text_change(buffer)
});
if let Some(length) = builder.length {
s.widget.set_max_length(length.try_into().unwrap());
}
// let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect();
let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect();
s.widget.set_css_classes(&classes);
s
}
pub fn builder() -> TextEntryBuilder<T> {
TextEntryBuilder::default()
}
pub fn set_value(&self, val: Option<T>) {
if let Some(ref v) = val {
self.widget.set_text(&(self.renderer)(v));
}
}
fn handle_text_change(&self, buffer: &gtk::EntryBuffer) {
if buffer.text().is_empty() {
*self.value.borrow_mut() = None;
self.widget.remove_css_class("error");
(self.on_update)(None);
return;
}
match (self.parser)(buffer.text().as_str()) {
Ok(v) => {
*self.value.borrow_mut() = Some(v.clone());
self.widget.remove_css_class("error");
(self.on_update)(Some(v));
}
// need to change the border to provide a visual indicator of an error
Err(_) => {
self.widget.add_css_class("error");
}
}
}
pub fn widget(&self) -> gtk::Widget {
self.widget.clone().upcast::<gtk::Widget>()
}
#[cfg(test)]
fn has_parse_error(&self) -> bool {
self.widget.has_css_class("error")
}
}
pub struct TextEntryBuilder<T: Clone + std::fmt::Debug + 'static> {
placeholder: String,
value: Option<T>,
length: Option<usize>,
css_classes: Vec<String>,
renderer: Box<dyn Fn(&T) -> String>,
parser: Box<Parser<T>>,
on_update: Box<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug + 'static> Default for TextEntryBuilder<T> {
fn default() -> TextEntryBuilder<T> {
TextEntryBuilder {
placeholder: "".to_owned(),
value: None,
length: None,
css_classes: vec![],
renderer: Box::new(|_| "".to_owned()),
parser: Box::new(|_| Err(ParseError)),
on_update: Box::new(|_| {}),
}
}
}
impl<T: Clone + std::fmt::Debug + 'static> TextEntryBuilder<T> {
pub fn build(self) -> TextEntry<T> {
TextEntry::from_builder(self)
}
pub fn with_placeholder(self, placeholder: String) -> Self {
Self {
placeholder,
..self
}
}
pub fn with_value(self, value: T) -> Self {
Self {
value: Some(value),
..self
}
}
pub fn with_length(self, length: usize) -> Self {
Self {
length: Some(length),
..self
}
}
pub fn with_css_classes(self, classes: Vec<String>) -> Self {
Self {
css_classes: classes,
..self
}
}
pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self {
Self {
renderer: Box::new(renderer),
..self
}
}
pub fn with_parser(self, parser: impl Fn(&str) -> Result<T, ParseError> + 'static) -> Self {
Self {
parser: Box::new(parser),
..self
}
}
pub fn with_on_update(self, on_update: impl Fn(Option<T>) + 'static) -> Self {
Self {
on_update: Box::new(on_update),
..self
}
}
}
pub fn time_field<OnUpdate>(
value: Option<TimeFormatter>,
on_update: OnUpdate,
) -> TextEntry<TimeFormatter>
where
OnUpdate: Fn(Option<TimeFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("HH:MM".to_owned())
.with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated))
.with_parser(TimeFormatter::parse)
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}
.build()
}
pub fn distance_field<OnUpdate>(
value: Option<DistanceFormatter>,
on_update: OnUpdate,
) -> TextEntry<DistanceFormatter>
where
OnUpdate: Fn(Option<DistanceFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 km".to_owned())
.with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DistanceFormatter::parse)
.with_on_update(on_update);
if let Some(distance) = value {
text_entry.with_value(distance)
} else {
text_entry
}
.build()
}
pub fn duration_field<OnUpdate>(
value: Option<DurationFormatter>,
on_update: OnUpdate,
) -> TextEntry<DurationFormatter>
where
OnUpdate: Fn(Option<DurationFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 m".to_owned())
.with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DurationFormatter::parse)
.with_on_update(on_update);
if let Some(duration) = value {
text_entry.with_value(duration)
} else {
text_entry
}
.build()
}
pub fn weight_field<OnUpdate>(
weight: Option<WeightFormatter>,
on_update: OnUpdate,
) -> TextEntry<WeightFormatter>
where
OnUpdate: Fn(Option<WeightFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 kg".to_owned())
.with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated))
.with_parser(WeightFormatter::parse)
.with_on_update(on_update);
if let Some(weight) = weight {
text_entry.with_value(weight)
} else {
text_entry
}
.build()
}
pub fn i32_field_builder() -> TextEntryBuilder<i32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| v.parse::<i32>().map_err(|_| ParseError))
}
pub fn month_field_builder() -> TextEntryBuilder<u32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| {
let val = v.parse::<u32>().map_err(|_| ParseError)?;
if val == 0 || val > 12 {
return Err(ParseError);
}
Ok(val)
})
}
#[cfg(test)]
mod test {
use super::*;
use crate::gtk_init::gtk_init;
fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) {
let current_value = Rc::new(RefCell::new(None));
let entry = TextEntry::builder()
.with_placeholder("step count".to_owned())
.with_renderer(|steps| format!("{}", steps))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let current_value = current_value.clone();
move |v| *current_value.borrow_mut() = v
})
.build();
(current_value, entry)
}
#[test]
fn it_responds_to_field_changes() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("15");
assert_eq!(*current_value.borrow(), Some(15));
}
#[test]
fn it_preserves_last_value_in_parse_error() {
crate::gtk_init::gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("a5");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
}
#[test]
fn it_update_on_empty_strings() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("1a");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
assert!(!entry.has_parse_error());
}
}

View File

@ -0,0 +1,271 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{distance_field, duration_field, time_field},
types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
};
use dimensioned::si;
use ft_core::{TimeDistance, TimeDistanceActivity, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub fn time_distance_summary(
activity: TimeDistanceActivity,
distance: DistanceFormatter,
duration: DurationFormatter,
) -> Option<gtk::Label> {
let text = match (*distance > si::M, *duration > si::S) {
(true, true) => Some(format!(
"{} of {:?} in {}",
distance.format(FormatOption::Full),
activity,
duration.format(FormatOption::Full)
)),
(true, false) => Some(format!(
"{} of {:?}",
distance.format(FormatOption::Full),
activity
)),
(false, true) => Some(format!(
"{} of {:?}",
duration.format(FormatOption::Full),
activity
)),
(false, false) => None,
};
text.map(|text| gtk::Label::builder().halign(gtk::Align::Start).label(&text).build())
}
pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box {
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build();
let first_row = gtk::Box::builder().homogeneous(true).build();
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(record.datetime.format("%H:%M").to_string())
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(format!("{:?}", record.activity))
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.distance
.map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()),
)
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.duration
.map(|duration| {
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
})
.unwrap_or("".to_owned()),
)
.build(),
);
layout.append(&first_row);
layout.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.comments
.map(|comments| comments.to_string())
.unwrap_or("".to_owned()),
)
.build(),
);
layout
}
type OnUpdate = Rc<RefCell<Box<dyn Fn(TimeDistance)>>>;
pub struct TimeDistanceEditPrivate {
#[allow(unused)]
workout: RefCell<ft_core::TimeDistance>,
on_update: OnUpdate,
}
impl Default for TimeDistanceEditPrivate {
fn default() -> Self {
Self {
workout: RefCell::new(TimeDistance {
datetime: chrono::Utc::now().into(),
activity: TimeDistanceActivity::Biking,
duration: None,
distance: None,
comments: None,
}),
on_update: Rc::new(RefCell::new(Box::new(|_| {}))),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TimeDistanceEditPrivate {
const NAME: &'static str = "TimeDistanceEdit";
type Type = TimeDistanceEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for TimeDistanceEditPrivate {}
impl WidgetImpl for TimeDistanceEditPrivate {}
impl BoxImpl for TimeDistanceEditPrivate {}
glib::wrapper! {
pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for TimeDistanceEdit {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.set_css_classes(&["time-distance-edit"]);
s
}
}
impl TimeDistanceEdit {
pub fn new<OnUpdate>(workout: TimeDistance, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(TimeDistance) + 'static,
{
let s = Self::default();
*s.imp().workout.borrow_mut() = workout.clone();
*s.imp().on_update.borrow_mut() = Box::new(on_update);
let details_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
details_row.append(
&time_field(
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
{
let s = s.clone();
move |t| s.update_time(t)
},
)
.widget(),
);
details_row.append(&s.activity_menu(workout.activity));
details_row.append(
&distance_field(workout.distance.map(DistanceFormatter::from), {
let s = s.clone();
move |d| s.update_distance(d)
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(DurationFormatter::from), {
let s = s.clone();
move |d| s.update_duration(d)
})
.widget(),
);
s.append(&details_row);
s.append(&gtk::Entry::new());
s
}
fn update_time(&self, time: Option<TimeFormatter>) {
if let Some(time_formatter) = time {
let mut workout = self.imp().workout.borrow_mut();
let tz = workout.datetime.timezone();
let new_time = workout
.datetime
.date_naive()
.and_time(*time_formatter)
.and_local_timezone(tz)
.unwrap()
.fixed_offset();
workout.datetime = new_time;
(self.imp().on_update.borrow())(workout.clone());
}
}
fn update_workout_type(&self, type_: TimeDistanceActivity) {
let mut workout = self.imp().workout.borrow_mut();
workout.activity = type_;
(self.imp().on_update.borrow())(workout.clone())
}
fn update_distance(&self, distance: Option<DistanceFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn update_duration(&self, duration: Option<DurationFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn activity_menu(&self, selected: TimeDistanceActivity) -> gtk::DropDown {
let options = TIME_DISTANCE_ACTIVITIES
.iter()
.map(|item| format!("{:?}", item))
.collect::<Vec<String>>();
let options = options.iter().map(|o| o.as_ref()).collect::<Vec<&str>>();
let selected_idx = TIME_DISTANCE_ACTIVITIES
.iter()
.position(|&v| v == selected)
.unwrap_or(0);
let menu = gtk::DropDown::from_strings(&options);
menu.set_selected(selected_idx as u32);
menu.connect_selected_item_notify({
let s = self.clone();
move |menu| {
let new_item = TIME_DISTANCE_ACTIVITIES[menu.selected() as usize];
s.update_workout_type(new_item);
}
});
menu
}
}

View File

@ -0,0 +1,42 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{FormatOption, WeightFormatter};
use gtk::prelude::*;
pub struct WeightLabel {
label: gtk::Label,
}
impl WeightLabel {
pub fn new(weight: Option<WeightFormatter>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "weight-view"])
.can_focus(true)
.build();
match weight {
Some(w) => label.set_text(&w.format(FormatOption::Abbreviated)),
None => label.set_text("No weight recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}

View File

@ -0,0 +1,10 @@
use std::sync::Once;
static INITIALIZED: Once = Once::new();
pub fn gtk_init() {
INITIALIZED.call_once(|| {
eprintln!("initializing GTK");
let _ = gtk::init();
});
}

101
fitnesstrax/app/src/main.rs Normal file
View File

@ -0,0 +1,101 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod about;
mod app;
mod app_window;
mod components;
#[cfg(test)]
mod gtk_init;
mod types;
mod view_models;
mod views;
use adw::prelude::*;
use app_window::AppWindow;
use gio::ActionEntry;
use std::{env, path::PathBuf};
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
fn setup_app_about_action(app: &adw::Application) {
let action = ActionEntry::builder("about")
.activate(|_app: &adw::Application, _, _| {
let window = about::AboutWindow::default();
window.present();
}).build();
app.add_action_entries([action]);
}
/// Sets up an application-global action, `app.quit`, which will terminate the application.
fn setup_app_close_action(app: &adw::Application) {
let action = ActionEntry::builder("quit")
.activate(|app: &adw::Application, _, _| {
// right now, stopping the application is dirt simple. But we could use this
// block to add extra code that does additional shutdown steps if we ever want
// some states that shouldn't be discarded.
app.quit();
})
.build();
app.add_action_entries([action]);
app.set_accels_for_action("app.quit", &["<Ctrl>Q"]);
}
fn main() {
// I still don't fully understand gio resources. resources_register_include! is convenient
// because I don't have to deal with filesystem locations at runtime. However, I think other
// GTK applications do that rather than compiling the resources directly into the app. So, I'm
// unclear as to how I want to handle this.
gio::resources_register_include!("com.luminescent-dreams.fitnesstrax.gresource")
.expect("to register resources");
let app_id = if std::env::var_os("ENV") == Some("dev".into()) {
APP_ID_DEV
} else {
APP_ID_PROD
};
let settings = gio::Settings::new(app_id);
let ft_app = app::App::new({
let path = settings.string("series-path");
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
});
let adw_app = adw::Application::builder()
.application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH)
.build();
adw_app.connect_activate(move |adw_app| {
let icon_theme = gtk::IconTheme::for_display(&gdk::Display::default().unwrap());
icon_theme.add_resource_path(&(RESOURCE_BASE_PATH.to_owned() + "/icons/scalable/actions"));
setup_app_about_action(adw_app);
setup_app_close_action(adw_app);
AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone());
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -0,0 +1,349 @@
use chrono::{Local, NaiveDate};
use dimensioned::si;
#[derive(Clone, Debug, PartialEq)]
pub struct ParseError;
// This interval doesn't feel right, either. The idea that I have a specific interval type for just
// NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live
// here, but in utilities.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DayInterval {
pub start: NaiveDate,
pub end: NaiveDate,
}
impl Default for DayInterval {
fn default() -> Self {
Self {
start: (Local::now() - chrono::Duration::days(7)).date_naive(),
end: Local::now().date_naive(),
}
}
}
impl DayInterval {
pub fn days(&self) -> impl Iterator<Item = NaiveDate> {
DayIterator {
current: self.start,
end: self.end,
}
}
}
struct DayIterator {
current: NaiveDate,
end: NaiveDate,
}
impl Iterator for DayIterator {
type Item = NaiveDate;
fn next(&mut self) -> Option<Self::Item> {
if self.current <= self.end {
let val = self.current;
self.current += chrono::Duration::days(1);
Some(val)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatOption {
Abbreviated,
#[allow(unused)]
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TimeFormatter(chrono::NaiveTime);
impl TimeFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => self.0.format("%H:%M"),
FormatOption::Full => self.0.format("%H:%M:%S"),
}
.to_string()
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
let parts = s
.split(':')
.map(|part| part.parse::<u32>().map_err(|_| ParseError))
.collect::<Result<Vec<u32>, ParseError>>()?;
match parts.len() {
0 => Err(ParseError),
1 => Err(ParseError),
2 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], 0)
.map(|v| TimeFormatter(v))
.ok_or(ParseError),
3 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], parts[2])
.map(|v| TimeFormatter(v))
.ok_or(ParseError),
_ => Err(ParseError),
}
}
}
impl std::ops::Deref for TimeFormatter {
type Target = chrono::NaiveTime;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<chrono::NaiveTime> for TimeFormatter {
fn from(value: chrono::NaiveTime) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct WeightFormatter(si::Kilogram<f64>);
impl WeightFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe),
FormatOption::Full => format!("{} kilograms", self.0.value_unsafe),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
s.parse::<f64>()
.map(|w| WeightFormatter(w * si::KG))
.map_err(|_| ParseError)
}
}
impl std::ops::Add for WeightFormatter {
type Output = WeightFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for WeightFormatter {
type Output = WeightFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for WeightFormatter {
type Target = si::Kilogram<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Kilogram<f64>> for WeightFormatter {
fn from(value: si::Kilogram<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DistanceFormatter(si::Meter<f64>);
impl DistanceFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.),
FormatOption::Full => format!("{} kilometers", self.0.value_unsafe / 1000.),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DistanceFormatter(value * 1000. * si::M))
}
}
impl std::ops::Add for DistanceFormatter {
type Output = DistanceFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DistanceFormatter {
type Output = DistanceFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DistanceFormatter {
type Target = si::Meter<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Meter<f64>> for DistanceFormatter {
fn from(value: si::Meter<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DurationFormatter(si::Second<f64>);
impl DurationFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option {
FormatOption::Abbreviated => ("h", "m"),
FormatOption::Full => (" hours", " minutes"),
};
if hours > 0 {
format!("{}{} {}{}", hours, h, minutes, m)
} else {
format!("{}{}", minutes, m)
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DurationFormatter(value * 60. * si::S))
}
#[allow(unused)]
fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60;
let minutes = minutes - (hours * 60);
(hours, minutes)
}
}
impl std::ops::Add for DurationFormatter {
type Output = DurationFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DurationFormatter {
type Output = DurationFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DurationFormatter {
type Target = si::Second<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Second<f64>> for DurationFormatter {
fn from(value: si::Second<f64>) -> Self {
Self(value)
}
}
#[cfg(test)]
mod test {
use super::*;
use dimensioned::si;
#[test]
fn it_parses_weight_values() {
assert_eq!(
WeightFormatter::parse("15.3"),
Ok(WeightFormatter(15.3 * si::KG))
);
assert_eq!(WeightFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_weight_values() {
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Abbreviated),
"15.3 kg"
);
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Full),
"15.3 kilograms"
);
}
#[test]
fn it_parses_distance_values() {
assert_eq!(
DistanceFormatter::parse("70"),
Ok(DistanceFormatter(70000. * si::M))
);
assert_eq!(DistanceFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_distance_values() {
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Abbreviated),
"70 km"
);
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Full),
"70 kilometers"
);
}
#[test]
fn it_parses_duration_values() {
assert_eq!(
DurationFormatter::parse("70"),
Ok(DurationFormatter(4200. * si::S))
);
assert_eq!(DurationFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_duration_values() {
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Abbreviated),
"1h 10m"
);
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Full),
"1 hours 10 minutes"
);
}
#[test]
fn it_parses_time_values() {
assert_eq!(
TimeFormatter::parse("13:25"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 0).unwrap()
)),
);
assert_eq!(
TimeFormatter::parse("13:25:50"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap()
)),
);
}
#[test]
fn it_formats_time_values() {
let time = TimeFormatter::from(chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap());
assert_eq!(time.format(FormatOption::Abbreviated), "13:25".to_owned());
assert_eq!(time.format(FormatOption::Full), "13:25:50".to_owned());
}
}

View File

@ -0,0 +1,657 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::app::{ReadError, RecordProvider};
use dimensioned::si;
use emseries::{Record, RecordId, Recordable};
use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord};
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros.
#[allow(unused_imports)]
use crate::app::WriteError;
#[allow(unused_imports)]
use chrono::NaiveDate;
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(Record<T>),
Updated(Record<T>),
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
fn exists(&self) -> bool {
match self {
RecordState::Original(_) => true,
RecordState::New(_) => true,
RecordState::Updated(_) => true,
RecordState::Deleted(_) => false,
}
}
#[allow(unused)]
fn data(&self) -> Option<&Record<T>> {
match self {
RecordState::Original(ref r) => Some(r),
RecordState::New(ref r) => None,
RecordState::Updated(ref r) => Some(r),
RecordState::Deleted(ref r) => Some(r),
}
}
fn set_value(&mut self, value: T) {
*self = match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::New(r) => RecordState::New(Record { data: value, ..*r }),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..*r }),
};
}
fn with_value(mut self, value: T) -> RecordState<T> {
self.set_value(value);
self
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => &r.data,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
impl<T: Clone + emseries::Recordable> std::ops::DerefMut for RecordState<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
match self {
RecordState::Original(ref mut r) => &mut r.data,
RecordState::New(ref mut r) => &mut r.data,
RecordState::Updated(ref mut r) => &mut r.data,
RecordState::Deleted(ref mut r) => &mut r.data,
}
}
}
#[derive(Clone)]
pub struct DayDetailViewModel {
provider: Arc<dyn RecordProvider>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub async fn new(
date: chrono::NaiveDate,
provider: impl RecordProvider + 'static,
) -> Result<Self, ReadError> {
let s = Self {
provider: Arc::new(provider),
date,
weight: Arc::new(RwLock::new(None)),
steps: Arc::new(RwLock::new(None)),
records: Arc::new(RwLock::new(HashMap::new())),
};
s.populate_records().await;
Ok(s)
}
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
}
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Weight {
date: self.date,
weight: new_weight,
},
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Steps {
date: self.date,
count: new_count,
},
}),
};
*record = Some(new_record);
}
pub fn new_time_distance(&self, activity: TimeDistanceActivity) -> Record<TimeDistance> {
let now = chrono::Local::now();
let base_time = now.time();
let tz = now.timezone();
let datetime = self
.date
.clone()
.and_time(base_time)
.and_local_timezone(tz)
.unwrap()
.into();
let id = RecordId::default();
let workout = TimeDistance {
datetime,
activity,
distance: None,
duration: None,
comments: None,
};
let tr = TraxRecord::from(workout.clone());
self.records
.write()
.unwrap()
.insert(id, RecordState::New(Record { id, data: tr }));
Record { id, data: workout }
}
pub fn time_distance_records(&self) -> Vec<Record<TimeDistance>> {
self.records
.read()
.unwrap()
.iter()
.filter(|(_, record)| record.exists())
.filter_map(|(id, record_state)| match **record_state {
TraxRecord::TimeDistance(ref workout) => Some(Record {
id: *id,
data: workout.clone(),
}),
_ => None,
})
.collect()
}
pub fn time_distance_summary(
&self,
activity: TimeDistanceActivity,
) -> (si::Meter<f64>, si::Second<f64>) {
self.time_distance_records()
.into_iter()
.filter(|rec| rec.data.activity == activity)
.fold(
(0. * si::M, 0. * si::S),
|(distance, duration), workout| match (workout.data.distance, workout.data.duration)
{
(Some(distance_), Some(duration_)) => {
(distance + distance_, duration + duration_)
}
(Some(distance_), None) => (distance + distance_, duration),
(None, Some(duration_)) => (distance, duration + duration_),
(None, None) => (distance, duration),
},
)
}
pub fn update_record(&self, update: Record<TraxRecord>) {
let mut records = self.records.write().unwrap();
records
.entry(update.id)
.and_modify(|record| record.set_value(update.data));
}
pub fn records(&self) -> Vec<Record<TraxRecord>> {
let read_lock = self.records.read().unwrap();
read_lock
.iter()
.filter_map(|(_, record_state)| record_state.data())
.cloned()
.collect::<Vec<Record<TraxRecord>>>()
}
#[allow(unused)]
fn get_record(&self, id: &RecordId) -> Option<Record<TraxRecord>> {
let record_set = self.records.read().unwrap();
record_set.get(id).map(|record| Record {
id: *id,
data: (**record).clone(),
})
}
pub fn remove_record(&self, id: RecordId) {
let mut record_set = self.records.write().unwrap();
let updated_record = match record_set.remove(&id) {
Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::New(_)) => None,
Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)),
None => None,
};
if let Some(updated_record) = updated_record {
record_set.insert(id, updated_record);
}
}
pub fn save(&self) {
let s = self.clone();
glib::spawn_future(async move { s.async_save().await });
}
pub async fn async_save(&self) {
let weight_record = self.weight.read().unwrap().clone();
match weight_record {
Some(RecordState::New(data)) => {
let _ = self
.provider
.put_record(TraxRecord::Weight(data.data))
.await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => {
let _ = self
.provider
.update_record(Record {
id: weight.id,
data: TraxRecord::Weight(weight.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let steps_record = self.steps.read().unwrap().clone();
match steps_record {
Some(RecordState::New(data)) => {
let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => {
let _ = self
.provider
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = self
.records
.write()
.unwrap()
.drain()
.map(|(_, record)| record)
.collect::<Vec<RecordState<TraxRecord>>>();
for record in records {
println!("saving record: {:?}", record);
match record {
RecordState::New(data) => {
let _ = self.provider.put_record(data.data).await;
}
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = self.provider.update_record(r.clone()).await;
}
RecordState::Deleted(r) => {
let _ = self.provider.delete_record(r.id).await;
}
}
}
self.populate_records().await;
}
pub async fn revert(&self) {
self.populate_records().await;
}
async fn populate_records(&self) {
let records = self.provider.records(self.date, self.date).await.unwrap();
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
*self.weight.write().unwrap() = weight_records
.first()
.and_then(|r| match r.data {
TraxRecord::Weight(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.steps.write().unwrap() = step_records
.first()
.and_then(|r| match r.data {
TraxRecord::Steps(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.records.write().unwrap() = records
.into_iter()
.map(|r| (r.id, RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>();
}
}
#[cfg(test)]
mod test {
use super::*;
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use dimensioned::si;
use emseries::Record;
#[derive(Clone, Debug)]
struct MockProvider {
records: Arc<RwLock<HashMap<RecordId, Record<TraxRecord>>>>,
put_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
updated_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
deleted_records: Arc<RwLock<Vec<RecordId>>>,
}
impl MockProvider {
fn new(records: Vec<Record<TraxRecord>>) -> Self {
let record_map = records
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<RecordId, Record<TraxRecord>>>();
Self {
records: Arc::new(RwLock::new(record_map)),
put_records: Arc::new(RwLock::new(vec![])),
updated_records: Arc::new(RwLock::new(vec![])),
deleted_records: Arc::new(RwLock::new(vec![])),
}
}
}
#[async_trait]
impl RecordProvider for MockProvider {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let start = emseries::Timestamp::Date(start);
let end = emseries::Timestamp::Date(end);
Ok(self
.records
.read()
.unwrap()
.iter()
.map(|(_, r)| r)
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
.cloned()
.collect::<Vec<Record<TraxRecord>>>())
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let id = RecordId::default();
let record = Record {
id: id,
data: record,
};
self.put_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(id, record);
Ok(id)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
println!("updated record: {:?}", record);
self.updated_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(record.id, record);
Ok(())
}
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
self.deleted_records.write().unwrap().push(id);
let _ = self.records.write().unwrap().remove(&id);
Ok(())
}
}
async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) {
let provider = MockProvider::new(vec![]);
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
async fn create_view_model() -> (DayDetailViewModel, MockProvider) {
let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap();
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let oct_13_am: DateTime<FixedOffset> = oct_13
.clone()
.and_hms_opt(3, 28, 0)
.unwrap()
.and_utc()
.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
let provider = MockProvider::new(vec![
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_12,
weight: 93. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_13,
weight: 95. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Steps(ft_core::Steps {
date: oct_13,
count: 2500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::TimeDistance(ft_core::TimeDistance {
datetime: oct_13_am.clone(),
activity: TimeDistanceActivity::Biking,
distance: Some(15000. * si::M),
duration: Some(3600. * si::S),
comments: Some("somecomments present".to_owned()),
}),
},
]);
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
#[tokio::test]
async fn it_honors_only_the_first_weight_and_step_record() {
let (view_model, _provider) = create_view_model().await;
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(2500));
}
#[tokio::test]
async fn it_can_create_a_weight_and_stepcount() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(view_model.weight(), None);
assert_eq!(view_model.steps(), None);
view_model.set_weight(95. * si::KG);
view_model.set_steps(250);
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(250));
view_model.set_weight(93. * si::KG);
view_model.set_steps(255);
assert_eq!(view_model.weight(), Some(93. * si::KG));
assert_eq!(view_model.steps(), Some(255));
view_model.async_save().await;
println!("provider: {:?}", provider);
assert_eq!(provider.put_records.read().unwrap().len(), 2);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_construct_new_records() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_a_new_record_before_saving() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
let record = record.map(TraxRecord::TimeDistance);
view_model.update_record(record.clone());
assert_eq!(view_model.get_record(&record.id), Some(record));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 60. * si::S)
);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Running),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let mut workout = view_model.time_distance_records().first().cloned().unwrap();
workout.data.duration = Some(1800. * si::S);
view_model.update_record(workout.map(TraxRecord::TimeDistance));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(15000. * si::M, 1800. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 1);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_remove_a_new_record() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let record = view_model.new_time_distance(TimeDistanceActivity::Biking);
view_model.remove_record(record.id);
view_model.save();
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_delete_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let workout = view_model.time_distance_records().first().cloned().unwrap();
view_model.remove_record(workout.id);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 1);
}
}

View File

@ -0,0 +1,18 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod day_detail;
pub use day_detail::DayDetailViewModel;

View File

@ -0,0 +1,84 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{DayDetail, DayEdit, Singleton, SingletonImpl},
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
#[derive(Default)]
pub struct DayDetailViewPrivate {
container: Singleton,
view_model: RefCell<Option<DayDetailViewModel>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailViewPrivate {
const NAME: &'static str = "DayDetailView";
type Type = DayDetailView;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailViewPrivate {}
impl WidgetImpl for DayDetailViewPrivate {}
impl BoxImpl for DayDetailViewPrivate {}
impl SingletonImpl for DayDetailViewPrivate {}
glib::wrapper! {
pub struct DayDetailView(ObjectSubclass<DayDetailViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl DayDetailView {
pub fn new(view_model: DayDetailViewModel) -> Self {
let s: Self = Object::builder().build();
*s.imp().view_model.borrow_mut() = Some(view_model);
s.append(&s.imp().container);
s.view();
s
}
fn view(&self) {
let view_model = self.imp().view_model.borrow();
let view_model = view_model
.as_ref()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayDetail::new(view_model, {
let s = self.clone();
move || s.edit()
}));
}
fn edit(&self) {
let view_model = self.imp().view_model.borrow();
let view_model = view_model
.as_ref()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayEdit::new(view_model, {
let s = self.clone();
move || s.view()
}));
}
}

View File

@ -0,0 +1,182 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::App,
components::{DateRangePicker, DaySummary},
types::DayInterval,
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
/// The historical view will show a window into the main database. It will show some version of
/// daily summaries, daily details, and will provide all functions the user may need for editing
/// records.
pub struct HistoricalViewPrivate {
app: Rc<RefCell<Option<App>>>,
list_view: gtk::ListView,
date_range_picker: DateRangePicker,
}
#[glib::object_subclass]
impl ObjectSubclass for HistoricalViewPrivate {
const NAME: &'static str = "HistoricalView";
type Type = HistoricalView;
type ParentType = gtk::Box;
fn new() -> Self {
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |_, list_item| {
list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.set_child(Some(&DaySummary::new()));
});
let date_range_picker = DateRangePicker::default();
let s = Self {
app: Rc::new(RefCell::new(None)),
list_view: gtk::ListView::builder()
.factory(&factory)
.single_click_activate(true)
.show_separators(true)
.build(),
date_range_picker,
};
factory.connect_bind({
let app = s.app.clone();
move |_, list_item| {
let date = list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.item()
.and_downcast::<Date>()
.expect("should be a Date");
let summary = list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.child()
.and_downcast::<DaySummary>()
.expect("should be a DaySummary");
if let Some(app) = app.borrow().clone() {
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date.date(), app.clone())
.await
.unwrap();
summary.set_data(view_model);
});
}
}
});
s
}
}
impl ObjectImpl for HistoricalViewPrivate {}
impl WidgetImpl for HistoricalViewPrivate {}
impl BoxImpl for HistoricalViewPrivate {}
glib::wrapper! {
pub struct HistoricalView(ObjectSubclass<HistoricalViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl HistoricalView {
pub fn new<SelectFn>(app: App, interval: DayInterval, on_select_day: Rc<SelectFn>) -> Self
where
SelectFn: Fn(chrono::NaiveDate) + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["historical"]);
*s.imp().app.borrow_mut() = Some(app);
s.imp().date_range_picker.connect_on_search({
let s = s.clone();
move |interval| s.set_interval(interval)
});
s.set_interval(interval);
s.imp().list_view.connect_activate({
let on_select_day = on_select_day.clone();
move |s, idx| {
// This gets triggered whenever the user clicks on an item on the list.
let item = s.model().unwrap().item(idx).unwrap();
let date = item.downcast_ref::<Date>().unwrap();
on_select_day(date.date());
}
});
let scroller = gtk::ScrolledWindow::builder()
.child(&s.imp().list_view)
.hexpand(true)
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
s.append(&s.imp().date_range_picker);
s.append(&scroller);
s
}
pub fn set_interval(&self, interval: DayInterval) {
let mut model = gio::ListStore::new::<Date>();
let mut days = interval.days().map(Date::new).collect::<Vec<Date>>();
days.reverse();
model.extend(days.into_iter());
self.imp()
.list_view
.set_model(Some(&gtk::NoSelection::new(Some(model))));
self.imp().date_range_picker.set_interval(interval.start, interval.end);
}
}
#[derive(Default)]
pub struct DatePrivate {
date: RefCell<chrono::NaiveDate>,
}
#[glib::object_subclass]
impl ObjectSubclass for DatePrivate {
const NAME: &'static str = "Date";
type Type = Date;
}
impl ObjectImpl for DatePrivate {}
glib::wrapper! {
pub struct Date(ObjectSubclass<DatePrivate>);
}
impl Date {
pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build();
*s.imp().date.borrow_mut() = date;
s
}
pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow()
}
}

View File

@ -0,0 +1,45 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use gtk::prelude::*;
mod day_detail_view;
pub use day_detail_view::DayDetailView;
mod historical_view;
pub use historical_view::HistoricalView;
mod placeholder_view;
pub use placeholder_view::PlaceholderView;
mod welcome_view;
pub use welcome_view::WelcomeView;
pub enum View {
Placeholder(PlaceholderView),
Welcome(WelcomeView),
Historical(HistoricalView),
}
impl View {
pub fn widget(&self) -> gtk::Widget {
match self {
View::Placeholder(widget) => widget.clone().upcast::<gtk::Widget>(),
View::Welcome(widget) => widget.clone().upcast::<gtk::Widget>(),
View::Historical(widget) => widget.clone().upcast::<gtk::Widget>(),
}
}
}

View File

@ -0,0 +1,46 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use glib::Object;
use gtk::subclass::prelude::*;
pub struct PlaceholderViewPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for PlaceholderViewPrivate {
const NAME: &'static str = "PlaceholderView";
type Type = PlaceholderView;
type ParentType = gtk::Box;
fn new() -> Self {
Self {}
}
}
impl ObjectImpl for PlaceholderViewPrivate {}
impl WidgetImpl for PlaceholderViewPrivate {}
impl BoxImpl for PlaceholderViewPrivate {}
glib::wrapper! {
pub struct PlaceholderView(ObjectSubclass<PlaceholderViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl Default for PlaceholderView {
fn default() -> Self {
let s: Self = Object::builder().build();
s
}
}

View File

@ -0,0 +1,97 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::components::FileChooserRow;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::path::PathBuf;
/// This is the view to show if the application has not yet been configured. It will walk the user
/// through the most critical setup steps so that we can move on to the other views in the app.
pub struct WelcomeViewPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for WelcomeViewPrivate {
const NAME: &'static str = "WelcomeView";
type Type = WelcomeView;
type ParentType = gtk::Box;
fn new() -> Self {
Self {}
}
}
impl ObjectImpl for WelcomeViewPrivate {}
impl WidgetImpl for WelcomeViewPrivate {}
impl BoxImpl for WelcomeViewPrivate {}
glib::wrapper! {
pub struct WelcomeView(ObjectSubclass<WelcomeViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl WelcomeView {
pub fn new<OnSave>(on_save: OnSave) -> Self
where
OnSave: Fn(PathBuf) + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["welcome"]);
// Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page
// branch.
let title = gtk::Label::builder()
.label("Welcome to FitnessTrax")
.css_classes(["welcome__title"])
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.vexpand(true)
.build();
let save_button = gtk::Button::builder()
.label("Save Settings")
.sensitive(false)
.build();
// The database selection row should be a box that shows a default database path, along with a
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
// updated to reflect the chosen path.
let db_row = FileChooserRow::new({
let save_button = save_button.clone();
move |_| save_button.set_sensitive(true)
});
content.append(&gtk::Label::new(Some("Welcome to FitnessTrax. The application has not yet been configured, so I will walk you through that. Let's start out by selecting your database.")));
content.append(&db_row);
save_button.connect_clicked({
let db_row = db_row.clone();
move |_| {
if let Some(path) = db_row.path() {
on_save(path);
}
}
});
s.append(&title);
s.append(&content);
s.append(&save_button);
s
}
}

View File

@ -0,0 +1,19 @@
[package]
name = "ft-core"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4" }
chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] }
emseries = { path = "../../emseries" }
serde = { version = "1", features = [ "derive" ] }
serde_json = { version = "1" }
[dev-dependencies]
tempfile = "*"

View File

@ -0,0 +1,328 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use chrono::SecondsFormat;
use chrono_tz::Etc::UTC;
use dimensioned::si;
use emseries::{Record, RecordId, Series, Timestamp};
use ft_core::{self, DurationWorkout, DurationWorkoutActivity, SetRepActivity, TraxRecord};
use serde::{
de::{self, Visitor},
Deserialize, Deserializer, Serialize, Serializer,
};
use std::{
fmt,
fs::File,
io::{BufRead, BufReader, Read},
str::FromStr,
};
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
/// string.
///
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
/// and the named time zone allows future parsers to know the exact interpretation of the time in
/// the frame of reference of the original recording.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
impl fmt::Display for DateTimeTz {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
if self.0.timezone() == UTC {
write!(f, "{}", self.0.to_rfc3339_opts(SecondsFormat::Secs, true))
} else {
write!(
f,
"{} {}",
self.0
.with_timezone(&chrono_tz::Etc::UTC)
.to_rfc3339_opts(SecondsFormat::Secs, true,),
self.0.timezone().name()
)
}
}
}
impl DateTimeTz {
pub fn map<F>(&self, f: F) -> DateTimeTz
where
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
{
DateTimeTz(f(self.0))
}
}
impl std::str::FromStr for DateTimeTz {
type Err = chrono::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: Vec<&str> = s.split_terminator(' ').collect();
if v.len() == 2 {
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
} else {
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
}
}
}
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
DateTimeTz(dt)
}
}
struct DateTimeTzVisitor;
impl<'de> Visitor<'de> for DateTimeTzVisitor {
type Value = DateTimeTz;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string date time representation that can be parsed")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
DateTimeTz::from_str(s).or(Err(E::custom(
"string is not a parsable datetime representation".to_owned(),
)))
}
}
impl Serialize for DateTimeTz {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for DateTimeTz {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(DateTimeTzVisitor)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Steps {
date: DateTimeTz,
steps: u32,
}
impl From<Steps> for ft_core::Steps {
fn from(s: Steps) -> Self {
Self {
date: s.date.0.naive_utc().date(),
count: s.steps,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Weight {
date: DateTimeTz,
weight: f64,
}
impl From<Weight> for ft_core::Weight {
fn from(w: Weight) -> Self {
Self {
date: w.date.0.naive_utc().date(),
weight: w.weight * si::KG,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum TDActivity {
Cycling,
Rowing,
Running,
Swimming,
Walking,
}
impl From<TDActivity> for ft_core::TimeDistanceActivity {
fn from(activity: TDActivity) -> Self {
match activity {
TDActivity::Cycling => ft_core::TimeDistanceActivity::Biking,
TDActivity::Rowing => ft_core::TimeDistanceActivity::Rowing,
TDActivity::Running => ft_core::TimeDistanceActivity::Running,
TDActivity::Swimming => ft_core::TimeDistanceActivity::Swimming,
TDActivity::Walking => ft_core::TimeDistanceActivity::Walking,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct TimeDistance {
date: DateTimeTz,
activity: TDActivity,
comments: Option<String>,
distance: Option<f64>,
duration: Option<f64>,
}
impl From<TimeDistance> for ft_core::TimeDistance {
fn from(td: TimeDistance) -> Self {
Self {
datetime: td.date.0.fixed_offset(),
activity: td.activity.into(),
comments: td.comments,
distance: td.distance.map(|d| d * si::M),
duration: td.duration.map(|d| d * si::S),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum SRActivity {
Pushups,
Situps,
}
impl From<SRActivity> for SetRepActivity {
fn from(activity: SRActivity) -> Self {
match activity {
SRActivity::Pushups => Self::Pushups,
SRActivity::Situps => Self::Situps,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct SetRep {
date: DateTimeTz,
activity: SRActivity,
sets: Vec<u32>,
comments: Option<String>,
}
impl From<SetRep> for ft_core::SetRep {
fn from(sr: SetRep) -> Self {
Self {
date: sr.date.0.naive_utc().date(),
activity: sr.activity.into(),
sets: sr.sets,
comments: sr.comments,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum RDActivity {
MartialArts,
}
impl From<RDActivity> for DurationWorkoutActivity {
fn from(_: RDActivity) -> Self {
Self::MartialArts
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct RepDuration {
date: DateTimeTz,
activity: RDActivity,
sets: Vec<f64>,
}
impl From<RepDuration> for DurationWorkout {
fn from(rd: RepDuration) -> Self {
Self {
datetime: rd.date.0.fixed_offset(),
activity: rd.activity.into(),
duration: rd.sets.into_iter().map(|d| d * si::S).next(),
comments: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum LegacyData {
RepDuration(RepDuration),
SetRep(SetRep),
Steps(Steps),
TimeDistance(TimeDistance),
Weight(Weight),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct LegacyRecord {
id: RecordId,
data: LegacyData,
}
impl From<LegacyRecord> for Record<TraxRecord> {
fn from(record: LegacyRecord) -> Self {
match record.data {
LegacyData::RepDuration(rd) => Record {
id: record.id,
data: TraxRecord::DurationWorkout(rd.into()),
},
LegacyData::SetRep(sr) => Record {
id: record.id,
data: TraxRecord::SetRep(sr.into()),
},
LegacyData::Steps(s) => Record {
id: record.id,
data: TraxRecord::Steps(s.into()),
},
LegacyData::TimeDistance(td) => Record {
id: record.id,
data: TraxRecord::TimeDistance(td.into()),
},
LegacyData::Weight(weight) => Record {
id: record.id,
data: TraxRecord::Weight(weight.into()),
},
}
}
}
fn main() {
let mut args = std::env::args();
let _ = args.next().unwrap();
let input_filename = args.next().unwrap();
println!("input filename: {}", input_filename);
// let output: Series<ft_core::TraxRecord> = Series::open_file("import.fitnesstrax").unwrap();
let input_file = File::open(input_filename).unwrap();
let mut buf_reader = BufReader::new(input_file);
// let mut contents = String::new();
// buf_reader.read_(&mut contents).unwrap();
let mut count = 0;
loop {
let mut line = String::new();
let res = buf_reader.read_line(&mut line);
match res {
Err(err) => {
panic!("failed after {} lines: {:?}", count, err);
}
Ok(0) => std::process::exit(0),
Ok(_) => {
let record = serde_json::from_str::<LegacyRecord>(&line).unwrap();
let record: Record<TraxRecord> = record.into();
println!("{}", serde_json::to_string(&record).unwrap());
count += 1;
}
}
}
}

View File

@ -0,0 +1,26 @@
#[cfg(test)]
mod test {
#[test]
#[ignore]
fn read_a_legacy_set_rep_record() {
unimplemented!()
}
#[test]
#[ignore]
fn read_a_legacy_steps_record() {
unimplemented!()
}
#[test]
#[ignore]
fn read_a_legacy_time_distance_record() {
unimplemented!()
}
#[test]
#[ignore]
fn read_a_legacy_weight_record() {
unimplemented!()
}
}

View File

@ -0,0 +1,8 @@
mod legacy;
mod types;
pub use types::{
DurationWorkout, DurationWorkoutActivity, SetRep, SetRepActivity, Steps, TimeDistance,
TimeDistanceActivity, TraxRecord, Weight, DURATION_WORKOUT_ACTIVITIES, SET_REP_ACTIVITIES,
TIME_DISTANCE_ACTIVITIES,
};

View File

@ -0,0 +1,261 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use chrono::{DateTime, FixedOffset, NaiveDate};
use dimensioned::si;
use emseries::{Recordable, Timestamp};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum SetRepActivity {
Pushups,
Situps,
}
pub const SET_REP_ACTIVITIES: [SetRepActivity; 2] =
[SetRepActivity::Pushups, SetRepActivity::Situps];
/// SetRep represents workouts like pushups or situps, which involve doing a "set" of a number of
/// actions, resting, and then doing another set.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SetRep {
/// I assume that a set/rep workout is only done once in a day.
pub date: NaiveDate,
/// The activity involved
pub activity: SetRepActivity,
/// Each set entry represents the number of times that the action was performed in a set. So, a
/// pushup workout that involved five sets would have five entries. Each entry would be x
/// number of pushups. A viable workout would be something like [6, 6, 4, 4, 5].
pub sets: Vec<u32>,
pub comments: Option<String>,
}
impl Recordable for SetRep {
fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
/// The number of steps one takes in a single day.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Steps {
pub date: NaiveDate,
pub count: u32,
}
impl Recordable for Steps {
fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum TimeDistanceActivity {
Biking,
Running,
Rowing,
Swimming,
Walking,
}
pub const TIME_DISTANCE_ACTIVITIES: [TimeDistanceActivity; 5] = [
TimeDistanceActivity::Biking,
TimeDistanceActivity::Rowing,
TimeDistanceActivity::Running,
TimeDistanceActivity::Swimming,
TimeDistanceActivity::Walking,
];
/// TimeDistance represents workouts characterized by a duration and a distance travelled. These
/// sorts of workouts can occur many times a day, depending on how one records things. I might
/// record a single 30-km workout if I go on a long-distanec ride. Or I might record multiple 5km
/// workouts if I am out running errands. Distance and Duration are both optional because different
/// people have different priorities and may choose to measure different things.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TimeDistance {
/// The precise time (and the relevant timezone) of the workout. One of the edge cases that I
/// account for is that a ride which occurred at 11pm on one day in one timezone would then
/// count as 1am on the next day if the user moves two timezones to the east. While technically
/// correct, for most users this would throw off many years of metrics in ways that can be very
/// hard to understand. Keeping the fixed offset means that we can have the most precise time
/// in the database, but we can still get a Naive Date from the DateTime, which will still read
/// as the original day.
pub datetime: DateTime<FixedOffset>,
/// The activity
pub activity: TimeDistanceActivity,
/// The distance travelled. This is optional because such a workout makes sense even without
/// the distance.
pub distance: Option<si::Meter<f64>>,
/// The duration of the workout, which is also optional. Some people may keep track of the
/// amount of distance travelled without tracking the duration.
pub duration: Option<si::Second<f64>>,
pub comments: Option<String>,
}
impl Recordable for TimeDistance {
fn timestamp(&self) -> Timestamp {
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
/// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to
/// need to track more than a single weight in a day.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Weight {
pub date: NaiveDate,
pub weight: si::Kilogram<f64>,
}
impl Recordable for Weight {
fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DurationWorkoutActivity {
MartialArts,
Yoga,
}
pub const DURATION_WORKOUT_ACTIVITIES: [DurationWorkoutActivity; 2] = [
DurationWorkoutActivity::MartialArts,
DurationWorkoutActivity::Yoga,
];
/// Generic workouts for which only duration really matters. This is for things
/// such as Martial Arts or Yoga, which are activities done for an amount of
/// time, but with no other details.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DurationWorkout {
pub datetime: DateTime<FixedOffset>,
pub activity: DurationWorkoutActivity,
pub duration: Option<si::Second<f64>>,
pub comments: Option<String>,
}
impl Recordable for DurationWorkout {
fn timestamp(&self) -> Timestamp {
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
/// The unified data structure for all records that are part of the app.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum TraxRecord {
DurationWorkout(DurationWorkout),
SetRep(SetRep),
Steps(Steps),
TimeDistance(TimeDistance),
Weight(Weight),
}
impl TraxRecord {
pub fn is_weight(&self) -> bool {
matches!(self, TraxRecord::Weight(_))
}
pub fn is_steps(&self) -> bool {
matches!(self, TraxRecord::Steps(_))
}
pub fn is_time_distance(&self) -> bool {
matches!(
self,
TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Biking,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Running,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Rowing,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Swimming,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Walking,
..
})
)
}
}
impl Recordable for TraxRecord {
fn timestamp(&self) -> Timestamp {
match self {
TraxRecord::TimeDistance(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::SetRep(rec) => rec.timestamp(),
TraxRecord::Steps(rec) => rec.timestamp(),
TraxRecord::Weight(rec) => rec.timestamp(),
TraxRecord::DurationWorkout(rec) => rec.timestamp(),
}
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
impl From<TimeDistance> for TraxRecord {
fn from(td: TimeDistance) -> Self {
Self::TimeDistance(td)
}
}
#[cfg(test)]
mod test {
use super::*;
use emseries::Series;
#[test]
fn can_record_records() {
let file = tempfile::NamedTempFile::new().expect("a temporary file");
let path = file.into_temp_path();
let mut series: Series<TraxRecord> = Series::open(&path).unwrap();
let record = TraxRecord::Steps(Steps {
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
count: 1000,
});
let id = series.put(record.clone()).unwrap();
let record_ = series.get(&id).unwrap();
assert_eq!(record_.data, record);
}
}

View File

@ -1,37 +1,6 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1650374568,
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"locked": {
"lastModified": 1653893745,
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems"
},
@ -51,36 +20,20 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1691421349,
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
"lastModified": 1704732714,
"narHash": "sha256-ABqK/HggMYA/jMUXgYyqVAcQ8QjeMyr1jcXfTpSHmps=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
"rev": "6723fa4e4f1a30d42a633bef5eb01caeb281adc3",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-23.05",
"ref": "nixos-23.11",
"type": "indirect"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1654275867,
"narHash": "sha256-pt14ZE4jVPGvfB2NynGsl34pgXfOqum5YJNpDK4+b9E=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "7a20c208aacf4964c19186dcad51f89165dc7ed0",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "release-22.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1681303793,
"narHash": "sha256-JEdQHsYuCfRL2PICHlOiH/2ue3DwoxUX7DJ6zZxZXFk=",
@ -95,60 +48,13 @@
"type": "indirect"
}
},
"pkgs-cargo2nix": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1682891040,
"narHash": "sha256-hjajsi7lq24uYitUh4o04UJi1g0Qe6ruPL0s5DgPQMY=",
"owner": "cargo2nix",
"repo": "cargo2nix",
"rev": "0167b39f198d72acdf009265634504fd6f5ace15",
"type": "github"
},
"original": {
"owner": "cargo2nix",
"repo": "cargo2nix",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"pkgs-cargo2nix": "pkgs-cargo2nix",
"typeshare": "typeshare",
"unstable": "unstable"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"pkgs-cargo2nix",
"flake-utils"
],
"nixpkgs": [
"pkgs-cargo2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1653878966,
"narHash": "sha256-T51Gck/vrJZi1m+uTbhEFTRgZmE59sydVONadADv358=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "8526d618af012a923ca116be9603e818b502a8db",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
@ -166,15 +72,15 @@
},
"typeshare": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_3"
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1690502632,
"narHash": "sha256-+k81RrxfphDUD5kekWbQ4xuZIHBEAQf67uivaQ34Afs=",
"lastModified": 1698205128,
"narHash": "sha256-jP+81TkldLtda8bzmsBWahETGsyFkoDOCT244YkA+S4=",
"owner": "1Password",
"repo": "typeshare",
"rev": "9f74772af53759aee2f53e64478523e53083719e",
"rev": "c3ee2ad8f27774c45db7af4f2ba746c4ae11de21",
"type": "github"
},
"original": {
@ -185,11 +91,11 @@
},
"unstable": {
"locked": {
"lastModified": 1690367991,
"narHash": "sha256-2VwOn1l8y6+cu7zjNE8MgeGJNNz1eat1HwHrINeogFA=",
"lastModified": 1704722960,
"narHash": "sha256-mKGJ3sPsT6//s+Knglai5YflJUF2DGj7Ai6Ynopz0kI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9cf0708f00fbe553319258e48ca89ff9a413703",
"rev": "317484b1ead87b9c1b8ac5261a8d2dd748a0492d",
"type": "github"
},
"original": {

View File

@ -2,13 +2,12 @@
description = "Lumenescent Dreams Tools";
inputs = {
nixpkgs.url = "nixpkgs/nixos-23.05";
nixpkgs.url = "nixpkgs/nixos-23.11";
unstable.url = "nixpkgs/nixos-unstable";
pkgs-cargo2nix.url = "github:cargo2nix/cargo2nix";
typeshare.url = "github:1Password/typeshare";
};
outputs = { self, nixpkgs, unstable, pkgs-cargo2nix, typeshare, ... }:
outputs = { self, nixpkgs, unstable, typeshare, ... }:
let
version = builtins.string 0 8 self.lastModifiedDate;
supportedSystems = [ "x86_64-linux" ];
@ -18,15 +17,13 @@
let
pkgs = import nixpkgs { system = "x86_64-linux"; };
pkgs-unstable = import unstable { system = "x86_64-linux"; };
cargo2nix = pkgs-cargo2nix.packages."x86_64-linux";
in
pkgs.mkShell {
name = "ld-tools-devshell";
buildInputs = [
pkgs.libadwaita
pkgs.cargo-nextest
pkgs.clang
pkgs.entr
pkgs.glade
pkgs.crate2nix
pkgs.glib
pkgs.gst_all_1.gst-plugins-bad
pkgs.gst_all_1.gst-plugins-base
@ -34,19 +31,73 @@
pkgs.gst_all_1.gst-plugins-ugly
pkgs.gst_all_1.gstreamer
pkgs.gtk4
pkgs.libadwaita
pkgs.librsvg
pkgs.nodejs
pkgs.openssl
pkgs.pipewire
pkgs.pkg-config
pkgs.sqlite
pkgs.rustup
pkgs.sqlite
pkgs.cargo-nextest
pkgs.crate2nix
pkgs.wasm-pack
pkgs.sqlx-cli
pkgs.udev
pkgs.wasm-pack
typeshare.packages."x86_64-linux".default
];
LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib";
ENV = "dev";
};
packages."x86_64-linux" =
let
pkgs = import nixpkgs { system = "x86_64-linux"; };
gtkNativeInputs = [
pkgs.pkg-config
pkgs.gtk4
pkgs.libadwaita
pkgs.wrapGAppsHook4
];
cargoOverrides = pkgs: pkgs.buildRustCrate.override {
defaultCrateOverrides = pkgs.defaultCrateOverrides // {
gobject-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
gio-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
gdk-pixbuf-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
libadwaita-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
dashboard = attrs: { nativeBuildInputs = gtkNativeInputs; };
fitnesstrax = import ./fitnesstrax/app/override.nix { gtkNativeInputs = gtkNativeInputs; };
kifu-gtk = import ./kifu/gtk/override.nix { gtkNativeInputs = gtkNativeInputs; };
};
};
cargo_nix = pkgs.callPackage ./Cargo.nix {
nixpkgs = nixpkgs;
buildRustCrateForPkgs = cargoOverrides;
};
in rec {
cyberpunk-splash = cargo_nix.workspaceMembers.cyberpunk-splash.build;
dashboard = cargo_nix.workspaceMembers.dashboard.build;
file-service = cargo_nix.workspaceMembers.file-service.build;
fitnesstrax = cargo_nix.workspaceMembers.fitnesstrax.build;
kifu-gtk = cargo_nix.workspaceMembers.kifu-gtk.build;
all = pkgs.symlinkJoin {
name = "all";
paths = [
cyberpunk-splash
dashboard
file-service
fitnesstrax
kifu-gtk
];
};
default = all;
};
};
}

View File

@ -6,14 +6,14 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2", "gtk_v4_6" ] }
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2", "gtk_v4_6" ] }
config = { path = "../config" }
config-derive = { path = "../config-derive" }
futures = { version = "0.3" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gdk = { version = "0.6", package = "gdk4" }
gtk = { version = "0.6", package = "gtk4", features = [ "v4_6" ] }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_6" ] }
serde = { version = "1" }
serde_json = { version = "*" }
tokio = { version = "1", features = ["full"] }

View File

@ -1,4 +1,4 @@
use glib::{Continue, Sender};
use glib::Sender;
use gtk::prelude::*;
use std::{
env,
@ -43,14 +43,14 @@ pub fn main() {
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
gtk::glib::MainContext::channel::<Message>(gtk::glib::Priority::DEFAULT);
*core.tx.write().unwrap() = Some(gtk_tx);
let window = ApplicationWindow::new(app);
window.window.present();
gtk_rx.attach(None, move |_msg| Continue(true));
gtk_rx.attach(None, move |_msg| glib::ControlFlow::Continue);
});
let args: Vec<String> = env::args().collect();

View File

@ -7,12 +7,12 @@ license = "GPL-3.0-only"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cairo-rs = "0.17"
gio = "0.17"
glib = "0.17"
gtk = { version = "0.6", package = "gtk4" }
cairo-rs = "0.18"
gio = "0.18"
glib = "0.18"
gtk = { version = "0.7", package = "gtk4" }
coordinates = { path = "../coordinates" }
image = { version = "0.24" }
[build-dependencies]
glib-build-tools = "0.16"
glib-build-tools = "0.18"

View File

@ -1,6 +1,6 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
&["resources"],
"resources/resources.gresources.xml",
"com.luminescent-dreams.hex-grid.gresource",
);

View File

@ -1,6 +1,6 @@
use crate::utilities;
use cairo::Context;
use gtk::{gdk_pixbuf::Pixbuf, prelude::*};
use gtk::gdk_pixbuf::Pixbuf;
use image::DynamicImage;
#[derive(Clone, Debug)]
@ -89,7 +89,8 @@ impl Tile {
88.,
));
context.clip();
context.set_source_pixbuf(&self.image, translate_x, translate_y);
// panic!("content.set_source_pixbuf got deprecated and I haven't figured out the replacement yet.");
// context.set_source_pixbuf(&self.image, translate_x, translate_y);
context.paint().expect("paint should succeed");
context.restore().unwrap();
}

12
icon-test/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "icon-test"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }

38
icon-test/src/main.rs Normal file
View File

@ -0,0 +1,38 @@
use adw::prelude::*;
fn main() {
let adw_app = adw::Application::builder().build();
adw_app.connect_activate(move |adw_app| {
let window = gtk::ApplicationWindow::builder()
.application(adw_app)
.width_request(400)
.height_request(400)
.build();
let sunrise_button = gtk::Button::builder()
.icon_name("daytime-sunrise-symbolic")
.width_request(64)
.height_request(64)
.build();
let walking_button = gtk::Button::builder()
.icon_name("walking2-symbolic")
.width_request(64)
.height_request(64)
.build();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.valign(gtk::Align::Start)
.build();
layout.append(&sunrise_button);
layout.append(&walking_button);
window.set_child(Some(&layout));
window.present();
});
let args: Vec<String> = std::env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -1,6 +0,0 @@
SOURCES = $(shell find ../core -name "*.rs")
dist/index.ts: $(SOURCES)
mkdir -p dist
typeshare ../core --lang=typescript --output-file=dist/index.ts

View File

@ -1,13 +0,0 @@
{
"name": "core-types",
"version": "0.0.1",
"description": "",
"types": "dist/index.ts",
"main": "dist/index.ts",
"scripts": {
"build": "make",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Savanni D'Gerinel <savanni@luminescent-dreams.com>",
"license": "GPL-3.0-or-later"
}

View File

@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = { version = "1" }
chrono = { version = "0.4" }
config = { path = "../../config" }
config-derive = { path = "../../config-derive" }
@ -14,7 +15,6 @@ grid = { version = "0.9" }
serde_json = { version = "1" }
serde = { version = "1", features = [ "derive" ] }
thiserror = { version = "1" }
typeshare = { version = "1" }
[dev-dependencies]
cool_asserts = { version = "2" }

View File

@ -1,17 +1,24 @@
use crate::{
types::{AppState, Config, ConfigOption, DatabasePath, GameState, Player, Rank},
ui::{configuration, home, playing_field, ConfigurationView, HomeView, PlayingFieldView},
database::Database,
types::{AppState, Config, ConfigOption, GameState, LibraryPath, Player, Rank},
};
use async_std::{
channel::{Receiver, Sender},
stream,
task::spawn,
};
use serde::{Deserialize, Serialize};
use std::{
future::Future,
path::PathBuf,
sync::{Arc, RwLock},
sync::{Arc, RwLock, RwLockReadGuard},
};
use typeshare::typeshare;
pub trait Observable<T> {
fn subscribe(&self) -> Receiver<T>;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum CoreRequest {
ChangeSetting(ChangeSettingRequest),
CreateGame(CreateGameRequest),
@ -23,34 +30,28 @@ pub enum CoreRequest {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum ChangeSettingRequest {
LibraryPath(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub struct PlayStoneRequest {
pub column: u8,
pub row: u8,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub struct CreateGameRequest {
pub black_player: PlayerInfoRequest,
pub white_player: PlayerInfoRequest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub enum PlayerInfoRequest {
Hotseat(HotseatPlayerRequest),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub struct HotseatPlayerRequest {
pub name: String,
pub rank: Option<String>,
@ -65,47 +66,78 @@ impl From<HotseatPlayerRequest> for Player {
}
}
/*
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum CoreResponse {
ConfigurationView(ConfigurationView),
HomeView(HomeView),
PlayingFieldView(PlayingFieldView),
UpdatedConfigurationView(ConfigurationView),
}
*/
#[derive(Clone, Debug)]
pub struct CoreApp {
config: Arc<RwLock<Config>>,
state: Arc<RwLock<AppState>>,
pub enum CoreNotification {
ConfigurationUpdated(Config),
BoardUpdated,
}
impl CoreApp {
pub fn new(config_path: std::path::PathBuf) -> Self {
let config = Config::from_path(config_path).expect("configuration to open");
let db_path: DatabasePath = config.get().unwrap();
let state = Arc::new(RwLock::new(AppState::new(db_path)));
#[derive(Clone, Debug)]
pub struct Core {
config: Arc<RwLock<Config>>,
// state: Arc<RwLock<AppState>>,
library: Arc<RwLock<Option<Database>>>,
subscribers: Arc<RwLock<Vec<Sender<CoreNotification>>>>,
}
impl Core {
pub fn new(config: Config) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
state,
// state,
library: Arc::new(RwLock::new(None)),
subscribers: Arc::new(RwLock::new(vec![])),
}
}
pub fn get_config(&self) -> Config {
self.config.read().unwrap().clone()
}
/// Change the configuration of the Core. This function will update any relevant core
/// functions, especially the contents of the library, and it will notify any subscribed objects
/// that the configuration has changed.
///
/// It will not handle persisting the new configuration, as the backing store for the
/// configuration is not a decision for the core library.
pub async fn set_config(&self, config: Config) {
*self.config.write().unwrap() = config.clone();
let subscribers = self.subscribers.read().unwrap().clone();
for subscriber in subscribers {
let subscriber = subscriber.clone();
let _ = subscriber.send(CoreNotification::ConfigurationUpdated(config.clone())).await;
}
}
pub fn library<'a>(&'a self) -> RwLockReadGuard<'_, Option<Database>> {
self.library.read().unwrap()
}
/*
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
match request {
CoreRequest::ChangeSetting(request) => match request {
ChangeSettingRequest::LibraryPath(path) => {
let mut config = self.config.write().unwrap();
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
path,
))));
CoreResponse::UpdatedConfigurationView(configuration(&config))
// let mut config = self.config.write().unwrap();
// config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
// path,
// ))));
// CoreResponse::UpdatedConfigurationView(configuration(&config))
unimplemented!()
}
},
CoreRequest::CreateGame(create_request) => {
/*
let mut app_state = self.state.write().unwrap();
let white_player = {
match create_request.white_player {
@ -124,30 +156,48 @@ impl CoreApp {
});
let game_state = app_state.game.as_ref().unwrap();
CoreResponse::PlayingFieldView(playing_field(game_state))
*/
unimplemented!()
}
CoreRequest::Home => {
CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
// CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
unimplemented!()
}
CoreRequest::OpenConfiguration => {
CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
// CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
unimplemented!()
}
CoreRequest::PlayingField => {
let app_state = self.state.read().unwrap();
let game = app_state.game.as_ref().unwrap();
CoreResponse::PlayingFieldView(playing_field(game))
// let app_state = self.state.read().unwrap();
// let game = app_state.game.as_ref().unwrap();
// CoreResponse::PlayingFieldView(playing_field(game))
unimplemented!()
}
CoreRequest::PlayStone(request) => {
let mut app_state = self.state.write().unwrap();
app_state.place_stone(request);
// let mut app_state = self.state.write().unwrap();
// app_state.place_stone(request);
let game = app_state.game.as_ref().unwrap();
CoreResponse::PlayingFieldView(playing_field(game))
// let game = app_state.game.as_ref().unwrap();
// CoreResponse::PlayingFieldView(playing_field(game))
unimplemented!()
}
CoreRequest::StartGame => {
unimplemented!()
}
}
}
*/
pub async fn run(&self) {}
// pub async fn run(&self) {}
}
impl Observable<CoreNotification> for Core {
fn subscribe(&self) -> Receiver<CoreNotification> {
let mut subscribers = self.subscribers.write().unwrap();
let (sender, receiver) = async_std::channel::unbounded();
subscribers.push(sender);
receiver
}
}

View File

@ -2,12 +2,12 @@ use crate::{BoardError, Color, Size};
use std::collections::HashSet;
#[derive(Clone, Debug, Default)]
pub struct Board {
pub struct Goban {
pub size: Size,
pub groups: Vec<Group>,
}
impl std::fmt::Display for Board {
impl std::fmt::Display for Goban {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, " ")?;
// for c in 'A'..'U' {
@ -31,7 +31,7 @@ impl std::fmt::Display for Board {
}
}
impl PartialEq for Board {
impl PartialEq for Goban {
fn eq(&self, other: &Self) -> bool {
if self.size != other.size {
return false;
@ -51,7 +51,7 @@ impl PartialEq for Board {
}
}
impl Board {
impl Goban {
pub fn new() -> Self {
Self {
size: Size {
@ -77,7 +77,7 @@ pub struct Coordinate {
pub row: u8,
}
impl Board {
impl Goban {
pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> {
if self.stone(&coordinate).is_some() {
return Err(BoardError::InvalidPosition);
@ -224,8 +224,8 @@ mod test {
* A stone placed in a suicidal position is legal if it captures other stones first.
*/
fn with_example_board(test: impl FnOnce(Board)) {
let board = Board::from_coordinates(
fn with_example_board(test: impl FnOnce(Goban)) {
let board = Goban::from_coordinates(
vec![
(Coordinate { column: 3, row: 3 }, Color::White),
(Coordinate { column: 3, row: 4 }, Color::White),
@ -284,7 +284,7 @@ mod test {
#[test]
fn it_gets_adjacencies_for_coordinate() {
let board = Board::new();
let board = Goban::new();
for column in 0..19 {
for row in 0..19 {
for coordinate in board.adjacencies(&Coordinate { column, row }) {
@ -302,7 +302,7 @@ mod test {
#[test]
fn it_counts_individual_liberties() {
let board = Board::from_coordinates(
let board = Goban::from_coordinates(
vec![
(Coordinate { column: 3, row: 3 }, Color::White),
(Coordinate { column: 0, row: 3 }, Color::White),
@ -357,7 +357,7 @@ mod test {
#[test]
fn stones_share_liberties() {
with_example_board(|board: Board| {
with_example_board(|board: Goban| {
let test_cases = vec![
(
board.clone(),
@ -567,11 +567,11 @@ mod test {
#[test]
fn validate_group_comparisons() {
{
let b1 = Board::from_coordinates(
let b1 = Goban::from_coordinates(
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
)
.unwrap();
let b2 = Board::from_coordinates(
let b2 = Goban::from_coordinates(
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
)
.unwrap();
@ -580,7 +580,7 @@ mod test {
}
{
let b1 = Board::from_coordinates(
let b1 = Goban::from_coordinates(
vec![
(Coordinate { column: 7, row: 9 }, Color::White),
(Coordinate { column: 8, row: 10 }, Color::White),
@ -588,7 +588,7 @@ mod test {
.into_iter(),
)
.unwrap();
let b2 = Board::from_coordinates(
let b2 = Goban::from_coordinates(
vec![
(Coordinate { column: 8, row: 10 }, Color::White),
(Coordinate { column: 7, row: 9 }, Color::White),
@ -603,7 +603,7 @@ mod test {
#[test]
fn two_boards_can_be_compared() {
let board = Board::from_coordinates(
let board = Goban::from_coordinates(
vec![
(Coordinate { column: 7, row: 9 }, Color::White),
(Coordinate { column: 8, row: 8 }, Color::White),

View File

@ -1,6 +1,6 @@
use std::{io::Read, path::PathBuf};
use sgf::{go, parse_sgf, Game};
use sgf::{parse_sgf, Game};
use thiserror::Error;
#[derive(Error, Debug)]
@ -21,12 +21,12 @@ impl From<std::io::Error> for Error {
#[derive(Debug)]
pub struct Database {
games: Vec<go::Game>,
games: Vec<Game>,
}
impl Database {
pub fn open_path(path: PathBuf) -> Result<Database, Error> {
let mut games: Vec<go::Game> = Vec::new();
let mut games: Vec<Game> = Vec::new();
let extension = PathBuf::from("sgf").into_os_string();
@ -43,10 +43,7 @@ impl Database {
match parse_sgf(&buffer) {
Ok(sgfs) => {
for sgf in sgfs {
match sgf {
Game::Go(game) => games.push(game),
Game::Unsupported(_) => {}
}
games.push(sgf);
}
}
Err(err) => println!("Error parsing {:?}: {:?}", entry.path(), err),
@ -60,7 +57,7 @@ impl Database {
Ok(Database { games })
}
pub fn all_games(&self) -> impl Iterator<Item = &go::Game> {
pub fn all_games(&self) -> impl Iterator<Item = &Game> {
self.games.iter()
}
}
@ -78,6 +75,7 @@ mod test {
assert_eq!(db.all_games().count(), 0);
}
#[ignore]
#[test]
fn it_reads_five_games_from_database() {
let db =

View File

@ -1,10 +1,7 @@
extern crate config_derive;
mod api;
pub use api::{
ChangeSettingRequest, CoreApp, CoreRequest, CoreResponse, CreateGameRequest,
HotseatPlayerRequest, PlayerInfoRequest,
};
pub use api::{Core, CoreNotification, Observable};
mod board;
pub use board::*;
@ -12,6 +9,5 @@ pub use board::*;
mod database;
mod types;
pub use types::{BoardError, Color, Rank, Size};
pub use types::{BoardError, Color, Config, ConfigOption, LibraryPath, Player, Rank, Size};
pub mod ui;

View File

@ -1,6 +1,6 @@
use crate::{
api::PlayStoneRequest,
board::{Board, Coordinate},
board::{Coordinate, Goban},
database::Database,
};
use config::define_config;
@ -8,23 +8,28 @@ use config_derive::ConfigOption;
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, time::Duration};
use thiserror::Error;
use typeshare::typeshare;
define_config! {
DatabasePath(DatabasePath),
LibraryPath(LibraryPath),
Me(Me),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(pub PathBuf);
pub struct LibraryPath(pub PathBuf);
impl std::ops::Deref for DatabasePath {
impl std::ops::Deref for LibraryPath {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<String> for LibraryPath {
fn from(s: String) -> Self {
Self(PathBuf::from(s))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct Me(Player);
@ -46,14 +51,12 @@ pub enum BoardError {
}
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize)]
#[typeshare]
pub enum Color {
Black,
White,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub struct Size {
pub width: u8,
pub height: u8,
@ -75,7 +78,7 @@ pub struct AppState {
}
impl AppState {
pub fn new(database_path: DatabasePath) -> Self {
pub fn new(database_path: LibraryPath) -> Self {
Self {
game: Some(GameState::default()),
database: Database::open_path(database_path.to_path_buf()).unwrap(),
@ -93,7 +96,6 @@ impl AppState {
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
pub enum Rank {
Kyu(u8),
Dan(u8),
@ -126,8 +128,8 @@ pub struct Player {
#[derive(Debug)]
pub struct GameState {
pub board: Board,
pub past_positions: Vec<Board>,
pub board: Goban,
pub past_positions: Vec<Goban>,
pub conversation: Vec<String>,
pub current_player: Color,
@ -142,7 +144,7 @@ pub struct GameState {
impl Default for GameState {
fn default() -> Self {
Self {
board: Board::new(),
board: Goban::new(),
past_positions: vec![],
conversation: vec![],
current_player: Color::Black,
@ -194,7 +196,7 @@ mod test {
#[test]
fn current_player_remains_the_same_after_self_capture() {
let mut state = GameState::default();
state.board = Board::from_coordinates(
state.board = Goban::from_coordinates(
vec![
(Coordinate { column: 17, row: 0 }, Color::White),
(Coordinate { column: 17, row: 1 }, Color::White),
@ -215,7 +217,7 @@ mod test {
#[test]
fn ko_rules_are_enforced() {
let mut state = GameState::default();
state.board = Board::from_coordinates(
state.board = Goban::from_coordinates(
vec![
(Coordinate { column: 7, row: 9 }, Color::White),
(Coordinate { column: 8, row: 8 }, Color::White),

View File

@ -1,24 +0,0 @@
use crate::{
types::{Config, DatabasePath},
ui::Field,
};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct ConfigurationView {
pub library: Field<()>,
}
pub fn configuration(config: &Config) -> ConfigurationView {
let path: Option<DatabasePath> = config.get();
ConfigurationView {
library: Field {
id: "library-path-field".to_owned(),
label: "Library".to_owned(),
value: path.map(|path| path.to_string_lossy().into_owned()),
action: (),
},
}
}

View File

@ -1,9 +1,7 @@
use serde::{Deserialize, Serialize};
use sgf::go::{Game, GameResult, Win};
use typeshare::typeshare;
use sgf::{Game, GameResult, Win};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[typeshare]
pub struct GamePreviewElement {
pub date: String,
pub name: String,
@ -23,13 +21,13 @@ impl GamePreviewElement {
None => "unknown".to_owned(),
};
let black_player = match game.info.black_rank {
Some(rank) => format!("{} ({})", black_player, rank.to_string()),
let black_player = match &game.info.black_rank {
Some(rank) => format!("{} ({})", black_player, rank),
None => black_player,
};
let white_player = match game.info.white_rank {
Some(rank) => format!("{} ({})", white_player, rank.to_string()),
let white_player = match &game.info.white_rank {
Some(rank) => format!("{} ({})", white_player, rank),
None => white_player,
};
@ -43,10 +41,11 @@ impl GamePreviewElement {
Win::Time => "Timeout".to_owned(),
Win::Forfeit => "Forfeit".to_owned(),
Win::Score(score) => format!("{:.1}", score),
Win::Unknown => "Unknown".to_owned(),
};
let result = match game.info.result {
Some(GameResult::Annulled) => "Annulled".to_owned(),
Some(GameResult::Void) => "Annulled".to_owned(),
Some(GameResult::Draw) => "Draw".to_owned(),
Some(GameResult::Black(ref win)) => format!("Black by {}", format_win(win)),
Some(GameResult::White(ref win)) => format!("White by {}", format_win(win)),

View File

@ -1,8 +1,6 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Menu<T: PartialEq + Eq> {
current: Option<T>,
options: Vec<T>,

View File

@ -1,11 +1,9 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
pub mod game_preview;
pub mod menu;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Action<A> {
pub id: String,
pub label: String,
@ -13,7 +11,6 @@ pub struct Action<A> {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Toggle<A> {
pub id: String,
pub label: String,
@ -22,7 +19,6 @@ pub struct Toggle<A> {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Field<A> {
pub id: String,
pub label: String,

View File

@ -1,7 +1,6 @@
use crate::ui::{Action, GamePreviewElement};
use serde::{Deserialize, Serialize};
use sgf::go::Game;
use typeshare::typeshare;
use sgf::Game;
fn rank_strings() -> Vec<String> {
vec![
@ -18,7 +17,6 @@ fn rank_strings() -> Vec<String> {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub enum PlayerElement {
Hotseat(HotseatPlayerElement),
// Remote(RemotePlayerElement),
@ -32,7 +30,6 @@ impl Default for PlayerElement {
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[typeshare]
pub struct HotseatPlayerElement {
pub placeholder: Option<String>,
pub default_rank: Option<String>,
@ -40,15 +37,12 @@ pub struct HotseatPlayerElement {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct RemotePlayerElement {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct BotPlayerElement {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct HomeView {
pub black_player: PlayerElement,
pub white_player: PlayerElement,

View File

@ -3,10 +3,8 @@ use crate::{
ui::types,
};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct PlayingFieldView {
pub board: types::BoardElement,
pub player_card_black: types::PlayerCardElement,

View File

@ -1,20 +1,17 @@
use crate::types::{Color, Size};
use crate::{
api::{CoreRequest, PlayStoneRequest},
Board, Coordinate,
Coordinate, Goban,
};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[typeshare]
pub struct Jitter {
pub x: i8,
pub y: i8,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[typeshare]
pub struct StoneElement {
pub color: Color,
pub jitter: Jitter,
@ -32,8 +29,6 @@ impl StoneElement {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum IntersectionElement {
Unplayable,
Empty(CoreRequest),
@ -41,7 +36,6 @@ pub enum IntersectionElement {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct BoardElement {
pub size: Size,
pub spaces: Vec<IntersectionElement>,
@ -80,8 +74,8 @@ impl BoardElement {
}
}
impl From<&Board> for BoardElement {
fn from(board: &Board) -> Self {
impl From<&Goban> for BoardElement {
fn from(board: &Goban) -> Self {
let spaces: Vec<IntersectionElement> =
(0..board.size.height)
.map(|row| {
@ -114,7 +108,6 @@ impl Default for BoardElement {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct PlayerCardElement {
pub color: Color,
pub name: String,
@ -123,11 +116,9 @@ pub struct PlayerCardElement {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct ChatElement {
pub messages: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct TextFieldElement {}

View File

@ -1,47 +0,0 @@
<!doctype html>
<html>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="styles.css" />
<body>
<div class="menu">
<ul>
<li> <a href="index.html">Index</a> </li>
<li> <a href="playing.html">Game Board for playing</a> </li>
<li> <a href="database.html">Game database</a> </li>
<li> <a href="analysis.html">Game board for analysis</a> </li>
<li> Connection management </li>
<li> Challenge list </li>
<li> Friends list </li>
<li> Open challenges </li>
</ul>
</div>
<div class="widget">
<div> US Go Congress 2023, DDK Tournament, Round 1, Board 25 </div>
<div class="game-analysis">
<div class="game-analysis__board">
<img src="game-screen.jpg" />
</div>
<div class="game-analysis__tree">
<div>
<img src="game-tree.jpg" />
</div>
<p> Shoring up the wall. Any chance to capture that group was lost a couple of moves back and Savanni knows it. </p>
</div>
</div>
<div class="game-info">
<div class="player-info">
<div> Savanni (10k) </div>
</div>
<div class="player-info">
<div> Opal (10k) </div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,118 +0,0 @@
<!doctype html>
<html>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="styles.css" />
<body>
<div class="menu">
<ul>
<li> <a href="index.html">Index</a> </li>
<li> <a href="playing.html">Game Board for playing</a> </li>
<li> <a href="database.html">Game database</a> </li>
<li> Game board for analysis </li>
<li> Connection management </li>
<li> Challenge list </li>
<li> Friends list </li>
<li> Open challenges </li>
</ul>
</div>
<div class="widget">
<div class="game-filter">
<input class="game-filter__term" placeholder="date" />
<input class="game-filter__term" placeholder="player name" />
<input class="game-filter__term" placeholder="minimum strength" />
<input class="game-filter__term" placeholder="maximum strength" />
</div>
<div class="game-database">
<div class="game-entry">
<a href="analysis.html"><img class="game-entry__icon" src="game-thumbnail.jpg" /></a>
<div class="game-entry__info-card">
<div class="game-entry__info-row">
2016-06-15
</div>
<div class="game-entry__info-row">
(B) Alpha Go (w)
</div>
<div class="game-entry__info-row">
(W) Lee Sedol
</div>
</div>
</div>
<div class="game-entry">
<img class="game-entry__icon" src="game-thumbnail.jpg" />
<div class="game-entry__info-card">
<div class="game-entry__info-row">
2016-06-15
</div>
<div class="game-entry__info-row">
(B) Alpha Go (w)
</div>
<div class="game-entry__info-row">
(W) Lee Sedol
</div>
</div>
</div>
<div class="game-entry">
<img class="game-entry__icon" src="game-thumbnail.jpg" />
<div class="game-entry__info-card">
<div class="game-entry__info-row">
2016-06-15
</div>
<div class="game-entry__info-row">
(B) Alpha Go (w)
</div>
<div class="game-entry__info-row">
(W) Lee Sedol
</div>
</div>
</div>
<div class="game-entry">
<img class="game-entry__icon" src="game-thumbnail.jpg" />
<div class="game-entry__info-card">
<div class="game-entry__info-row">
2016-06-15
</div>
<div class="game-entry__info-row">
(B) Alpha Go (w)
</div>
<div class="game-entry__info-row">
(W) Lee Sedol
</div>
</div>
</div>
<div class="game-entry">
<img class="game-entry__icon" src="game-thumbnail.jpg" />
<div class="game-entry__info-card">
<div class="game-entry__info-row">
2016-06-15
</div>
<div class="game-entry__info-row">
(B) Alpha Go (w)
</div>
<div class="game-entry__info-row">
(W) Lee Sedol
</div>
</div>
</div>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More