Compare commits

..

263 Commits

Author SHA1 Message Date
bdd62cbcfd Add ability for warp and weft to have guide markers
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-01-02 18:21:04 +00:00
4f4a71bf72 fix issue in object comment visibility
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-30 20:55:12 +00:00
239adc4816 Feature flag for groups 2024-12-30 20:38:09 +00:00
15bfb43965 feature flag user discoverability 2024-12-30 20:17:37 +00:00
384cf75400 Feature flag control group discoverability 2024-12-30 19:49:27 +00:00
89ffa94553 put account following behind a feature flag 2024-12-30 18:07:50 +00:00
e03ceed668 Move online safety notice to Wiki 2024-12-30 11:21:57 +00:00
31d6f41276 First draft started online safety notice 2024-12-29 23:50:06 +00:00
fe24bcef1e Allow group comment replies to be moderated: 2024-12-29 22:40:06 +00:00
29d0af6e5b Allow group forum replies to be moderated 2024-12-29 22:23:19 +00:00
045a0af4a2 show group entry attachments in root moderation UI 2024-12-29 21:18:09 +00:00
8446c209b3 Add ability to moderate group entries 2024-12-29 20:17:04 +00:00
c6fdc1d537 Allow group content to be moderated 2024-12-29 19:45:07 +00:00
397ec5072b Add dedicated report component 2024-12-29 19:40:16 +00:00
82f0a1eb6d Add support for moderation emails for user updates 2024-12-29 19:11:13 +00:00
e174abce33 Report buttons on user profiles and group content 2024-12-28 23:47:09 +00:00
d72038212f Add a report page 2024-12-28 23:25:48 +00:00
957cbebdd2 allow comment notifications to be delayed until after moderation 2024-12-28 21:53:37 +00:00
fdb363abe4 Allow object comments to be moderated 2024-12-28 21:33:11 +00:00
859d78cf5d Send alert email when moderation is needed 2024-12-28 19:19:39 +00:00
f0a0a55bce Allow for publish of objects after moderation 2024-12-28 19:10:48 +00:00
0019f4e019 Merge branch 'project-moderation' of git.wilw.dev:wilw/treadl into project-moderation 2024-12-28 16:00:03 +00:00
af07226227 Add basic moderation checks for project objects 2024-12-28 15:27:04 +00:00
46965c0040 Fix issue in loading colours as floats 2024-12-28 10:52:14 +00:00
97584a8d91 Allow "empty" threads in treadling also
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-20 10:44:07 +00:00
bed153b5f8 Merge branch 'main' of git.wilw.dev:wilw/treadl
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-20 10:38:39 +00:00
a1d05684ed rename references of shaft in Treadling WIF parsing to use treadling 2024-12-19 22:36:05 +00:00
5f903d61b1 Fix issue in loading "empty" threads from WIF 2024-12-19 20:18:59 +00:00
0d942dc864 Allow for WIF files to be imported without a threading or treadling section [skip ci] 2024-12-12 16:57:55 +00:00
870a53e956 Improve robustness of the WIF parsing engine
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-11 16:45:22 +00:00
ff4f48ba00 Improve WIF parser robustness
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-09 20:07:43 +00:00
e9fb964b51 Add status badge to footer
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-12-07 22:52:27 +00:00
1bb38a8e09 tidied up Taskfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-28 09:28:57 +00:00
b8f7622b9f Update README
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-27 17:47:55 +00:00
dc9b388465 Add all-in-one Docker build
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-11-27 17:13:24 +00:00
e866895a84 Update build script
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-27 11:54:02 +00:00
72d164f394 Resolve issue in inline WIF comments 2024-11-27 11:52:03 +00:00
210a984a07 Add support for group publication
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
2024-10-27 21:17:18 +00:00
3927cc6d67 Resolve lint issues
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-27 20:00:33 +00:00
92513715bd Notify subscribers to group topics on new posts 2024-10-27 19:51:19 +00:00
b5b86d599a improved forum topic thread UI 2024-10-27 19:38:59 +00:00
8a69a1b21d Attachment support 2024-10-27 19:26:29 +00:00
0f47a25529 Move to state for forum topics 2024-10-27 11:10:43 +00:00
716ca31a60 Move to state for forum replies 2024-10-27 11:07:11 +00:00
e3fd2c8f27 Forum UX improvements 2024-10-27 11:00:14 +00:00
d56c201ec7 Allow forum replies to be deleted 2024-10-27 10:32:35 +00:00
65e059655f Group forum topic improved UX 2024-10-26 20:58:28 +01:00
dd7af64508 Basic topic viewing and reply creation 2024-10-26 20:40:35 +01:00
06bd0fb8ac Allow topics to be created and deleted 2024-10-25 21:37:07 +01:00
8c1145e54f Some UI components 2024-10-24 22:09:37 +01:00
5692258cc1 Add endpoints and server-side logic for forums 2024-10-24 21:40:30 +01:00
7dac76558d Move preview generation into a thread
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-24 15:18:10 +01:00
229eec89ea Update Docker base image
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2024-10-24 11:38:08 +01:00
81bed97d42 Resolve lint issues
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-23 20:56:30 +01:00
402a25d980 Prevent renaming to username with invalid characters 2024-10-23 20:44:02 +01:00
f021914089 Allow admins to promote other group admins 2024-10-23 20:38:06 +01:00
a8a000ae55 web/backend support for group member permissions 2024-10-23 20:12:56 +01:00
980a5bb14b Removed un-needed files 2024-10-22 20:17:26 +01:00
8afd7c5694 Add support for group imagery 2024-10-22 20:16:44 +01:00
17806d410b Basic UI for updating group permissions 2024-10-22 20:04:39 +01:00
9cd1ae4628 Improved error handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-10 17:51:29 +01:00
d037aa6a9f Resolve lint issues
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-10 17:27:56 +01:00
a7f87e0b76 Fixed register button bug on homepage
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-10 17:26:17 +01:00
6ddf2d4ae7 Small fixes for inviting
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-06 20:40:12 +01:00
059fc0d966 Update woodpecker file
Some checks are pending
ci/woodpecker/manual/woodpecker Pipeline is pending
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-06 19:41:59 +01:00
f8f06f8b68 Final tweaks 2024-10-06 19:40:10 +01:00
e74a7461fa Add all webargs: 2024-10-06 19:33:10 +01:00
22ebf35382 arg checks for projects and objects 2024-10-06 17:36:34 +01:00
2398ef5cf9 Use webarg guarding on more endpoints 2024-10-06 13:42:22 +01:00
c0a5f32060 Added webarg checks for account endpoints 2024-10-06 11:28:09 +01:00
849ff0a1e9 Add API Sentry 2024-10-06 10:35:28 +01:00
f3b3ce3d57 Add API linter 2024-10-06 10:30:49 +01:00
032e737ab9 Add web Sentry 2024-10-06 10:17:54 +01:00
1428c83050 Linted web code 2024-10-05 21:25:50 +01:00
6ad9105c82 Add lint tool 2024-10-05 19:40:50 +01:00
c060a6fc41 Additional package changes 2024-10-05 19:37:33 +01:00
48db95ff6e Removing some web deps 2024-10-05 19:33:45 +01:00
ddb723ab88 Update web deps 2024-10-05 19:26:21 +01:00
933e601572 Update all API deps 2024-10-05 19:18:15 +01:00
fddfa5df0b Remove botocore dependency
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-08-23 11:12:17 +01:00
39b65dd806 Updated gitignore
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-08-23 11:02:57 +01:00
047d0b25d8 Updated build architecture to match deploy server 2024-08-23 11:01:36 +01:00
d08e7826b9 Updated register view
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-08-23 10:55:20 +01:00
41493c3534 Updated dependencies for API
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-08-23 10:39:46 +01:00
845178997d add a 'how find us' registration component
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
2024-08-23 09:04:10 +01:00
0d6febbde6 bump target Android build 2024-07-12 21:28:30 +01:00
ccc6fbe13a improved homepage info section
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-05-02 22:35:15 +01:00
40f7e25d8f tidied up undo code
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-04-05 17:51:59 +02:00
e85ad4f4bc fix tieup undo 2024-04-04 18:59:18 +02:00
934086251b implement redo 2024-04-01 16:11:35 +01:00
ba9713e3eb add basic controls and logic for undo 2024-04-01 13:55:38 +01:00
c25a2c5fe2 remove nova editor config 2024-04-01 10:56:17 +01:00
d22dbd7629 improve UI in changing shaft and treadle count
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-04-01 10:49:46 +01:00
2e5d25c6f2 fixed issue in PNG-creation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-17 17:10:06 +00:00
116224d784 add snippet docs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-06 14:14:59 +00:00
9920a0a596 full snippet support
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-06 13:36:05 +00:00
ec32489de5 chooser basics for snippets 2024-03-04 21:17:30 +00:00
78a197d5e4 allow saving snippets 2024-03-04 20:29:27 +00:00
fad3bc835b more UI for snippets 2024-03-03 21:39:30 +01:00
9d3ed248b3 base code for snippet handling 2024-03-03 21:29:18 +01:00
14ca22f3e9 small UI tweaks and consistency updates for mobile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-16 22:23:06 +00:00
2fe05b2118 final tweaks for draft release
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-15 18:30:01 +00:00
f8808bde3d small UI updates
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 23:10:48 +00:00
2a55cfcc2e remove unneeded files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 23:05:26 +00:00
007e4822a6 use static methods for Util
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 23:03:43 +00:00
3df577e666 support for load more on explore
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 22:37:03 +00:00
a85f0de85a improved mobile object loading indicator
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 22:20:44 +00:00
25ed716849 added base support for desktop
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 22:00:08 +00:00
350ab15306 Android deep linking
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 21:47:07 +00:00
a22c2d7d16 use prod app values
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 19:56:09 +00:00
0b041b04ad add Apple deep link config 2024-01-14 19:51:15 +00:00
b1d9a41f9d ensure only valid user can edit object 2024-01-14 19:40:11 +00:00
33241747cd improved user profile screen 2024-01-14 18:43:57 +00:00
12f985b7aa more useful links to users 2024-01-14 16:11:50 +00:00
9eff558ebf migrated user routes to go router 2024-01-14 16:07:08 +00:00
bad485ac1d go based routing for groups 2024-01-14 15:50:38 +00:00
bfd828f520 more robust loading of projects and groups 2024-01-14 13:40:49 +00:00
7647542421 use go router for settings page 2024-01-14 10:52:00 +00:00
2b37756567 move project screen to use go router params 2024-01-14 10:37:02 +00:00
8abdf00ef8 improved logout support 2024-01-13 17:51:12 +00:00
062d5f94e4 support for late login 2024-01-13 17:41:01 +00:00
9d2574bcd6 implementation of basic model 2024-01-13 17:28:58 +00:00
d4ccd62a34 add project discovery to explore page on mobile 2024-01-13 15:11:01 +00:00
522c13cd75 explore view improvements 2024-01-11 21:47:34 +00:00
1179d8859f Add mobile explore tab 2024-01-11 20:04:25 +00:00
6e15952ffc fix bug with dropdown nav being overlapped
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-10 19:05:00 +00:00
20b94e553d re-use preview URLs to save on disk space 2024-01-10 18:57:47 +00:00
572d39e947 mobile improvements 2024-01-07 23:10:20 +00:00
dcf44f6b1d improved control over when pattern previews are generated 2024-01-07 22:48:32 +00:00
4b410ec31e improvements for user avatar handling 2024-01-07 22:29:09 +00:00
f63866a04c mobile improvements 2024-01-07 21:52:02 +00:00
104879ee27 mobile improvements 2024-01-07 18:12:35 +00:00
f94769e228 mobile improvements 2024-01-07 17:51:22 +00:00
88c0b44444 add support for interlacement view in previews generate 2024-01-07 17:42:14 +00:00
3403134072 generate images on creation/update 2024-01-07 14:17:10 +00:00
e6178c8a72 code to upload images to s3 2024-01-07 12:21:39 +00:00
58bf8ca74e support for drawdown only 2024-01-07 11:59:32 +00:00
6cfcf0c5a1 complete drawdown image creation 2024-01-07 11:51:11 +00:00
65b379f162 serverside colourways 2024-01-07 11:29:58 +00:00
4bf03c7c67 serverside weft drawing 2024-01-07 10:56:59 +00:00
aeb60dd840 serverside warp drawing 2024-01-07 10:47:30 +00:00
3b2c1e7f4c allow for import of multiple types of files 2024-01-06 16:44:55 +00:00
a2cde7de81 add basic file chooser 2024-01-06 13:01:12 +00:00
b14f438597 add ability to share projects 2024-01-06 12:39:33 +00:00
ac97481e6e add ability to share objects 2024-01-06 12:22:25 +00:00
1129a9df48 improvements to pattern viewer 2024-01-06 11:03:29 +00:00
afc32578cf introduce interlacement view 2024-01-05 18:49:39 +00:00
f44d56182b default positioning for canvas on-load 2024-01-05 18:33:15 +00:00
14f930af13 use a InteractiveViewer for panning a pattern 2024-01-05 17:57:10 +00:00
9ed84493cc fixed drawdown colour bug 2024-01-04 22:27:30 +00:00
ee11984a00 implemented drawdown 2024-01-04 22:09:56 +00:00
dcb9453ccd drawdown grid 2024-01-04 20:57:38 +00:00
45725f52c1 weft colourway 2024-01-03 23:49:34 +00:00
5e108cf8c8 warp colourway 2024-01-03 23:10:23 +00:00
1406f9c4c8 warp weft tieup rendering 2024-01-03 22:35:09 +00:00
3e79d950b1 grid drawing working 2024-01-03 21:55:44 +00:00
ea792bd75d some initial canvas work 2024-01-03 20:36:28 +00:00
46ed954e53 Remove option for download from pattern editor (for now)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-30 16:07:39 +00:00
2f92b3b883 Small UX enhancements
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-30 14:23:43 +00:00
e51f3af984 Remove use of Drift from code 2023-12-30 14:13:49 +00:00
1e85112f6d Improved mobile navigation 2023-12-30 14:12:04 +00:00
c3598dfa5c Improved project breadcrumbs 2023-12-30 14:09:21 +00:00
3aa70c48e7 Disable draft tour 2023-12-30 12:21:03 +00:00
c368e675b4 Improved UI 2023-12-30 12:10:23 +00:00
68351f84e3 Allow for downloading patterns from editor 2023-12-30 12:05:07 +00:00
e46d1c63a7 Update method for generating previews 2023-12-30 11:48:49 +00:00
29aba03ba8 Add support for viewing back of cloth 2023-12-30 10:44:27 +00:00
e387c0e05a Remove canvas repositioning based on viewing back 2023-12-30 10:39:53 +00:00
164ca73913 Back view for warp/weft/tieups 2023-12-30 10:16:16 +00:00
d70858ffb7 Colour layout improvements 2023-12-30 09:41:26 +00:00
073335a322 Controls for choosing front or back view 2023-12-30 00:04:34 +00:00
450efe8b36 Add support for eraser tool 2023-12-29 23:44:03 +00:00
ba1ba5ed94 Improved tools UI 2023-12-29 23:30:28 +00:00
80cf4d3b4c Modal based nav prompt for unsaved drafts 2023-12-29 23:02:04 +00:00
79299ab978 Improved user-less routing 2023-12-29 22:51:19 +00:00
46e2f76778 Update to use react router data APIs 2023-12-29 22:15:55 +00:00
a00de971ae More improvements 2023-12-27 15:15:08 +00:00
8eb4eb4bd9 Sticky toolbox 2023-12-27 15:03:56 +00:00
78c3908bf9 Tools improvements 2023-12-27 14:53:45 +00:00
c394de8286 New tools view init 2023-12-27 14:30:56 +00:00
3b691fed81 Search improvements
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-23 11:11:28 +00:00
00f4e7a8b9 Fix drawdown image export
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-23 10:25:01 +00:00
dd6d5ab18a Add ability to re-generate previews
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-23 09:25:05 +00:00
aed7813d62 Add account documentation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-12-07 09:34:40 +00:00
b0fb8af468 Improved handling of draft width
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-10-31 11:00:09 +00:00
17d0ae31a8 Add ability to auto-extend warp and weft
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-10-28 16:17:53 -04:00
822f2cef84 Fixed bug in requesting a group join
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-02 20:49:31 +01:00
ca300ced7a Fixed bug in joining an open group
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-02 20:36:32 +01:00
4c61879883 Updated login/registration views slightly, and bumped mobile versions for review.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-16 19:50:40 +01:00
aa6afec3b7 Improvements to onboarding screen text and reliability
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-12 23:47:51 +01:00
e780dff8cc Add app download suggestion card
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-12 22:56:03 +01:00
a0b655d69a Updated mobile app config to enable Android builds
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-11 10:11:19 +01:00
b3a44e17bc Streamlined useEffect in DraftPreview
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-10 21:04:19 +01:00
ee4b3ef439 Unset preview if previewUrl can be generated
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-10 20:52:47 +01:00
cda8874547 Add a migration script from old URL string format to new image files 2023-06-10 20:18:31 +01:00
4f53120d11 Add ability to rename objects 2023-06-10 18:35:28 +01:00
fed94fa543 Support for re-naming projects in mobile app 2023-06-10 17:48:30 +01:00
b93e048509 Correctly update project list when changing visibility 2023-06-10 17:11:48 +01:00
9f09905f31 Support for the new style of pattern previews on mobile 2023-06-10 12:47:01 +01:00
20d9d3391c Improved local function of mobile app 2023-06-09 18:40:43 +01:00
961ca473c7 Now building on latest Flutter 2023-06-09 18:33:57 +01:00
64297a2d28 Further updates to mobile app to bring in-line with latest flutter 2023-06-08 22:37:20 +01:00
84c965b175 Some fixes for mobile new Flutter version 2023-06-07 13:04:19 +01:00
5e528d2a21 Use previewURL instead of data URL across webapp 2023-06-06 22:53:07 +01:00
7be01d0955 Improved process for uploading to S3 2023-06-06 22:19:13 +01:00
2812bb3a2d Basic architecture for uploading preview files 2023-06-06 20:51:05 +01:00
835777e562 Better handling of avatar URLs in feed endpoint
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-04 22:44:22 +01:00
a3a04fa8e9 Catch issue with no onChange on FollowButton component
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-04 22:33:48 +01:00
d77dbc12ab Show supporter status in discover cards 2023-06-04 22:29:33 +01:00
cf9601de90 Allow for following from discover view 2023-06-04 22:21:40 +01:00
f36694c54e Display supporter badges in explore mode 2023-06-04 21:53:22 +01:00
8473d2c480 Improved re-render when current user changes in user profiles 2023-06-04 16:55:27 +01:00
00946f92d9 Improved handling of non-logged-in users for following 2023-06-04 16:54:11 +01:00
41f8dee19e Improved security on objects and comments 2023-06-04 16:43:28 +01:00
4a6c96edb5 Improvements to the feed producer and surrounding UI 2023-06-04 16:24:02 +01:00
46be02067f Improved supporter dropdown 2023-06-04 15:38:49 +01:00
8f498cfe1c Move ‘support Treadl’ section to navbar 2023-06-04 15:20:40 +01:00
369fd67101 Improved rendering of Feed on homepage 2023-06-03 13:35:36 +01:00
a2128c7b35 Improved contextual info for the feed 2023-06-03 13:14:31 +01:00
0b697d22dc Render a basic feed 2023-06-02 20:16:11 +01:00
9fd8ea9755 Add a draft feed generation function 2023-06-02 19:50:44 +01:00
4c899c2309 Clean up followings on account deletion 2023-06-02 19:02:28 +01:00
77fc0a502b Switch to using user followings rather than followers 2023-06-02 18:57:00 +01:00
781d3e23dd Basic structure for following users 2023-06-02 18:38:41 +01:00
92860ea082 Improved highlight/select view for threads
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-01 18:51:01 +00:00
9f75631d58 Small fixes and tweaks prior to release
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-31 17:22:44 +00:00
bdb4db100e Add feature to de-select threads 2023-05-31 15:46:58 +00:00
e68ebface7 Add documentation for removing/inserting threads 2023-05-31 15:32:20 +00:00
27e7a11eba Add a confirm step in to the thread deletion flow 2023-05-30 22:43:06 +00:00
ee5cd5dea5 Improved delete-thread button 2023-05-30 22:34:12 +00:00
55f21bd18e Allow threads to be added to the weft 2023-05-30 22:16:10 +00:00
be5cb2cb12 Support for deleting threads on the weft 2023-05-30 21:53:56 +00:00
d3b9142e4f Add ability to insert warp threads 2023-05-30 21:11:32 +00:00
21ee690409 Move thread deletion logic to Tools component 2023-05-30 19:07:25 +00:00
0a56844784 Support for deleting threads 2023-05-28 16:57:11 +00:00
ee6584a396 Allow for warp thread selection 2023-05-28 16:35:55 +00:00
ac6020cb8e Ensure login required before visiting new group page
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-22 20:28:21 +00:00
958edd7556 Small fix tweaks
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-22 20:17:03 +00:00
2d680d7142 Navbar compatible search box 2023-05-22 19:11:42 +00:00
ecbfb2d9ca Component-ised search bar 2023-05-22 17:55:03 +00:00
ec38525a43 Improved loading throughout 2023-05-22 17:26:29 +00:00
d90b40d6f2 Add a PatternLoader component 2023-05-22 16:48:06 +00:00
edd9c1e3ee Use loading indicators on discover card 2023-05-22 16:38:23 +00:00
f701bc46d8 Explore area from marketing homepage 2023-05-21 22:26:05 +00:00
9e9491e064 Add ability to load more explore pages
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-21 21:34:06 +00:00
a6de05a0ca Add discover card to explore and support for explore pagination 2023-05-21 21:09:50 +00:00
d4f56345c6 Add endpoints and front-end bits for the new explore page 2023-05-21 20:22:40 +00:00
447f76e807 Add draft explore page
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-20 17:34:45 +00:00
a583234743 Update navbar and home hero styles
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-20 14:57:29 +00:00
30ebc7d22d Reduce raised components
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-15 17:34:52 +00:00
49ccdbd8ab Removing all floats and replace with flex 2023-05-15 17:14:12 +00:00
57de689815 General UI enhancements 2023-05-15 16:27:22 +00:00
55325dfe8b Improved footer design 2023-05-15 16:12:55 +00:00
214b80b72c Improved handling of longer file names in object lists 2023-05-15 15:23:56 +00:00
084ae20664 Small UI tweaks 2023-05-15 15:11:18 +00:00
4b656d31e1 Fix bug in profile avatar display
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-28 16:10:02 +00:00
2f21d8fe2c Allow for un-setting uploaded avatars 2023-04-28 16:00:55 +00:00
196587616a Navbar avatar UI enhancements 2023-04-27 16:41:51 +00:00
22c80781d4 Added and basic usage of boring-avatars package 2023-04-27 16:32:28 +00:00
57032a60f0 Add support for WIF exports from WeaveIt
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-26 13:51:15 +00:00
d7a737e814 Fix issue in building API
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-22 14:46:39 +00:00
be97df6331 update gitignore
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-22 15:17:54 +01:00
87e53b42a6 merged CI changes 2023-04-22 15:16:41 +01:00
651 changed files with 25187 additions and 13322 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
api/.venv
web/node_modules
*.pyc

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.swp
.DS_Store

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,5 +0,0 @@
{
"workspace.art_style" : 1,
"workspace.color" : 10,
"workspace.name" : "Treadl"
}

View File

@ -1,38 +1,35 @@
pipeline: steps:
buildweb: buildweb:
group: build group: build
image: node image: node
when: when:
path: "web/*" path: "web/**/*"
environment: environment:
- VITE_API_URL=https://api.treadl.com - VITE_API_URL=https://api.treadl.com
- VITE_IMAGINARY_URL=https://images.treadl.com - VITE_IMAGINARY_URL=https://images.treadl.com
- VITE_SOURCE_REPO_URL=https://git.wilw.dev/wilw/treadl - VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
- VITE_PATREON_URL=https://www.patreon.com/treadl
- VITE_KOFI_URL=https://ko-fi.com/wilw88
- VITE_CONTACT_EMAIL=hello@treadl.com
- VITE_APP_NAME=Treadl
commands: commands:
- cd web - cd web
- yarn install - npm install
- yarn build - npx vite build
buildapi: buildapi:
group: build group: build
image: woodpeckerci/plugin-docker-buildx image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password] secrets: [docker_username, docker_password]
when: when:
path: "api/*" path: "api/**/*"
settings: settings:
repo: wilw/treadl-api repo: wilw/treadl-api
dockerfile: api/Dockerfile dockerfile: api/Dockerfile
context: api context: api
platforms: linux/amd64
deployweb: deployweb:
image: alpine image: alpine
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ] secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
when: when:
path: "web/*" path: "web/**/*"
commands: commands:
- cd web - cd web
- apk update - apk update
@ -41,4 +38,5 @@ pipeline:
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com - s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache' - 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
branches: main when:
branch: main

104
README.md
View File

@ -2,54 +2,43 @@
This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform. This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform.
## Running and developing Treadl locally
To run Treadl locally, we recommend taking the following steps:
1. Check out this repository locally.
1. Follow the instructions in the `api/` directory to launch a MongoDB instance and to run the Treadl API.
1. Follow the instructions in the `web/` directory to install the local dependencies and run the web UI.
## Deploying your own version of Treadl ## Deploying your own version of Treadl
If you'd like to launch your own version of Treadl in a web production environment, follow the steps below. These instructions set-up a basic version of Treadl, and you may want or need to take additional steps for more advanced options. ### Run with Docker (recommended)
We recommend forking this repository. That way you can make adjustments to the code to suit your needs, and pull in upstream updates as we continue to develop them. We publish and maintain a [Docker image](https://hub.docker.com/r/wilw/treadl) for Treadl, which is the easiest way to get started.
### 1. Launch a MongoDB cluster/instance We recommend using Docker Compose and our [template `docker-compose.yml`](https://git.wilw.dev/wilw/treadl/src/branch/main/docker/docker-compose.yml) to configure the app and the MongoDB database. Download this file to your computer and then run `docker compose up` to start Treadl.
Treadl uses MongoDB as its data store, and this should be setup first. You can either use a commercial hosted offering, or host the database yourself. In production, it is very important to change the values in the file's `environment` block to suit your own setup. We also strongly recommend the use of a reverse-proxy to handle TLS connections to the app.
Hosted options:
* [MongoDB Atlas](https://www.mongodb.com) ### Alternative deployment
* [DigitalOcean managed MongoDB](https://www.digitalocean.com/products/managed-databases-mongodb)
Self-hosted guides: In scenarios where you want more control over the deployment, or you are more concerned with scalability, you may wish to use a more manual approach.
* [Creating a MongoDB Replica Set](https://www.linode.com/docs/guides/create-a-mongodb-replica-set) In this case you'll need to:
* [MongoDB official Docker Image](https://hub.docker.com/_/mongo) - Launch (or re-use) a MongoDB cluster/instance
- Provision a server or service for running the Flask app (in the `api/` directory), ensuring all dependencies are installed and that it runs with the needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/api/envfile.template)
- Build the web front-end (with `npx vite build` using your needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/web/.env), having installed dependencies with `npm install`) and host the resulting `dist/` directory on a server or object store.
Either way, once launched, make a note of the cluster/instance's:
* URI: The database's URI, probably in a format like `mongodb+srv://USERNAME:PASSWORD@host.com/AUTHDATABASE?retryWrites=true&w=majority` ### S3-compatible object storage
* Database: The name of the database, within your cluster/instance, where you want Treadl to store the data.
### 2. Provision an S3-compatible bucket Treadl uses S3-compatible object storage for storing user uploads. If you want to allow file uploads (apart from WIF files, which are processed directly), you should create and configure a bucket for Treadl to use.
Treadl uses S3-compatible object storage for storing assets (e.g. uploaded files). You should create and configure a bucket for Treadl to use.
Hosted options: Hosted options:
* [Amazon S3](https://aws.amazon.com/s3) * [Amazon S3](https://aws.amazon.com/s3)
* [Linode Object Storage](https://www.linode.com/products/object-storage) - Recommended option. * [Linode Object Storage](https://www.linode.com/products/object-storage)
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces) * [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
Self-hosted options: Self-hosted options:
* [MinIO](https://min.io/download) * [MinIO](https://min.io/download)
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for later: Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for inclusion in your environment file/variables:
* Bucket name: The name of the S3-compatible bucket you created * Bucket name: The name of the S3-compatible bucket you created
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using. * Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
@ -58,64 +47,49 @@ Once you have a bucket, generate some access keys for the bucket that will enabl
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._ _Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
### 3. Provision the API
The best way to run the web API is to do so via Docker. A `Dockerfile` is provided in the `api/` directory. ## Running Treadl locally in development mode
Simply build the image and transfer it to your server (or just build it directly on the server, if easier). To run Treadl locally, first ensure you have the needed software installed:
Make a copy of the `envfile.template` file included in the `api/` directory into a new file named `envfile` and make changes to this file to suit your needs. For example, you will likely need to: - Python ^3.12
- Node.js (we recommend v22.x)
- Docker (we use this for the Mongo database)
- It can be installed via the Docker website or your package manager
- Ensure the Docker service is running
- [Taskfile](https://taskfile.dev) (convenience tool for running tasks)
- This can be installed using `brew install go-task`
* Add in the Mongo URI and database into the relevant parts To begin, clone this repository to your computer:
* Add the S3 detais into the relevant parts
* Add Mailgun connection details (for sending outbound mail)
* Change the app's URL and email addresses
Once ready, you can launch the API by passing in this envfile (assuming you built the image with a name of `treadl-api`): ```bash
git clone https://git.wilw.dev/wilw/treadl.git
```shell
$ docker run --env-file envfile -d treadl-api
``` ```
_Note: a reverse proxy (such as Nginx or Traefik) should be running on your server to proxy traffic through to port 8000 on your running Treadl API container._ Next, initialise the project by installing dependencies and creating an environment file for the API:
### 4. Host the front-end ```bash
task init
The front-end is formed from static files that can be simply served from a webserver, from a CDN-fronted object store, or anything else.
Before building or hosting the front-end, please copy the `.env.development` file into a new file called `.env.production` and make changes to it as required. For example, you will need to:
* Include the URL of the web API you deployed earlier in the relevant field.
* Include a contact email address.
**Vercel**
We use [Vercel](https://vercel.com) to host the web UI. Once you have an account to which you are logged-in to locally, the front-end can be deployed by simply running:
```shell
$ vercel --prod
``` ```
_Note: You will need to configure Vercel to use your own domain, and set-up a project, etc. first._ This generates a 'envfile' in your 'api' directory. You can edit this as needed (though the defaults should allow you to at least launch the app). Note: if you run this command again then any changes you made to your `envfile` will be overwritten.
**Manual** Finally, you can start the API and web UI by running:
Simply build the app and then deploy the resulting `build/` directory to a server or storage of your choice: ```bash
task
```shell
$ yarn build
$ s3cmd cp build/ s3://my-treadl-ui # Example
``` ```
### 5. Optional extras Note: this command also starts the MongoDB database on port 27017. If the DB is already running, you'll see errors reported, but the API and web will still be launched.
**Imaginary server** You can now navigate to [http://localhost:8002](http://localhost:8002) to start using the app.
To help improve the performance of the app, you may wish to make use of [Imaginary](https://github.com/h2non/imaginary) to crop/resize large images. The web UI is already equipped to handle Imaginary if a server is configured. If you pull updates from the repository in the future (e.g. with `git pull`) you may need to ensure your dependencies are up-to-date before starting the app again. This can be done with:
To use this feature, simply rebuild the app ensuring that an environment entry is made into `.env.production` that includes `"VITE_IMAGINARY_URL=https://your.imaginaryserver.com"`. ```bash
task install-deps
```
_Note: If this is not set, Treadl will by default fetch the full size images straight from the S3 source._
## Contributions ## Contributions

118
Taskfile.yml Normal file
View File

@ -0,0 +1,118 @@
version: '3'
vars:
VENV: ".venv/bin/activate"
tasks:
default:
desc: Run web bundler and API
deps:
- start-db
- run-api
- run-web
run-web:
desc: Run web frontend
dir: 'web'
cmds:
- echo "[Web] Starting React app..."
- npx vite --port 8002
run-api:
desc: Run API server
dir: 'api'
dotenv: ['envfile']
cmds:
- echo "[FLASK] Starting Flask app..."
- bash -c "source {{.VENV}} && flask run --debug"
start-db:
desc: Start database
ignore_error: true
cmds:
- echo "[DB] Starting database..."
- docker run --rm -d --name mongo -v ~/.mongo:/data/db -p 27017:27017 mongo:6
init:
desc: Initialize project
cmds:
- task: install-deps
- cp api/envfile.template api/envfile
install-deps:
desc: Install all dependencies
deps:
- install-deps-web
- install-deps-api
install-deps-web:
desc: Install web dependencies
dir: 'web'
cmds:
- echo "[Web] Installing dependencies..."
- npm install
install-deps-api:
desc: Install API dependencies
dir: 'api'
cmds:
- echo "[FLASK] Installing dependencies..."
- cmd: python3.12 -m venv .venv
ignore_error: true
- bash -c "source {{.VENV}} && pip install poetry"
- bash -c "source {{.VENV}} && poetry install"
lint:
desc: Lint all
deps:
- lint-web
- lint-api
lint-web:
desc: Lint web frontend
dir: 'web'
cmds:
- echo "[Web] Linting React app..."
- npx standard --fix
lint-api:
desc: Lint API server
dir: 'api'
cmds:
- echo "[FLASK] Linting Flask app..."
- bash -c "source {{.VENV}} && ruff format ."
- bash -c "source {{.VENV}} && ruff check --fix ."
clean:
desc: Remove all dependencies
cmds:
- rm -rf web/node_modules
- rm -rf api/.venv
build-docker:
desc: Build all-in-one Docker image
cmds:
- echo "Building Docker image..."
- docker build -f docker/Dockerfile -t wilw/treadl --platform linux/amd64,linux/arm64 .
deploy:
desc: Deploy all
deps:
- deploy-web
- deploy-api
deploy-web:
desc: Deploy web front-end
dir: 'web'
cmds:
- npm install
- npx vite build
- aws --profile personal s3 sync dist s3://treadl.com
- 'curl -X POST -H "AccessKey: $BUNNY_PERSONAL" https://api.bunny.net/pullzone/782753/purgeCache'
deploy-api:
desc: Deploy API
dir: 'api'
cmds:
- docker build -t wilw/treadl-api --platform linux/amd64 .
- docker push wilw/treadl-api

BIN
api/.DS_Store vendored

Binary file not shown.

2
api/.gitignore vendored
View File

@ -7,3 +7,5 @@ __pycache__/
config-prod.yml config-prod.yml
envfile envfile
firebase.json firebase.json
.DS_Store
migration_projects/

View File

@ -1,19 +1,16 @@
FROM python:3.9-slim-buster FROM amd64/python:3.12-slim
# set work directory # set work directory
WORKDIR /app WORKDIR /app
# Install dependencies # Install dependencies
RUN pip install poetry RUN pip install poetry
COPY poetry.lock . COPY poetry.lock .
COPY pyproject.toml . COPY pyproject.toml .
RUN poetry config virtualenvs.create false --local
RUN poetry export --without-hashes -f requirements.txt | pip install -r /dev/stdin RUN poetry install
# Add remaining files # Add remaining files
COPY app.py . COPY . /app/
COPY api/ .
COPY util/ .
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"] CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]

View File

@ -1,67 +1,3 @@
# Treadl web API # Treadl web API
This directory contains the code for the back-end Treadl API. This directory contains the code for the back-end Treadl API.
## Run locally
To run this web API locally, follow the steps below.
### 1. Run a local MongoDB instance
Install MongoDB for your operating system and then launch a local version in the background. For example:
```shell
$ mongod --fork --dbpath=/path/to/.mongo --logpath /dev/null
```
(Remember to restart the database upon system restart or if the instance stops for another reason.)
### 2. Create and activate a virtual environment
Install and activate the environment using `virtualenv`:
```shell
$ virtualenv -p python3 .venv # You only need to run this the first time
$ source .venv/bin/activate
```
### 3. Install dependencies
We use Poetry to manage dependencies. If you don't have this yet, please refer to [the Poetry documentation](https://python-poetry.org) to install it. Once done, install the dependencies (ensuring you have `source`d your virtualenv first):
```shell
$ poetry install
```
### 4. Create an `envfile`
Copy the template file into a new `envfile`:
```shell
$ cp envfile.template envfile
```
If you need to, make any changes to your new `envfile`. Note that changes are probably not required if you are running this locally. When happy, you can `source` this file too:
```shell
$ source envfile
```
### 5. Run the API
Ensure that both the virtualenv and `envfile` have been loaded into the environment:
```shell
$ source .venv/bin/activate
$ source envfile
```
Now you can run the API:
```shell
$ flask run
```
The API will now be available on port 2001.
Remember that you will need a local instance of [MongoDB](https://www.mongodb.com) running for the API to connect to.

View File

@ -1,37 +1,72 @@
import datetime, jwt, bcrypt, re, os import datetime
import jwt
import bcrypt
import re
import os
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, mail, util from util import database, mail, util
from api import uploads
jwt_secret = os.environ['JWT_SECRET'] jwt_secret = os.environ["JWT_SECRET"]
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 8
def register(username, email, password):
if not username or len(username) < 4 or not email or len(email) < 6:
raise util.errors.BadRequest('Your username or email is too short or invalid.')
username = username.lower()
email = email.lower()
if not re.match("^[a-z0-9_]+$", username):
raise util.errors.BadRequest('Usernames can only contain letters, numbers, and underscores')
if not password or len(password) < MIN_PASSWORD_LENGTH:
raise util.errors.BadRequest('Your password should be at least {0} characters.'.format(MIN_PASSWORD_LENGTH))
db = database.get_db()
existingUser = db.users.find_one({'$or': [{'username': username}, {'email': email}]})
if existingUser:
raise util.errors.BadRequest('An account with this username or email already exists.')
try: def register(username, email, password, how_find_us):
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) if not username or len(username) < 4 or not email or len(email) < 6:
result = db.users.insert_one({ 'username': username, 'email': email, 'password': hashed_password, 'createdAt': datetime.datetime.now(), 'subscriptions': {'email': ['groups.invited', 'groups.joinRequested', 'groups.joined', 'messages.replied', 'projects.commented']}}) raise util.errors.BadRequest("Your username or email is too short or invalid.")
mail.send({ username = username.lower()
'to': os.environ.get('ADMIN_EMAIL'), email = email.lower()
'subject': '{} signup'.format(os.environ.get('APP_NAME')), if not re.match("^[a-z0-9_]+$", username):
'text': 'A new user signed up with username {0} and email {1}'.format(username, email) raise util.errors.BadRequest(
}) "Usernames can only contain letters, numbers, and underscores"
mail.send({ )
'to': email, if not password or len(password) < MIN_PASSWORD_LENGTH:
'subject': 'Welcome to {}!'.format(os.environ.get('APP_NAME')), raise util.errors.BadRequest(
'text': '''Dear {0}, "Your password should be at least {0} characters.".format(
MIN_PASSWORD_LENGTH
)
)
db = database.get_db()
existingUser = db.users.find_one(
{"$or": [{"username": username}, {"email": email}]}
)
if existingUser:
raise util.errors.BadRequest(
"An account with this username or email already exists."
)
try:
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
result = db.users.insert_one(
{
"username": username,
"email": email,
"password": hashed_password,
"createdAt": datetime.datetime.now(),
"subscriptions": {
"email": [
"groups.invited",
"groups.joinRequested",
"groups.joined",
"messages.replied",
"projects.commented",
]
},
}
)
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} signup".format(os.environ.get("APP_NAME")),
"text": "A new user signed up with username {0} and email {1}, discovered from {2}".format(
username, email, how_find_us
),
}
)
mail.send(
{
"to": email,
"subject": "Welcome to {}!".format(os.environ.get("APP_NAME")),
"text": """Dear {0},
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started. Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
@ -61,155 +96,226 @@ We hope you enjoy using {3} and if you have any comments or feedback please tell
Best wishes, Best wishes,
The {3} Team The {3} Team
'''.format( """.format(
username, username,
os.environ.get('APP_URL'), os.environ.get("APP_URL"),
os.environ.get('CONTACT_EMAIL'), os.environ.get("CONTACT_EMAIL"),
os.environ.get('APP_NAME'), os.environ.get("APP_NAME"),
)}) ),
return {'token': generate_access_token(result.inserted_id)} }
except Exception as e: )
print(e) return {"token": generate_access_token(result.inserted_id)}
raise util.errors.BadRequest('Unable to register your account. Please try again later') except Exception as e:
print(e)
raise util.errors.BadRequest(
"Unable to register your account. Please try again later"
)
def login(email, password): def login(email, password):
db = database.get_db() db = database.get_db()
user = db.users.find_one({'$or': [{'username': email.lower()}, {'email': email.lower()}]}) user = db.users.find_one(
try: {"$or": [{"username": email.lower()}, {"email": email.lower()}]}
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']): )
return {'token': generate_access_token(user['_id'])} try:
else: if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
raise util.errors.BadRequest('Your username or password is incorrect.') return {"token": generate_access_token(user["_id"])}
except Exception as e: else:
raise util.errors.BadRequest('Your username or password is incorrect.') raise util.errors.BadRequest("Your username or password is incorrect.")
except Exception:
raise util.errors.BadRequest("Your username or password is incorrect.")
def logout(user): def logout(user):
db = database.get_db() db = database.get_db()
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}}) db.users.update_one(
return {'loggedOut': True} {"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
)
return {"loggedOut": True}
def update_email(user, data): def update_email(user, data):
if not data: raise util.errors.BadRequest('Invalid request') if not data:
if 'email' not in data: raise util.errors.BadRequest('Invalid request') raise util.errors.BadRequest("Invalid request")
if len(data['email']) < 4: raise util.errors.BadRequest('New email is too short') if "email" not in data:
db = database.get_db() raise util.errors.BadRequest("Invalid request")
db.users.update_one({'_id': user['_id']}, {'$set': {'email': data['email']}}) if len(data["email"]) < 4:
mail.send({ raise util.errors.BadRequest("New email is too short")
'to': user['email'], db = database.get_db()
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')), db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}})
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format( mail.send(
user['username'], {
data['email'], "to": user["email"],
os.environ.get('APP_NAME'), "subject": "Your email address has changed on {}".format(
os.environ.get("APP_NAME")
),
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
user["username"],
data["email"],
os.environ.get("APP_NAME"),
),
}
) )
}) mail.send(
mail.send({ {
'to': data['email'], "to": data["email"],
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')), "subject": "Your email address has changed on {}".format(
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format( os.environ.get("APP_NAME")
user['username'], ),
data['email'], "text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
os.environ.get('APP_NAME'), user["username"],
data["email"],
os.environ.get("APP_NAME"),
),
}
) )
}) return {"email": data["email"]}
return {'email': data['email']}
def update_password(user, data): def update_password(user, data):
if not data: raise util.errors.BadRequest('Invalid request') if not data:
if 'newPassword' not in data: raise util.errors.BadRequest('Invalid request') raise util.errors.BadRequest("Invalid request")
if len(data['newPassword']) < MIN_PASSWORD_LENGTH: raise util.errors.BadRequest('New password should be at least {0} characters long'.format(MIN_PASSWORD_LENGTH)) if "newPassword" not in data:
raise util.errors.BadRequest("Invalid request")
if len(data["newPassword"]) < MIN_PASSWORD_LENGTH:
raise util.errors.BadRequest(
"New password should be at least {0} characters long".format(
MIN_PASSWORD_LENGTH
)
)
db = database.get_db() db = database.get_db()
if 'currentPassword' in data: if "currentPassword" in data:
if not user: raise util.errors.BadRequest('User context is required') if not user:
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']): raise util.errors.BadRequest("User context is required")
raise util.errors.BadRequest('Incorrect password') if not bcrypt.checkpw(
elif 'token' in data: data["currentPassword"].encode("utf-8"), user["password"]
try: ):
id = jwt.decode(data['token'], jwt_secret)['sub'] raise util.errors.BadRequest("Incorrect password")
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']}) elif "token" in data:
if not user: raise Exception try:
except Exception as e: id = jwt.decode(data["token"], jwt_secret, algorithms="HS256")["sub"]
raise util.errors.BadRequest('There was a problem updating your password. Your token may be invalid or out of date') user = db.users.find_one(
else: {"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
raise util.errors.BadRequest('Current password or reset token is required') )
if not user: raise util.errors.BadRequest('Unable to change your password') if not user:
raise Exception
except Exception:
raise util.errors.BadRequest(
"There was a problem updating your password. Your token may be invalid or out of date"
)
else:
raise util.errors.BadRequest("Current password or reset token is required")
if not user:
raise util.errors.BadRequest("Unable to change your password")
hashed_password = bcrypt.hashpw(data['newPassword'].encode("utf-8"), bcrypt.gensalt()) hashed_password = bcrypt.hashpw(
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.passwordReset': ''}}) data["newPassword"].encode("utf-8"), bcrypt.gensalt()
mail.send({
'to_user': user,
'subject': 'Your {} password has changed'.format(os.environ.get('APP_NAME')),
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.'.format(
user['username'],
os.environ.get('APP_NAME'),
) )
}) db.users.update_one(
return {'passwordUpdated': True} {"_id": user["_id"]},
{"$set": {"password": hashed_password}, "$unset": {"tokens.passwordReset": ""}},
)
mail.send(
{
"to_user": user,
"subject": "Your {} password has changed".format(
os.environ.get("APP_NAME")
),
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.".format(
user["username"],
os.environ.get("APP_NAME"),
),
}
)
return {"passwordUpdated": True}
def delete(user, password): def delete(user, password):
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']): if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
raise util.errors.BadRequest('Incorrect password') raise util.errors.BadRequest("Incorrect password")
db = database.get_db() db = database.get_db()
for project in db.projects.find({'user': user['_id']}): for project in db.projects.find({"user": user["_id"]}):
db.objects.remove({'project': project['_id']}) db.objects.delete_many({"project": project["_id"]})
db.projects.remove({'_id': project['_id']}) db.projects.delete_one({"_id": project["_id"]})
db.users.remove({'_id': user['_id']}) db.comments.delete_many({"user": user["_id"]})
return {'deletedUser': user['_id']} db.users.update_many(
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
)
db.users.delete_one({"_id": user["_id"]})
return {"deletedUser": user["_id"]}
def generate_access_token(user_id): def generate_access_token(user_id):
payload = { payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30), "exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
'iat': datetime.datetime.utcnow(), "iat": datetime.datetime.utcnow(),
'sub': str(user_id) "sub": str(user_id),
} }
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode("utf-8") token = jwt.encode(payload, jwt_secret, algorithm="HS256")
db = database.get_db() db = database.get_db()
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}}) db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
return token return token
def get_user_context(token): def get_user_context(token):
if not token: return None if not token:
try: return None
payload = jwt.decode(token, jwt_secret) try:
id = payload['sub'] payload = jwt.decode(token, jwt_secret, algorithms="HS256")
if id: id = payload["sub"]
db = database.get_db() if id:
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token}) db = database.get_db()
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}}) user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
user['currentToken'] = token db.users.update_one(
return user {"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
except Exception as e: )
print(e) user["currentToken"] = token
return None return user
except Exception as e:
print(e)
return None
def reset_password(data): def reset_password(data):
if not data or not 'email' in data: raise util.errors.BadRequest('Invalid request') if not data or "email" not in data:
if len(data['email']) < 5: raise util.errors.BadRequest('Your email is too short') raise util.errors.BadRequest("Invalid request")
db = database.get_db() if len(data["email"]) < 5:
user = db.users.find_one({'email': data['email'].lower()}) raise util.errors.BadRequest("Your email is too short")
if user: db = database.get_db()
payload = { user = db.users.find_one({"email": data["email"].lower()})
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1), if user:
'iat': datetime.datetime.utcnow(), payload = {
'sub': str(user['_id']) "exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
} "iat": datetime.datetime.utcnow(),
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode('utf-8') "sub": str(user["_id"]),
mail.send({ }
'to_user': user, token = jwt.encode(payload, jwt_secret, algorithm="HS256")
'subject': 'Reset your password', mail.send(
'text': 'Dear {0},\n\nA password reset email was recently requested for your {2} account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.'.format( {
user['username'], "to_user": user,
'{}/password/reset?token={}'.format(os.environ.get('APP_URL'), token), "subject": "Reset your password",
os.environ.get('APP_NAME'), "text": "Dear {0},\n\nA password reset email was recently requested for your {2} account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.".format(
) user["username"],
}) "{}/password/reset?token={}".format(
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}}) os.environ.get("APP_URL"), token
return {'passwordResetEmailSent': True} ),
os.environ.get("APP_NAME"),
),
}
)
db.users.update_one(
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
)
return {"passwordResetEmailSent": True}
def update_push_token(user, data): def update_push_token(user, data):
if not data or 'pushToken' not in data: raise util.errors.BadRequest('Push token is required') if not data or "pushToken" not in data:
db = database.get_db() raise util.errors.BadRequest("Push token is required")
db.users.update_one({'_id': user['_id']}, {'$set': {'pushToken': data['pushToken']}}) db = database.get_db()
return {'addedPushToken': data['pushToken']} db.users.update_one(
{"_id": user["_id"]}, {"$set": {"pushToken": data["pushToken"]}}
)
return {"addedPushToken": data["pushToken"]}

View File

@ -1,165 +1,190 @@
import os, re import os
import re
from util import database, util from util import database, util
from api import uploads from api import uploads
DOMAIN = os.environ.get('APP_DOMAIN') DOMAIN = os.environ.get("APP_DOMAIN")
def webfinger(resource): def webfinger(resource):
if not resource: raise util.errors.BadRequest('Resource required') if not resource:
resource = resource.lower() raise util.errors.BadRequest("Resource required")
exp = re.compile('acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)', re.IGNORECASE) resource = resource.lower()
matches = exp.findall(resource) exp = re.compile("acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)", re.IGNORECASE)
if not matches or not matches[0]: raise util.errors.BadRequest('Resource invalid') matches = exp.findall(resource)
username, host = matches[0] if not matches or not matches[0]:
if not username or not host: raise util.errors.BadRequest('Resource invalid') raise util.errors.BadRequest("Resource invalid")
if host != DOMAIN: raise util.errors.NotFound('Host unknown') username, host = matches[0]
if not username or not host:
raise util.errors.BadRequest("Resource invalid")
if host != DOMAIN:
raise util.errors.NotFound("Host unknown")
db = database.get_db() db = database.get_db()
user = db.users.find_one({'username': username}) user = db.users.find_one({"username": username})
if not user: raise util.errors.NotFound('User unknown') if not user:
raise util.errors.NotFound("User unknown")
return {
"subject": resource,
"aliases": [
"https://{}/{}".format(DOMAIN, username),
"https://{}/u/{}".format(DOMAIN, username),
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://{}/{}".format(DOMAIN, username),
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://{}/u/{}".format(DOMAIN, username),
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://{}/authorize_interaction".format(DOMAIN)
+ "?uri={uri}",
},
],
}
return {
"subject": resource,
"aliases": [
"https://{}/{}".format(DOMAIN, username),
"https://{}/u/{}".format(DOMAIN, username)
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://{}/{}".format(DOMAIN, username)
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://{}/u/{}".format(DOMAIN, username)
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://{}/authorize_interaction".format(DOMAIN) + "?uri={uri}"
}
]
}
def user(username): def user(username):
if not username: raise util.errors.BadRequest('Username required') if not username:
username = username.lower() raise util.errors.BadRequest("Username required")
db = database.get_db() username = username.lower()
user = db.users.find_one({'username': username}) db = database.get_db()
if not user: raise util.errors.NotFound('User unknown') user = db.users.find_one({"username": username})
avatar_url = user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])) if not user:
raise util.errors.NotFound("User unknown")
avatar_url = user.get("avatar") and uploads.get_presigned_url(
"users/{0}/{1}".format(user["_id"], user["avatar"])
)
pub_key = None pub_key = None
if user.get('services', {}).get('activityPub', {}).get('publicKey'): if user.get("services", {}).get("activityPub", {}).get("publicKey"):
pub_key = user['services']['activityPub']['publicKey'] pub_key = user["services"]["activityPub"]["publicKey"]
else: else:
priv_key, pub_key = util.generate_rsa_keypair() priv_key, pub_key = util.generate_rsa_keypair()
db.users.update_one({'_id': user['_id']}, {'$set': { db.users.update_one(
'services.activityPub.publicKey': pub_key, {"_id": user["_id"]},
'services.activityPub.privateKey': priv_key, {
}}) "$set": {
"services.activityPub.publicKey": pub_key,
"services.activityPub.privateKey": priv_key,
}
},
)
resp = { resp = {
"@context": [ "@context": [
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
], ],
"id": "https://{}/u/{}".format(DOMAIN, username), "id": "https://{}/u/{}".format(DOMAIN, username),
"type": "Person", "type": "Person",
#"following": "https://fosstodon.org/users/wilw/following", # "following": "https://fosstodon.org/users/wilw/following",
#"followers": "https://fosstodon.org/users/wilw/followers", # "followers": "https://fosstodon.org/users/wilw/followers",
"inbox": "https://{}/inbox".format(DOMAIN), "inbox": "https://{}/inbox".format(DOMAIN),
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username), "outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
"preferredUsername": username, "preferredUsername": username,
"name": username, "name": username,
"summary": user.get('bio', ''), "summary": user.get("bio", ""),
"url": "https://{}/{}".format(DOMAIN, username), "url": "https://{}/{}".format(DOMAIN, username),
"discoverable": True, "discoverable": True,
"published": "2021-01-27T00:00:00Z", "published": "2021-01-27T00:00:00Z",
"publicKey": { "publicKey": {
"id": "https://{}/u/{}#main-key".format(DOMAIN, username), "id": "https://{}/u/{}#main-key".format(DOMAIN, username),
"owner": "https://{}/u/{}".format(DOMAIN, username), "owner": "https://{}/u/{}".format(DOMAIN, username),
"publicKeyPem": pub_key.decode('utf-8') "publicKeyPem": pub_key.decode("utf-8"),
}, },
"attachment": [], "attachment": [],
"endpoints": { "endpoints": {"sharedInbox": "https://{}/inbox".format(DOMAIN)},
"sharedInbox": "https://{}/inbox".format(DOMAIN) "icon": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
}, "image": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
"icon": {
"type": "Image",
"mediaType": "image/jpeg",
"url": avatar_url
},
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": avatar_url
} }
}
if user.get('website'): if user.get("website"):
resp['attachment'].append({ resp["attachment"].append(
"type": "PropertyValue", {
"name": "Website", "type": "PropertyValue",
"value": "<a href=\"https://{}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">{}</span><span class=\"invisible\"></span></a>".format(user['website'], user['website']) "name": "Website",
}) "value": '<a href="https://{}" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">{}</span><span class="invisible"></span></a>'.format(
user["website"], user["website"]
),
}
)
return resp
return resp
def outbox(username, page, min_id, max_id): def outbox(username, page, min_id, max_id):
if not username: raise util.errors.BadRequest('Username required') if not username:
username = username.lower() raise util.errors.BadRequest("Username required")
db = database.get_db() username = username.lower()
user = db.users.find_one({'username': username}) db = database.get_db()
if not user: raise util.errors.NotFound('User unknown') user = db.users.find_one({"username": username})
if not user:
raise util.errors.NotFound("User unknown")
if not page or page != 'true': if not page or page != "true":
return { return {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"id": "https://{}/u/{}/outbox".format(DOMAIN, username), "id": "https://{}/u/{}/outbox".format(DOMAIN, username),
"type": "OrderedCollection", "type": "OrderedCollection",
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username) "first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username),
} }
if page == 'true': if page == "true":
min_string = '&min_id={}'.format(min_id) if min_id else '' min_string = "&min_id={}".format(min_id) if min_id else ""
max_string = '&max_id={}'.format(max_id) if max_id else '' max_string = "&max_id={}".format(max_id) if max_id else ""
ret = { ret = {
"id": "https://{}/u/{}/outbox?page=true{}{}".format(DOMAIN, username, min_string, max_string), "id": "https://{}/u/{}/outbox?page=true{}{}".format(
"type": "OrderedCollectionPage", DOMAIN, username, min_string, max_string
#"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true", ),
#"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true", "type": "OrderedCollectionPage",
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username), # "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
"orderedItems": [] # "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
} "partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
"orderedItems": [],
project_list = list(db.projects.find({'user': user['_id'], 'visibility': 'public'}))
for p in project_list:
ret['orderedItems'].append({
"id": "https://{}/{}/{}/activity".format(DOMAIN, username, p['path']),
"type": "Create",
"actor": "https://{}/u/{}".format(DOMAIN, username),
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2021-10-18T20:06:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"object": {
"id": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
"type": "Note",
"summary": None,
#"inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2022-08-03T15:43:30Z",
"url": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://{}/u/{}/followers".format(DOMAIN, username),
],
"sensitive": False,
"content": "{} created a project: {}".format(username, p['name']),
} }
})
return ret project_list = list(
db.projects.find({"user": user["_id"], "visibility": "public"})
)
for p in project_list:
ret["orderedItems"].append(
{
"id": "https://{}/{}/{}/activity".format(
DOMAIN, username, p["path"]
),
"type": "Create",
"actor": "https://{}/u/{}".format(DOMAIN, username),
"published": p["createdAt"].strftime(
"%Y-%m-%dT%H:%M:%SZ"
), # "2021-10-18T20:06:18Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"id": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
"type": "Note",
"summary": None,
# "inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
"published": p["createdAt"].strftime(
"%Y-%m-%dT%H:%M:%SZ"
), # "2022-08-03T15:43:30Z",
"url": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [
"https://{}/u/{}/followers".format(DOMAIN, username),
],
"sensitive": False,
"content": "{} created a project: {}".format(
username, p["name"]
),
},
}
)
return ret

File diff suppressed because it is too large Load Diff

View File

@ -1,171 +1,252 @@
import re, datetime, os import datetime
import pymongo import os
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, util, mail from util import database, util, mail
from api import uploads, groups from api import uploads, groups
APP_NAME = os.environ.get('APP_NAME') APP_NAME = os.environ.get("APP_NAME")
APP_URL = os.environ.get('APP_URL') APP_URL = os.environ.get("APP_URL")
def get(user): def get(user):
db = database.get_db() db = database.get_db()
admin_groups = list(db.groups.find({'admins': user['_id']})) admin_groups = list(db.groups.find({"admins": user["_id"]}))
invites = list(db.invitations.find({'$or': [{'recipient': user['_id']}, {'recipientGroup': {'$in': list(map(lambda g: g['_id'], admin_groups))}}]})) invites = list(
inviters = list(db.users.find({'_id': {'$in': [i['user'] for i in invites]}}, {'username': 1, 'avatar': 1})) db.invitations.find(
for invite in invites: {
invite['recipient'] = user['_id'] "$or": [
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1}) {"recipient": user["_id"]},
for u in inviters: {
if u['_id'] == invite['user']: "recipientGroup": {
if 'avatar' in u: "$in": list(map(lambda g: g["_id"], admin_groups))
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) }
invite['invitedBy'] = u },
break ]
sent_invites = list(db.invitations.find({'user': user['_id']})) }
recipients = list(db.users.find({'_id': {'$in': list(map(lambda i: i.get('recipient'), sent_invites))}}, {'username': 1, 'avatar': 1})) )
for invite in sent_invites: )
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1}) inviters = list(
for u in recipients: db.users.find(
if u['_id'] == invite.get('recipient'): {"_id": {"$in": [i["user"] for i in invites]}}, {"username": 1, "avatar": 1}
if 'avatar' in u: )
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) )
invite['invitedBy'] = u for invite in invites:
break invite["recipient"] = user["_id"]
return {'invitations': invites, 'sentInvitations': sent_invites} if invite["type"] in ["group", "groupJoinRequest"]:
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
inviter = next((u for u in inviters if u["_id"] == invite["user"]), None)
if inviter:
if "avatar" in inviter:
inviter["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(inviter["_id"], inviter["avatar"])
)
invite["invitedBy"] = inviter
sent_invites = list(db.invitations.find({"user": user["_id"]}))
recipients = list(
db.users.find(
{"_id": {"$in": list(map(lambda i: i.get("recipient"), sent_invites))}},
{"username": 1, "avatar": 1},
)
)
for invite in sent_invites:
if invite["type"] in ["group", "groupJoinRequest"]:
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
recipient = next(
(u for u in recipients if u["_id"] == invite.get("recipient")), None
)
if recipient:
if "avatar" in recipient:
recipient["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
)
invite["invitedBy"] = recipient
return {"invitations": invites, "sentInvitations": sent_invites}
def accept(user, id): def accept(user, id):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
invite = db.invitations.find_one({'_id': id}) invite = db.invitations.find_one({"_id": id})
if not invite: raise util.errors.NotFound('Invitation not found') if not invite:
if invite['type'] == 'group': raise util.errors.NotFound("Invitation not found")
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to accept') if invite["type"] == "group":
group = db.groups.find_one({'_id': invite['typeId']}, {'name': 1}) if invite["recipient"] != user["_id"]:
if not group: raise util.errors.Forbidden("This invitation is not yours to accept")
db.invitations.remove({'_id': id}) group = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
return {'acceptedInvitation': id} if not group:
groups.create_member(user, group['_id'], user['_id'], invited = True) db.invitations.delete_one({"_id": id})
db.invitations.remove({'_id': id}) return {"acceptedInvitation": id}
return {'acceptedInvitation': id, 'group': group} groups.create_member(user, group["_id"], user["_id"], invited=True)
if invite['type'] == 'groupJoinRequest': db.invitations.delete_one({"_id": id})
group = db.groups.find_one({'_id': invite['typeId']}) return {"acceptedInvitation": id, "group": group}
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to accept this request') if invite["type"] == "groupJoinRequest":
requester = db.users.find_one({'_id': invite['user']}) group = db.groups.find_one({"_id": invite["typeId"]})
if not group or not requester: if user["_id"] not in group.get("admins", []):
db.invitations.remove({'_id': id}) raise util.errors.Forbidden(
return {'acceptedInvitation': id} "You need to be an admin of this group to accept this request"
groups.create_member(requester, group['_id'], requester['_id'], invited = True) )
db.invitations.remove({'_id': id}) requester = db.users.find_one({"_id": invite["user"]})
return {'acceptedInvitation': id, 'group': group} if not group or not requester:
db.invitations.delete_one({"_id": id})
return {"acceptedInvitation": id}
groups.create_member(requester, group["_id"], requester["_id"], invited=True)
db.invitations.delete_one({"_id": id})
return {"acceptedInvitation": id, "group": group}
def delete(user, id): def delete(user, id):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
invite = db.invitations.find_one({'_id': id}) invite = db.invitations.find_one({"_id": id})
if not invite: raise util.errors.NotFound('Invitation not found') if not invite:
if invite['type'] == 'group': raise util.errors.NotFound("Invitation not found")
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to decline') if invite["type"] == "group":
if invite['type'] == 'groupJoinRequest': if invite["recipient"] != user["_id"]:
group = db.groups.find_one({'_id': invite['typeId']}) raise util.errors.Forbidden("This invitation is not yours to decline")
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to manage this request') if invite["type"] == "groupJoinRequest":
db.invitations.remove({'_id': id}) group = db.groups.find_one({"_id": invite["typeId"]})
return {'deletedInvitation': id} if user["_id"] not in group.get("admins", []):
raise util.errors.Forbidden(
"You need to be an admin of this group to manage this request"
)
db.invitations.delete_one({"_id": id})
return {"deletedInvitation": id}
def create_group_invitation(user, group_id, data): def create_group_invitation(user, group_id, data):
if not data or 'user' not in data: raise util.errors.BadRequest('Invalid request') if not data or "user" not in data:
db = database.get_db() raise util.errors.BadRequest("Invalid request")
recipient_id = ObjectId(data['user']) db = database.get_db()
group_id = ObjectId(group_id) recipient_id = ObjectId(data["user"])
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1}) group_id = ObjectId(group_id)
if not group: raise util.errors.NotFound('Group not found') group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to invite users') if not group:
recipient = db.users.find_one({'_id': recipient_id}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1}) raise util.errors.NotFound("Group not found")
if not recipient: raise util.errors.NotFound('User not found') if user["_id"] not in group.get("admins", []):
if group_id in recipient.get('groups', []): raise util.errors.BadRequest('This user is already in this group') raise util.errors.Forbidden("You need to be a group admin to invite users")
if db.invitations.find_one({'recipient': recipient_id, 'typeId': group_id, 'type': 'group'}): recipient = db.users.find_one(
raise util.errors.BadRequest('This user has already been invited to this group') {"_id": recipient_id},
invite = { {"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
'createdAt': datetime.datetime.now(), )
'user': user['_id'], if not recipient:
'recipient': recipient_id, raise util.errors.NotFound("User not found")
'type': 'group', if group_id in recipient.get("groups", []):
'typeId': group_id raise util.errors.BadRequest("This user is already in this group")
} if db.invitations.find_one(
result = db.invitations.insert_one(invite) {"recipient": recipient_id, "typeId": group_id, "type": "group"}
if 'groups.invited' in recipient.get('subscriptions', {}).get('email', []): ):
mail.send({ raise util.errors.BadRequest("This user has already been invited to this group")
'to_user': recipient, invite = {
'subject': 'You\'ve been invited to a group on {}!'.format(APP_NAME), "createdAt": datetime.datetime.now(),
'text': 'Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.'.format( "user": user["_id"],
recipient['username'], "recipient": recipient_id,
group['name'], "type": "group",
APP_URL, "typeId": group_id,
APP_NAME, }
) result = db.invitations.insert_one(invite)
}) if "groups.invited" in recipient.get("subscriptions", {}).get("email", []):
invite['_id'] = result.inserted_id mail.send(
return invite {
"to_user": recipient,
"subject": "You've been invited to a group on {}!".format(APP_NAME),
"text": "Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.".format(
recipient["username"],
group["name"],
APP_URL,
APP_NAME,
),
}
)
invite["_id"] = result.inserted_id
return invite
def create_group_request(user, group_id): def create_group_request(user, group_id):
db = database.get_db() db = database.get_db()
group_id = ObjectId(group_id) group_id = ObjectId(group_id)
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1}) group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
if not group: raise util.errors.NotFound('Group not found') if not group:
if group_id in user.get('groups'): raise util.errors.BadRequest('You are already a member of this group') raise util.errors.NotFound("Group not found")
admin = db.users.find_one({'_id': {'$in': group.get('admins', [])}}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1}) if group_id in user.get("groups", []):
if not admin: raise util.errors.NotFound('No users can approve you to join this group') raise util.errors.BadRequest("You are already a member of this group")
if db.invitations.find_one({'recipient': user['_id'], 'typeId': group_id, 'type': 'group'}): admin = db.users.find_one(
raise util.errors.BadRequest('You have already been invited to this group') {"_id": {"$in": group.get("admins", [])}},
if db.invitations.find_one({'user': user['_id'], 'typeId': group_id, 'type': 'groupJoinRequest'}): {"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
raise util.errors.BadRequest('You have already requested access to this group') )
invite = { if not admin:
'createdAt': datetime.datetime.now(), raise util.errors.NotFound("No users can approve you to join this group")
'user': user['_id'], if db.invitations.find_one(
'recipientGroup': group['_id'], {"recipient": user["_id"], "typeId": group_id, "type": "group"}
'type': 'groupJoinRequest', ):
'typeId': group_id raise util.errors.BadRequest("You have already been invited to this group")
} if db.invitations.find_one(
result = db.invitations.insert_one(invite) {"user": user["_id"], "typeId": group_id, "type": "groupJoinRequest"}
if 'groups.joinRequested' in admin.get('subscriptions', {}).get('email', []): ):
mail.send({ raise util.errors.BadRequest("You have already requested access to this group")
'to_user': admin, invite = {
'subject': 'Someone wants to join your group', "createdAt": datetime.datetime.now(),
'text': 'Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.'.format( "user": user["_id"],
admin['username'], "recipientGroup": group["_id"],
user['username'], "type": "groupJoinRequest",
group['name'], "typeId": group_id,
APP_URL, }
APP_NAME, result = db.invitations.insert_one(invite)
) if "groups.joinRequested" in admin.get("subscriptions", {}).get("email", []):
}) mail.send(
invite['_id'] = result.inserted_id {
return invite "to_user": admin,
"subject": "Someone wants to join your group",
"text": "Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.".format(
admin["username"],
user["username"],
group["name"],
APP_URL,
APP_NAME,
),
}
)
invite["_id"] = result.inserted_id
return invite
def get_group_invitations(user, id): def get_group_invitations(user, id):
db = database.get_db() db = database.get_db()
group_id = ObjectId(id) group_id = ObjectId(id)
group = db.groups.find_one({'_id': group_id}, {'admins': 1}) group = db.groups.find_one({"_id": group_id}, {"admins": 1})
if not group: raise util.errors.NotFound('Group not found') if not group:
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations') raise util.errors.NotFound("Group not found")
invites = list(db.invitations.find({'type': 'group', 'typeId': group_id})) if user["_id"] not in group.get("admins", []):
recipients = list(db.users.find({'_id': {'$in': [i['recipient'] for i in invites]}}, {'username': 1, 'avatar': 1})) raise util.errors.Forbidden("You need to be a group admin to see invitations")
for invite in invites: invites = list(db.invitations.find({"type": "group", "typeId": group_id}))
for recipient in recipients: recipients = list(
if invite['recipient'] == recipient['_id']: db.users.find(
if 'avatar' in recipient: {"_id": {"$in": [i["recipient"] for i in invites]}},
recipient['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(recipient['_id'], recipient['avatar'])) {"username": 1, "avatar": 1},
invite['recipientUser'] = recipient )
break )
return {'invitations': invites} for invite in invites:
for recipient in recipients:
if invite["recipient"] == recipient["_id"]:
if "avatar" in recipient:
recipient["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
)
invite["recipientUser"] = recipient
break
return {"invitations": invites}
def delete_group_invitation(user, id, invite_id): def delete_group_invitation(user, id, invite_id):
db = database.get_db() db = database.get_db()
group_id = ObjectId(id) group_id = ObjectId(id)
invite_id = ObjectId(invite_id) invite_id = ObjectId(invite_id)
group = db.groups.find_one({'_id': group_id}, {'admins': 1}) group = db.groups.find_one({"_id": group_id}, {"admins": 1})
if not group: raise util.errors.NotFound('Group not found') if not group:
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations') raise util.errors.NotFound("Group not found")
invite = db.invitations.find_one({'_id': invite_id}) if user["_id"] not in group.get("admins", []):
if not invite or invite['typeId'] != group_id: raise util.errors.NotFound('This invite could not be found') raise util.errors.Forbidden("You need to be a group admin to see invitations")
db.invitations.remove({'_id': invite_id}) invite = db.invitations.find_one({"_id": invite_id})
return {'deletedInvite': invite_id} if not invite or invite["typeId"] != group_id:
raise util.errors.NotFound("This invite could not be found")
db.invitations.delete_one({"_id": invite_id})
return {"deletedInvite": invite_id}

View File

@ -1,154 +1,272 @@
import datetime, base64, os import datetime
import base64
import os
from bson.objectid import ObjectId from bson.objectid import ObjectId
import requests import requests
from util import database, wif, util, mail from util import database, wif, util, mail
from api import uploads from api import uploads
APP_NAME = os.environ.get('APP_NAME') APP_NAME = os.environ.get("APP_NAME")
APP_URL = os.environ.get("APP_URL")
def delete(user, id): def delete(user, id):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id), {'project': 1}) obj = db.objects.find_one(ObjectId(id), {"project": 1})
if not obj: if not obj:
raise util.errors.NotFound('Object not found') raise util.errors.NotFound("Object not found")
project = db.projects.find_one(obj.get('project'), {'user': 1}) project = db.projects.find_one(obj.get("project"), {"user": 1})
if not project: if not project:
raise util.errors.NotFound('Project not found') raise util.errors.NotFound("Project not found")
if project['user'] != user['_id']: if not util.can_edit_project(user, project):
raise util.errors.Forbidden('Forbidden', 403) raise util.errors.Forbidden("Forbidden", 403)
db.objects.remove(ObjectId(id)) db.objects.delete_one({"_id": ObjectId(id)})
return {'deletedObject': id} return {"deletedObject": id}
def get(user, id): def get(user, id):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id)) obj = db.objects.find_one({"_id": ObjectId(id)})
if not obj: if not obj:
raise util.errors.NotFound('Object not found') raise util.errors.NotFound("Object not found")
return obj proj = db.projects.find_one({"_id": obj["project"]})
if not proj:
raise util.errors.NotFound("Project not found")
is_owner = user and (user.get("_id") == proj["user"])
if not is_owner and proj["visibility"] != "public":
raise util.errors.Forbidden("Forbidden")
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
raise util.errors.Forbidden("Awaiting moderation")
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(proj["_id"], obj["storedName"])
)
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
obj["previewUrl"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(proj["_id"], obj["preview"])
)
del obj["preview"]
if obj.get("fullPreview"):
obj["fullPreviewUrl"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(proj["_id"], obj["fullPreview"])
)
obj["projectObject"] = proj
if owner:
if "avatar" in owner:
owner["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(str(owner["_id"]), owner["avatar"])
)
obj["projectObject"]["owner"] = owner
return obj
def copy_to_project(user, id, project_id): def copy_to_project(user, id, project_id):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id)) obj = db.objects.find_one(ObjectId(id))
if not obj: raise util.errors.NotFound('This object could not be found') if not obj:
original_project = db.projects.find_one(obj['project']) raise util.errors.NotFound("This object could not be found")
if not original_project: original_project = db.projects.find_one(obj["project"])
raise util.errors.NotFound('Project not found') if not original_project:
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']): raise util.errors.NotFound("Project not found")
raise util.errors.Forbidden('This project is not open-source') if not original_project.get("openSource") and not util.can_edit_project(
target_project = db.projects.find_one(ObjectId(project_id)) user, original_project
if not target_project or target_project['user'] != user['_id']: ):
raise util.errors.Forbidden('You don\'t own the target project') raise util.errors.Forbidden("This project is not open-source")
if original_project.get("visibility") != "public" and not util.can_edit_project(
user, original_project
):
raise util.errors.Forbidden("This project is not public")
target_project = db.projects.find_one(ObjectId(project_id))
if not target_project or not util.can_edit_project(user, target_project):
raise util.errors.Forbidden("You don't own the target project")
obj["_id"] = ObjectId()
obj["project"] = target_project["_id"]
obj["createdAt"] = datetime.datetime.now()
obj["commentCount"] = 0
if "preview" in obj:
del obj["preview"]
if obj.get("pattern"):
images = wif.generate_images(obj)
if images:
obj.update(images)
db.objects.insert_one(obj)
return obj
obj['_id'] = ObjectId()
obj['project'] = target_project['_id']
obj['createdAt'] = datetime.datetime.now()
obj['commentCount'] = 0
db.objects.insert_one(obj)
return obj
def get_wif(user, id): def get_wif(user, id):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id)) obj = db.objects.find_one(ObjectId(id))
if not obj: raise util.errors.NotFound('Object not found') if not obj:
project = db.projects.find_one(obj['project']) raise util.errors.NotFound("Object not found")
if not project.get('openSource') and not (user and user['_id'] == project['user']): project = db.projects.find_one(obj["project"])
raise util.errors.Forbidden('This project is not open-source') if not project.get("openSource") and not util.can_edit_project(user, project):
try: raise util.errors.Forbidden("This project is not open-source")
output = wif.dumps(obj).replace('\n', '\\n') if project.get("visibility") != "public" and not util.can_edit_project(
return {'wif': output} user, project
except Exception as e: ):
raise util.errors.BadRequest('Unable to create WIF file') raise util.errors.Forbidden("This project is not public")
try:
output = wif.dumps(obj).replace("\n", "\\n")
return {"wif": output}
except Exception:
raise util.errors.BadRequest("Unable to create WIF file")
def get_pdf(user, id): def get_pdf(user, id):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id)) obj = db.objects.find_one(ObjectId(id))
if not obj: raise util.errors.NotFound('Object not found') if not obj:
project = db.projects.find_one(obj['project']) raise util.errors.NotFound("Object not found")
if not project.get('openSource') and not (user and user['_id'] == project['user']): project = db.projects.find_one(obj["project"])
raise util.errors.Forbidden('This project is not open-source') if not project.get("openSource") and not util.can_edit_project(user, project):
try: raise util.errors.Forbidden("This project is not open-source")
response = requests.get('https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object=' + id + '&landscape=true&paperWidth=23.39&paperHeight=33.11') if project.get("visibility") != "public" and not util.can_edit_project(
response.raise_for_status() user, project
pdf = uploads.get_file('objects/' + id + '/export.pdf') ):
body64 = base64.b64encode(pdf['Body'].read()) raise util.errors.Forbidden("This project is not public")
bytes_str = str(body64).replace("b'", '')[:-1] try:
return {'pdf': body64.decode('ascii')} response = requests.get(
except Exception as e: "https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object="
print(e) + id
raise util.errors.BadRequest('Unable to export PDF') + "&landscape=true&paperWidth=23.39&paperHeight=33.11"
)
response.raise_for_status()
pdf = uploads.get_file("objects/" + id + "/export.pdf")
body64 = base64.b64encode(pdf["Body"].read())
return {"pdf": body64.decode("ascii")}
except Exception as e:
print(e)
raise util.errors.BadRequest("Unable to export PDF")
def update(user, id, data): def update(user, id, data):
db = database.get_db() db = database.get_db()
obj = db.objects.find_one(ObjectId(id), {'project': 1}) obj = db.objects.find_one(ObjectId(id), {"project": 1})
if not obj: raise util.errors.NotFound('Object not found') if not obj:
project = db.projects.find_one(obj.get('project'), {'user': 1}) raise util.errors.NotFound("Object not found")
if not project: raise util.errors.NotFound('Project not found') project = db.projects.find_one(obj.get("project"), {"user": 1})
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden') if not project:
allowed_keys = ['name', 'description', 'pattern', 'preview'] raise util.errors.NotFound("Project not found")
updater = util.build_updater(data, allowed_keys) if not util.can_edit_project(user, project):
if updater: raise util.errors.Forbidden("Forbidden")
db.objects.update({'_id': ObjectId(id)}, updater) allowed_keys = ["name", "description", "pattern"]
return get(user, id)
updater = util.build_updater(data, allowed_keys)
if updater:
db.objects.update_one({"_id": ObjectId(id)}, updater)
if data.get("pattern"):
obj.update(data)
wif.generate_images(obj)
return get(user, id)
def create_comment(user, id, data): def create_comment(user, id, data):
if not data or not data.get('content'): raise util.errors.BadRequest('Comment data is required') if not data or not data.get("content"):
db = database.get_db() raise util.errors.BadRequest("Comment data is required")
obj = db.objects.find_one({'_id': ObjectId(id)}) db = database.get_db()
if not obj: raise util.errors.NotFound('We could not find the specified object') obj = db.objects.find_one({"_id": ObjectId(id)})
project = db.projects.find_one({'_id': obj['project']}) if not obj:
comment = { raise util.errors.NotFound("We could not find the specified object")
'content': data.get('content', ''), comment = {
'object': ObjectId(id), "content": data.get("content", ""),
'user': user['_id'], "object": ObjectId(id),
'createdAt': datetime.datetime.now() "user": user["_id"],
} "createdAt": datetime.datetime.now(),
result = db.comments.insert_one(comment) "moderationRequired": True,
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': 1}}) }
comment['_id'] = result.inserted_id result = db.comments.insert_one(comment)
comment['authorUser'] = { db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
'username': user['username'], comment["_id"] = result.inserted_id
'avatar': user.get('avatar'), comment["authorUser"] = {
'avatarUrl': uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user.get('avatar'))) "username": user["username"],
} "avatar": user.get("avatar"),
project_owner = db.users.find_one({'_id': project['user'], 'subscriptions.email': 'projects.commented'}) "avatarUrl": uploads.get_presigned_url(
if project_owner and project_owner['_id'] != user['_id']: "users/{0}/{1}".format(user["_id"], user.get("avatar"))
mail.send({
'to_user': project_owner,
'subject': '{} commented on {}'.format(user['username'], project['name']),
'text': 'Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}'.format(
project_owner['username'],
user['username'],
obj['name'],
project['name'],
comment['content'],
'{}/{}/{}/{}'.format(
APP_URL, project_owner['username'], project['path'], str(id)
), ),
APP_NAME, }
) util.send_moderation_request(user, "comments", comment)
}) return comment
return comment
def send_comment_notification(id):
db = database.get_db()
comment = db.comments.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": comment["user"]})
obj = db.objects.find_one({"_id": comment["object"]})
project = db.projects.find_one({"_id": obj["project"]})
project_owner = db.users.find_one(
{"_id": project["user"], "subscriptions.email": "projects.commented"}
)
if project_owner and project_owner["_id"] != user["_id"]:
mail.send(
{
"to_user": project_owner,
"subject": "{} commented on {}".format(
user["username"], project["name"]
),
"text": "Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}".format(
project_owner["username"],
user["username"],
obj["name"],
project["name"],
comment["content"],
"{}/{}/{}/{}".format(
APP_URL, project_owner["username"], project["path"], str(id)
),
APP_NAME,
),
}
)
def get_comments(user, id): def get_comments(user, id):
db = database.get_db() id = ObjectId(id)
comments = list(db.comments.find({'object': ObjectId(id)})) db = database.get_db()
user_ids = list(map(lambda c:c['user'], comments)) obj = db.objects.find_one({"_id": id}, {"project": 1})
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1})) if not obj:
for comment in comments: raise util.errors.NotFound("Object not found")
for u in users: proj = db.projects.find_one({"_id": obj["project"]}, {"user": 1, "visibility": 1})
if comment['user'] == u['_id']: if not proj:
comment['authorUser'] = u raise util.errors.NotFound("Project not found")
if 'avatar' in u: is_owner = user and (user.get("_id") == proj["user"])
comment['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) if not is_owner and proj["visibility"] != "public":
return {'comments': comments} raise util.errors.Forbidden("This project is private")
query = {
"object": id,
"$or": [
{"moderationRequired": {"$ne": True}},
{"user": user["_id"] if user else None},
],
}
comments = list(db.comments.find(query))
user_ids = list(map(lambda c: c["user"], comments))
users = list(
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
)
for comment in comments:
for u in users:
if comment["user"] == u["_id"]:
comment["authorUser"] = u
if "avatar" in u:
comment["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(u["_id"], u["avatar"])
)
return {"comments": comments}
def delete_comment(user, id, comment_id): def delete_comment(user, id, comment_id):
db = database.get_db() db = database.get_db()
comment = db.comments.find_one({'_id': ObjectId(comment_id)}) comment = db.comments.find_one({"_id": ObjectId(comment_id)})
obj = db.objects.find_one({'_id': ObjectId(id)}) obj = db.objects.find_one({"_id": ObjectId(id)})
if not comment or not obj or obj['_id'] != comment['object']: raise util.errors.NotFound('Comment not found') if not comment or not obj or obj["_id"] != comment["object"]:
project = db.projects.find_one({'_id': obj['project']}) raise util.errors.NotFound("Comment not found")
if comment['user'] != user['_id'] and comment['user'] != project['user']: raise util.errors.Forbidden('You can\'t delete this comment') project = db.projects.find_one({"_id": obj["project"]})
db.comments.remove({'_id': comment['_id']}) if comment["user"] != user["_id"] and not util.can_edit_project(user, project):
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': -1}}) raise util.errors.Forbidden("You can't delete this comment")
return {'deletedComment': comment['_id']} db.comments.delete_one({"_id": comment["_id"]})
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": -1}})
return {"deletedComment": comment["_id"]}

View File

@ -1,190 +1,362 @@
import datetime, re import datetime
import re
import os
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, wif, util from util import database, wif, util, mail
from api import uploads from api import uploads, objects
default_pattern = { default_pattern = {
'warp': { "warp": {
'shafts': 8, "shafts": 8,
'threads': 100, "threading": [{"shaft": 0}] * 100,
'threading': [{'shaft': 0}] * 100, "defaultColour": "178,53,111",
'defaultColour': '178,53,111', "defaultSpacing": 1,
'defaultSpacing': 1, "defaultThickness": 1,
'defaultThickness': 1, "guideFrequency": 8,
}, },
'weft': { "weft": {
'treadles': 8, "treadles": 8,
'threads': 50, "treadling": [{"treadle": 0}] * 50,
'treadling': [{'treadle': 0}] * 50, "defaultColour": "53,69,178",
'defaultColour': '53,69,178', "defaultSpacing": 1,
'defaultSpacing': 1, "defaultThickness": 1,
'defaultThickness': 1 "guideFrequency": 8,
}, },
'tieups': [[]] * 8, "tieups": [[]] * 8,
'colours': ['256,256,256', '0,0,0', '50,0,256', '0,68,256', '0,256,256', '0,256,0', '119,256,0', '256,256,0', '256,136,0', '256,0,0', '256,0,153', '204,0,256', '132,102,256', '102,155,256', '102,256,256', '102,256,102', '201,256,102', '256,256,102', '256,173,102', '256,102,102', '256,102,194', '224,102,256', '31,0,153', '0,41,153', '0,153,153', '0,153,0', '71,153,0', '153,153,0', '153,82,0', '153,0,0', '153,0,92', '122,0,153', '94,68,204', '68,102,204', '68,204,204', '68,204,68', '153,204,68', '204,204,68', '204,136,68', '204,68,68', '204,68,153', '170,68,204', '37,0,204', '0,50,204', '0,204,204', '0,204,0', '89,204,0', '204,204,0', '204,102,0', '204,0,0', '204,0,115', '153,0,204', '168,136,256', '136,170,256', '136,256,256', '136,256,136', '230,256,136', '256,256,136', '256,178,136', '256,136,136', '256,136,204', '240,136,256', '49,34,238', '34,68,238', '34,238,238', '34,238,34', '71,238,34', '238,238,34', '238,82,34', '238,34,34', '238,34,92', '122,34,238', '128,102,238', '102,136,238', '102,238,238', '102,238,102', '187,238,102', '238,238,102', '238,170,102', '238,102,102', '238,102,187', '204,102,238', '178,53,111', '53,69,178'], "colours": [
"256,256,256",
"0,0,0",
"50,0,256",
"0,68,256",
"0,256,256",
"0,256,0",
"119,256,0",
"256,256,0",
"256,136,0",
"256,0,0",
"256,0,153",
"204,0,256",
"132,102,256",
"102,155,256",
"102,256,256",
"102,256,102",
"201,256,102",
"256,256,102",
"256,173,102",
"256,102,102",
"256,102,194",
"224,102,256",
"31,0,153",
"0,41,153",
"0,153,153",
"0,153,0",
"71,153,0",
"153,153,0",
"153,82,0",
"153,0,0",
"153,0,92",
"122,0,153",
"94,68,204",
"68,102,204",
"68,204,204",
"68,204,68",
"153,204,68",
"204,204,68",
"204,136,68",
"204,68,68",
"204,68,153",
"170,68,204",
"37,0,204",
"0,50,204",
"0,204,204",
"0,204,0",
"89,204,0",
"204,204,0",
"204,102,0",
"204,0,0",
"204,0,115",
"153,0,204",
"168,136,256",
"136,170,256",
"136,256,256",
"136,256,136",
"230,256,136",
"256,256,136",
"256,178,136",
"256,136,136",
"256,136,204",
"240,136,256",
"49,34,238",
"34,68,238",
"34,238,238",
"34,238,34",
"71,238,34",
"238,238,34",
"238,82,34",
"238,34,34",
"238,34,92",
"122,34,238",
"128,102,238",
"102,136,238",
"102,238,238",
"102,238,102",
"187,238,102",
"238,238,102",
"238,170,102",
"238,102,102",
"238,102,187",
"204,102,238",
"178,53,111",
"53,69,178",
],
} }
def derive_path(name): def derive_path(name):
path = name.replace(' ', '-').lower() path = name.replace(" ", "-").lower()
return re.sub('[^0-9a-z\-]+', '', path) return re.sub("[^0-9a-z\-]+", "", path)
def get_by_username(username, project_path): def get_by_username(username, project_path):
db = database.get_db() db = database.get_db()
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1}) owner = db.users.find_one({"username": username}, {"_id": 1, "username": 1})
if not owner: if not owner:
raise util.errors.BadRequest('User not found') raise util.errors.BadRequest("User not found")
project = db.projects.find_one({'user': owner['_id'], 'path': project_path}) project = db.projects.find_one({"user": owner["_id"], "path": project_path})
if not project: if not project:
raise util.errors.NotFound('Project not found') raise util.errors.NotFound("Project not found")
project['owner'] = owner project["owner"] = owner
project['fullName'] = owner['username'] + '/' + project['path'] project["fullName"] = owner["username"] + "/" + project["path"]
return project return project
def create(user, data): def create(user, data):
if not data: raise util.errors.BadRequest('Invalid request') if not data:
name = data.get('name', '') raise util.errors.BadRequest("Invalid request")
if len(name) < 3: raise util.errors.BadRequest('A longer name is required') name = data.get("name", "")
db = database.get_db() if len(name) < 3:
raise util.errors.BadRequest("A longer name is required")
db = database.get_db()
path = derive_path(name)
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
raise util.errors.BadRequest("Bad Name")
groups = data.get("groupVisibility", [])
group_visibility = []
for group in groups:
group_visibility.append(ObjectId(group))
proj = {
"name": name,
"description": data.get("description", ""),
"visibility": data.get("visibility", "public"),
"openSource": data.get("openSource", True),
"groupVisibility": group_visibility,
"path": path,
"user": user["_id"],
"createdAt": datetime.datetime.now(),
}
result = db.projects.insert_one(proj)
proj["_id"] = result.inserted_id
proj["owner"] = {"_id": user["_id"], "username": user["username"]}
proj["fullName"] = user["username"] + "/" + proj["path"]
return proj
path = derive_path(name)
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
raise util.errors.BadRequest('Bad Name')
groups = data.get('groupVisibility', [])
group_visibility = []
for group in groups:
group_visibility.append(ObjectId(group))
proj = {
'name': name,
'description': data.get('description', ''),
'visibility': data.get('visibility', 'public'),
'openSource': data.get('openSource', True),
'groupVisibility': group_visibility,
'path': path,
'user': user['_id'],
'createdAt': datetime.datetime.now()
}
result = db.projects.insert_one(proj)
proj['_id'] = result.inserted_id
proj['owner'] = {'_id': user['_id'], 'username': user['username']}
proj['fullName'] = user['username'] + '/' + proj['path']
return proj
def get(user, username, path): def get(user, username, path):
db = database.get_db() db = database.get_db()
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}) owner = db.users.find_one(
if not owner: raise util.errors.NotFound('User not found') {"username": username},
project = db.projects.find_one({'user': owner['_id'], 'path': path}) {
if not project: raise util.errors.NotFound('Project not found') "_id": 1,
if not util.can_view_project(user, project): "username": 1,
raise util.errors.Forbidden('This project is private') "avatar": 1,
"isSilverSupporter": 1,
"isGoldSupporter": 1,
},
)
if not owner:
raise util.errors.NotFound("User not found")
project = db.projects.find_one({"user": owner["_id"], "path": path})
if not project:
raise util.errors.NotFound("Project not found")
if not util.can_view_project(user, project):
raise util.errors.Forbidden("This project is private")
if "avatar" in owner:
owner["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(owner["_id"], owner["avatar"])
)
project["owner"] = owner
project["fullName"] = owner["username"] + "/" + project["path"]
return project
if 'avatar' in owner:
owner['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(owner['_id'], owner['avatar']))
project['owner'] = owner
project['fullName'] = owner['username'] + '/' + project['path']
return project
def update(user, username, project_path, update): def update(user, username, project_path, update):
db = database.get_db() db = database.get_db()
project = get_by_username(username, project_path) project = get_by_username(username, project_path)
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden') if not util.can_edit_project(user, project):
raise util.errors.Forbidden("Forbidden")
current_path = project_path
if "name" in update:
if len(update["name"]) < 3:
raise util.errors.BadRequest("The name is too short.")
path = derive_path(update["name"])
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
raise util.errors.BadRequest(
"You already have a project with a similar name"
)
update["path"] = path
current_path = path
update["groupVisibility"] = list(
map(lambda g: ObjectId(g), update.get("groupVisibility", []))
)
allowed_keys = [
"name",
"description",
"path",
"visibility",
"openSource",
"groupVisibility",
]
updater = util.build_updater(update, allowed_keys)
if updater:
db.projects.update_one({"_id": project["_id"]}, updater)
return get(user, username, current_path)
current_path = project_path
if 'name' in update:
if len(update['name']) < 3: raise util.errors.BadRequest('The name is too short.')
path = derive_path(update['name'])
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
raise util.errors.BadRequest('You already have a project with a similar name')
update['path'] = path
current_path = path
update['groupVisibility'] = list(map(lambda g: ObjectId(g), update.get('groupVisibility', [])))
allowed_keys = ['name', 'description', 'path', 'visibility', 'openSource', 'groupVisibility']
updater = util.build_updater(update, allowed_keys)
if updater:
db.projects.update({'_id': project['_id']}, updater)
return get(user, username, current_path)
def delete(user, username, project_path): def delete(user, username, project_path):
db = database.get_db() db = database.get_db()
project = get_by_username(username, project_path) project = get_by_username(username, project_path)
if project['user'] != user['_id']: if not util.can_edit_project(user, project):
raise util.errors.Forbidden('Forbidden') raise util.errors.Forbidden("Forbidden")
db.projects.remove({'_id': project['_id']}) db.projects.delete_one({"_id": project["_id"]})
db.objects.remove({'project': project['_id']}) db.objects.delete_many({"project": project["_id"]})
return {'deletedProject': project['_id'] } return {"deletedProject": project["_id"]}
def get_objects(user, username, path): def get_objects(user, username, path):
db = database.get_db() db = database.get_db()
project = get_by_username(username, path) project = get_by_username(username, path)
if not project: raise util.errors.NotFound('Project not found') if not project:
if not util.can_view_project(user, project): raise util.errors.NotFound("Project not found")
raise util.errors.Forbidden('This project is private') if not util.can_view_project(user, project):
raise util.errors.Forbidden("This project is private")
query = {"project": project["_id"]}
if not util.can_edit_project(user, project):
query["moderationRequired"] = {"$ne": True}
objs = list(
db.objects.find(
query,
{
"createdAt": 1,
"name": 1,
"description": 1,
"project": 1,
"preview": 1,
"fullPreview": 1,
"type": 1,
"storedName": 1,
"isImage": 1,
"imageBlurHash": 1,
"commentCount": 1,
},
)
)
for obj in objs:
if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
)
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
obj["previewUrl"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(project["_id"], obj["preview"])
)
del obj["preview"]
if obj.get("fullPreview"):
obj["fullPreviewUrl"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(project["_id"], obj["fullPreview"])
)
return objs
objs = list(db.objects.find({'project': project['_id']}, {'createdAt': 1, 'name': 1, 'description': 1, 'project': 1, 'preview': 1, 'type': 1, 'storedName': 1, 'isImage': 1, 'imageBlurHash': 1, 'commentCount': 1}))
for obj in objs:
if obj['type'] == 'file' and 'storedName' in obj:
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
return objs
def create_object(user, username, path, data): def create_object(user, username, path, data):
if not data and not data.get('type'): raise util.errors.BadRequest('Invalid request') if not data and not data.get("type"):
if not data.get('type'): raise util.errors.BadRequest('Object type is required.') raise util.errors.BadRequest("Invalid request")
db = database.get_db() if not data.get("type"):
project = get_by_username(username, path) raise util.errors.BadRequest("Object type is required.")
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden') db = database.get_db()
file_count = db.objects.find({'project': project['_id']}).count() project = get_by_username(username, path)
if not util.can_edit_project(user, project):
raise util.errors.Forbidden("Forbidden")
if data['type'] == 'file': if data["type"] == "file":
if not 'storedName' in data: if "storedName" not in data:
raise util.errors.BadRequest('File stored name must be included') raise util.errors.BadRequest("File stored name must be included")
obj = { obj = {
'project': project['_id'], "project": project["_id"],
'name': data.get('name', 'Untitled file'), "name": data.get("name", "Untitled file"),
'storedName': data['storedName'], "storedName": data["storedName"],
'createdAt': datetime.datetime.now(), "createdAt": datetime.datetime.now(),
'type': 'file', "type": "file",
} "moderationRequired": True,
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', data['storedName'].lower()): }
obj['isImage'] = True if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
result = db.objects.insert_one(obj) obj["isImage"] = True
obj['_id'] = result.inserted_id result = db.objects.insert_one(obj)
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName'])) obj["_id"] = result.inserted_id
if obj.get('isImage'): obj["url"] = uploads.get_presigned_url(
def handle_cb(h): "projects/{0}/{1}".format(project["_id"], obj["storedName"])
db.objects.update_one({'_id': obj['_id']}, {'$set': {'imageBlurHash': h}}) )
uploads.blur_image('projects/' + str(project['_id']) + '/' + data['storedName'], handle_cb) if obj.get("isImage"):
return obj
if data['type'] == 'pattern':
if data.get('wif'):
try:
pattern = wif.loads(data['wif'])
if pattern:
obj = {
'project': project['_id'],
'name': pattern['name'],
'createdAt': datetime.datetime.now(),
'type': 'pattern',
'pattern': pattern
}
result = db.objects.insert_one(obj)
obj['_id'] = result.inserted_id
return obj
except Exception as e:
raise util.errors.BadRequest('Unable to load WIF file. It is either invalid or in a format we cannot understand.')
elif data.get('name'):
pattern = default_pattern.copy()
pattern['warp'].update({'shafts': data.get('shafts', 8)})
pattern['weft'].update({'treadles': data.get('treadles', 8)})
obj = {
'project': project['_id'],
'name': data['name'],
'createdAt': datetime.datetime.now(),
'type': 'pattern',
'pattern': pattern
}
result = db.objects.insert_one(obj)
obj['_id'] = result.inserted_id
return obj
raise util.errors.BadRequest('Unable to create object')
def handle_cb(h):
db.objects.update_one(
{"_id": obj["_id"]}, {"$set": {"imageBlurHash": h}}
)
uploads.blur_image(
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
)
util.send_moderation_request(user, "object", obj)
return obj
if data["type"] == "pattern":
obj = {
"project": project["_id"],
"createdAt": datetime.datetime.now(),
"type": "pattern",
}
if data.get("wif"):
try:
pattern = wif.loads(data["wif"])
if pattern:
obj["name"] = pattern["name"]
obj["pattern"] = pattern
except Exception as e:
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "Error loading WIF file",
"text": "A WIF file failed to parse with error: {}. The contents are below:\n\n{}".format(
e, data["wif"]
),
}
)
raise util.errors.BadRequest(
"Unable to load WIF file. It is either invalid or in a format we cannot understand."
)
else:
pattern = default_pattern.copy()
pattern["warp"].update({"shafts": data.get("shafts", 8)})
pattern["weft"].update({"treadles": data.get("treadles", 8)})
obj["name"] = data.get("name") or "Untitled Pattern"
obj["pattern"] = pattern
result = db.objects.insert_one(obj)
obj["_id"] = result.inserted_id
images = wif.generate_images(obj)
if images:
db.objects.update_one({"_id": obj["_id"]}, {"$set": images})
return objects.get(user, obj["_id"])
raise util.errors.BadRequest("Unable to create object")

View File

@ -1,35 +1,135 @@
import re, datetime import datetime
import pymongo
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, util, mail from util import database, util
from api import uploads, groups from api import uploads, objects, groups
def get_users(user): def get_users(user):
db = database.get_db() db = database.get_db()
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed') if not util.is_root(user):
users = list(db.users.find({}, {'username': 1, 'avatar': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1, 'roles': 1, 'groups': 1}).sort('lastSeenAt', -1)) raise util.errors.Forbidden("Not allowed")
group_ids = [] users = list(
for u in users: group_ids += u.get('groups', []) db.users.find(
groups = list(db.groups.find({'_id': {'$in': group_ids}})) {},
projects = list(db.projects.find({}, {'name': 1, 'path': 1, 'user': 1})) {
for u in users: "username": 1,
if 'avatar' in u: "avatar": 1,
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar'])) "email": 1,
u['projects'] = [] "createdAt": 1,
for p in projects: "lastSeenAt": 1,
if p['user'] == u['_id']: "roles": 1,
u['projects'].append(p) "groups": 1,
u['groupMemberships'] = [] },
if u.get('groups'): )
for g in groups: .sort("lastSeenAt", -1)
if g['_id'] in u.get('groups', []): .limit(200)
u['groupMemberships'].append(g) )
return {'users': users} group_ids = []
for u in users:
group_ids += u.get("groups", [])
groups = list(db.groups.find({"_id": {"$in": group_ids}}, {"name": 1}))
projects = list(db.projects.find({}, {"name": 1, "path": 1, "user": 1}))
for u in users:
if "avatar" in u:
u["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
)
u["projects"] = []
for p in projects:
if p["user"] == u["_id"]:
u["projects"].append(p)
u["groupMemberships"] = []
if u.get("groups"):
for g in groups:
if g["_id"] in u.get("groups", []):
u["groupMemberships"].append(g)
return {"users": users}
def get_groups(user): def get_groups(user):
db = database.get_db() db = database.get_db()
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed') if not util.is_root(user):
groups = list(db.groups.find({})) raise util.errors.Forbidden("Not allowed")
for group in groups: groups = list(db.groups.find({}))
group['memberCount'] = db.users.find({'groups': group['_id']}).count() for group in groups:
return {'groups': groups} group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
return {"groups": groups}
def get_moderation(user):
db = database.get_db()
if not util.is_root(user):
raise util.errors.Forbidden("Not allowed")
object_list = list(db.objects.find({"moderationRequired": True}))
for obj in object_list:
if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
)
comment_list = list(db.comments.find({"moderationRequired": True}))
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
for entry in group_entry_list:
for a in entry.get("attachments", []):
if a["type"] == "file" and "storedName" in a:
a["url"] = uploads.get_presigned_url(
"groups/{0}/{1}".format(entry["group"], a["storedName"])
)
group_topic_reply_list = list(
db.groupForumTopicReplies.find({"moderationRequired": True})
)
for reply in group_topic_reply_list:
for a in reply.get("attachments", []):
if a["type"] == "file" and "storedName" in a:
a["url"] = uploads.get_presigned_url(
"groups/{0}/topics/{1}/{2}".format(
reply["group"], reply["topic"], a["storedName"]
)
)
return {
"objects": object_list,
"comments": comment_list,
"users": user_list,
"groups": group_list,
"groupEntries": group_entry_list,
"groupForumTopicReplies": group_topic_reply_list,
}
def moderate(user, item_type, item_id, allowed):
db = database.get_db()
if not util.is_root(user):
raise util.errors.Forbidden("Not allowed")
if item_type not in [
"objects",
"comments",
"users",
"groups",
"groupEntries",
"groupForumTopicReplies",
]:
raise util.errors.BadRequest("Invalid item type")
item_id = ObjectId(item_id)
item = db[item_type].find_one({"_id": item_id})
# For now, handle only allowed moderations.
# Disallowed will be manually managed.
if item and allowed:
db[item_type].update_one(
{"_id": item_id},
{
"$set": {
"moderationRequired": False,
"moderated": True,
"moderatedAt": datetime.datetime.now(),
"moderatedBy": user["_id"],
}
},
)
if item_type == "comments":
objects.send_comment_notification(item_id)
if item_type == "groupEntries":
groups.send_entry_notification(item_id)
if item_type == "groupForumTopicReplies":
groups.send_forum_topic_reply_notification(item_id)
return {"success": True}

View File

@ -1,74 +1,253 @@
import re, random import re
import random
import pymongo import pymongo
from util import database, util from util import database, util
from api import uploads from api import uploads
def all(user, params): def all(user, params):
if not params or 'query' not in params: raise util.errors.BadRequest('Username parameter needed') if not params or "query" not in params:
expression = re.compile(params['query'], re.IGNORECASE) raise util.errors.BadRequest("Query parameter needed")
db = database.get_db() expression = re.compile(params["query"], re.IGNORECASE)
db = database.get_db()
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(10).sort('username', pymongo.ASCENDING)) users = list(
for u in users: db.users.find(
if 'avatar' in u: {"username": expression},
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) {"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
)
.limit(10)
.sort("username", pymongo.ASCENDING)
)
for u in users:
if "avatar" in u:
u["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(u["_id"], u["avatar"])
)
projects = list(db.projects.find({'name': expression, '$or': [ my_projects = list(db.projects.find({"user": user["_id"]}, {"name": 1, "path": 1}))
{'user': user['_id']}, objects = list(
{'groupVisibility': {'$in': user.get('groups', [])}}, db.objects.find(
{'visibility': 'public'} {
]}, {'name': 1, 'path': 1, 'user': 1}).limit(5)) "project": {"$in": list(map(lambda p: p["_id"], my_projects))},
proj_users = list(db.users.find({'_id': {'$in': list(map(lambda p:p['user'], projects))}}, {'username': 1, 'avatar': 1})) "name": expression,
for proj in projects: },
for proj_user in proj_users: {"name": 1, "type": 1, "isImage": 1, "project": 1},
if proj['user'] == proj_user['_id']: )
proj['owner'] = proj_user )
proj['fullName'] = proj_user['username'] + '/' + proj['path'] for o in objects:
if 'avatar' in proj_user: proj = next(p for p in my_projects if p["_id"] == o["project"])
proj['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(proj_user['_id'], proj_user['avatar'])) if proj:
o["path"] = user["username"] + "/" + proj["path"] + "/" + str(o["_id"])
groups = list(db.groups.find({'name': expression, 'unlisted': {'$ne': True}}, {'name': 1, 'closed': 1}).limit(5)) projects = list(
db.projects.find(
{
"name": expression,
"$or": [
{"user": user["_id"]},
{"groupVisibility": {"$in": user.get("groups", [])}},
{"visibility": "public"},
],
},
{"name": 1, "path": 1, "user": 1},
).limit(10)
)
proj_users = list(
db.users.find(
{"_id": {"$in": list(map(lambda p: p["user"], projects))}},
{"username": 1, "avatar": 1},
)
)
for proj in projects:
for proj_user in proj_users:
if proj["user"] == proj_user["_id"]:
proj["owner"] = proj_user
proj["fullName"] = proj_user["username"] + "/" + proj["path"]
if "avatar" in proj_user:
proj["owner"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(proj_user["_id"], proj_user["avatar"])
)
groups = list(
db.groups.find(
{"name": expression, "unlisted": {"$ne": True}}, {"name": 1, "closed": 1}
).limit(5)
)
return {"users": users, "projects": projects, "groups": groups, "objects": objects}
return {'users': users, 'projects': projects, 'groups': groups}
def users(user, params): def users(user, params):
if not user: raise util.errors.Forbidden('You need to be logged in') if not user:
if not params or 'username' not in params: raise util.errors.BadRequest('Username parameter needed') raise util.errors.Forbidden("You need to be logged in")
expression = re.compile(params['username'], re.IGNORECASE) if not params or "username" not in params:
db = database.get_db() raise util.errors.BadRequest("Username parameter needed")
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(5).sort('username', pymongo.ASCENDING)) expression = re.compile(params["username"], re.IGNORECASE)
for u in users: db = database.get_db()
if 'avatar' in u: users = list(
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) db.users.find(
return {'users': users} {"username": expression},
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
)
.limit(5)
.sort("username", pymongo.ASCENDING)
)
for u in users:
if "avatar" in u:
u["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(u["_id"], u["avatar"])
)
return {"users": users}
def discover(user):
if not user: raise util.errors.Forbidden('You need to be logged in')
db = database.get_db() def discover(user, count=3):
projects = [] db = database.get_db()
users = [] projects = []
count = 3 users = []
groups = []
all_projects = list(db.projects.find({'name': {'$not': re.compile('my new project', re.IGNORECASE)}, 'visibility': 'public', 'user': {'$ne': user['_id']}}, {'name': 1, 'path': 1, 'user': 1})) all_projects_query = {
random.shuffle(all_projects) "name": {"$not": re.compile("my new project", re.IGNORECASE)},
for p in all_projects: "visibility": "public",
if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}): }
owner = db.users.find_one({'_id': p['user']}, {'username': 1}) if user and user.get("_id"):
p['fullName'] = owner['username'] + '/' + p['path'] all_projects_query["user"] = {"$ne": user["_id"]}
projects.append(p) all_projects = list(
if len(projects) >= count: break db.projects.find(all_projects_query, {"name": 1, "path": 1, "user": 1})
)
random.shuffle(all_projects)
for p in all_projects:
if db.objects.find_one(
{"project": p["_id"], "name": {"$ne": "Untitled pattern"}}
):
owner = db.users.find_one({"_id": p["user"]}, {"username": 1, "avatar": 1})
p["fullName"] = owner["username"] + "/" + p["path"]
p["owner"] = owner
if "avatar" in p["owner"]:
p["owner"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(p["owner"]["_id"], p["owner"]["avatar"])
)
projects.append(p)
if len(projects) >= count:
break
interest_fields = ['bio', 'avatar', 'website', 'facebook', 'twitter', 'instagram', 'location'] interest_fields = [
all_users = list(db.users.find({'_id': {'$ne': user['_id']}, '$or': list(map(lambda f: {f: {'$exists': True}}, interest_fields))}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})) "bio",
random.shuffle(all_users) "avatar",
for u in all_users: "website",
if 'avatar' in u: "facebook",
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar'])) "twitter",
users.append(u) "instagram",
if len(users) >= count: break "location",
]
all_users_query = {
"$or": list(map(lambda f: {f: {"$exists": True}}, interest_fields))
}
if user and user.get("_id"):
all_users_query["_id"] = {"$ne": user["_id"]}
all_users = list(
db.users.find(
all_users_query,
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
)
)
random.shuffle(all_users)
for u in all_users:
if "avatar" in u:
u["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(u["_id"], u["avatar"])
)
if user:
u["following"] = u["_id"] in list(
map(lambda f: f["user"], user.get("following", []))
)
users.append(u)
if len(users) >= count:
break
return { all_groups = list(
'highlightProjects': projects, db.groups.find(
'highlightUsers': users, {"advertised": True, "name": {"$ne": "My group"}}, {"name": 1, "image": 1}
} )
)
random.shuffle(all_groups)
for g in all_groups:
if "image" in g:
g["imageUrl"] = uploads.get_presigned_url(
"groups/{0}/{1}".format(g["_id"], g["image"])
)
groups.append(g)
if len(groups) >= count:
break
return {
"highlightProjects": projects,
"highlightUsers": users,
"highlightGroups": groups,
}
def explore(page=1):
db = database.get_db()
per_page = 10
project_map = {}
user_map = {}
all_public_projects = list(
db.projects.find(
{
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
"visibility": "public",
},
{"name": 1, "path": 1, "user": 1},
)
)
all_public_project_ids = list(map(lambda p: p["_id"], all_public_projects))
for project in all_public_projects:
project_map[project["_id"]] = project
objects = list(
db.objects.find(
{
"project": {"$in": all_public_project_ids},
"name": {"$not": re.compile("untitled pattern", re.IGNORECASE)},
"preview": {"$exists": True},
},
{"project": 1, "name": 1, "createdAt": 1, "type": 1, "preview": 1},
)
.sort("createdAt", pymongo.DESCENDING)
.skip((page - 1) * per_page)
.limit(per_page)
)
for object in objects:
object["projectObject"] = project_map.get(object["project"])
if "preview" in object and ".png" in object["preview"]:
object["previewUrl"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(object["project"], object["preview"])
)
del object["preview"]
authors = list(
db.users.find(
{
"_id": {
"$in": list(
map(lambda o: o.get("projectObject", {}).get("user"), objects)
)
}
},
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
)
)
for a in authors:
if "avatar" in a:
a["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(a["_id"], a["avatar"])
)
user_map[a["_id"]] = a
for object in objects:
object["userObject"] = user_map.get(object.get("projectObject", {}).get("user"))
object["projectObject"]["owner"] = user_map.get(
object.get("projectObject", {}).get("user")
)
return {"objects": objects}

41
api/api/snippets.py Normal file
View File

@ -0,0 +1,41 @@
import datetime
from bson.objectid import ObjectId
from util import database, util
def list_for_user(user):
db = database.get_db()
snippets = db.snippets.find({"user": user["_id"]}).sort("createdAt", -1)
return {"snippets": list(snippets)}
def create(user, data):
if not data:
raise util.errors.BadRequest("Invalid request")
name = data.get("name", "")
snippet_type = data.get("type", "")
if len(name) < 3:
raise util.errors.BadRequest("A longer name is required")
if snippet_type not in ["warp", "weft"]:
raise util.errors.BadRequest("Invalid snippet type")
db = database.get_db()
snippet = {
"name": name,
"user": user["_id"],
"createdAt": datetime.datetime.utcnow(),
"type": snippet_type,
"threading": data.get("threading", []),
"treadling": data.get("treadling", []),
}
result = db.snippets.insert_one(snippet)
snippet["_id"] = result.inserted_id
return snippet
def delete(user, id):
db = database.get_db()
snippet = db.snippets.find_one({"_id": ObjectId(id), "user": user["_id"]})
if not snippet:
raise util.errors.NotFound("Snippet not found")
db.snippets.delete_one({"_id": snippet["_id"]})
return {"deletedSnippet": snippet["_id"]}

View File

@ -1,83 +1,108 @@
import os, time, re import os
import time
import re
from threading import Thread from threading import Thread
from bson.objectid import ObjectId from bson.objectid import ObjectId
import boto3 import boto3
from botocore.client import Config
import blurhash import blurhash
from util import database from util import database, util
from api.groups import has_group_permission
def sanitise_filename(s): def sanitise_filename(s):
bad_chars = re.compile('[^a-zA-Z0-9_.]') bad_chars = re.compile("[^a-zA-Z0-9_.]")
s = bad_chars.sub('_', s) s = bad_chars.sub("_", s)
return s return s
def get_s3(): def get_s3():
session = boto3.session.Session() session = boto3.session.Session()
s3_client = session.client(
service_name="s3",
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
endpoint_url=os.environ["AWS_S3_ENDPOINT"],
)
return s3_client
s3_client = session.client(
service_name='s3',
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
endpoint_url=os.environ['AWS_S3_ENDPOINT'],
)
return s3_client
def get_presigned_url(path): def get_presigned_url(path):
return os.environ['AWS_S3_ENDPOINT'] + os.environ['AWS_S3_BUCKET'] + '/' + path return os.environ["AWS_S3_ENDPOINT"] + os.environ["AWS_S3_BUCKET"] + "/" + path
s3 = get_s3() s3 = get_s3()
return s3.generate_presigned_url('get_object', return s3.generate_presigned_url(
Params = { "get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
'Bucket': os.environ['AWS_S3_BUCKET'], )
'Key': path
}
) def upload_file(path, data):
s3 = get_s3()
s3.upload_fileobj(
data,
os.environ["AWS_S3_BUCKET"],
path,
)
def get_file(key): def get_file(key):
s3 = get_s3() s3 = get_s3()
return s3.get_object( return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
Bucket = os.environ['AWS_S3_BUCKET'],
Key = key
)
def generate_file_upload_request(user, file_name, file_size, file_type, for_type, for_id):
if int(file_size) > (1024 * 1024 * 30): # 30MB
raise util.errors.BadRequest('File size is too big')
db = database.get_db()
allowed = False
path = ''
if for_type == 'project':
project = db.projects.find_one(ObjectId(for_id))
allowed = project and project.get('user') == user['_id']
path = 'projects/' + for_id + '/'
if for_type == 'user':
allowed = for_id == str(user['_id'])
path = 'users/' + for_id + '/'
if for_type == 'group':
allowed = ObjectId(for_id) in user.get('groups', [])
path = 'groups/' + for_id + '/'
if not allowed:
raise util.errors.Forbidden('You\'re not allowed to upload this file')
file_body, file_extension = os.path.splitext(file_name) def generate_file_upload_request(
new_name = sanitise_filename('{0}_{1}{2}'.format(file_body or file_name, int(time.time()), file_extension or '')) user, file_name, file_size, file_type, for_type, for_id
s3 = get_s3() ):
signed_url = s3.generate_presigned_url('put_object', if int(file_size) > (1024 * 1024 * 30): # 30MB
Params = { raise util.errors.BadRequest("File size is too big")
'Bucket': os.environ['AWS_S3_BUCKET'], db = database.get_db()
'Key': path + new_name, allowed = False
'ContentType': file_type path = ""
} if for_type == "project":
) project = db.projects.find_one(ObjectId(for_id))
return { allowed = project and util.can_edit_project(user, project)
'signedRequest': signed_url, path = "projects/" + for_id + "/"
'fileName': new_name if for_type == "user":
} allowed = for_id == str(user["_id"])
path = "users/" + for_id + "/"
if for_type == "group":
allowed = ObjectId(for_id) in user.get("groups", [])
path = "groups/" + for_id + "/"
if for_type == "groupForum":
topic = db.groupForumTopics.find_one(ObjectId(for_id))
if not topic:
raise util.errors.NotFound("Topic not found")
group = db.groups.find_one(topic["group"])
if not group:
raise util.errors.NotFound("Group not found")
allowed = has_group_permission(user, group, "postForumTopicReplies")
path = "groups/" + str(group["_id"]) + "/topics/" + for_id + "/"
if not allowed:
raise util.errors.Forbidden("You're not allowed to upload this file")
file_body, file_extension = os.path.splitext(file_name)
new_name = sanitise_filename(
"{0}_{1}{2}".format(
file_body or file_name, int(time.time()), file_extension or ""
)
)
s3 = get_s3()
signed_url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": os.environ["AWS_S3_BUCKET"],
"Key": path + new_name,
"ContentType": file_type,
},
)
return {"signedRequest": signed_url, "fileName": new_name}
def handle_blur_image(key, func): def handle_blur_image(key, func):
f = get_file(key)['Body'] f = get_file(key)["Body"]
bhash = blurhash.encode(f, x_components=4, y_components=3) bhash = blurhash.encode(f, x_components=4, y_components=3)
func(bhash) func(bhash)
def blur_image(key, func): def blur_image(key, func):
thr = Thread(target=handle_blur_image, args=[key, func]) thr = Thread(target=handle_blur_image, args=[key, func])
thr.start() thr.start()

View File

@ -1,93 +1,355 @@
import datetime import datetime
import re
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, util from util import database, util
from api import uploads from api import uploads
def me(user): def me(user):
return { db = database.get_db()
'_id': user['_id'], return {
'username': user['username'], "_id": user["_id"],
'bio': user.get('bio'), "username": user["username"],
'email': user.get('email'), "bio": user.get("bio"),
'avatar': user.get('avatar'), "email": user.get("email"),
'avatarUrl': user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])), "avatar": user.get("avatar"),
'roles': user.get('roles', []), "avatarUrl": user.get("avatar")
'groups': user.get('groups', []), and uploads.get_presigned_url(
'subscriptions': user.get('subscriptions'), "users/{0}/{1}".format(user["_id"], user["avatar"])
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []), ),
'isSilverSupporter': user.get('isSilverSupporter'), "roles": user.get("roles", []),
'isGoldSupporter': user.get('isGoldSupporter'), "groups": user.get("groups", []),
} "subscriptions": user.get("subscriptions"),
"finishedTours": user.get("completedTours", []) + user.get("skippedTours", []),
"isSilverSupporter": user.get("isSilverSupporter"),
"isGoldSupporter": user.get("isGoldSupporter"),
"followerCount": db.users.count_documents({"following.user": user["_id"]}),
}
def get(user, username): def get(user, username):
db = database.get_db() db = database.get_db()
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}) fetch_user = db.users.find_one(
if not fetch_user: {"username": username},
raise util.errors.NotFound('User not found') {
project_query = {'user': fetch_user['_id']} "username": 1,
if not user or not user['_id'] == fetch_user['_id']: "createdAt": 1,
project_query['visibility'] = 'public' "avatar": 1,
"avatarBlurHash": 1,
"bio": 1,
"location": 1,
"website": 1,
"twitter": 1,
"facebook": 1,
"linkedIn": 1,
"instagram": 1,
"isSilverSupporter": 1,
"isGoldSupporter": 1,
},
)
if not fetch_user:
raise util.errors.NotFound("User not found")
project_query = {"user": fetch_user["_id"]}
if not user or not user["_id"] == fetch_user["_id"]:
project_query["visibility"] = "public"
if "avatar" in fetch_user:
fetch_user["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(str(fetch_user["_id"]), fetch_user["avatar"])
)
if user:
fetch_user["following"] = fetch_user["_id"] in list(
map(lambda f: f["user"], user.get("following", []))
)
user_projects = list(
db.projects.find(
project_query, {"name": 1, "path": 1, "description": 1, "visibility": 1}
).limit(15)
)
for project in user_projects:
project["fullName"] = fetch_user["username"] + "/" + project["path"]
project["owner"] = {
"_id": fetch_user["_id"],
"username": fetch_user["username"],
"avatar": fetch_user.get("avatar"),
"avatarUrl": fetch_user.get("avatarUrl"),
}
fetch_user["projects"] = user_projects
return fetch_user
fetch_user['projects'] = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
for project in fetch_user['projects']:
project['fullName'] = fetch_user['username'] + '/' + project['path']
if 'avatar' in fetch_user:
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
return fetch_user
def update(user, username, data): def update(user, username, data):
if not data: raise util.errors.BadRequest('Invalid request') if not data:
db = database.get_db() raise util.errors.BadRequest("Invalid request")
if user['username'] != username: db = database.get_db()
raise util.errors.Forbidden('Not allowed') if user["username"] != username:
allowed_keys = ['username', 'avatar', 'bio', 'location', 'website', 'twitter', 'facebook', 'linkedIn', 'instagram'] raise util.errors.Forbidden("Not allowed")
if 'username' in data: allowed_keys = [
if not data.get('username') or len(data['username']) < 3: "username",
raise util.errors.BadRequest('New username is not valid') "avatar",
if db.users.find({'username': data['username'].lower()}).count(): "bio",
raise util.errors.BadRequest('A user with this username already exists') "location",
data['username'] = data['username'].lower() "website",
if 'avatar' in data and len(data['avatar']) > 3: # Not a default avatar "twitter",
def handle_cb(h): "facebook",
db.users.update_one({'_id': user['_id']}, {'$set': {'avatarBlurHash': h}}) "linkedIn",
uploads.blur_image('users/' + str(user['_id']) + '/' + data['avatar'], handle_cb) "instagram",
updater = util.build_updater(data, allowed_keys) ]
if updater: if "username" in data:
db.users.update({'username': username}, updater) if not data.get("username") or len(data["username"]) < 3:
return get(user, data.get('username', username)) raise util.errors.BadRequest("New username is not valid")
if not re.match("^[a-z0-9_]+$", data["username"]):
raise util.errors.BadRequest(
"Usernames can only contain letters, numbers, and underscores"
)
if db.users.count_documents({"username": data["username"].lower()}):
raise util.errors.BadRequest("A user with this username already exists")
data["username"] = data["username"].lower()
if data.get("avatar") and len(data["avatar"]) > 3: # Not a default avatar
def handle_cb(h):
db.users.update_one({"_id": user["_id"]}, {"$set": {"avatarBlurHash": h}})
uploads.blur_image(
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
)
updater = util.build_updater(data, allowed_keys)
if updater:
if "avatar" in updater.get(
"$unset", {}
): # Also unset blurhash if removing avatar
updater["$unset"]["avatarBlurHash"] = ""
if "$set" in updater and (
"avatar" in data or "bio" in data or "website" in data or "username" in data
):
updater["$set"]["moderationRequired"] = True
util.send_moderation_request(user, "users", user)
db.users.update_one({"username": username}, updater)
return get(user, data.get("username", username))
def finish_tour(user, username, tour, status): def finish_tour(user, username, tour, status):
db = database.get_db() db = database.get_db()
if user['username'] != username: if user["username"] != username:
raise util.errors.Forbidden('Not allowed') raise util.errors.Forbidden("Not allowed")
key = 'completedTours' if status == 'completed' else 'skippedTours' key = "completedTours" if status == "completed" else "skippedTours"
db.users.update_one({'_id': user['_id']}, {'$addToSet': {key: tour}}) db.users.update_one({"_id": user["_id"]}, {"$addToSet": {key: tour}})
return {'finishedTour': tour} return {"finishedTour": tour}
def get_projects(user, id): def get_projects(user, id):
db = database.get_db() db = database.get_db()
u = db.users.find_one(id, {'username': 1, 'avatar': 1}) u = db.users.find_one(id, {"username": 1, "avatar": 1})
if not u: raise util.errors.NotFound('User not found') if not u:
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar'])) raise util.errors.NotFound("User not found")
projects = [] if "avatar" in u:
for project in db.projects.find({'user': ObjectId(id)}): u["avatarUrl"] = uploads.get_presigned_url(
project['owner'] = u "users/{0}/{1}".format(str(u["_id"]), u["avatar"])
project['fullName'] = u['username'] + '/' + project['path'] )
projects.append(project) projects = []
return projects project_query = {"user": ObjectId(id)}
if not user or not user["_id"] == ObjectId(id):
project_query["visibility"] = "public"
for project in db.projects.find(project_query):
project["owner"] = u
project["fullName"] = u["username"] + "/" + project["path"]
projects.append(project)
return projects
def create_email_subscription(user, username, subscription): def create_email_subscription(user, username, subscription):
db = database.get_db() db = database.get_db()
if user['username'] != username: raise util.errors.Forbidden('Forbidden') if user["username"] != username:
u = db.users.find_one({'username': username}) raise util.errors.Forbidden("Forbidden")
db.users.update({'_id': u['_id']}, {'$addToSet': {'subscriptions.email': subscription}}) u = db.users.find_one({"username": username})
subs = db.users.find_one(u['_id'], {'subscriptions': 1}) db.users.update_one(
return {'subscriptions': subs.get('subscriptions', {})} {"_id": u["_id"]}, {"$addToSet": {"subscriptions.email": subscription}}
)
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
return {"subscriptions": subs.get("subscriptions", {})}
def delete_email_subscription(user, username, subscription): def delete_email_subscription(user, username, subscription):
db = database.get_db() db = database.get_db()
if user['username'] != username: raise util.errors.Forbidden('Forbidden') if user["username"] != username:
u = db.users.find_one({'username': username}) raise util.errors.Forbidden("Forbidden")
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}}) u = db.users.find_one({"username": username})
subs = db.users.find_one(u['_id'], {'subscriptions': 1}) db.users.update_one(
return {'subscriptions': subs.get('subscriptions', {})} {"_id": u["_id"]}, {"$pull": {"subscriptions.email": subscription}}
)
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
return {"subscriptions": subs.get("subscriptions", {})}
def create_follower(user, username):
db = database.get_db()
target_user = db.users.find_one({"username": username.lower()})
if not target_user:
raise util.errors.NotFound("User not found")
if target_user["_id"] == user["_id"]:
raise util.errors.BadRequest("Cannot follow yourself")
follow_object = {
"user": target_user["_id"],
"followedAt": datetime.datetime.utcnow(),
}
db.users.update_one(
{"_id": user["_id"]}, {"$addToSet": {"following": follow_object}}
)
return follow_object
def delete_follower(user, username):
db = database.get_db()
target_user = db.users.find_one({"username": username.lower()})
if not target_user:
raise util.errors.NotFound("User not found")
db.users.update_one(
{"_id": user["_id"]}, {"$pull": {"following": {"user": target_user["_id"]}}}
)
return {"unfollowed": True}
def get_feed(user, username):
db = database.get_db()
if user["username"] != username:
raise util.errors.Forbidden("Forbidden")
following_user_ids = list(map(lambda f: f["user"], user.get("following", [])))
following_project_ids = list(
map(
lambda p: p["_id"],
db.projects.find(
{"user": {"$in": following_user_ids}, "visibility": "public"},
{"_id": 1},
),
)
)
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days=365)
# Fetch the items for the feed
recent_projects = list(
db.projects.find(
{
"_id": {"$in": following_project_ids},
"createdAt": {"$gt": one_year_ago},
"visibility": "public",
},
{"user": 1, "createdAt": 1, "name": 1, "path": 1, "visibility": 1},
)
.sort("createdAt", -1)
.limit(20)
)
recent_objects = list(
db.objects.find(
{
"project": {"$in": following_project_ids},
"createdAt": {"$gt": one_year_ago},
},
{"project": 1, "createdAt": 1, "name": 1},
)
.sort("createdAt", -1)
.limit(30)
)
recent_comments = list(
db.comments.find(
{"user": {"$in": following_user_ids}, "createdAt": {"$gt": one_year_ago}},
{"user": 1, "createdAt": 1, "object": 1, "content": 1},
)
.sort("createdAt", -1)
.limit(30)
)
# Process objects (as don't know the user)
object_project_ids = list(map(lambda o: o["project"], recent_objects))
object_projects = list(
db.projects.find(
{"_id": {"$in": object_project_ids}, "visibility": "public"}, {"user": 1}
)
)
for obj in recent_objects:
for proj in object_projects:
if obj["project"] == proj["_id"]:
obj["user"] = proj.get("user")
# Process comments as don't know the project
comment_object_ids = list(map(lambda c: c["object"], recent_comments))
comment_objects = list(
db.objects.find({"_id": {"$in": comment_object_ids}}, {"project": 1})
)
for com in recent_comments:
for obj in comment_objects:
if com["object"] == obj["_id"]:
com["project"] = obj.get("project")
# Prepare the feed items, and sort it
feed_items = []
for p in recent_projects:
p["feedType"] = "project"
feed_items.append(p)
for o in recent_objects:
o["feedType"] = "object"
feed_items.append(o)
for c in recent_comments:
c["feedType"] = "comment"
feed_items.append(c)
feed_items.sort(key=lambda d: d["createdAt"], reverse=True)
feed_items = feed_items[:20]
# Post-process the feed, adding user/project objects
feed_user_ids = set()
feed_project_ids = set()
for f in feed_items:
feed_user_ids.add(f.get("user"))
feed_project_ids.add(f.get("project"))
feed_projects = list(
db.projects.find(
{"_id": {"$in": list(feed_project_ids)}, "visibility": "public"},
{"name": 1, "path": 1, "user": 1, "visibility": 1},
)
)
feed_users = list(
db.users.find(
{
"$or": [
{"_id": {"$in": list(feed_user_ids)}},
{"_id": {"$in": list(map(lambda p: p["user"], feed_projects))}},
]
},
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
)
)
for u in feed_users:
if "avatar" in u:
u["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
)
feed_user_map = {}
feed_project_map = {}
for u in feed_users:
feed_user_map[str(u["_id"])] = u
for p in feed_projects:
feed_project_map[str(p["_id"])] = p
for f in feed_items:
if f.get("user") and feed_user_map.get(str(f["user"])):
f["userObject"] = feed_user_map.get(str(f["user"]))
if f.get("project") and feed_project_map.get(str(f["project"])):
f["projectObject"] = feed_project_map.get(str(f["project"]))
if f.get("projectObject", {}).get("user") and feed_user_map.get(
str(f["projectObject"]["user"])
):
f["projectObject"]["userObject"] = feed_user_map.get(
str(f["projectObject"]["user"])
)
# Filter out orphaned or non-public comments/objects
def filter_func(f):
if f["feedType"] == "comment" and not f.get("projectObject"):
return False
if f["feedType"] == "object" and not f.get("projectObject"):
return False
return True
feed_items = list(filter(filter_func, feed_items))
return {"feed": feed_items}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
{
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": [
"*"
]
},
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::treadl-dev/*"
]
}
]
}

View File

@ -11,7 +11,7 @@
"s3:GetObject" "s3:GetObject"
], ],
"Resource": [ "Resource": [
"arn:aws:s3::treadl/*" "arn:aws:s3:::treadl/*"
] ]
} }
] ]

View File

@ -0,0 +1,34 @@
# Script to migrate from the old data: string URLs for images to image files directly on S3.
from pymongo import MongoClient
import base64
import os
db = MongoClient("mongodb://USER:PASS@db/admin")["treadl"]
os.makedirs("migration_projects/projects", exist_ok=True)
for obj in db.objects.find(
{"preview": {"$regex": "^data:"}}, {"preview": 1, "project": 1}
):
preview = obj["preview"]
preview = preview.replace("data:image/png;base64,", "")
imgdata = base64.b64decode(preview)
filename = "some_image.png"
os.makedirs("migration_projects/projects/" + str(obj["project"]), exist_ok=True)
with open(
"migration_projects/projects/"
+ str(obj["project"])
+ "/preview_"
+ str(obj["_id"])
+ ".png",
"wb",
) as f:
f.write(imgdata)
db.objects.update_one(
{"_id": obj["_id"]},
{"$set": {"previewNew": "preview_" + str(obj["_id"]) + ".png"}},
)
# exit()

2384
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,33 @@
[tool.poetry] [tool.poetry]
name = "api" name = "api"
version = "0.1.0" version = "0.1.0"
package-mode = false
description = "Treadl API" description = "Treadl API"
authors = ["Will <will@treadl.com>"] authors = ["Will <will@treadl.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.12"
flask = "^1.1.1" flask = "^3.0.3"
bcrypt = "^3.1.7" bcrypt = "^4.2.0"
pyjwt = "^1.7.1" pyjwt = "^2.9.0"
boto3 = "^1.10.50" boto3 = "^1.35.34"
flask-cors = "^3.0.8" flask-cors = "^5.0.0"
dnspython = "^1.16.0" dnspython = "^2.6.1"
requests = "^2.22.0" requests = "^2.32.3"
botocore = "^1.13.50" pymongo = "^4.10.1"
pymongo = "^3.10.1" flask_limiter = "^3.8.0"
flask_limiter = "^1.3.1" firebase-admin = "^6.5.0"
werkzeug = "^1.0.1" blurhash-python = "^1.2.2"
firebase-admin = "^4.3.0" gunicorn = "^23.0.0"
blurhash-python = "^1.0.2" sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
gunicorn = "^20.0.4" pyOpenSSL = "^24.2.1"
sentry-sdk = {extras = ["flask"], version = "^1.5.10"} webargs = "^8.6.0"
pyOpenSSL = "^22.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.9"
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api" build-backend = "poetry.masonry.api"

View File

@ -1,12 +1,12 @@
import os import os
from pymongo import MongoClient from pymongo import MongoClient
from flask import g
db = None db = None
def get_db():
global db
if db is None: def get_db():
db = MongoClient(os.environ['MONGO_URL'])[os.environ['MONGO_DATABASE']] global db
return db
if db is None:
db = MongoClient(os.environ["MONGO_URL"])[os.environ["MONGO_DATABASE"]]
return db

View File

@ -2,33 +2,39 @@ import os
from threading import Thread from threading import Thread
import requests import requests
def handle_send(data):
if 'from' not in data:
data['from'] = '{} <{}>'.format(os.environ.get('APP_NAME'), os.environ.get('FROM_EMAIL'))
if 'to_user' in data:
user = data['to_user']
data['to'] = user['username'] + ' <' + user['email'] + '>'
del data['to_user']
data['text'] += '\n\nFrom the team at {0}\n\n\n\n--\n\nDon\'t like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}'.format(
os.environ.get('APP_NAME'),
os.environ.get('APP_URL'),
os.environ.get('CONTACT_EMAIL')
)
data['reply-to'] = os.environ.get('CONTACT_EMAIL')
base_url = os.environ.get('MAILGUN_URL') def handle_send(data):
api_key = os.environ.get('MAILGUN_KEY') if "from" not in data:
if base_url and api_key: data["from"] = "{} <{}>".format(
auth = ('api', api_key) os.environ.get("APP_NAME"), os.environ.get("FROM_EMAIL")
try: )
response = requests.post(base_url, auth=auth, data=data) if "to_user" in data:
response.raise_for_status() user = data["to_user"]
except: data["to"] = user["username"] + " <" + user["email"] + ">"
print('Unable to send email') del data["to_user"]
else: data["text"] += (
print('Not sending email. Message pasted below.') "\n\nFrom the team at {0}\n\n\n\n--\n\nDon't like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}".format(
print(data) os.environ.get("APP_NAME"),
os.environ.get("APP_URL"),
os.environ.get("CONTACT_EMAIL"),
)
)
data["reply-to"] = os.environ.get("CONTACT_EMAIL")
base_url = os.environ.get("MAILGUN_URL")
api_key = os.environ.get("MAILGUN_KEY")
if base_url and api_key:
auth = ("api", api_key)
try:
response = requests.post(base_url, auth=auth, data=data)
response.raise_for_status()
except Exception:
print("Unable to send email")
else:
print("Not sending email. Message pasted below.")
print(data)
def send(data): def send(data):
thr = Thread(target=handle_send, args=[data]) thr = Thread(target=handle_send, args=[data])
thr.start() thr.start()

View File

@ -4,52 +4,63 @@ from firebase_admin import messaging
default_app = firebase_admin.initialize_app() default_app = firebase_admin.initialize_app()
def handle_send_multiple(users, title, body, extra = {}):
tokens = []
for user in users:
if user.get('pushToken'): tokens.append(user['pushToken'])
if not tokens: return
# Create a list containing up to 500 messages. def handle_send_multiple(users, title, body, extra={}):
messages = list(map(lambda t: messaging.Message( tokens = []
notification=messaging.Notification(title, body), for user in users:
apns=messaging.APNSConfig( if user.get("pushToken"):
payload=messaging.APNSPayload( tokens.append(user["pushToken"])
aps=messaging.Aps(badge=1, sound='default'), if not tokens:
), return
),
token=t,
data=extra,
), tokens))
try:
response = messaging.send_all(messages)
print('{0} messages were sent successfully'.format(response.success_count))
except Exception as e:
print('Error sending notification', str(e))
def send_multiple(users, title, body, extra = {}): # Create a list containing up to 500 messages.
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra]) messages = list(
thr.start() map(
lambda t: messaging.Message(
notification=messaging.Notification(title, body),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(badge=1, sound="default"),
),
),
token=t,
data=extra,
),
tokens,
)
)
try:
response = messaging.send_all(messages)
print("{0} messages were sent successfully".format(response.success_count))
except Exception as e:
print("Error sending notification", str(e))
def send_single(user, title, body, extra = {}):
token = user.get('pushToken') def send_multiple(users, title, body, extra={}):
if not token: return thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
message = messaging.Message( thr.start()
notification=messaging.Notification(
title = title,
body = body, def send_single(user, title, body, extra={}):
), token = user.get("pushToken")
apns=messaging.APNSConfig( if not token:
payload=messaging.APNSPayload( return
aps=messaging.Aps(badge=1, sound='default'), message = messaging.Message(
), notification=messaging.Notification(
), title=title,
data = extra, body=body,
token = token, ),
) apns=messaging.APNSConfig(
try: payload=messaging.APNSPayload(
response = messaging.send(message) aps=messaging.Aps(badge=1, sound="default"),
# Response is a message ID string. ),
print('Successfully sent message:', response) ),
except Exception as e: data=extra,
print('Error sending notification', str(e)) token=token,
)
try:
response = messaging.send(message)
# Response is a message ID string.
print("Successfully sent message:", response)
except Exception as e:
print("Error sending notification", str(e))

View File

@ -1,4 +1,6 @@
import json, datetime import os
import json
import datetime
from flask import request, Response from flask import request, Response
import werkzeug import werkzeug
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
@ -6,90 +8,145 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from bson.objectid import ObjectId from bson.objectid import ObjectId
from api import accounts from api import accounts
from util import util from util import util, mail
errors = werkzeug.exceptions errors = werkzeug.exceptions
def get_user(required = True):
headers = request.headers def get_user(required=True):
if not headers.get('Authorization') and required: headers = request.headers
raise util.errors.Unauthorized('This resource requires authentication') if not headers.get("Authorization") and required:
if headers.get('Authorization'): raise util.errors.Unauthorized("This resource requires authentication")
user = accounts.get_user_context(headers.get('Authorization').replace('Bearer ', '')) if headers.get("Authorization"):
if user is None and required: user = accounts.get_user_context(
raise util.errors.Unauthorized('Invalid token') headers.get("Authorization").replace("Bearer ", "")
return user )
return None if user is None and required:
raise util.errors.Unauthorized("Invalid token")
return user
return None
def limit_by_client(): def limit_by_client():
data = request.get_json() data = request.get_json()
if data: if data:
if data.get('email'): return data.get('email') if data.get("email"):
if data.get('token'): return data.get('token') return data.get("email")
return get_remote_address() if data.get("token"):
return data.get("token")
return get_remote_address()
def limit_by_user(): def limit_by_user():
user = util.get_user(required = False) user = util.get_user(required=False)
return user['_id'] if user else get_remote_address() return user["_id"] if user else get_remote_address()
def is_root(user):
return user and "root" in user.get("roles", [])
def can_view_project(user, project): def can_view_project(user, project):
if not project: return False if not project:
if project.get('visibility') == 'public': return False
return True if project.get("visibility") == "public":
if not user: return False return True
if project.get('visibility') == 'private' and user['_id'] == project['user']: if not user:
return True return False
if set(user.get('groups', [])).intersection(project.get('groupVisibility', [])): if project.get("visibility") == "private" and can_edit_project(user, project):
return True return True
if 'root' in user.get('roles', []): return True if set(user.get("groups", [])).intersection(project.get("groupVisibility", [])):
return False return True
if "root" in user.get("roles", []):
return True
return False
def can_edit_project(user, project):
if not user or not project:
return False
return project.get("user") == user["_id"] or is_root(user)
def filter_keys(obj, allowed_keys): def filter_keys(obj, allowed_keys):
filtered = {} filtered = {}
for key in allowed_keys: for key in allowed_keys:
if key in obj: if key in obj:
filtered[key] = obj[key] filtered[key] = obj[key]
return filtered return filtered
def build_updater(obj, allowed_keys): def build_updater(obj, allowed_keys):
if not obj: return {} if not obj:
allowed = filter_keys(obj, allowed_keys) return {}
updater = {} allowed = filter_keys(obj, allowed_keys)
for key in allowed: updater = {}
if not allowed[key]: for key in allowed:
if '$unset' not in updater: updater['$unset'] = {} if not allowed[key]:
updater['$unset'][key] = '' if "$unset" not in updater:
else: updater["$unset"] = {}
if '$set' not in updater: updater['$set'] = {} updater["$unset"][key] = ""
updater['$set'][key] = allowed[key] else:
return updater if "$set" not in updater:
updater["$set"] = {}
updater["$set"][key] = allowed[key]
return updater
def send_report_email(report):
if not report:
return
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} report".format(os.environ.get("APP_NAME")),
"text": "A new report has been submitted: {0}".format(
json.dumps(report, indent=4)
),
}
)
def send_moderation_request(from_user, item_type, item):
if not from_user or not item_type or not item:
return
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} moderation needed".format(os.environ.get("APP_NAME")),
"text": "New content has been added by {0} ({1}) and needs moderating: {2} ({3})".format(
from_user["username"], from_user["email"], item_type, item["_id"]
),
}
)
def generate_rsa_keypair(): def generate_rsa_keypair():
private_key = rsa.generate_private_key( private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
public_exponent=65537, private_pem = private_key.private_bytes(
key_size=4096 encoding=serialization.Encoding.PEM,
) format=serialization.PrivateFormat.PKCS8,
private_pem = private_key.private_bytes( encryption_algorithm=serialization.NoEncryption(),
encoding=serialization.Encoding.PEM, )
format=serialization.PrivateFormat.PKCS8, public_key = private_key.public_key()
encryption_algorithm=serialization.NoEncryption() public_pem = public_key.public_bytes(
) encoding=serialization.Encoding.PEM,
public_key = private_key.public_key() format=serialization.PublicFormat.SubjectPublicKeyInfo,
public_pem = public_key.public_bytes( )
encoding=serialization.Encoding.PEM, return private_pem, public_pem
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
return private_pem, public_pem
class MongoJsonEncoder(json.JSONEncoder): class MongoJsonEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, (datetime.datetime, datetime.date)): if isinstance(obj, (datetime.datetime, datetime.date)):
return obj.isoformat() return obj.isoformat()
elif isinstance(obj, ObjectId): elif isinstance(obj, ObjectId):
return str(obj) return str(obj)
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
def jsonify(*args, **kwargs): def jsonify(*args, **kwargs):
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder) resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
resp = Response(resp_data) resp = Response(resp_data)
resp.headers['Content-Type'] = 'application/json' resp.headers["Content-Type"] = "application/json"
return resp return resp

View File

@ -1,197 +1,585 @@
import io
import configparser import configparser
import time
from threading import Thread
from PIL import Image, ImageDraw
from api import uploads
from util import database
def normalise_colour(max_color, triplet): def normalise_colour(max_color, triplet):
color_factor = 256/max_color color_factor = 256 / max_color
components = triplet.split(',') components = triplet.split(",")
new_components = [] new_components = []
for component in components: for component in components:
new_components.append(str(int(float(color_factor) * int(component)))) new_components.append(str(int(float(color_factor) * int(float(component)))))
return ','.join(new_components) return ",".join(new_components)
def denormalise_colour(max_color, triplet): def denormalise_colour(max_color, triplet):
color_factor = max_color/256 color_factor = max_color / 256
components = triplet.split(',') components = triplet.split(",")
new_components = [] new_components = []
for component in components: for component in components:
new_components.append(str(int(float(color_factor) * int(component)))) new_components.append(str(int(float(color_factor) * int(component))))
return ','.join(new_components) return ",".join(new_components)
def colour_tuple(triplet):
if not triplet:
return None
components = triplet.split(",")
return tuple(map(lambda c: int(c), components))
def darken_colour(c_tuple, val):
def darken(c):
c = c * val
if c < 0:
c = 0
if c > 255:
c = 255
return int(c)
return tuple(map(darken, c_tuple))
def get_colour_index(colours, colour): def get_colour_index(colours, colour):
for (index, c) in enumerate(colours): for index, c in enumerate(colours):
if c == colour: return index + 1 if c == colour:
return 1 return index + 1
return 1
def dumps(obj): def dumps(obj):
if not obj or not obj['pattern']: raise Exception('Invalid pattern') if not obj or not obj["pattern"]:
wif = [] raise Exception("Invalid pattern")
wif = []
wif.append('[WIF]') wif.append("[WIF]")
wif.append('Version=1.1') wif.append("Version=1.1")
wif.append('Source Program=Treadl') wif.append("Source Program=Treadl")
wif.append('Source Version=1') wif.append("Source Version=1")
wif.append('\n[CONTENTS]') wif.append("\n[CONTENTS]")
wif.append('COLOR PALETTE=true') wif.append("COLOR PALETTE=true")
wif.append('TEXT=true') wif.append("TEXT=true")
wif.append('WEAVING=true') wif.append("WEAVING=true")
wif.append('WARP=true') wif.append("WARP=true")
wif.append('WARP COLORS=true') wif.append("WARP COLORS=true")
wif.append('WEFT COLORS=true') wif.append("WEFT COLORS=true")
wif.append('WEFT=true') wif.append("WEFT=true")
wif.append('COLOR TABLE=true') wif.append("COLOR TABLE=true")
wif.append('THREADING=true') wif.append("THREADING=true")
wif.append('TIEUP=true') wif.append("TIEUP=true")
wif.append('TREADLING=true') wif.append("TREADLING=true")
wif.append('\n[TEXT]') wif.append("\n[TEXT]")
wif.append('Title={0}'.format(obj['name'])) wif.append("Title={0}".format(obj["name"]))
wif.append('\n[COLOR TABLE]') wif.append("\n[COLOR TABLE]")
for (index, colour) in enumerate(obj['pattern']['colours']): for index, colour in enumerate(obj["pattern"]["colours"]):
wif.append('{0}={1}'.format(index + 1, denormalise_colour(999, colour))) wif.append("{0}={1}".format(index + 1, denormalise_colour(999, colour)))
wif.append('\n[COLOR PALETTE]') wif.append("\n[COLOR PALETTE]")
wif.append('Range=0,999') wif.append("Range=0,999")
wif.append('Entries={0}'.format(len(obj['pattern']['colours']))) wif.append("Entries={0}".format(len(obj["pattern"]["colours"])))
wif.append('\n[WEAVING]') wif.append("\n[WEAVING]")
wif.append('Rising Shed=true') wif.append("Rising Shed=true")
wif.append('Treadles={0}'.format(obj['pattern']['weft']['treadles'])) wif.append("Treadles={0}".format(obj["pattern"]["weft"]["treadles"]))
wif.append('Shafts={0}'.format(obj['pattern']['warp']['shafts'])) wif.append("Shafts={0}".format(obj["pattern"]["warp"]["shafts"]))
wif.append('\n[WARP]') wif.append("\n[WARP]")
wif.append('Units=centimeters') wif.append("Units=centimeters")
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['warp']['defaultColour']))) wif.append(
wif.append('Threads={0}'.format(obj['pattern']['warp']['threads'])) "Color={0}".format(
wif.append('Spacing=0.212') get_colour_index(
wif.append('Thickness=0.212') obj["pattern"]["colours"], obj["pattern"]["warp"]["defaultColour"]
)
)
)
wif.append("Threads={0}".format(len(obj["pattern"]["warp"]["threading"])))
wif.append("Spacing=0.212")
wif.append("Thickness=0.212")
wif.append('\n[WARP COLORS]') wif.append("\n[WARP COLORS]")
for (index, thread) in enumerate(obj['pattern']['warp']['threading']): for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
if 'colour' in thread: if "colour" in thread:
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour']))) wif.append(
"{0}={1}".format(
index + 1,
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
)
)
wif.append('\n[THREADING]') wif.append("\n[THREADING]")
for (index, thread) in enumerate(obj['pattern']['warp']['threading']): for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
wif.append('{0}={1}'.format(index + 1, thread['shaft'])) wif.append("{0}={1}".format(index + 1, thread["shaft"]))
wif.append('\n[WEFT]') wif.append("\n[WEFT]")
wif.append('Units=centimeters') wif.append("Units=centimeters")
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['weft']['defaultColour']))) wif.append(
wif.append('Threads={0}'.format(obj['pattern']['weft']['threads'])) "Color={0}".format(
wif.append('Spacing=0.212') get_colour_index(
wif.append('Thickness=0.212') obj["pattern"]["colours"], obj["pattern"]["weft"]["defaultColour"]
)
)
)
wif.append("Threads={0}".format(len(obj["pattern"]["weft"]["treadling"])))
wif.append("Spacing=0.212")
wif.append("Thickness=0.212")
wif.append('\n[WEFT COLORS]') wif.append("\n[WEFT COLORS]")
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']): for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
if 'colour' in thread: if "colour" in thread:
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour']))) wif.append(
"{0}={1}".format(
index + 1,
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
)
)
wif.append('\n[TREADLING]') wif.append("\n[TREADLING]")
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']): for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
wif.append('{0}={1}'.format(index + 1, thread['treadle'])) wif.append("{0}={1}".format(index + 1, thread["treadle"]))
wif.append('\n[TIEUP]') wif.append("\n[TIEUP]")
for (index, tieup) in enumerate(obj['pattern']['tieups']): for index, tieup in enumerate(obj["pattern"]["tieups"]):
wif.append('{0}={1}'.format(str(index + 1), ','.join(str(x) for x in tieup))) wif.append("{0}={1}".format(str(index + 1), ",".join(str(x) for x in tieup)))
return "\n".join(wif)
return '\n'.join(wif)
def loads(wif_file): def loads(wif_file):
config = configparser.ConfigParser(allow_no_value=True, strict=False) # Ensure file exists:
config.read_string(wif_file.lower()) if not wif_file or type(wif_file) is not str:
draft = {} raise Exception("Invalid file: null or empty or not string")
text = config['text'] # Some user-uploaded files (Quickdraw?) start with strange HTTP header info.
draft['name'] = text.get('title') # Remove all preceding non-section lines:
wif_file = "[" + wif_file.split("[", 1)[1]
min_color = 0 # Make all section names lowercase
max_color = 255 normalized_lines = []
if 'color palette' in config: for line in wif_file.splitlines():
color_palette = config['color palette'] if line.strip().startswith("[") and line.strip().endswith("]"):
color_range = color_palette.get('range').split(',') section_name = line.strip()[1:-1].lower()
min_color = int(color_range[0]) normalized_lines.append(f"[{section_name}]")
max_color = int(color_range[1]) else:
normalized_lines.append(line)
wif_file = "\n".join(normalized_lines)
if 'color table' in config: # Load config
color_table = config['color table'] config = configparser.ConfigParser(
draft['colours'] = [None]*len(color_table) allow_no_value=True, strict=False, inline_comment_prefixes=("#", ";")
for x in color_table: )
draft['colours'][int(x)-1] = normalise_colour(max_color, color_table[x]) config.read_string(wif_file)
if not draft.get('colours'): draft['colours'] = [] DEFAULT_TITLE = "Untitled Pattern"
if len(draft['colours']) < 2: draft = {}
draft['colours'] += [normalise_colour(255, '255,255,255'), normalise_colour(255, '0,0,255')]
weaving = config['weaving'] if "wif" in config:
draft["wifInfo"] = dict(config["wif"])
draft["wifInfo"]["importedFile"] = wif_file
if "text" in config:
text = config["text"]
draft["name"] = text.get("title") or DEFAULT_TITLE
if not draft.get("name"):
draft["name"] = DEFAULT_TITLE
threading = config['threading'] max_color = 255
warp = config['warp'] if "color palette" in config:
draft['warp'] = {} color_palette = config["color palette"]
draft['warp']['shafts'] = weaving.getint('shafts') color_range = color_palette.get("range").split(",")
draft['warp']['threading'] = [] max_color = int(color_range[1])
if "color table" in config:
color_table = config["color table"]
draft["colours"] = [None] * len(color_table)
for x in color_table:
draft["colours"][int(x) - 1] = normalise_colour(max_color, color_table[x])
if not draft.get("colours"):
draft["colours"] = []
if len(draft["colours"]) < 2:
draft["colours"] += [
normalise_colour(255, "255,255,255"),
normalise_colour(255, "0,0,255"),
]
if warp.get('color'): weaving = config["weaving"] if "weaving" in config else None
warp_colour_index = warp.getint('color') - 1
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index]
else: threading = config["threading"] if "threading" in config else []
# In case of no color table or colour index out of bounds warp = config["warp"] if "warp" in config else None
draft['warp']['defaultColour'] = draft['colours'][0] draft["warp"] = {}
draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
draft["warp"]["threading"] = []
for x in threading: # Work out default warp colour
shaft = threading[x] if warp and warp.get("color"):
if ',' in shaft: warp_colour_index = warp.getint("color") - 1
shaft = shaft.split(",")[0] if warp_colour_index < len(draft["colours"]):
shaft = int(shaft) draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
while int(x) >= len(draft['warp']['threading']) - 1: if not draft.get("warp").get("defaultColour"):
draft['warp']['threading'].append({'shaft': 0}) # In case of no color table or colour index out of bounds
draft['warp']['threading'][int(x) - 1] = {'shaft': shaft} draft["warp"]["defaultColour"] = draft["colours"][0]
draft['warp']['threads'] = len(draft['warp']['threading'])
try:
warp_colours = config['warp colors']
for x in warp_colours:
draft['warp']['threading'][int(x) - 1]['colour'] = draft['colours'][warp_colours.getint(x)-1]
except Exception as e:
pass
treadling = config['treadling'] for x in threading:
weft = config['weft'] shaft = threading[x].strip()
draft['weft'] = {} if "," in shaft:
draft['weft']['treadles'] = weaving.getint('treadles') shaft = shaft.split(",")[0]
draft['weft']['treadling'] = [] shaft = int(shaft) if shaft else 0
while int(x) >= len(draft["warp"]["threading"]) - 1:
if weft.get('color'): draft["warp"]["threading"].append({"shaft": 0})
weft_colour_index = weft.getint('color') - 1 draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index] if shaft > draft["warp"]["shafts"]:
else: draft["warp"]["shafts"] = shaft
# In case of no color table or colour index out of bounds draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
draft['weft']['defaultColour'] = draft['colours'][1]
for x in treadling:
shaft = treadling[x]
if ',' in shaft:
shaft = shaft.split(",")[0]
shaft = int(shaft)
while int(x) >= len(draft['weft']['treadling']) - 1:
draft['weft']['treadling'].append({'treadle': 0})
draft['weft']['treadling'][int(x) - 1] = {'treadle': shaft}
draft['weft']['threads'] = len(draft['weft']['treadling'])
try:
weft_colours = config['weft colors']
for x in weft_colours:
draft['weft']['treadling'][int(x) - 1]['colour'] = draft['colours'][weft_colours.getint(x)-1]
except: pass
tieup = config['tieup']
draft['tieups'] = []#[0]*len(tieup)
for x in tieup:
while int(x) >= len(draft['tieups']) - 1:
draft['tieups'].append([])
split = tieup[x].split(',')
try: try:
draft['tieups'][int(x)-1] = [int(i) for i in split] warp_colours = config["warp colors"]
except: for x in warp_colours:
draft['tieups'][int(x)-1] = [] draft["warp"]["threading"][int(x) - 1]["colour"] = draft["colours"][
warp_colours.getint(x) - 1
]
except Exception:
pass
if not draft["warp"]["threading"]: # Make a bunch of empty threads
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
return draft treadling = config["treadling"] if "treadling" in config else []
weft = config["weft"] if "weft" in config else None
draft["weft"] = {}
draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
draft["weft"]["treadling"] = []
# Work out default weft colour
if weft and weft.get("color"):
weft_colour_index = weft.getint("color") - 1
if weft_colour_index < len(draft["colours"]):
draft["weft"]["defaultColour"] = draft["colours"][weft_colour_index]
if not draft.get("weft").get("defaultColour"):
# In case of no color table or colour index out of bounds
draft["weft"]["defaultColour"] = draft["colours"][1]
for x in treadling:
treadle = treadling[x].strip()
if "," in treadle:
treadle = treadle.split(",")[0]
treadle = int(treadle) if treadle else 0
while int(x) >= len(draft["weft"]["treadling"]) - 1:
draft["weft"]["treadling"].append({"treadle": 0})
draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
if treadle > draft["weft"]["treadles"]:
draft["weft"]["treadles"] = treadle
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
try:
weft_colours = config["weft colors"]
for x in weft_colours:
draft["weft"]["treadling"][int(x) - 1]["colour"] = draft["colours"][
weft_colours.getint(x) - 1
]
except Exception:
pass
if not draft["weft"]["treadling"]: # Make a bunch of empty threads
draft["weft"]["treadling"] = [{"treadle": 0} for i in range(20)]
tieup = config["tieup"] if "tieup" in config else None
draft["tieups"] = []
if tieup:
for x in tieup:
while int(x) >= len(draft["tieups"]) - 1:
draft["tieups"].append([])
try:
split = tieup[x].split(",")
draft["tieups"][int(x) - 1] = [int(i) for i in split]
except Exception:
draft["tieups"][int(x) - 1] = []
return draft
def generate_images_thread(obj):
preview_image = draw_image(obj)
full_preview_image = draw_image(obj, with_plan=True)
if preview_image or full_preview_image:
db = database.get_db()
db.objects.update_one(
{"_id": obj["_id"]},
{
"$set": {
"preview": preview_image,
"fullPreview": full_preview_image,
}
},
)
def generate_images(obj):
thr = Thread(target=generate_images_thread, args=[obj])
thr.start()
def draw_image(obj, with_plan=False):
if not obj or not obj["pattern"]:
raise Exception("Invalid pattern")
BASE_SIZE = 10
pattern = obj["pattern"]
warp = pattern["warp"]
weft = pattern["weft"]
tieups = pattern["tieups"]
full_width = (
len(warp["threading"]) * BASE_SIZE
+ BASE_SIZE
+ weft["treadles"] * BASE_SIZE
+ BASE_SIZE
if with_plan
else len(warp["threading"]) * BASE_SIZE
)
full_height = (
warp["shafts"] * BASE_SIZE + len(weft["treadling"]) * BASE_SIZE + BASE_SIZE * 2
if with_plan
else len(weft["treadling"]) * BASE_SIZE
)
warp_top = 0
warp_left = 0
warp_right = len(warp["threading"]) * BASE_SIZE
warp_bottom = warp["shafts"] * BASE_SIZE + BASE_SIZE
weft_left = warp_right + BASE_SIZE
weft_top = warp["shafts"] * BASE_SIZE + BASE_SIZE * 2
weft_right = warp_right + BASE_SIZE + weft["treadles"] * BASE_SIZE + BASE_SIZE
weft_bottom = weft_top + len(weft["treadling"]) * BASE_SIZE
tieup_left = warp_right + BASE_SIZE
tieup_top = BASE_SIZE
tieup_right = tieup_left + weft["treadles"] * BASE_SIZE
tieup_bottom = warp_bottom
drawdown_top = warp_bottom + BASE_SIZE if with_plan else 0
drawdown_right = warp_right if with_plan else full_width
drawdown_left = warp_left if with_plan else 0
drawdown_bottom = weft_bottom if with_plan else full_height
warp_guides = warp.get("guideFrequency") or 0
weft_guides = weft.get("guideFrequency") or 0
WHITE = (255, 255, 255)
GREY = (150, 150, 150)
BLACK = (0, 0, 0)
img = Image.new("RGBA", (full_width, full_height), WHITE)
draw = ImageDraw.Draw(img)
# Draw warp
if with_plan:
draw.rectangle(
[(warp_left, warp_top), (warp_right, warp_bottom)],
fill=None,
outline=GREY,
width=1,
)
for y in range(1, warp["shafts"] + 1):
ycoord = y * BASE_SIZE
draw.line(
[
(warp_left, ycoord),
(warp_right, ycoord),
],
fill=GREY,
width=1,
joint=None,
)
col_index = 1
for i, x in enumerate(range(len(warp["threading"]) - 1, 0, -1)):
is_guide = warp_guides and col_index % warp_guides == 0
col_index += 1
thread = warp["threading"][i]
xcoord = x * BASE_SIZE
draw.line(
[
(xcoord, warp_top),
(xcoord, warp_bottom),
],
fill=BLACK if is_guide else GREY,
width=2 if is_guide else 1,
joint=None,
)
if thread.get("shaft", 0) > 0:
ycoord = warp_bottom - (thread["shaft"] * BASE_SIZE)
draw.rectangle(
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
fill=BLACK,
outline=None,
width=1,
)
colour = warp["defaultColour"]
if thread and thread.get("colour"):
colour = thread["colour"]
draw.rectangle(
[
(xcoord, warp_top),
(xcoord + BASE_SIZE, warp_top + BASE_SIZE),
],
fill=colour_tuple(colour),
)
# Draw weft
draw.rectangle(
[(weft_left, weft_top), (weft_right, weft_bottom)],
fill=None,
outline=GREY,
width=1,
)
for x in range(1, weft["treadles"] + 1):
xcoord = weft_left + x * BASE_SIZE
draw.line(
[
(xcoord, weft_top),
(xcoord, weft_bottom),
],
fill=GREY,
width=1,
joint=None,
)
row_index = 0
for i, y in enumerate(range(0, len(weft["treadling"]))):
is_guide = weft_guides and row_index % weft_guides == 0
row_index += 1
thread = weft["treadling"][i]
ycoord = weft_top + y * BASE_SIZE
draw.line(
[
(weft_left, ycoord),
(weft_right, ycoord),
],
fill=BLACK if is_guide else GREY,
width=2 if is_guide else 1,
joint=None,
)
if thread.get("treadle", 0) > 0:
xcoord = weft_left + (thread["treadle"] - 1) * BASE_SIZE
draw.rectangle(
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
fill=BLACK,
outline=None,
width=1,
)
colour = weft["defaultColour"]
if thread and thread.get("colour"):
colour = thread["colour"]
draw.rectangle(
[
(weft_right - BASE_SIZE, ycoord),
(weft_right, ycoord + BASE_SIZE),
],
fill=colour_tuple(colour),
)
# Draw tieups
draw.rectangle(
[(tieup_left, tieup_top), (tieup_right, tieup_bottom)],
fill=None,
outline=GREY,
width=1,
)
for y in range(1, warp["shafts"] + 1):
ycoord = y * BASE_SIZE
draw.line(
[
(tieup_left, ycoord),
(tieup_right, ycoord),
],
fill=GREY,
width=1,
joint=None,
)
for x, tieup in enumerate(tieups):
xcoord = tieup_left + x * BASE_SIZE
draw.line(
[
(xcoord, tieup_top),
(xcoord, tieup_bottom),
],
fill=GREY,
width=1,
joint=None,
)
for entry in tieup:
if entry > 0:
ycoord = tieup_bottom - (entry * BASE_SIZE)
draw.rectangle(
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
fill=BLACK,
outline=None,
width=1,
)
# Draw drawdown
draw.rectangle(
[(drawdown_left, drawdown_top), (drawdown_right, drawdown_bottom)],
fill=None,
outline=(0, 0, 0),
width=1,
)
for y, weft_thread in enumerate(weft["treadling"]):
for x, warp_thread in enumerate(warp["threading"]):
# Ensure selected treadle and shaft is within configured pattern range
treadle = (
0
if weft_thread["treadle"] > weft["treadles"]
else weft_thread["treadle"]
)
shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"]
# Work out if should be warp or weft in "front"
tieup = (
tieups[treadle - 1] if (treadle > 0 and treadle <= len(tieups)) else []
)
tieup = [t for t in tieup if t <= warp["shafts"]]
thread_type = "warp" if shaft in tieup else "weft"
# Calculate current colour
weft_colour = weft_thread.get("colour") or weft.get("defaultColour")
warp_colour = warp_thread.get("colour") or warp.get("defaultColour")
colour = colour_tuple(warp_colour if thread_type == "warp" else weft_colour)
# Calculate drawdown coordinates
x1 = drawdown_right - (x + 1) * BASE_SIZE
x2 = drawdown_right - x * BASE_SIZE
y1 = drawdown_top + y * BASE_SIZE
y2 = drawdown_top + (y + 1) * BASE_SIZE
# Draw the thread, with shadow
d = [0.6, 0.8, 0.9, 1.1, 1.3, 1.3, 1.1, 0.9, 0.8, 0.6, 0.5]
if thread_type == "warp":
for i, grad_x in enumerate(range(x1, x2)):
draw.line(
[
(grad_x, y1),
(grad_x, y2),
],
fill=(darken_colour(colour, d[i])),
width=1,
joint=None,
)
else:
for i, grad_y in enumerate(range(y1, y2)):
draw.line(
[
(x1, grad_y),
(x2, grad_y),
],
fill=(darken_colour(colour, d[i])),
width=1,
joint=None,
)
in_mem_file = io.BytesIO()
img.save(in_mem_file, "PNG")
in_mem_file.seek(0)
file_name = "preview-{0}_{1}-{2}.png".format(
"full" if with_plan else "base", obj["_id"], int(time.time())
)
path = "projects/{}/{}".format(obj["project"], file_name)
uploads.upload_file(path, in_mem_file)
return file_name

40
docker/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Stage 1: Build React SPA
FROM node:20 AS react-build
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm install
COPY web/ ./
RUN npx vite build
# Stage 2: Set up Nginx with React and Flask
FROM python:3.12-slim
WORKDIR /app
# Install Flask and dependencies
RUN pip install poetry
COPY api/poetry.lock .
COPY api/pyproject.toml .
RUN poetry config virtualenvs.create false --local
RUN poetry install
# Copy Flask app
COPY api/ ./
# Install Nginx
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
RUN unlink /etc/nginx/sites-enabled/default # Ensure default Nginx configuration is not used
# Copy React build files into Nginx's static directory
COPY --from=react-build /app/dist /usr/share/nginx/html
# Copy custom Nginx configuration file
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# Expose ports for Nginx
EXPOSE 80
# Start both Flask and Nginx using a script
COPY docker/start.sh /start.sh
RUN chmod +x /start.sh
CMD ["/start.sh"]

33
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
services:
treadl:
image: wilw/treadl:latest
ports:
- "8080:80"
environment:
# App settings
- JWT_SECRET=secret # Change this to a secure secret
- APP_URL=http://example.com
- APP_DOMAIN=example.com
- APP_NAME=Treadl
# MongoDB connection
- MONGO_URL=mongodb://mongo:27017/treadl
- MONGO_DATABASE=treadl
# Mailgun email settings
- MAILGUN_URL=
- MAILGUN_KEY
- FROM_EMAIL= # An email address to send emails from
# Email addresses
- CONTACT_EMAIL= # An email address for people to contact you
- ADMIN_EMAIL= # An email address for admin notifications
# S3 storage settings
- AWS_S3_ENDPOINT=https://eu-central-1.linodeobjects.com/
- AWS_S3_BUCKET=treadl
- AWS_ACCESS_KEY_ID=
- AWS_SECRET_ACCESS_KEY=
mongo:
image: mongo:6

22
docker/nginx.conf Normal file
View File

@ -0,0 +1,22 @@
server {
listen 80;
# Serve React static files for all non-API routes
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html;
}
# Proxy API requests to Flask backend
location /api/ {
proxy_pass http://127.0.0.1:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
gzip on;
gzip_types text/plain application/json text/css application/javascript;
}

8
docker/start.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# Start Flask app in the background
gunicorn -b 0.0.0.0:5000 app:app &
# Start Nginx in the foreground
nginx -g "daemon off;"

View File

@ -4,7 +4,33 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
channel: stable channel: "stable"
project_type: app project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
- platform: linux
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
- platform: macos
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
- platform: windows
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -6,7 +6,7 @@ The source code for Treadl's iOS and Android application.
The application is written in Dart using the Flutter framework. The application is written in Dart using the Flutter framework.
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality. The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality and for adding images to projects when out and about.
## Start ## Start

View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
compileSdkVersion 31 compileSdkVersion 33
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
@ -44,8 +44,8 @@ android {
defaultConfig { defaultConfig {
applicationId "com.treadl" applicationId "com.treadl"
minSdkVersion 19 minSdkVersion 29
targetSdkVersion 31 targetSdkVersion 34
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }

View File

@ -42,7 +42,15 @@
<intent-filter> <intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK" /> <action android:name="FLUTTER_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="treadl.com" />
<data android:scheme="https" android:host="treadl.com" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,16 +1,21 @@
// Generated file. // Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file. // If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app; package io.flutter.app;
import android.app.Application;
import android.content.Context; import android.content.Context;
import androidx.annotation.CallSuper; import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex; import androidx.multidex.MultiDex;
/** /**
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. * Extension of {@link android.app.Application}, adding multidex support.
*/ */
public class FlutterMultiDexApplication extends FlutterApplication { public class FlutterMultiDexApplication extends Application {
@Override @Override
@CallSuper @CallSuper
protected void attachBaseContext(Context base) { protected void attachBaseContext(Context base) {

View File

@ -1,12 +1,12 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext.kotlin_version = '1.8.20'
repositories { repositories {
google() google()
jcenter() jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.5.0' classpath 'com.android.tools.build:gradle:7.4.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.3' classpath 'com.google.gms:google-services:4.3.3'
} }
@ -27,6 +27,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@ -1,6 +1,5 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

BIN
mobile/assets/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>9.0</string> <string>11.0</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
# platform :ios, '9.0' # platform :ios, '11.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -1,153 +1,186 @@
PODS: PODS:
- Firebase/CoreOnly (8.11.0): - DKImagePickerController/Core (4.3.4):
- FirebaseCore (= 8.11.0) - DKImagePickerController/ImageDataManager
- Firebase/Messaging (8.11.0): - DKImagePickerController/Resource
- Firebase/CoreOnly - DKImagePickerController/ImageDataManager (4.3.4)
- FirebaseMessaging (~> 8.11.0) - DKImagePickerController/PhotoGallery (4.3.4):
- firebase_core (1.13.1): - DKImagePickerController/Core
- Firebase/CoreOnly (= 8.11.0) - DKPhotoGallery
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter - Flutter
- firebase_messaging (11.2.8): - Firebase/CoreOnly (10.9.0):
- Firebase/Messaging (= 8.11.0) - FirebaseCore (= 10.9.0)
- Firebase/Messaging (10.9.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.9.0)
- firebase_core (2.13.1):
- Firebase/CoreOnly (= 10.9.0)
- Flutter
- firebase_messaging (14.6.2):
- Firebase/Messaging (= 10.9.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseCore (8.11.0): - FirebaseCore (10.9.0):
- FirebaseCoreDiagnostics (~> 8.0) - FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.7) - GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.7) - GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreDiagnostics (8.12.0): - FirebaseCoreInternal (10.10.0):
- GoogleDataTransport (~> 9.1) - "GoogleUtilities/NSData+zlib (~> 7.8)"
- GoogleUtilities/Environment (~> 7.7) - FirebaseInstallations (10.10.0):
- GoogleUtilities/Logger (~> 7.7) - FirebaseCore (~> 10.0)
- nanopb (~> 2.30908.0) - GoogleUtilities/Environment (~> 7.8)
- FirebaseInstallations (8.12.0): - GoogleUtilities/UserDefaults (~> 7.8)
- FirebaseCore (~> 8.0) - PromisesObjC (~> 2.1)
- GoogleUtilities/Environment (~> 7.7) - FirebaseMessaging (10.9.0):
- GoogleUtilities/UserDefaults (~> 7.7) - FirebaseCore (~> 10.0)
- PromisesObjC (< 3.0, >= 1.2) - FirebaseInstallations (~> 10.0)
- FirebaseMessaging (8.11.0): - GoogleDataTransport (~> 9.2)
- FirebaseCore (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- FirebaseInstallations (~> 8.0) - GoogleUtilities/Environment (~> 7.8)
- GoogleDataTransport (~> 9.1) - GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7) - GoogleUtilities/UserDefaults (~> 7.8)
- GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleUtilities/Reachability (~> 7.7)
- GoogleUtilities/UserDefaults (~> 7.7)
- nanopb (~> 2.30908.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- fluttertoast (0.0.2): - GoogleDataTransport (9.2.3):
- Flutter - GoogleUtilities/Environment (~> 7.7)
- Toast - nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleDataTransport (9.1.2):
- GoogleUtilities/Environment (~> 7.2)
- nanopb (~> 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2) - PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.7.0): - GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Network - GoogleUtilities/Network
- GoogleUtilities/Environment (7.7.0): - GoogleUtilities/Environment (7.11.1):
- PromisesObjC (< 3.0, >= 1.2) - PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.7.0): - GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Network (7.7.0): - GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib" - "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability - GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.7.0)" - "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.7.0): - GoogleUtilities/Reachability (7.11.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.7.0): - GoogleUtilities/UserDefaults (7.11.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- image_picker (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- nanopb (2.30908.0): - nanopb (2.30909.0):
- nanopb/decode (= 2.30908.0) - nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30908.0) - nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30908.0) - nanopb/decode (2.30909.0)
- nanopb/encode (2.30908.0) - nanopb/encode (2.30909.0)
- PromisesObjC (2.0.0) - path_provider_foundation (0.0.1):
- shared_preferences_ios (0.0.1):
- Flutter - Flutter
- Toast (4.0.0) - FlutterMacOS
- PromisesObjC (2.2.0)
- SDWebImage (5.18.8):
- SDWebImage/Core (= 5.18.8)
- SDWebImage/Core (5.18.8)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.4)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- wakelock (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- image_picker (from `.symlinks/plugins/image_picker/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase - Firebase
- FirebaseCore - FirebaseCore
- FirebaseCoreDiagnostics - FirebaseCoreInternal
- FirebaseInstallations - FirebaseInstallations
- FirebaseMessaging - FirebaseMessaging
- GoogleDataTransport - GoogleDataTransport
- GoogleUtilities - GoogleUtilities
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- Toast - SDWebImage
- SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core: firebase_core:
:path: ".symlinks/plugins/firebase_core/ios" :path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging: firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
fluttertoast: image_picker_ios:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
image_picker: path_provider_foundation:
:path: ".symlinks/plugins/image_picker/ios" :path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_ios: share_plus:
:path: ".symlinks/plugins/shared_preferences_ios/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
firebase_core: 08f6a85f62060111de5e98d6a214810d11365de9 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
firebase_messaging: 36238f3d0b933af8c919aef608408aae06ba22e8 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0 Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
FirebaseCoreDiagnostics: 3b40dfadef5b90433a60ae01f01e90fe87aa76aa firebase_core: ce64b0941c6d87c6ef5022ae9116a158236c8c94
FirebaseInstallations: 25764cf322e77f99449395870a65b2bef88e1545 firebase_messaging: 42912365e62efc1ea3e00724e5eecba6068ddb88
FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 FirebaseInstallations: 52153982b057d3afcb4e1fbb3eb0b6d00611e681
GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940 FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3 GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
PromisesObjC: 68159ce6952d93e17b2dfe273b8c40907db5ba58 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162 shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
COCOAPODS: 1.10.1 COCOAPODS: 1.14.2

View File

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 51; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -48,6 +48,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; }; BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; }; BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
@ -116,6 +117,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BE18F7F22B54707500363B2E /* Runner.entitlements */,
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */, BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */, BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@ -170,7 +172,7 @@
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1300; LastUpgradeCheck = 1430;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
@ -232,10 +234,12 @@
}; };
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputPaths = ( inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
); );
name = "Thin Binary"; name = "Thin Binary";
outputPaths = ( outputPaths = (
@ -246,6 +250,7 @@
}; };
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
@ -370,6 +375,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 38T664W57F; DEVELOPMENT_TEAM = 38T664W57F;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1300" LastUpgradeVersion = "1430"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -2,7 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
@ -12,6 +14,8 @@
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Treadl</string> <string>Treadl</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@ -46,5 +50,9 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict>
</plist>

View File

@ -4,5 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -4,5 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -1,28 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'util.dart';
import 'model.dart';
class Api { class Api {
String _token; String? _token;
final String apiBase = 'https://api.treadl.com'; final String apiBase = 'https://api.treadl.com';
//final String apiBase = 'http://localhost:2001'; //final String apiBase = 'http://192.168.5.134:2001';
Future<String> loadToken() async { Api({token: null}) {
if (token != null) _token = token;
}
Future<String?> loadToken() async {
if (_token != null) { if (_token != null) {
return _token; return _token!;
} }
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
final String token = prefs.getString('apiToken'); String? token = prefs.getString('apiToken');
return token; return token;
} }
Future<Map<String,String>> getHeaders(method) async { Future<Map<String,String>> getHeaders(method) async {
Map<String,String> headers = {}; Map<String,String> headers = {};
String token = await loadToken(); String? token = await loadToken();
if (token != null) { if (token != null) {
headers['Authorization'] = 'Bearer ' + token; headers['Authorization'] = 'Bearer ' + token!;
} }
if (method == 'POST' || method == 'DELETE') { if (method == 'POST' || method == 'DELETE') {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
@ -34,17 +41,23 @@ class Api {
http.Client client = http.Client(); http.Client client = http.Client();
return await client.get(url, headers: await getHeaders('GET')); return await client.get(url, headers: await getHeaders('GET'));
} }
Future<http.Response> _post(Uri url, Map<String, dynamic> data) async { Future<http.Response> _post(Uri url, Map<String, dynamic>? data) async {
String json = jsonEncode(data); String? json = null;
if (data != null) {
json = jsonEncode(data!);
}
http.Client client = http.Client(); http.Client client = http.Client();
return await client.post(url, headers: await getHeaders('POST'), body: json); return await client.post(url, headers: await getHeaders('POST'), body: json);
} }
Future<http.Response> _put(Uri url, Map<String, dynamic> data) async { Future<http.Response> _put(Uri url, Map<String, dynamic>? data) async {
String json = jsonEncode(data); String? json = null;
if (data != null) {
json = jsonEncode(data!);
}
http.Client client = http.Client(); http.Client client = http.Client();
return await client.put(url, headers: await getHeaders('POST'), body: json); return await client.put(url, headers: await getHeaders('POST'), body: json);
} }
Future<http.Response> _delete(Uri url, [Map<String, dynamic> data]) async { Future<http.Response> _delete(Uri url, [Map<String, dynamic>? data]) async {
http.Client client = http.Client(); http.Client client = http.Client();
if (data != null) { if (data != null) {
String json = jsonEncode(data); String json = jsonEncode(data);
@ -54,10 +67,10 @@ class Api {
} }
} }
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic> data]) async { Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic>? data]) async {
String url = apiBase + path; String url = apiBase + path;
Uri uri = Uri.parse(url); Uri uri = Uri.parse(url);
http.Response response; http.Response? response;
if (method == 'POST') { if (method == 'POST') {
response = await _post(uri, data); response = await _post(uri, data);
} }
@ -70,16 +83,19 @@ class Api {
if (method == 'DELETE') { if (method == 'DELETE') {
response = await _delete(uri, data); response = await _delete(uri, data);
} }
int status = response.statusCode; if (response == null) {
return {'success': false, 'message': 'No response for your request'};
}
int status = response!.statusCode;
if (status == 200) { if (status == 200) {
print('SUCCESS'); print('SUCCESS');
Map<String, dynamic> respData = jsonDecode(response.body); Map<String, dynamic> respData = jsonDecode(response!.body);
return {'success': true, 'payload': respData}; return {'success': true, 'payload': respData};
} }
else { else {
print('ERROR'); print('ERROR');
Map<String, dynamic> respData = jsonDecode(response.body); Map<String, dynamic> respData = jsonDecode(response!.body);
return {'success': false, 'code': response.statusCode, 'message': respData['message']}; return {'success': false, 'code': status, 'message': respData['message']};
} }
} }
@ -93,4 +109,17 @@ class Api {
int status = response.statusCode; int status = response.statusCode;
return status == 200; return status == 200;
} }
Future<File?> downloadFile(String url, String fileName) async {
Uri uri = Uri.parse(url);
http.Client client = http.Client();
http.Response response = await client.get(uri);
if(response.statusCode == 200) {
final String dirPath = await Util.storagePath();
final file = File('$dirPath/$fileName');
await file.writeAsBytes(response.bodyBytes);
return file;
}
return null;
}
} }

View File

@ -1,31 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
class DataImage extends StatefulWidget {
final String _data;
DataImage(this._data) {}
@override
DataImageState createState() => new DataImageState(_data);
}
class DataImageState extends State<MyHomePage> {
String _base64;
DataImageState(this._base64) {}
@override
Widget build(BuildContext context) {
if (_base64 == null)
return new Container();
Uint8List bytes = BASE64.decode(_base64);
return new Scaffold(
appBar: new AppBar(title: new Text('Example App')),
body: new ListTile(
leading: new Image.memory(bytes),
title: new Text(_base64),
),
);
}
}

115
mobile/lib/explore.dart Normal file
View File

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'api.dart';
import 'util.dart';
import 'lib.dart';
class _ExploreTabState extends State<ExploreTab> {
List<dynamic> objects = [];
List<dynamic> projects = [];
bool loading = false;
int explorePage = 1;
final Api api = Api();
final Util util = Util();
@override
initState() {
super.initState();
getExploreData();
getData();
}
void getExploreData() async {
if (explorePage == -1) return;
var data = await api.request('GET', '/search/explore?page=${explorePage}');
if (data['success'] == true) {
setState(() {
loading = false;
objects = objects + data['payload']['objects'];
explorePage = data['payload']['objects'].length == 0 ? -1 : (explorePage + 1); // Set to -1 to disable 'load more'
});
}
}
void getData() async {
setState(() {
loading = true;
});
var data2 = await api.request('GET', '/search/discover');
if (data2['success'] == true) {
setState(() {
projects = data2['payload']['highlightProjects'];
});
}
}
@override
Widget build(BuildContext context) {
List<Widget> patternCards = objects.map<Widget>((object) =>
PatternCard(object)
).toList();
if (explorePage > -1) {
patternCards.add(Container(
decoration: BoxDecoration(
color: Colors.pink[50],
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child:Center(
child: CupertinoButton(
child: Text('Load more'),
onPressed: () => getExploreData(),
)
)
));
}
return Scaffold(
appBar: AppBar(
title: Text('Explore'),
),
body: loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 10),
CustomText('Discover projects', 'h1', margin: 5),
SizedBox(height: 5),
Container(
height: 130,
child: ListView(
scrollDirection: Axis.horizontal,
children: projects.map((p) => ProjectCard(p)).toList()
)
),
SizedBox(height: 10),
CustomText('Recent patterns', 'h1', margin: 5),
SizedBox(height: 5),
Expanded(child: Container(
margin: EdgeInsets.only(left: 15, right: 15),
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 0.9,
children: patternCards,
),
)),
]
)
),
);
}
}
class ExploreTab extends StatefulWidget {
@override
_ExploreTabState createState() => _ExploreTabState();
}

View File

@ -6,31 +6,41 @@ import 'group_noticeboard.dart';
import 'group_members.dart'; import 'group_members.dart';
class _GroupScreenState extends State<GroupScreen> { class _GroupScreenState extends State<GroupScreen> {
final String id;
Map<String, dynamic>? _group;
int _selectedIndex = 0; int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [];
final Map<String, dynamic> _group;
_GroupScreenState(this._group) { _GroupScreenState(this.id) { }
_widgetOptions = <Widget> [
GroupNoticeBoardTab(this._group), @override
GroupMembersTab(this._group) void initState() {
]; fetchGroup();
super.initState();
} }
void _onItemTapped(int index) { void fetchGroup() async {
setState(() { Api api = Api();
_selectedIndex = index; var data = await api.request('GET', '/groups/' + id);
}); if (data['success'] == true) {
setState(() {
_group = data['payload'];
});
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_group['name']) title: Text(_group?['name'] ?? 'Group')
), ),
body: Center( body: Center(
child: _widgetOptions.elementAt(_selectedIndex), child: _group != null ?
[
GroupNoticeBoardTab(_group!),
GroupMembersTab(_group!)
].elementAt(_selectedIndex)
: CircularProgressIndicator(),
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[ items: const <BottomNavigationBarItem>[
@ -45,15 +55,17 @@ class _GroupScreenState extends State<GroupScreen> {
], ],
currentIndex: _selectedIndex, currentIndex: _selectedIndex,
selectedItemColor: Colors.pink[600], selectedItemColor: Colors.pink[600],
onTap: _onItemTapped, onTap: (int index) => setState(() {
_selectedIndex = index;
}),
), ),
); );
} }
} }
class GroupScreen extends StatefulWidget { class GroupScreen extends StatefulWidget {
final Map<String,dynamic> group; final String id;
GroupScreen(this.group) { } GroupScreen(this.id) { }
@override @override
_GroupScreenState createState() => _GroupScreenState(group); _GroupScreenState createState() => _GroupScreenState(id);
} }

View File

@ -1,12 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'group_noticeboard.dart';
import 'user.dart'; import 'user.dart';
class _GroupMembersTabState extends State<GroupMembersTab> { class _GroupMembersTabState extends State<GroupMembersTab> {
final Util util = new Util();
final Map<String,dynamic> _group; final Map<String,dynamic> _group;
final Api api = Api(); final Api api = Api();
List<dynamic> _members = []; List<dynamic> _members = [];
@ -33,15 +32,8 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
Widget getMemberCard(member) { Widget getMemberCard(member) {
return new ListTile( return new ListTile(
onTap: () { onTap: () => context.push('/' + member['username']),
Navigator.push( leading: Util.avatarImage(Util.avatarUrl(member), size: 40),
context,
MaterialPageRoute(
builder: (context) => UserScreen(member),
),
);
},
leading: util.avatarImage(util.avatarUrl(member), size: 40),
trailing: Icon(Icons.keyboard_arrow_right), trailing: Icon(Icons.keyboard_arrow_right),
title: Text(member['username']) title: Text(member['username'])
); );

View File

@ -1,15 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'util.dart';
import 'api.dart'; import 'api.dart';
import 'user.dart';
import 'lib.dart'; import 'lib.dart';
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> { class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
final TextEditingController _newEntryController = TextEditingController(); final TextEditingController _newEntryController = TextEditingController();
final Util utils = new Util();
final Api api = Api(); final Api api = Api();
Map<String,dynamic> _group; Map<String,dynamic> _group;
List<dynamic> _entries = []; List<dynamic> _entries = [];
@ -42,8 +38,10 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
} }
void _sendPost(context) async { void _sendPost(context) async {
String text = _newEntryController.text;
if (text.length == 0) return;
setState(() => _posting = true); setState(() => _posting = true);
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text}); var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
if (data['success'] == true) { if (data['success'] == true) {
_newEntryController.value = TextEditingValue(text: ''); _newEntryController.value = TextEditingValue(text: '');
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'package:provider/provider.dart';
import 'package:http/http.dart' as http; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'group.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
import 'lib.dart';
class _GroupsTabState extends State<GroupsTab> { class _GroupsTabState extends State<GroupsTab> {
List<dynamic> _groups = []; List<dynamic> _groups = [];
@ -16,6 +16,8 @@ class _GroupsTabState extends State<GroupsTab> {
} }
void getGroups() async { void getGroups() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() => _loading = true); setState(() => _loading = true);
Api api = Api(); Api api = Api();
var data = await api.request('GET', '/groups'); var data = await api.request('GET', '/groups');
@ -28,70 +30,61 @@ class _GroupsTabState extends State<GroupsTab> {
} }
Widget buildGroupCard(Map<String,dynamic> group) { Widget buildGroupCard(Map<String,dynamic> group) {
String description = group['description']; String? description = group['description'];
if (description != null && description.length > 80) { if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...'; description = description.substring(0, 77) + '...';
} else { } else if (description == null) {
description = ''; description = 'This group doesn\'t have a description.';
} }
return Card( return Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () => context.push('/groups/' + group['_id']),
Navigator.push( child: ListTile(
context, leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
MaterialPageRoute( trailing: Icon(Icons.keyboard_arrow_right),
builder: (context) => GroupScreen(group), title: Text(group['name']),
), subtitle: Text(description.replaceAll("\n", " ")),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new ListTile(
leading: Icon(Icons.people),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(group['name']),
subtitle: Text(description.replaceAll("\n", " ")),
),
]
)
) )
) )
; );
}
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
else if (_loading)
return CircularProgressIndicator();
else if (_groups != null && _groups.length > 0)
return ListView.builder(
itemCount: _groups.length,
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
);
else
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
]);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Groups'), title: Text('My Groups'),
), ),
body: _loading ? body: Container(
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.all(10.0),
child: (_groups != null && _groups.length > 0) ? alignment: Alignment.center,
ListView.builder( child: getBody()
itemCount: _groups.length, )
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
])
),
); );
} }
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'explore.dart';
import 'projects.dart'; import 'projects.dart';
import 'groups.dart'; import 'groups.dart';
@ -13,6 +14,7 @@ class HomeScreen extends StatefulWidget {
class _MyStatefulWidgetState extends State<HomeScreen> { class _MyStatefulWidgetState extends State<HomeScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [ List<Widget> _widgetOptions = <Widget> [
ExploreTab(),
ProjectsTab(), ProjectsTab(),
GroupsTab() GroupsTab()
]; ];
@ -32,12 +34,16 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[ items: const <BottomNavigationBarItem>[
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.folder), icon: Icon(Icons.explore),
label: 'Projects', label: 'Explore',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.person), icon: Icon(Icons.folder),
label: 'Groups', label: 'My Projects',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: 'My Groups',
), ),
], ],
currentIndex: _selectedIndex, currentIndex: _selectedIndex,

View File

@ -4,17 +4,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'user.dart'; import 'user.dart';
import 'object.dart';
import 'project.dart';
class Alert extends StatelessWidget { class Alert extends StatelessWidget {
final String type; final String type;
final String title; final String title;
final String description; final String description;
final String actionText; final String actionText;
final Widget descriptionWidget; final Widget? descriptionWidget;
final Function action; final Function? action;
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {} Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
@override @override
@ -39,7 +42,7 @@ class Alert extends StatelessWidget {
color: accentColor, color: accentColor,
borderRadius: new BorderRadius.all(Radius.circular(10.0)), borderRadius: new BorderRadius.all(Radius.circular(10.0)),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.grey[50], spreadRadius: 5), BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
], ],
), ),
child: Column( child: Column(
@ -48,12 +51,12 @@ class Alert extends StatelessWidget {
Icon(icon, color: color), Icon(icon, color: color),
SizedBox(height: 20), SizedBox(height: 20),
Text(description, textAlign: TextAlign.center), Text(description, textAlign: TextAlign.center),
descriptionWidget, descriptionWidget != null ? descriptionWidget! : Text(""),
action != null ? CupertinoButton( action != null ? CupertinoButton(
child: Text(actionText), child: Text(actionText),
onPressed: action, onPressed: () => action!(),
) : null ) : Text("")
].where((o) => o != null).toList() ]
) )
); );
} }
@ -61,17 +64,16 @@ class Alert extends StatelessWidget {
class NoticeboardPost extends StatefulWidget { class NoticeboardPost extends StatefulWidget {
final Map<String,dynamic> _entry; final Map<String,dynamic> _entry;
final Function onDelete; final Function? onDelete;
final Function onReply; final Function? onReply;
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null}); NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply); _NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
} }
class _NoticeboardPostState extends State<NoticeboardPost> { class _NoticeboardPostState extends State<NoticeboardPost> {
final Map<String,dynamic> _entry; final Map<String,dynamic> _entry;
final Util utils = new Util();
final Api api = new Api(); final Api api = new Api();
final Function onDelete; final Function? onDelete;
final Function onReply; final Function? onReply;
final TextEditingController _replyController = TextEditingController(); final TextEditingController _replyController = TextEditingController();
bool _isReplying = false; bool _isReplying = false;
bool _replying = false; bool _replying = false;
@ -84,7 +86,9 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
if (data['success'] == true) { if (data['success'] == true) {
_replyController.value = TextEditingValue(text: ''); _replyController.value = TextEditingValue(text: '');
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());
onReply(data['payload']); if (onReply != null) {
onReply!(data['payload']);
}
setState(() { setState(() {
_replying = false; _replying = false;
_isReplying = false; _isReplying = false;
@ -95,8 +99,10 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
void _deletePost() async { void _deletePost() async {
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']); var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
if (data['success'] == true) { if (data['success'] == true) {
onDelete(_entry); if (onDelete != null) {
Navigator.of(context).pop(); onDelete!(_entry);
}
context.pop();
} }
} }
@ -104,17 +110,17 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var createdAt = DateTime.parse(_entry['createdAt']); var createdAt = DateTime.parse(_entry['createdAt']);
bool isReply = _entry['inReplyTo'] != null; bool isReply = _entry['inReplyTo'] != null;
int replyCount = _entry['replies'] == null ? 0 : _entry['replies'].length; int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length;
String replyText = 'Write a reply...'; String replyText = 'Write a reply...';
if (replyCount == 1) replyText = '1 Reply'; if (replyCount == 1) replyText = '1 Reply';
if (replyCount > 1) replyText = replyCount.toString() + ' replies'; if (replyCount > 1) replyText = replyCount.toString() + ' replies';
if (_isReplying) replyText = 'Cancel reply'; if (_isReplying) replyText = 'Cancel reply';
List<Widget> replyWidgets = []; List<Widget> replyWidgets = [];
if (_entry['replies'] != null) { if (_entry['replies'] != null) {
for (int i = 0; i < _entry['replies'].length; i++) { for (int i = 0; i < _entry['replies']!.length; i++) {
replyWidgets.add(new Container( replyWidgets.add(new Container(
key: Key(_entry['replies'][i]['_id']), key: Key(_entry['replies']![i]['_id']),
child: NoticeboardPost(_entry['replies'][i], onDelete: onDelete) child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete)
)); ));
} }
} }
@ -127,22 +133,22 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
RaisedButton( ElevatedButton(
color: Colors.orange, //color: Colors.orange,
onPressed: () { onPressed: () {
launch('https://www.treadl.com'); launch('https://www.treadl.com');
}, },
child: Text('Report this post'), child: Text('Report this post'),
), ),
SizedBox(height: 10), SizedBox(height: 10),
RaisedButton( ElevatedButton(
color: Colors.red, //color: Colors.red,
onPressed: _deletePost, onPressed: _deletePost,
child: Text('Delete post'), child: Text('Delete post'),
), ),
FlatButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text('Cancel'), child: Text('Cancel'),
) )
@ -161,12 +167,8 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Row( Row(
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onTap: () { onTap: () => context.push('/' + _entry['authorUser']['username']),
Navigator.push(context, MaterialPageRoute( child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
builder: (context) => UserScreen(_entry['authorUser']),
));
},
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
), ),
SizedBox(width: 5), SizedBox(width: 5),
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)), Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
@ -176,18 +178,18 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
!isReply ? GestureDetector( !isReply ? GestureDetector(
onTap: () => setState(() => _isReplying = !_isReplying), onTap: () => setState(() => _isReplying = !_isReplying),
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)), child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
): null, ): SizedBox(width: 0),
].where((o) => o != null).toList(), ],
), ),
Row(children: [ Row(children: [
SizedBox(width: 45), SizedBox(width: 45),
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left)) Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
]), ]),
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : null, _isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
Column( Column(
children: replyWidgets children: replyWidgets
), ),
].where((o) => o != null).toList(), ],
)) ))
); );
} }
@ -215,7 +217,7 @@ class NoticeboardInput extends StatelessWidget {
), ),
)), )),
IconButton( IconButton(
onPressed: _onPost, onPressed: () => _onPost!(),
color: Colors.pink, color: Colors.pink,
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send), icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
) )
@ -225,3 +227,174 @@ class NoticeboardInput extends StatelessWidget {
} }
} }
class UserChip extends StatelessWidget {
final Map<String,dynamic> user;
UserChip(this.user) {}
@override
Widget build(BuildContext context) {
ImageProvider? avatar = Util.avatarUrl(user);
return GestureDetector(
onTap: () => context.push('/' + user['username']),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Util.avatarImage(avatar, size: 20),
SizedBox(width: 5),
Text(user['username'], style: TextStyle(color: Colors.grey))
]
)
);
}
}
class PatternCard extends StatelessWidget {
final Map<String,dynamic> object;
PatternCard(this.object) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
},
child: Column(
children: [
Container(
height: 100,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(object['previewUrl']),
),
),
),
Container(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserChip(object['projectObject']['owner']),
SizedBox(height: 5),
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class ProjectCard extends StatelessWidget {
final Map<String,dynamic> project;
ProjectCard(this.project) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
},
child: Column(
children: [
Container(
width: 200,
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.folder, color: Colors.pink[200]),
SizedBox(height: 10),
UserChip(project['owner']),
SizedBox(height: 5),
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class CustomText extends StatelessWidget {
final String text;
final String type;
final double margin;
TextStyle? style;
CustomText(this.text, this.type, {this.margin = 0}) {
if (this.type == 'h1') {
style = TextStyle(fontSize: 25, fontWeight: FontWeight.bold);
}
else {
style = TextStyle();
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(this.margin),
child: Text(text, style: style)
);
}
}
class LoginNeeded extends StatelessWidget {
final String? text;
LoginNeeded({this.text}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/login.png'), width: 300),
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
CupertinoButton(
onPressed: () {
context.push('/welcome');
},
child: new Text("Login or register",
textAlign: TextAlign.center,
)
)
]
);
}
}
class EmptyBox extends StatelessWidget {
final String title;
final String? description;
EmptyBox(this.title, {this.description}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
]);
}
}

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
@ -11,15 +13,14 @@ class _LoginScreenState extends State<LoginScreen> {
final Api api = Api(); final Api api = Api();
bool _loggingIn = false; bool _loggingIn = false;
void _submit(context) async { void _submit(BuildContext context) async {
setState(() => _loggingIn = true); setState(() => _loggingIn = true);
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text}); var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
setState(() => _loggingIn = false); setState(() => _loggingIn = false);
if (data['success'] == true) { if (data['success'] == true) {
String token = data['payload']['token']; AppModel model = Provider.of<AppModel>(context, listen: false);
SharedPreferences prefs = await SharedPreferences.getInstance(); await model.setToken(data['payload']['token']);
prefs.setString('apiToken', token); context.go('/onboarding');
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
} }
else { else {
showDialog( showDialog(
@ -31,7 +32,7 @@ class _LoginScreenState extends State<LoginScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('Try again'), child: Text('Try again'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -46,50 +47,46 @@ class _LoginScreenState extends State<LoginScreen> {
title: Text('Login to Treadl'), title: Text('Login to Treadl'),
), ),
body: Container( body: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: SingleChildScrollView( child: ListView(
child: Column( children: <Widget>[
mainAxisAlignment: MainAxisAlignment.center, Text('Login with your Treadl account', style: TextStyle(fontSize: 20)),
children: <Widget>[ SizedBox(height: 30),
Image(image: AssetImage('assets/logo.png'), width: 100), TextField(
SizedBox(height: 20), autofocus: true,
Text('Login using your Treadl account.'), controller: _emailController,
SizedBox(height: 20), decoration: InputDecoration(
TextField( hintText: 'sam@example.com', labelText: 'Email address or username',
autofocus: true, border: OutlineInputBorder(),
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Email address or username'
),
), ),
SizedBox(height: 10), ),
TextField( SizedBox(height: 10),
onEditingComplete: () => _submit(context), TextField(
controller: _passwordController, onEditingComplete: () => _submit(context),
obscureText: true, controller: _passwordController,
decoration: InputDecoration( obscureText: true,
hintText: 'Type your password', labelText: 'Your password' decoration: InputDecoration(
), hintText: 'Type your password', labelText: 'Your password',
border: OutlineInputBorder(),
), ),
SizedBox(height: 5), ),
Row( SizedBox(height: 5),
mainAxisAlignment: MainAxisAlignment.end, Row(
children: [GestureDetector( mainAxisAlignment: MainAxisAlignment.end,
onTap: () => launch('https://treadl.com/password/forgotten'), children: [GestureDetector(
child: Text('Forgotten your password?'), onTap: () => launch('https://treadl.com/password/forgotten'),
)] child: Text('Forgotten your password?'),
), )]
SizedBox(height: 20), ),
RaisedButton( SizedBox(height: 20),
onPressed: () => _submit(context), ElevatedButton(
color: Colors.pink, onPressed: () => _submit(context),
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login", child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15) style: TextStyle(color: Colors.white, fontSize: 15)
) )
), ),
] ]
)
) )
), ),
); );

View File

@ -3,19 +3,54 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart';
//import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart'; import 'api.dart';
import 'store.dart'; import 'model.dart';
import 'welcome.dart'; import 'welcome.dart';
import 'login.dart'; import 'login.dart';
import 'register.dart'; import 'register.dart';
import 'onboarding.dart'; import 'onboarding.dart';
import 'home.dart'; import 'home.dart';
import 'project.dart';
import 'object.dart';
import 'settings.dart';
import 'group.dart';
import 'user.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => Startup()),
GoRoute(path: '/welcome', pageBuilder: (context, state) {
return CustomTransitionPage(
key: state.pageKey,
child: WelcomeScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Change the opacity of the screen using a Curve based on the the animation's value
return FadeTransition(
opacity:
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
child: child,
);
},
);
}),
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
],
);
void main() { void main() {
runApp( runApp(
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => Store(), create: (context) => AppModel(),
child: MyApp() child: MyApp()
) )
); );
@ -37,21 +72,14 @@ class _AppState extends State<MyApp> {
// Initialize FlutterFire: // Initialize FlutterFire:
future: _initialization, future: _initialization,
builder: (context, snapshot) { builder: (context, snapshot) {
return MaterialApp( return MaterialApp.router(
routerConfig: router,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Treadl', title: 'Treadl',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.pink, primarySwatch: Colors.pink,
textSelectionColor: Colors.blue, scaffoldBackgroundColor: Color.fromRGBO(255, 251, 248, 1),
), ),
home: Startup(),
routes: <String, WidgetBuilder>{
'/welcome': (BuildContext context) => WelcomeScreen(),
'/login': (BuildContext context) => LoginScreen(),
'/register': (BuildContext context) => RegisterScreen(),
'/onboarding': (BuildContext context) => OnboardingScreen(),
'/home': (BuildContext context) => HomeScreen(),
}
); );
}, },
); );
@ -64,12 +92,12 @@ class Startup extends StatelessWidget {
Startup() { Startup() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) { FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.notification != null) { if (message.notification != null) {
print(message.notification.body); print(message.notification!);
String text = ''; String text = '';
if (message.notification != null && message.notification.body != null) { if (message.notification != null && message.notification!.body != null) {
text = message.notification.body; text = message.notification!.body!;
} }
Fluttertoast.showToast( /*Fluttertoast.showToast(
msg: text, msg: text,
toastLength: Toast.LENGTH_LONG, toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.TOP, gravity: ToastGravity.TOP,
@ -77,7 +105,7 @@ class Startup extends StatelessWidget {
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
textColor: Colors.black, textColor: Colors.black,
fontSize: 16.0 fontSize: 16.0
); );*/
} }
}); });
} }
@ -86,10 +114,10 @@ class Startup extends StatelessWidget {
if (_handled) return; if (_handled) return;
_handled = true; _handled = true;
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
final String token = prefs.getString('apiToken'); String? token = prefs.getString('apiToken');
if (token != null) { if (token != null) {
Provider.of<Store>(context, listen: false).setToken(token); AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(token!);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission( await _firebaseMessaging.requestPermission(
alert: true, alert: true,
@ -100,19 +128,14 @@ class Startup extends StatelessWidget {
provisional: false, provisional: false,
sound: true, sound: true,
); );
String _pushToken = await _firebaseMessaging.getToken(); String? _pushToken = await _firebaseMessaging.getToken();
if (_pushToken != null) { if (_pushToken != null) {
print("sending push"); print("sending push");
Api api = Api(); Api api = Api();
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken}); api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
} }
print('111');
// Push without including current route in stack:
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
print('222');
} else {
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
} }
context.go('/home');
} }
@override @override

65
mobile/lib/model.dart Normal file
View File

@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
class User {
final String id;
final String username;
String? avatar;
String? avatarUrl;
User(this.id, this.username, {this.avatar, this.avatarUrl}) {}
static User loadJSON(Map<String,dynamic> input) {
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
}
}
class AppModel extends ChangeNotifier {
User? user;
void setUser(User? u) {
user = u;
notifyListeners();
}
String? apiToken;
Future<void> setToken(String? newToken) async {
apiToken = newToken;
SharedPreferences prefs = await SharedPreferences.getInstance();
if (apiToken != null) {
Api api = Api(token: apiToken!);
prefs.setString('apiToken', apiToken!);
var data = await api.request('GET', '/users/me');
if (data['success'] == true) {
setUser(User.loadJSON(data['payload']));
print(data);
}
} else {
prefs.remove('apiToken');
}
}
/*
/// Internal, private state of the cart.
final List<Item> _items = [];
/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// The current total price of all items (assuming all items cost $42).
int get totalPrice => _items.length * 42;
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
/// cart from the outside.
void add(Item item) {
_items.add(item);
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
/// Removes all items from the cart.
void removeAll() {
_items.clear();
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}*/
}

View File

@ -3,22 +3,64 @@ import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:go_router/go_router.dart';
import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'util.dart';
import 'model.dart';
import 'patterns/pattern.dart';
import 'patterns/viewer.dart';
class _ObjectScreenState extends State<ObjectScreen> { class _ObjectScreenState extends State<ObjectScreen> {
final Map<String,dynamic> _object; final String username;
final Function _onDelete; final String projectPath;
final String id;
Map<String,dynamic>? object;
Map<String,dynamic>? pattern;
bool _isLoading = false;
final Api api = Api(); final Api api = Api();
_ObjectScreenState(this._object, this._onDelete) { } _ObjectScreenState(this.username, this.projectPath, this.id) { }
@override
initState() {
super.initState();
fetchObject();
}
void fetchObject() async {
var data = await api.request('GET', '/objects/' + id);
if (data['success'] == true) {
setState(() {
object = data['payload'];
pattern = data['payload']['pattern'];
});
}
}
void _shareObject() async {
setState(() => _isLoading = true);
File? file;
if (object!['type'] == 'pattern') {
var data = await api.request('GET', '/objects/' + id + '/wif');
if (data['success'] == true) {
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
}
} else {
String fileName = Uri.file(object!['url']).pathSegments.last;
file = await api.downloadFile(object!['url'], fileName);
}
if (file != null) {
Util.shareFile(file!, withDelete: true);
}
setState(() => _isLoading = false);
}
void _deleteObject(BuildContext context, BuildContext modalContext) async { void _deleteObject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/objects/' + _object['_id']); var data = await api.request('DELETE', '/objects/' + id);
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.go('/home');
Navigator.pop(modalContext);
Navigator.pop(context);
_onDelete(_object['_id']);
} }
} }
@ -26,13 +68,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
showDialog( showDialog(
context: modalContext, context: modalContext,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text('Really delete this object?'), title: new Text('Really delete this item?'),
content: new Text('This action cannot be undone.'), content: new Text('This action cannot be undone.'),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('No'), child: Text('No'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
CupertinoDialogAction( CupertinoDialogAction(
isDestructiveAction: true, isDestructiveAction: true,
@ -44,6 +86,45 @@ class _ObjectScreenState extends State<ObjectScreen> {
); );
} }
void _renameObject(BuildContext context) {
TextEditingController renameController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Rename this item'),
content: TextField(
autofocus: true,
controller: renameController,
decoration: InputDecoration(hintText: "Enter a new name for the item"),
),
actions: <Widget>[
TextButton(
child: Text('CANCEL'),
onPressed: () {
context.pop();
},
),
TextButton(
child: Text('OK'),
onPressed: () async {
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
if (data['success']) {
context.pop();
object!['name'] = data['payload']['name'];
setState(() {
object = object;
});
}
context.pop();
},
),
],
);
},
);
}
void _showSettingsModal(context) { void _showSettingsModal(context) {
showCupertinoModalPopup( showCupertinoModalPopup(
context: context, context: context,
@ -51,13 +132,17 @@ class _ObjectScreenState extends State<ObjectScreen> {
return CupertinoActionSheet( return CupertinoActionSheet(
title: Text('Manage this object'), title: Text('Manage this object'),
cancelButton: CupertinoActionSheetAction( cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(modalContext).pop(), onPressed: () => modalContext.pop(),
child: Text('Cancel') child: Text('Cancel')
), ),
actions: [ actions: [
CupertinoActionSheetAction(
onPressed: () => _renameObject(context),
child: Text('Rename item'),
),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () => _confirmDeleteObject(modalContext), onPressed: () => _confirmDeleteObject(modalContext),
child: Text('Delete object'), child: Text('Delete item'),
isDestructiveAction: true, isDestructiveAction: true,
), ),
] ]
@ -67,43 +152,79 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
Widget getObjectWidget() { Widget getObjectWidget() {
if (_object['isImage'] == true) { if (object == null) {
return Image.network(_object['url']); return Center(child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [CircularProgressIndicator()]
));
} }
else if (_object['type'] == 'pattern') { else if (object!['isImage'] == true && object!['url'] != null) {
var dat = Uri.parse(_object['preview']).data; print(object!['url']);
return Image.memory(dat.contentAsBytes()); return Image.network(object!['url']);
}
else if (object!['type'] == 'pattern') {
if (pattern != null) {
return PatternViewer(pattern!, withEditor: true);
}
else if (object!['previewUrl'] != null) {
return Image.network(object!['previewUrl']!);;
}
else {
return Column(
children: [
SizedBox(height: 50),
Icon(Icons.pattern, size: 40),
SizedBox(height: 20),
Text('A preview of this pattern is not yet available'),
],
);
}
} }
else { else {
return RaisedButton(child: Text('View file'), onPressed: () { return Center(child: Column(
launch(_object['url']); mainAxisAlignment: MainAxisAlignment.center,
}); children: [
Text('Treadl cannot display this type of item.'),
SizedBox(height: 20),
ElevatedButton(child: Text('View file'), onPressed: () {
launch(object!['url']);
}),
],
));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
String description = ''; String description = '';
if (_object['description'] != null) if (object?['description'] != null)
description = _object['description']; description = object!['description']!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_object['name']), title: Text(object?['name'] ?? 'Object'),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.ios_share),
onPressed: () {
_shareObject();
},
),
Util.canEditProject(user, object?['projectObject']) ? IconButton(
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
onPressed: () { onPressed: () {
_showSettingsModal(context); _showSettingsModal(context);
}, },
), ) : SizedBox(height: 0),
] ]
), ),
body: Container( body: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.all(10.0),
child: ListView( child: Column(
children: <Widget>[ children: [
getObjectWidget(), _isLoading ? LinearProgressIndicator() : SizedBox(height: 0),
Html(data: description) Expanded(child: getObjectWidget()),
] ]
) )
), ),
@ -112,9 +233,11 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
class ObjectScreen extends StatefulWidget { class ObjectScreen extends StatefulWidget {
final Map<String,dynamic> _object; final String username;
final Function _onDelete; final String projectPath;
ObjectScreen(this._object, this._onDelete) { } final String id;
ObjectScreen(this.username, this.projectPath, this.id, ) { }
@override @override
_ObjectScreenState createState() => _ObjectScreenState(_object, _onDelete); _ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
} }

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
class _OnboardingScreenState extends State<OnboardingScreen> { class _OnboardingScreenState extends State<OnboardingScreen> {
@ -9,7 +11,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
); );
final Api api = Api(); final Api api = Api();
bool _loading = false; bool _loading = false;
String _pushToken; String? _pushToken;
@override @override
void dispose() { void dispose() {
@ -18,26 +20,24 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
} }
void _requestPushPermissions() async { void _requestPushPermissions() async {
setState(() => _loading = true); try {
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; setState(() => _loading = true);
await _firebaseMessaging.requestPermission( FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
alert: true, await _firebaseMessaging.requestPermission(
announcement: false, alert: true,
badge: true, announcement: false,
carPlay: false, badge: true,
criticalAlert: false, carPlay: false,
provisional: false, criticalAlert: false,
sound: true, provisional: false,
); sound: true,
_pushToken = await _firebaseMessaging.getToken(); );
/*final FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); _pushToken = await _firebaseMessaging.getToken();
await _firebaseMessaging.requestNotificationPermissions( if (_pushToken != null) {
const IosNotificationSettings(sound: true, badge: true, alert: true, provisional: false), api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
); }
_pushToken = await _firebaseMessaging.getToken();*/
if (_pushToken != null) {
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
} }
on Exception { }
setState(() => _loading = false); setState(() => _loading = false);
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut); _controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
} }
@ -57,14 +57,15 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
children: <Widget>[ children: <Widget>[
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center), Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 10),
Text('Treadl is a free and safe place for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center), Text('Treadl is a safe space for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 10),
Image(image: AssetImage('assets/folder.png'), width: 300), Image(image: AssetImage('assets/folder.png'), width: 300),
SizedBox(height: 10), SizedBox(height: 10),
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store and showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center), Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store or showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 20),
RaisedButton( CupertinoButton(
child: Text('OK, I know what projects are!'), color: Colors.white,
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)),
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut), onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
) )
] ]
@ -83,13 +84,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center), Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 10),
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center), Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 20),
RaisedButton( CupertinoButton(
color: Colors.white,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_loading ? CircularProgressIndicator() : null, _loading ? CircularProgressIndicator() : SizedBox(width: 0),
_loading ? SizedBox(width: 5) : null, _loading ? SizedBox(width: 10) : SizedBox(width: 0),
Text('What\'s next?'), Text('Continue', style: TextStyle(color: Colors.pink)),
].where((o) => o != null).toList()), ]),
onPressed: _requestPushPermissions, onPressed: _requestPushPermissions,
) )
] ]
@ -101,15 +103,16 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center), Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 25), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 10),
Image(image: AssetImage('assets/completed.png'), width: 300), Image(image: AssetImage('assets/completed.png'), width: 300),
SizedBox(height: 10), SizedBox(height: 10),
Text('You\'re ready to get started. If you have any questions or want to get in touch then just send us a quick tweet.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center), Text('You\'re ready to get started. We hope you enjoy using Treadl.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
SizedBox(height: 10), SizedBox(height: 20),
RaisedButton( CupertinoButton(
child: Text('Let\'s go'), color: Colors.white,
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false), child: Text('Get started', style: TextStyle(color: Colors.pink)),
onPressed: () => context.go('/home'),
), ),
] ]
) )

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import '../util.dart';
class DrawdownPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
DrawdownPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var weft = pattern['weft'];
var warp = pattern['warp'];
var tieups = pattern['tieups'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 1;
// Draw grid
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
for (int tread = 0; tread < weft['treadling']?.length; tread++) {
for (int thread = 0; thread < warp['threading']?.length; thread++) {
// Ensure we only get a treadle in the allowed bounds
int treadle = weft['treadling'][tread]['treadle'] > weft['treadles'] ? 0 : weft['treadling'][tread]['treadle'];
int shaft = warp['threading'][thread]['shaft'];
Color weftColour = Util.rgb(weft['treadling'][tread]['colour'] ?? weft['defaultColour']);
Color warpColour = Util.rgb(warp['threading'][thread]['colour'] ?? warp['defaultColour']);
// Only capture valid tie-ups (e.g. in case there is data for more shafts, which are then reduced)
// Dart throws error if index < 0 so check fiest
List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : [];
List<dynamic> filteredTieup = tieup.where((t) => t< warp['shafts']).toList();
String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft';
Rect rect = Offset(
size.width - BASE_SIZE * (thread + 1),
tread * BASE_SIZE
) & Size(BASE_SIZE, BASE_SIZE);
canvas.drawRect(
rect,
Paint()
..color = threadType == 'warp' ? warpColour : weftColour
);
canvas.drawRect(
rect,
Paint()
..shader = ui.Gradient.linear(
threadType == 'warp' ? rect.centerLeft : rect.topCenter,
threadType == 'warp' ? rect.centerRight : rect.bottomCenter,
[
Color.fromRGBO(0,0,0,0.4),
Color.fromRGBO(0,0,0,0.0),
Color.fromRGBO(0,0,0,0.4),
],
[0.0,0.5,1.0],
)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'warp.dart';
import 'weft.dart';
import 'tieup.dart';
import 'drawdown.dart';
class Pattern extends StatelessWidget {
final Map<String,dynamic> pattern;
final Function? onUpdate;
final double BASE_SIZE = 5;
@override
Pattern(this.pattern, {this.onUpdate}) {}
@override
Widget build(BuildContext context) {
var warp = pattern['warp'];
var weft = pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
double draftHeight = warp['shafts'] * BASE_SIZE + weft['treadling']?.length * BASE_SIZE + BASE_SIZE;
double tieupTop = BASE_SIZE;
double tieupRight = BASE_SIZE;
double tieupWidth = weft['treadles'] * BASE_SIZE;
double tieupHeight = warp['shafts'] * BASE_SIZE;
double warpTop = 0;
double warpRight = weft['treadles'] * BASE_SIZE + BASE_SIZE * 2;
double warpWidth = warp['threading']?.length * BASE_SIZE;
double warpHeight = warp['shafts'] * BASE_SIZE + BASE_SIZE;
double weftRight = 0;
double weftTop = warp['shafts'] * BASE_SIZE + BASE_SIZE * 2;
double weftWidth = weft['treadles'] * BASE_SIZE + BASE_SIZE;
double weftHeight = weft['treadling'].length * BASE_SIZE;
double drawdownTop = warpHeight + BASE_SIZE;
double drawdownRight = weftWidth + BASE_SIZE;
double drawdownWidth = warpWidth;
double drawdownHeight = weftHeight;
return Container(
width: draftWidth,
height: draftHeight,
child: Stack(
children: [
Positioned(
right: tieupRight,
top: tieupTop,
child: GestureDetector(
onTapDown: (details) {
var tieups = pattern['tieups'];
double dx = details.localPosition.dx;
double dy = details.localPosition.dy;
int tie = (dx / BASE_SIZE).toInt();
int shaft = ((tieupHeight - dy) / BASE_SIZE).toInt() + 1;
if (tieups[tie].contains(shaft)) {
tieups[tie].remove(shaft);
} else {
tieups[tie].add(shaft);
}
print(tieups);
if (onUpdate != null) {
onUpdate!({'tieups': tieups});
}
// Toggle tieups[tie][shaft]
},
child: CustomPaint(
size: Size(tieupWidth, tieupHeight),
painter: TieupPainter(BASE_SIZE, this.pattern),
)),
),
Positioned(
right: warpRight,
top: warpTop,
child: CustomPaint(
size: Size(warpWidth, warpHeight),
painter: WarpPainter(BASE_SIZE, this.pattern),
),
),
Positioned(
right: weftRight,
top: weftTop,
child: CustomPaint(
size: Size(weftWidth, weftHeight),
painter: WeftPainter(BASE_SIZE, this.pattern),
),
),
Positioned(
right: drawdownRight,
top: drawdownTop,
child: CustomPaint(
size: Size(drawdownWidth, drawdownHeight),
painter: DrawdownPainter(BASE_SIZE, this.pattern),
),
)
]
)
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
class TieupPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
TieupPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var tieup = pattern['tieups'];
var paint = Paint()
..color = Colors.black..strokeWidth = 0.5;
// Draw grid
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
for (var i = 0; i < tieup.length; i++) {
List<dynamic>? tie = tieup[i];
if (tie != null) {
for (var j = 0; j < tie!.length; j++) {
canvas.drawRect(
Offset(i.toDouble()*BASE_SIZE, size.height - (tie[j]*BASE_SIZE)) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint);
}
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'pattern.dart';
class PatternViewer extends StatefulWidget {
final Map<String,dynamic> pattern;
final bool withEditor;
PatternViewer(this.pattern, {this.withEditor = false}) {}
@override
State<PatternViewer> createState() => _PatternViewerState(this.pattern, this.withEditor);
}
class _PatternViewerState extends State<PatternViewer> {
Map<String,dynamic> pattern;
final bool withEditor;
bool controllerInitialised = false;
final controller = TransformationController();
final double BASE_SIZE = 5;
_PatternViewerState(this.pattern, this.withEditor) {}
void updatePattern(update) {
setState(() {
pattern!.addAll(update);
});
}
@override
Widget build(BuildContext context) {
if (!controllerInitialised) {
var warp = pattern['warp'];
var weft = pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
final zoomFactor = 1.0;
final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0;
final yTranslate = 0.0;
controller.value.setEntry(0, 0, zoomFactor);
controller.value.setEntry(1, 1, zoomFactor);
controller.value.setEntry(2, 2, zoomFactor);
controller.value.setEntry(0, 3, -xTranslate);
controller.value.setEntry(1, 3, -yTranslate);
setState(() => controllerInitialised = true);
}
return InteractiveViewer(
minScale: 0.5,
maxScale: 5,
constrained: false,
transformationController: controller,
child: RepaintBoundary(child: Pattern(pattern))
);
/*return Column(
children: [
Text('Hi'),
Expanded(child: InteractiveViewer(
minScale: 0.5,
maxScale: 5,
constrained: false,
transformationController: controller,
child: RepaintBoundary(child: Pattern(pattern))))
,
Text('Another'),
]
);*/
}
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../util.dart';
class WarpPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
WarpPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var warp = pattern['warp'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 0.5;
var thickPaint = Paint()
..color = Colors.black
..strokeWidth = 1.5;
// Draw grid
int columnsPainted = 0;
for (double i = size.width; i >= 0; i -= BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
columnsPainted += 1;
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
}
// Draw threads
for (var i = 0; i < warp['threading'].length; i++) {
var thread = warp['threading'][i];
int? shaft = thread?['shaft'];
String? colour = warp['defaultColour'];
double x = size.width - (i+1)*BASE_SIZE;
if (shaft != null) {
if (shaft! > 0) {
canvas.drawRect(
Offset(x, size.height - shaft!*BASE_SIZE) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint
);
}
}
if (thread?['colour'] != null) {
colour = thread!['colour'];
}
if (colour != null) {
canvas.drawRect(
Offset(x, 0) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
Paint()
..color = Util.rgb(colour!)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../util.dart';
class WeftPainter extends CustomPainter {
final Map<String,dynamic> pattern;
final double BASE_SIZE;
@override
WeftPainter(this.BASE_SIZE, this.pattern) {}
@override
void paint(Canvas canvas, Size size) {
var weft = pattern['weft'];
var paint = Paint()
..color = Colors.black
..strokeWidth = 0.5;
var thickPaint = Paint()
..color = Colors.black
..strokeWidth = 1.5;
// Draw grid
int rowsPainted = 0;
for (double i = 0; i <= size.width; i += BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
}
for (double y = 0; y <= size.height; y += BASE_SIZE) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
rowsPainted += 1;
}
for (var i = 0; i < weft['treadling'].length; i++) {
var thread = weft['treadling'][i];
int? treadle = thread?['treadle'];
String? colour = weft['defaultColour'];
double y = i.toDouble()*BASE_SIZE;
if (treadle != null && treadle! > 0) {
canvas.drawRect(
Offset((treadle!.toDouble()-1)*BASE_SIZE, y) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
paint
);
}
if (thread?['colour'] != null) {
colour = thread!['colour'];
}
if (colour != null) {
canvas.drawRect(
Offset(size.width - BASE_SIZE, y) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
Paint()
..color = Util.rgb(colour!)
);
}
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@ -2,30 +2,432 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:io'; import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'object.dart'; import 'util.dart';
import 'model.dart';
import 'lib.dart';
class _ProjectScreenState extends State<ProjectScreen> {
final String username;
final String projectPath;
final String fullPath;
final Function? onUpdate;
final Function? onDelete;
final picker = ImagePicker();
final Api api = Api();
Map<String,dynamic>? project;
List<dynamic> _objects = [];
bool _loading = false;
Map<String,dynamic>? _creatingObject;
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
fullPath = username + '/' + projectPath;
@override
initState() {
super.initState();
getProject(fullPath);
getObjects(fullPath);
}
void getProject(String fullName) async {
setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName);
if (data['success'] == true) {
setState(() {
project = data['payload'];
_loading = false;
});
}
}
void getObjects(String fullName) async {
setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName + '/objects');
if (data['success'] == true) {
setState(() {
_objects = data['payload']['objects'];
_loading = false;
});
}
}
void _shareProject() {
Util.shareUrl('Check out my project on Treadl', Util.appUrl(fullPath));
}
void _onDeleteProject() {
context.pop();
onDelete!(project!['_id']);
}
void _onUpdateProject(project) {
setState(() {
project = project;
});
onUpdate!(project!['_id'], project!);
}
void _onUpdateObject(String id, Map<String,dynamic> update) {
List<dynamic> _newObjects = _objects.map((o) {
if (o['_id'] == id) {
o.addAll(update);
}
return o;
}).toList();
setState(() {
_objects = _newObjects;
});
}
void _onDeleteObject(String id) {
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
setState(() {
_objects = _newObjects;
});
}
void _createObject(objectData) async {
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
setState(() => _creatingObject = null);
if (resp['success']) {
List<dynamic> newObjects = _objects;
newObjects.add(resp['payload']);
setState(() {
_objects = newObjects;
});
}
}
void _createObjectFromWif(String name, String wif) {
setState(() => _creatingObject = {
'name': name,
'type': 'pattern',
});
_createObject({
'name': name,
'type': 'pattern',
'wif': wif,
});
}
void _createObjectFromFile(String name, XFile file) async {
final int size = await file.length();
final String forId = project!['_id'];
final String type = file.mimeType ?? 'text/plain';
setState(() => _creatingObject = {
'name': name,
'type': 'file',
});
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
if (!data['success']) {
setState(() => _creatingObject = null);
return;
}
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], File(file.path), type);
if (!uploadSuccess) {
setState(() => _creatingObject = null);
return;
}
_createObject({
'name': name,
'storedName': data['payload']['fileName'],
'type': 'file',
});
}
void _chooseFile() async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
PlatformFile file = result.files.single;
XFile xFile = XFile(file.path!);
String? ext = file.extension;
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
final String contents = await xFile.readAsString();
_createObjectFromWif(file.name, contents);
} else {
_createObjectFromFile(file.name, xFile);
}
}
}
void _chooseImage() async {
File file;
try {
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
if (imageFile == null) return;
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
String time = f.format(new DateTime.now());
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
_createObjectFromFile(name, imageFile);
}
on Exception {
showDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text('Treadl needs access'),
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('OK'),
onPressed: () => context.pop(),
),
],
)
);
}
}
void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
}
Widget getNetworkImageBox(String url) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
image: new DecorationImage(
fit: BoxFit.cover,
alignment: FractionalOffset.topCenter,
image: new NetworkImage(url),
)
),
),
);
}
Widget getIconBox(Icon icon) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0),
),
child: icon
),
);
}
Widget getObjectCard(int index) {
Map<String,dynamic>? objectToShow;
if (index >= _objects.length) {
objectToShow = _creatingObject;
objectToShow!['creating'] = true;
} else {
objectToShow = _objects[index];
}
Map<String,dynamic> object = objectToShow!;
Widget leader;
String type;
if (object['isImage'] == true) {
type = 'Image';
if (object['url'] != null) {
leader = getNetworkImageBox(object['url']!);
}
else {
leader = getIconBox(Icon(Icons.photo));
}
}
else if (object['type'] == 'pattern') {
type = 'Weaving pattern';
if (object['previewUrl'] != null) {
leader = getNetworkImageBox(object['previewUrl']!);
}
else {
leader = getIconBox(Icon(Icons.pattern));
}
}
else if (object['type'] == 'file') {
type = 'File';
leader = getIconBox(Icon(Icons.insert_drive_file));
}
else {
type = 'Unknown';
leader = getIconBox(Icon(Icons.file_present));
}
if (object['creating'] == true) {
leader = CircularProgressIndicator();
}
return new Card(
child: InkWell(
onTap: () {
context.push('/' + username + '/' + projectPath + '/' + object['_id']);
},
child: ListTile(
leading: leader,
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(object['name']),
subtitle: Text(type),
),
)
);
}
Widget getBody() {
if (_loading || project == null)
return CircularProgressIndicator();
else if ((_objects != null && _objects.length > 0) || _creatingObject != null)
return ListView.builder(
itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
);
else
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text(project?['name'] ?? 'Project'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.ios_share),
onPressed: () {
_shareProject();
},
),
onUpdate != null ? IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showSettingsModal();
},
) : SizedBox(width: 0),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody(),
),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
distance: 70,
type: ExpandableFabType.up,
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Icons.add),
),
children: [
Row(children:[
Container(
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Text('Add an image', style: TextStyle(fontSize: 15, color: Colors.white)),
),
SizedBox(width: 10),
FloatingActionButton(
heroTag: null,
onPressed: _chooseImage,
child: Icon(Icons.image_outlined),
),
]),
Row(children:[
Container(
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Text('Add a WIF or other file', style: TextStyle(fontSize: 15, color: Colors.white)),
),
SizedBox(width: 10),
FloatingActionButton(
heroTag: null,
child: const Icon(Icons.insert_drive_file_outlined),
onPressed: _chooseFile,
),
]),
],
) : null,
);
}
}
class ProjectScreen extends StatefulWidget {
final String username;
final String projectPath;
final Map<String,dynamic>? project;
final Function? onUpdate;
final Function? onDelete;
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
@override
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
}
class _ProjectSettingsDialog extends StatelessWidget { class _ProjectSettingsDialog extends StatelessWidget {
final Map<String,dynamic> _project; final String fullPath;
final Map<String,dynamic> project;
final Function _onDelete; final Function _onDelete;
final Function _onUpdateProject; final Function _onUpdateProject;
final Api api = Api(); final Api api = Api();
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {} _ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
fullPath = project['owner']['username'] + '/' + project['path'];
void _renameProject(BuildContext context) async {
TextEditingController renameController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Rename your project'),
content: TextField(
autofocus: true,
controller: renameController,
decoration: InputDecoration(hintText: "Enter a new name for the project"),
),
actions: <Widget>[
TextButton(
child: Text('CANCEL'),
onPressed: () {
context.pop();
},
),
TextButton(
child: Text('OK'),
onPressed: () async {
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
if (data['success']) {
context.pop();
_onUpdateProject(data['payload']);
}
context.pop();
},
),
],
);
},
);
}
void _toggleVisibility(BuildContext context, bool checked) async { void _toggleVisibility(BuildContext context, bool checked) async {
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'}); var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
_onUpdateProject(data['payload']); _onUpdateProject(data['payload']);
} }
} }
void _deleteProject(BuildContext context, BuildContext modalContext) async { void _deleteProject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']); var data = await api.request('DELETE', '/projects/' + fullPath);
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
Navigator.pop(modalContext); context.pop();
_onDelete(); _onDelete();
} }
} }
@ -40,7 +442,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('No'), child: Text('No'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
CupertinoDialogAction( CupertinoDialogAction(
isDestructiveAction: true, isDestructiveAction: true,
@ -57,7 +459,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
return CupertinoActionSheet( return CupertinoActionSheet(
title: Text('Manage this project'), title: Text('Manage this project'),
cancelButton: CupertinoActionSheetAction( cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text('Cancel') child: Text('Cancel')
), ),
actions: [ actions: [
@ -67,7 +469,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CupertinoSwitch( CupertinoSwitch(
value: _project['visibility'] == 'private', value: project?['visibility'] == 'private',
onChanged: (c) => _toggleVisibility(context, c), onChanged: (c) => _toggleVisibility(context, c),
), ),
SizedBox(width: 10), SizedBox(width: 10),
@ -75,6 +477,10 @@ class _ProjectSettingsDialog extends StatelessWidget {
] ]
) )
), ),
CupertinoActionSheetAction(
onPressed: () { _renameProject(context); },
child: Text('Rename project'),
),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () { _confirmDeleteProject(context); }, onPressed: () { _confirmDeleteProject(context); },
child: Text('Delete project'), child: Text('Delete project'),
@ -84,243 +490,3 @@ class _ProjectSettingsDialog extends StatelessWidget {
); );
} }
} }
class _ProjectScreenState extends State<ProjectScreen> {
final Function _onDelete;
final picker = ImagePicker();
final Api api = Api();
Map<String,dynamic> _project;
List<dynamic> _objects = [];
bool _loading = false;
bool _creating = false;
_ProjectScreenState(this._project, this._onDelete) { }
@override
initState() {
super.initState();
getObjects(_project['fullName']);
}
void getObjects(String fullName) async {
setState(() => _loading = true);
print(fullName);
var data = await api.request('GET', '/projects/' + fullName + '/objects');
if (data['success'] == true) {
setState(() {
_objects = data['payload']['objects'];
_loading = false;
});
}
}
void _onDeleteProject() {
Navigator.pop(context);
_onDelete(_project['_id']);
}
void _onUpdateProject(project) {
setState(() {
_project = project;
});
}
void _onDeleteObject(String id) {
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
setState(() {
_objects = _newObjects;
});
}
void _chooseImage() async {
File file;
try {
final imageFile = await picker.getImage(source: ImageSource.gallery);
if (imageFile == null) return;
file = File(imageFile.path);
}
on Exception {
showDialog(
context: context,
builder: (BuildContext context) => CupertinoAlertDialog(
title: Text('Treadl needs access'),
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
actions: <Widget>[
CupertinoDialogAction(
isDefaultAction: true,
child: Text('OK'),
onPressed: () => Navigator.pop(context),
),
],
)
);
return;
}
final int size = await file.length();
final String forId = _project['_id'];
final String fullPath = _project['owner']['username'] + '/' + _project['path'];
final String name = file.path.split('/').last;
final String ext = name.split('.').last;
final String type = 'image/jpeg';//$ext';
setState(() => _creating = true);
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
print(data);
if (!data['success']) {
setState(() => _creating = false);
return;
}
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], file, type);
print(uploadSuccess);
if (!uploadSuccess) {
setState(() => _creating = false);
return;
}
var newObjectData = {
'name': name,
'storedName': data['payload']['fileName'],
'type': 'file',
};
var objectData = await api.request('POST', '/projects/$fullPath/objects', newObjectData);
setState(() => _creating = false);
if (objectData['success']) {
List<dynamic> newObjects = _objects;
newObjects.add(objectData['payload']);
setState(() {
_objects = newObjects;
});
}
}
void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
}
Widget getImageBox(data, [bool isMemory, bool isNetwork]) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
image: new DecorationImage(
fit: BoxFit.fitWidth,
alignment: FractionalOffset.topCenter,
image: isMemory == true ? new MemoryImage(data) : new NetworkImage(data)
)
),
),
);
}
Widget getIconBox(Icon icon) {
return new AspectRatio(
aspectRatio: 1 / 1,
child: icon
);
}
Widget getObjectCard(int index) {
if (index >= _objects.length) {
return new Card(
child: Container(
padding: EdgeInsets.all(10),
child: Center(child:CircularProgressIndicator())
)
);
}
var object = _objects[index];
Widget leader;
String type;
if (object['isImage'] == true) {
type = 'Image';
leader = getImageBox(object['url']);
}
else if (object['type'] == 'pattern' && object['preview'] != null) {
type = 'Weaving pattern';
var dat = Uri.parse(object['preview']).data;
leader = getImageBox(dat.contentAsBytes(), true);
}
else if (object['type'] == 'file') {
type = 'File';
leader = getIconBox(Icon(Icons.insert_drive_file));
}
return new Card(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ObjectScreen(object, _onDeleteObject),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new ListTile(
leading: leader,
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(object['name']),
subtitle: Text(type),
),
]
)
)
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_project['name']),
actions: <Widget>[
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showSettingsModal();
},
),
]
),
body: _loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0),
child: ((_objects != null && _objects.length > 0) || _creating) ?
ListView.builder(
itemCount: _objects.length + (_creating ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
Text('Add something to this project using the button below.', textAlign: TextAlign.center),
])
),
floatingActionButton: FloatingActionButton(
onPressed: _chooseImage,
child: Icon(Icons.cloud_upload),
backgroundColor: Colors.pink[500],
),
);
}
}
class ProjectScreen extends StatefulWidget {
final Map<String,dynamic> _project;
final Function _onDelete;
ProjectScreen(this._project, this._onDelete) { }
@override
_ProjectScreenState createState() => _ProjectScreenState(_project, _onDelete);
}

View File

@ -1,10 +1,170 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'routeArguments.dart'; import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'project.dart'; import 'model.dart';
import 'settings.dart'; import 'lib.dart';
class _ProjectsTabState extends State<ProjectsTab> {
List<dynamic> _projects = [];
bool _loading = false;
bool _creatingProject = false;
final Api api = Api();
@override
initState() {
super.initState();
getProjects();
}
void getProjects() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() {
_loading = true;
});
var data = await api.request('GET', '/users/me/projects');
if (data['success'] == true) {
setState(() {
_projects = data['payload']['projects'];
_loading = false;
});
}
}
void _onCreatingProject() {
setState(() {
_creatingProject = true;
});
}
void _onCreateProject(newProject) {
List<dynamic> _newProjects = _projects;
_newProjects.insert(0, newProject);
setState(() {
_projects = _newProjects;
_creatingProject = false;
});
}
void _onUpdateProject(String id, Map<String,dynamic> update) {
List<dynamic> _newProjects = _projects.map((p) {
if (p['_id'] == id) {
p.addAll(update);
}
return p;
}).toList();
setState(() {
_projects = _newProjects;
});
}
void _onDeleteProject(String id) {
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
setState(() {
_projects = _newProjects;
});
}
void showNewProjectDialog() async {
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
}
Widget buildProjectCard(Map<String,dynamic> project) {
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...';
}
if (project['visibility'] == 'public') {
description = "PUBLIC PROJECT\n" + description;
}
else description = "PRIVATE PROJECT\n" + description;
return new Card(
child: InkWell(
onTap: () {
context.push('/' + project['owner']['username'] + '/' + project['path']);
},
child: Container(
padding: EdgeInsets.all(5),
child: ListTile(
leading: new AspectRatio(
aspectRatio: 1 / 1,
child: new Container(
decoration: new BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0),
),
child: Icon(Icons.folder, color: Colors.pink[300])
),
),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
subtitle: Text(description),
),
))
)
;
}
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
if (_loading)
return CircularProgressIndicator();
else if (_projects != null && _projects.length > 0)
return ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
);
else return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
]
);
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text('My Projects'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
context.push('/settings');
},
),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody()
),
floatingActionButton: user != null ? FloatingActionButton(
onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500],
) : null,
);
}
}
class ProjectsTab extends StatefulWidget {
@override
_ProjectsTabState createState() => _ProjectsTabState();
}
class _NewProjectDialogState extends State<_NewProjectDialog> { class _NewProjectDialogState extends State<_NewProjectDialog> {
final TextEditingController _newProjectNameController = TextEditingController(); final TextEditingController _newProjectNameController = TextEditingController();
@ -29,7 +189,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'}); var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
if (data['success'] == true) { if (data['success'] == true) {
_onComplete(data['payload']); _onComplete(data['payload']);
Navigator.of(context).pop(); context.pop();
} }
} }
@ -65,7 +225,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
SizedBox(height: 10), SizedBox(height: 10),
CupertinoButton( CupertinoButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text('Cancel'), child: Text('Cancel'),
) )
@ -82,155 +242,3 @@ class _NewProjectDialog extends StatefulWidget {
@override @override
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete); _NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
} }
class _ProjectsTabState extends State<ProjectsTab> {
List<dynamic> _projects = [];
bool _loading = false;
bool _creatingProject = false;
final Api api = Api();
@override
initState() {
super.initState();
getProjects();
}
void getProjects() async {
setState(() {
_loading = true;
});
var data = await api.request('GET', '/users/me/projects');
if (data['success'] == true) {
setState(() {
_projects = data['payload']['projects'];
_loading = false;
});
}
}
void _onCreatingProject() {
setState(() {
_creatingProject = true;
});
}
void _onCreateProject(newProject) {
List<dynamic> _newProjects = _projects;
_newProjects.insert(0, newProject);
setState(() {
_projects = _newProjects;
_creatingProject = false;
});
}
void _onDeleteProject(String id) {
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
setState(() {
_projects = _newProjects;
});
}
void showNewProjectDialog() async {
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
}
Widget buildProjectCard(Map<String,dynamic> project) {
String description = project['description'] != null ? project['description'] : '';
if (description != null && description.length > 80) {
description = description.substring(0, 77) + '...';
}
return new Card(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProjectScreen(project, _onDeleteProject),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new ListTile(
leading: Icon(Icons.folder_open),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
subtitle: Text(description.replaceAll("\n", " ")),
),
/*ButtonBar(
children: <Widget>[
FlatButton(
child: const Text('VIEW'),
onPressed: () {
}
),
],
),*/
]
),
)
)
;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Your Projects'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingsScreen(),
),
);
},
),
]
),
body: _loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: (_projects != null && _projects.length > 0) ?
ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
])
),
floatingActionButton: FloatingActionButton(
onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500],
),
);
}
}
class ProjectsTab extends StatefulWidget {
@override
_ProjectsTabState createState() => _ProjectsTabState();
}

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
@ -17,10 +19,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text}); var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
setState(() => _registering = false); setState(() => _registering = false);
if (data['success'] == true) { if (data['success'] == true) {
String token = data['payload']['token']; AppModel model = Provider.of<AppModel>(context, listen: false);
SharedPreferences prefs = await SharedPreferences.getInstance(); model.setToken(data['payload']['token']);
prefs.setString('apiToken', token); context.go('/onboarding');
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
} }
else { else {
showDialog( showDialog(
@ -32,7 +33,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('Try again'), child: Text('Try again'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -47,66 +48,59 @@ class _RegisterScreenState extends State<RegisterScreen> {
title: Text('Register with Treadl'), title: Text('Register with Treadl'),
), ),
body: Container( body: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: SingleChildScrollView( child: ListView(
child:Column( children: <Widget>[
mainAxisAlignment: MainAxisAlignment.center, TextField(
children: <Widget>[ autofocus: true,
Image(image: AssetImage('assets/logo.png'), width: 100), controller: _usernameController,
SizedBox(height: 20), decoration: InputDecoration(
Text('Register a free account.'), hintText: 'username', labelText: 'Choose a username',
SizedBox(height: 20), border: OutlineInputBorder(),
TextField(
autofocus: true,
controller: _usernameController,
decoration: InputDecoration(
hintText: 'username', labelText: 'Choose a username',
border: OutlineInputBorder(),
),
), ),
SizedBox(height: 10), ),
TextField( SizedBox(height: 10),
controller: _emailController, TextField(
decoration: InputDecoration( controller: _emailController,
hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.', decoration: InputDecoration(
border: OutlineInputBorder() hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.',
), border: OutlineInputBorder()
), ),
SizedBox(height: 10), ),
TextField( SizedBox(height: 10),
onEditingComplete: () => _submit(context), TextField(
controller: _passwordController, onEditingComplete: () => _submit(context),
obscureText: true, controller: _passwordController,
decoration: InputDecoration( obscureText: true,
hintText: 'Type your password', labelText: 'Choose a strong password', decoration: InputDecoration(
border: OutlineInputBorder() hintText: 'Type your password', labelText: 'Choose a strong password',
), border: OutlineInputBorder()
), ),
SizedBox(height: 20), ),
RichText( SizedBox(height: 20),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: 'By registering you agree to Treadl\'s ',
style: Theme.of(context).textTheme.bodyText1,
children: <TextSpan>[
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _submit(context),
//color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan( style: TextStyle(color: Colors.white, fontSize: 15)
text: 'By registering you agree to Treadl\'s ', )
style: Theme.of(context).textTheme.bodyText1, ),
children: <TextSpan>[ ]
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
RaisedButton(
onPressed: () => _submit(context),
color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
)
) )
), ),
); );

View File

@ -1,6 +0,0 @@
class ProjectScreenArguments {
final String projectId;
final String projectName;
final String projectPath;
ProjectScreenArguments(this.projectId, this.projectName, this.projectPath);
}

View File

@ -1,18 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
void _logout(BuildContext context) async { void _logout(BuildContext context) async {
AppModel model = Provider.of<AppModel>(context, listen: false);
Api api = Api(); Api api = Api();
api.request('POST', '/accounts/logout'); api.request('POST', '/accounts/logout');
SharedPreferences prefs = await SharedPreferences.getInstance(); model.setToken(null);
prefs.remove('apiToken'); model.setUser(null);
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false); context.pop();
} }
void _deleteAccount(BuildContext context) async { void _deleteAccount(BuildContext context) async {
@ -31,19 +34,20 @@ class SettingsScreen extends StatelessWidget {
), ),
]), ]),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Cancel'), child: Text('Cancel'),
onPressed: () { Navigator.of(context).pop(); } onPressed: () => context.pop(),
), ),
RaisedButton( ElevatedButton(
child: Text('Delete Account'), child: Text('Delete Account'),
onPressed: () async { onPressed: () async {
Api api = Api(); Api api = Api();
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text}); var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
if (data['success'] == true) { if (data['success'] == true) {
SharedPreferences prefs = await SharedPreferences.getInstance(); AppModel model = Provider.of<AppModel>(context, listen: false);
prefs.remove('apiToken'); model.setToken(null);
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false); model.setUser(null);
context.go('/home');
} else { } else {
showDialog( showDialog(
context: context, context: context,
@ -54,7 +58,7 @@ class SettingsScreen extends StatelessWidget {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('OK'), child: Text('OK'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -75,6 +79,8 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('About Treadl'), title: Text('About Treadl'),
@ -93,15 +99,23 @@ class SettingsScreen extends StatelessWidget {
SizedBox(height: 30), SizedBox(height: 30),
ListTile( user != null ? Column(
leading: Icon(Icons.exit_to_app), children: [
title: Text('Logout'), ListTile(
onTap: () => _logout(context), leading: Icon(Icons.exit_to_app),
), title: Text('Logout'),
ListTile( onTap: () => _logout(context),
leading: Icon(Icons.delete), ),
title: Text('Delete Account'), ListTile(
onTap: () => _deleteAccount(context), leading: Icon(Icons.delete),
title: Text('Delete Account'),
onTap: () => _deleteAccount(context),
),
]
) : CupertinoButton(
color: Colors.pink,
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
onPressed: () => context.push('/welcome'),
), ),
SizedBox(height: 30), SizedBox(height: 30),

View File

@ -1,9 +0,0 @@
import 'package:flutter/foundation.dart';
class Store extends ChangeNotifier {
String apiToken;
void setToken(String newToken) {
apiToken = newToken;
}
}

View File

@ -5,18 +5,21 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'util.dart'; import 'util.dart';
import 'api.dart'; import 'api.dart';
import 'lib.dart';
class _UserScreenState extends State<UserScreen> { class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
final Util util = new Util(); final String username;
final Api api = Api(); final Api api = Api();
Map<String,dynamic> _user; TabController? _tabController;
Map<String,dynamic>? _user;
bool _loading = false; bool _loading = false;
_UserScreenState(this._user) { } _UserScreenState(this.username) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
getUser(_user['username']); _tabController = new TabController(length: 2, vsync: this);
getUser(username);
} }
void getUser(String username) async { void getUser(String username) async {
@ -31,75 +34,137 @@ class _UserScreenState extends State<UserScreen> {
} }
} }
@override Widget getBody() {
Widget build(BuildContext context) { if (_loading)
String created; return CircularProgressIndicator();
if (_user['createdAt'] != null) { else if (_user != null && _tabController != null) {
DateTime createdAt = DateTime.parse(_user['createdAt']); var u = _user!;
created = DateFormat('MMMM y').format(createdAt); String? created;
} if (u['createdAt'] != null) {
return Scaffold( DateTime createdAt = DateTime.parse(u['createdAt']!);
appBar: AppBar( created = DateFormat('MMMM y').format(createdAt);
title: Text(_user['username']), }
), return Column(
body: _loading ? crossAxisAlignment: CrossAxisAlignment.stretch,
Container( children: [
margin: const EdgeInsets.all(10.0), Row(children: [
alignment: Alignment.center, Util.avatarImage(Util.avatarUrl(u), size: 120),
child: CircularProgressIndicator() Container(
) padding: EdgeInsets.only(left: 10),
: Container( child: Column(
padding: EdgeInsets.all(10), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, Text(u['username'], style: Theme.of(context).textTheme.titleLarge),
children: [ SizedBox(height: 5),
Row(children: [ u['location'] != null ?
util.avatarImage(util.avatarUrl(_user), size: 120), Row(children: [
Expanded(child: Container( Icon(CupertinoIcons.location),
padding: EdgeInsets.only(left: 10), SizedBox(width: 10),
child: Column( Text(u['location'])
crossAxisAlignment: CrossAxisAlignment.stretch, ]) : SizedBox(height: 1),
children: [ SizedBox(height: 10),
Text(_user['username'], style: Theme.of(context).textTheme.titleMedium), Text('Member' + (created != null ? (' since ' + created!) : ''),
SizedBox(height: 5), style: TextStyle(color: Colors.grey[500])
_user['location'] != null ? ),
Row(children: [ SizedBox(height: 10),
Icon(CupertinoIcons.location), u['website'] != null ?
Text(_user['location']) GestureDetector(
]) : SizedBox(height: 1), onTap: () {
SizedBox(height: 10), String url = u['website'];
Text('Member' + (created != null ? (' since ' + created) : ''), if (!url.startsWith('http')) {
style: TextStyle(color: Colors.grey[500]) url = 'http://' + url;
), }
SizedBox(height: 10), launch(url);
_user['website'] != null ? },
GestureDetector( child: Text(u['website'],
onTap: () { style: TextStyle(color: Colors.pink))
String url = _user['website']; ) : SizedBox(height: 1),
if (!url.startsWith('http')) {
url = 'http://' + url;
}
launch(url);
},
child: Text(_user['website'],
style: TextStyle(color: Colors.pink))
) : SizedBox(height: 1),
] ]
) )
)) )
]), ]),
SizedBox(height: 30), SizedBox(height: 10),
Text(_user['bio'] != null ? _user['bio'] : '') TabBar(
] unselectedLabelColor: Colors.black,
) labelColor: Colors.pink,
tabs: [
Tab(
text: 'Profile',
icon: Icon(Icons.person),
),
Tab(
text: 'Projects',
icon: Icon(Icons.folder),
)
],
controller: _tabController!,
indicatorSize: TabBarIndicatorSize.tab,
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 30),
u['bio'] != null ? Text(u['bio']) :
EmptyBox('This user doesn\'t have any more profile information.')
]
),
(u['projects'] != null && u['projects'].length > 0) ?
Container(
margin: EdgeInsets.only(top: 10),
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 1.3,
children: u['projects'].map<Widget>((p) =>
ProjectCard(p)
).toList()
),
) :
Container(
margin: EdgeInsets.all(10),
child: EmptyBox('This user doesn\'t have any public projects'),
),
],
),
)
]);
}
else
return Text('User not found');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(username),
actions: <Widget>[
IconButton(
icon: Icon(Icons.person),
onPressed: () {
launch('https://www.treadl.com/' + username);
},
),
]
),
body: Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: getBody()
), ),
); );
} }
} }
class UserScreen extends StatefulWidget { class UserScreen extends StatefulWidget {
final Map<String,dynamic> user; final String username;
UserScreen(this.user) { } UserScreen(this.username) { }
@override @override
_UserScreenState createState() => _UserScreenState(user); _UserScreenState createState() => _UserScreenState(username);
} }

View File

@ -1,31 +1,98 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'dart:io';
import 'dart:convert';
import 'model.dart';
String APP_URL = 'https://www.treadl.com';
class Util { class Util {
ImageProvider avatarUrl(Map<String,dynamic> user) { static ImageProvider? avatarUrl(Map<String,dynamic> user) {
ImageProvider a = AssetImage('assets/avatars/9.png');
if (user != null && user['avatar'] != null) { if (user != null && user['avatar'] != null) {
if (user['avatar'].length < 3) { if (user['avatar'].length < 3) {
a = AssetImage('assets/avatars/${user['avatar']}.png'); return AssetImage('assets/avatars/${user['avatar']}.png');
} }
else { else {
a =NetworkImage(user['avatarUrl']); return NetworkImage(user['avatarUrl']);
} }
} }
return a; return null;
} }
Widget avatarImage(ImageProvider image, {double size=30}) { static Widget avatarImage(ImageProvider? image, {double size=30}) {
if (image != null) {
return new Container(
width: size,
height: size,
decoration: new BoxDecoration(
shape: BoxShape.circle,
image: new DecorationImage(
fit: BoxFit.fill,
image: image
)
)
);
}
return new Container( return new Container(
width: size, width: size,
height: size, height: size,
decoration: new BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
image: new DecorationImage( color: Colors.pink[400],
fit: BoxFit.fill, ),
image: image child: Icon(Icons.person, size: size/1.5, color: Colors.white)
)
)
); );
} }
static Color rgb(String input) {
List<String> parts = input.split(',');
List<int> iParts = parts.map((p) => int.parse(p)).toList();
iParts = iParts.map((p) => p > 255 ? 255 : p).toList();
return Color.fromRGBO(iParts[0], iParts[1], iParts[2], 1);
}
static String appUrl(String path) {
return APP_URL + '/' + path;
}
static Future<String> storagePath() async {
final Directory directory = await getApplicationDocumentsDirectory();
return directory.path;
}
static Future<File> writeFile(String fileName, String data) async {
final String dirPath = await Util.storagePath();
final file = File('$dirPath/$fileName');
String contents = data.replaceAll(RegExp(r'\\n'), '\r\n');
return await file.writeAsString(contents);
}
static Future<bool> deleteFile(File file) async {
await file.delete();
return true;
}
static void shareFile(File file, {bool? withDelete}) async {
await Share.shareXFiles([XFile(file.path)]);
if (withDelete == true) {
await Util.deleteFile(file);
}
}
static void shareUrl(String text, String url) async {
await Share.share('$text: $url');
}
static String ellipsis(String input, int cutoff) {
return (input.length <= cutoff)
? input
: '${input.substring(0, cutoff)}...';
}
static bool canEditProject(User? user, Map<String,dynamic>? project) {
if (user == null || project == null) return false;
return project['user'] == user.id;
}
} }

View File

@ -1,14 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'store.dart'; import 'package:go_router/go_router.dart';
import 'login.dart'; import 'login.dart';
class WelcomeScreen extends StatelessWidget { class WelcomeScreen extends StatelessWidget {
void _login(BuildContext context) { void _login(BuildContext context) {
Navigator.of(context).pushNamed('/login'); context.push('/login');
} }
void _register(BuildContext context) { void _register(BuildContext context) {
Navigator.of(context).pushNamed('/register'); context.push('/register');
} }
@override @override
@ -36,11 +36,20 @@ class WelcomeScreen extends StatelessWidget {
SizedBox(height: 15), SizedBox(height: 15),
CupertinoButton( CupertinoButton(
onPressed: () => _register(context), onPressed: () => _register(context),
color: Colors.pink[400],
child: new Text("Register", child: new Text("Register",
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )
), ),
SizedBox(height: 35),
CupertinoButton(
onPressed: () => context.pop(),
child: new Text("Cancel",
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
)
),
]), ]),
)) ))
); );

1
mobile/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

139
mobile/linux/CMakeLists.txt Normal file
View File

@ -0,0 +1,139 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "mobile")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.mobile")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Define the application target. To change its name, change BINARY_NAME above,
# not the value here, or `flutter run` will no longer work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -0,0 +1,25 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

6
mobile/linux/main.cc Normal file
View File

@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@ -0,0 +1,104 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "mobile");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "mobile");
}
gtk_window_set_default_size(window, 1280, 720);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
nullptr));
}

View File

@ -0,0 +1,18 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

7
mobile/macos/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -0,0 +1,24 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import file_selector_macos
import firebase_core
import firebase_messaging
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

43
mobile/macos/Podfile Normal file
View File

@ -0,0 +1,43 @@
platform :osx, '10.14'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

137
mobile/macos/Podfile.lock Normal file
View File

@ -0,0 +1,137 @@
PODS:
- file_selector_macos (0.0.1):
- FlutterMacOS
- Firebase/CoreOnly (10.9.0):
- FirebaseCore (= 10.9.0)
- Firebase/Messaging (10.9.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.9.0)
- firebase_core (2.13.1):
- Firebase/CoreOnly (~> 10.9.0)
- FlutterMacOS
- firebase_messaging (14.6.2):
- Firebase/CoreOnly (~> 10.9.0)
- Firebase/Messaging (~> 10.9.0)
- firebase_core
- FlutterMacOS
- FirebaseCore (10.9.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreInternal (10.17.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseInstallations (10.17.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebaseMessaging (10.9.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FlutterMacOS (1.0.0)
- GoogleDataTransport (9.2.5):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.12.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.12.0):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.12.0):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.12.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.12.0)"
- GoogleUtilities/Reachability (7.12.0):
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.12.0):
- GoogleUtilities/Logger
- nanopb (2.30909.1):
- nanopb/decode (= 2.30909.1)
- nanopb/encode (= 2.30909.1)
- nanopb/decode (2.30909.1)
- nanopb/encode (2.30909.1)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.3.1)
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
trunk:
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
firebase_core:
:path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos
firebase_messaging:
:path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
file_selector_macos: 0f85c1108e2fd597b58246bc0b0c1cb483d7593b
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
firebase_core: bef54c6955ffe824bb73ec34090f4013b6921bc1
firebase_messaging: 5fb518ebbce926b8828c283509d68da4cf238eac
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
FirebaseCoreInternal: 2cf9202e226e3f78d2bf6d56c472686b935bfb7f
FirebaseInstallations: 9387bf15abfc69a714f54e54f74a251264fdb79b
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34
nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
COCOAPODS: 1.14.2

View File

@ -0,0 +1,791 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
9C5D2FCBBECF447966A41993 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */; };
D38B0D024BD4B8AC726C2930 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
4F0C6318D69FF8395EECA36F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
622224DB9710132C3A78B0F2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
83DE93373EA349791F3FD1E0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D38B0D024BD4B8AC726C2930 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9C5D2FCBBECF447966A41993 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
C58C1204A1B892E15F747458 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* mobile.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
C58C1204A1B892E15F747458 /* Pods */ = {
isa = PBXGroup;
children = (
83DE93373EA349791F3FD1E0 /* Pods-Runner.debug.xcconfig */,
622224DB9710132C3A78B0F2 /* Pods-Runner.release.xcconfig */,
4F0C6318D69FF8395EECA36F /* Pods-Runner.profile.xcconfig */,
9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */,
19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */,
C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */,
1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
2BC135214874DC0129B89279 /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
F76E7AC3FEF1EB49B333FBDB /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
EABEE0E3EB15CD5AF2892A5B /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* mobile.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2BC135214874DC0129B89279 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
EABEE0E3EB15CD5AF2892A5B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
F76E7AC3FEF1EB49B333FBDB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}

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