Compare commits

...

82 Commits

Author SHA1 Message Date
62645b6b4b Add linting step to taskfile for mobile 2025-04-07 21:58:16 +01:00
9030efd6ed linted all of mobile files according to the flutter rules 2025-04-07 21:55:21 +01:00
f5e0be7486 Add flutter linting package and fixed some rules 2025-04-07 20:27:40 +01:00
3b6b765bcd removed un-needed Report 2025-04-06 21:38:22 +01:00
6079da7fb9 updated gitignore 2025-04-06 21:21:58 +01:00
9eebc28517 Remove un-needed files 2025-04-06 21:21:32 +01:00
57fa2edef6 Remove .cxx directory from version control 2025-04-06 21:21:06 +01:00
9739ff4901 Updated gitignore 2025-04-06 21:20:28 +01:00
0593f5b96b Updated gitignore 2025-04-06 21:20:03 +01:00
d4a7c7be66 Resolved issue with JVM version mismatches 2025-04-06 21:18:37 +01:00
d78a7ac298 Initial upgrade tasks 2025-04-06 20:23:52 +01:00
5a173b4b8a version numbers for build 2025-04-06 16:45:48 +01:00
4dc1ee4fa1 minor tweaks and flow improvements 2025-04-06 14:02:19 +01:00
038d50b6fb Improved support for project editing/deleting 2025-04-04 21:41:56 +01:00
dd99cd19d9 Fix support for saving files 2025-04-04 20:59:21 +01:00
a065cdb680 resolve issue in email updating 2025-04-03 22:02:01 +01:00
36f9fe0b25 Allow basic account editing via mobile 2025-04-03 21:48:38 +01:00
eb6b97b9f1 skeleton route for edit profile page on mobile 2025-04-02 09:01:48 +01:00
698defe8a9 Improved email verification flow on mobile 2025-04-02 08:45:38 +01:00
1292184c14 Improved support for complete email verification flow 2025-04-01 20:55:33 +01:00
88e6ee82d8 Add TODO for next task 2025-03-31 20:36:40 +01:00
e2bfd52011 Add base mobile email verification screen 2025-03-31 20:35:13 +01:00
bfc607e8a8 rate limit verification emails 2025-03-25 17:54:46 +00:00
2eb4fc0a0a prevent email sending if address not verified 2025-03-25 17:44:56 +00:00
1ead04c5a1 Allow for checking if verification complete yet 2025-03-25 17:34:51 +00:00
1171ac389c allow for email veri emails 2025-03-25 17:11:25 +00:00
bb2c611f51 Fix issue in rendering patterns 2025-03-24 17:52:21 +00:00
23042c2b5d mobile improvements to catch-up with deprecated widgets 2025-03-23 21:41:52 +00:00
5228648682 Upgrade mobile dependencies 2025-03-23 20:54:04 +00:00
3f39485b5b Clear out old preview pattern images automatically 2025-03-22 22:28:47 +00:00
a8ae5f3dda Auto delete stale user avatars 2025-03-22 20:50:03 +00:00
398e33e196 Delete files on S3 as objects/users/projects deleted 2025-03-22 20:24:29 +00:00
081b699cf0 Revert to using signed URLs for all uploads 2025-03-22 19:42:17 +00:00
ca93ff03bc Merge branch 'main' of git.wilw.dev:wilw/treadl 2025-03-01 19:10:51 +00:00
ba333818e6 Add env vars allowing latest awscli to work with Linode obj storage 2025-03-01 09:00:53 +00:00
570ff8ff5f Resolve lint issues 2025-03-01 08:37:30 +00:00
e76b5af6c1 Upgrade API deps 2025-03-01 08:35:53 +00:00
7a623ec557 Upgrade web deps and to react-router7 2025-03-01 08:23:49 +00:00
f1324bd39e Resolve lint issues 2025-01-19 21:25:01 +00:00
493f83d5f0 Prevent WIF parser from adding extra threads to the warp and weft
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-01-19 21:24:19 +00:00
b68bd6aeff Move privacy policy and terms to Treadl wiki
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-01-19 19:37:33 +00:00
d6429cdabf Allow for collecting email address in report form submissions
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-01-19 19:08:05 +00:00
10a8f5d2f2 Merge branch 'origin/main' 2025-01-19 13:44:59 +00:00
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
02dbf2d8e6 Add ability to build prod locally 2024-12-30 22:15:22 +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
699 changed files with 10254 additions and 9246 deletions

3
.dockerignore Normal file
View File

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

2
.gitignore vendored
View File

@ -1 +1,3 @@
*.swp *.swp
.DS_Store
mobile/android/app/.cxx/

View File

@ -8,17 +8,10 @@ steps:
- 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_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320 - VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
- VITE_SOURCE_REPO_URL=https://git.wilw.dev/wilw/treadl
- VITE_PATREON_URL=https://www.patreon.com/treadl
- VITE_KOFI_URL=https://ko-fi.com/wilw88
- VITE_IOS_APP_URL=https://apps.apple.com/gb/app/treadl/id1525094357
- "VITE_ANDROID_APP_URL=https://play.google.com/store/apps/details/Treadl?id=com.treadl"
- 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

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

130
Taskfile.yml Normal file
View File

@ -0,0 +1,130 @@
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-mobile
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 ."
lint-mobile:
desc: Lint mobile app
dir: 'mobile'
cmds:
- echo "[MOBILE] Linting Flutter app..."
- dart fix --apply
- dart analyze
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'
env:
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
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

View File

@ -1,4 +1,4 @@
FROM python:3.12-slim FROM amd64/python:3.12-slim
# set work directory # set work directory
WORKDIR /app WORKDIR /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

@ -5,6 +5,7 @@ import re
import os 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 projects, uploads
jwt_secret = os.environ["JWT_SECRET"] jwt_secret = os.environ["JWT_SECRET"]
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 8
@ -40,6 +41,7 @@ def register(username, email, password, how_find_us):
{ {
"username": username, "username": username,
"email": email, "email": email,
"emailVerified": False,
"password": hashed_password, "password": hashed_password,
"createdAt": datetime.datetime.now(), "createdAt": datetime.datetime.now(),
"subscriptions": { "subscriptions": {
@ -53,6 +55,7 @@ def register(username, email, password, how_find_us):
}, },
} }
) )
send_verification_email(db.users.find_one({"_id": result.inserted_id}))
mail.send( mail.send(
{ {
"to": os.environ.get("ADMIN_EMAIL"), "to": os.environ.get("ADMIN_EMAIL"),
@ -62,48 +65,7 @@ def register(username, email, password, 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.
LOGGING-IN
To login to your account please visit {1} and click Login. Use your username ({0}) and password to get back into your account.
INTRODUCTION
{3} has been designed as a resource for weavers not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.
Projects can be created within {3} using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other {3} users to see. The choice is yours!
{3} is free to use. For more information please visit our website at {1}.
GETTING STARTED
Creating a profile: You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.
Creating a group: You have the option to do things alone, or create a group. By clicking on the Create a group button, you can name your group, and then invite members via email or directly through {3} if they are existing {3} users.
Creating a new project: When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a Welcome to your project screen, where if you click on add something, you have the option of creating a new weaving pattern directly inside {3} or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within {3}, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).
Once complete you then have the option of saving the file privately, shared within a group, or made public for other {3} users to see.
We hope you enjoy using {3} and if you have any comments or feedback please tell us by emailing {2}!
Best wishes,
The {3} Team
""".format(
username,
os.environ.get("APP_URL"),
os.environ.get("CONTACT_EMAIL"),
os.environ.get("APP_NAME"),
),
}
)
return {"token": generate_access_token(result.inserted_id)} return {"token": generate_access_token(result.inserted_id)}
except Exception as e: except Exception as e:
print(e) print(e)
@ -142,33 +104,18 @@ def update_email(user, data):
if len(data["email"]) < 4: if len(data["email"]) < 4:
raise util.errors.BadRequest("New email is too short") raise util.errors.BadRequest("New email is too short")
db = database.get_db() db = database.get_db()
db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}}) db.users.update_one(
mail.send( {"_id": user["_id"]},
{ {
"to": user["email"], "$set": {
"subject": "Your email address has changed on {}".format( "email": data["email"],
os.environ.get("APP_NAME") "emailVerified": False,
), "emailVerifiedAt": None,
"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"], "$unset": {"tokens.emailVerification": ""},
data["email"], },
os.environ.get("APP_NAME"),
),
}
)
mail.send(
{
"to": data["email"],
"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"),
),
}
) )
send_verification_email(db.users.find_one({"_id": user["_id"]}))
return {"email": data["email"]} return {"email": data["email"]}
@ -219,6 +166,7 @@ def update_password(user, data):
mail.send( mail.send(
{ {
"force": True,
"to_user": user, "to_user": user,
"subject": "Your {} password has changed".format( "subject": "Your {} password has changed".format(
os.environ.get("APP_NAME") os.environ.get("APP_NAME")
@ -232,18 +180,95 @@ def update_password(user, data):
return {"passwordUpdated": True} return {"passwordUpdated": True}
def verify_email(token):
if not token:
raise util.errors.BadRequest("Invalid request")
db = database.get_db()
user = None
try:
id = jwt.decode(token, jwt_secret, algorithms="HS256")["sub"]
user = db.users.find_one(
{"_id": ObjectId(id), "tokens.emailVerification": token}
)
if not user:
raise Exception
except Exception:
raise util.errors.BadRequest(
"There was a problem verifying your email. Your token may be invalid or out of date"
)
db.users.update_one(
{"_id": user["_id"]},
{
"$set": {
"emailVerified": True,
"emailVerifiedAt": datetime.datetime.utcnow(),
},
"$addToSet": {
"emailVerifications": {
"email": user["email"],
"verifiedAt": datetime.datetime.utcnow(),
}
},
"$unset": {"tokens.emailVerification": ""},
},
)
mail.send(
{
"to": user["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.
LOGGING-IN
To login to your account please visit {1} and click Login. Use your username ({0}) and password to get back into your account.
INTRODUCTION
{3} has been designed as a resource for weavers not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.
Projects can be created within {3} using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other {3} users to see. The choice is yours!
{3} is free to use. For more information please visit our website at {1}.
GETTING STARTED
Creating a profile: You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.
Creating a group: You have the option to do things alone, or create a group. By clicking on the Create a group button, you can name your group, and then invite members via email or directly through {3} if they are existing {3} users.
Creating a new project: When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a Welcome to your project screen, where if you click on add something, you have the option of creating a new weaving pattern directly inside {3} or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within {3}, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).
Once complete you then have the option of saving the file privately, shared within a group, or made public for other {3} users to see.
We hope you enjoy using {3} and if you have any comments or feedback please tell us by emailing {2}!
Best wishes,
The {3} Team
""".format(
user["username"],
os.environ.get("APP_URL"),
os.environ.get("CONTACT_EMAIL"),
os.environ.get("APP_NAME"),
),
}
)
return {"emailVerified": 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.delete_many({"project": project["_id"]}) projects.delete(user, user["username"], project.get("path"))
db.projects.delete_one({"_id": project["_id"]})
db.comments.delete_many({"user": user["_id"]}) db.comments.delete_many({"user": user["_id"]})
db.users.update_many( db.users.update_many(
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}} {"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
) )
db.users.delete_one({"_id": user["_id"]}) db.users.delete_one({"_id": user["_id"]})
uploads.delete_folder("users/" + str(user["_id"]))
return {"deletedUser": user["_id"]} return {"deletedUser": user["_id"]}
@ -278,6 +303,45 @@ def get_user_context(token):
return None return None
def send_verification_email(user):
db = database.get_db()
if user.get("emailVerified"):
return {"emailVerified": True}
existing_token = user.get("tokens", {}).get("emailVerification")
if existing_token:
ten_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
decoded = jwt.decode(existing_token, jwt_secret, algorithms="HS256")
if datetime.datetime.fromtimestamp(decoded["iat"]) > ten_mins_ago:
raise util.errors.BadRequest(
"Email verifications can only be sent every 10 minutes. Remember to check your spam folder."
)
payload = {
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
"iat": datetime.datetime.utcnow(),
"sub": str(user["_id"]),
}
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
mail.send(
{
"force": True,
"to_user": user,
"subject": "Verify your email address on {}".format(
os.environ.get("APP_NAME")
),
"text": "Dear {0},\n\nThis email address has been used with an account on {2}. Please verify that you own this email address by following the link below:\n\n{1}\n\nIf you did not sign up for an account, please ignore this email or reach out to us.".format(
user["username"],
"{}/email/verify?token={}".format(os.environ.get("APP_URL"), token),
os.environ.get("APP_NAME"),
),
}
)
db.users.update_one(
{"_id": user["_id"]}, {"$set": {"tokens.emailVerification": token}}
)
return {"verificationEmailSent": True}
def reset_password(data): def reset_password(data):
if not data or "email" not in data: if not data or "email" not in data:
raise util.errors.BadRequest("Invalid request") raise util.errors.BadRequest("Invalid request")
@ -294,6 +358,7 @@ def reset_password(data):
token = jwt.encode(payload, jwt_secret, algorithm="HS256") token = jwt.encode(payload, jwt_secret, algorithm="HS256")
mail.send( mail.send(
{ {
"force": True,
"to_user": user, "to_user": user,
"subject": "Reset your password", "subject": "Reset your password",
"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( "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(

View File

@ -37,6 +37,7 @@ def create(user, data):
"name": data["name"], "name": data["name"],
"description": data.get("description", ""), "description": data.get("description", ""),
"closed": data.get("closed", False), "closed": data.get("closed", False),
"advertised": data.get("advertised", False),
"memberPermissions": [ "memberPermissions": [
"viewMembers", "viewMembers",
"viewNoticeboard", "viewNoticeboard",
@ -91,9 +92,21 @@ def update(user, id, update):
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if user["_id"] not in group.get("admins", []): if user["_id"] not in group.get("admins", []):
raise util.errors.Forbidden("You're not a group admin") raise util.errors.Forbidden("You're not a group admin")
allowed_keys = ["name", "description", "closed", "memberPermissions", "image"] allowed_keys = [
"name",
"description",
"closed",
"advertised",
"memberPermissions",
"image",
]
updater = util.build_updater(update, allowed_keys) updater = util.build_updater(update, allowed_keys)
if updater: if updater:
if "$set" in updater and (
"name" in update or "description" in update or "image" in update
):
updater["$set"]["moderationRequired"] = True
util.send_moderation_request(user, "groups", group)
db.groups.update_one({"_id": id}, updater) db.groups.update_one({"_id": id}, updater)
return get_one(user, id) return get_one(user, id)
@ -129,6 +142,7 @@ def create_entry(user, id, data):
"group": id, "group": id,
"user": user["_id"], "user": user["_id"],
"content": data["content"], "content": data["content"],
"moderationRequired": True,
} }
if "attachments" in data: if "attachments" in data:
entry["attachments"] = data["attachments"] entry["attachments"] = data["attachments"]
@ -153,12 +167,24 @@ def create_entry(user, id, data):
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url( entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(user["_id"], user["avatar"]) "users/{0}/{1}".format(user["_id"], user["avatar"])
) )
util.send_moderation_request(user, "groupEntries", entry)
return entry
def send_entry_notification(id):
db = database.get_db()
entry = db.groupEntries.find_one({"_id": ObjectId(id)})
# If this is a reply, then send the reply email instead
if entry.get("inReplyTo"):
return send_entry_reply_notification(id)
group = db.groups.find_one({"_id": entry["group"]})
user = db.users.find_one({"_id": entry["user"]})
for u in db.users.find( for u in db.users.find(
{ {
"_id": {"$ne": user["_id"]}, "_id": {"$ne": user["_id"]},
"groups": id, "groups": group["_id"],
"subscriptions.email": "groupFeed-" + str(id), "subscriptions.email": "groupFeed-" + str(group["_id"]),
}, },
{"email": 1, "username": 1}, {"email": 1, "username": 1},
): ):
@ -170,18 +196,17 @@ def create_entry(user, id, data):
u["username"], u["username"],
user["username"], user["username"],
group["name"], group["name"],
data["content"], entry["content"],
"{}/groups/{}".format(APP_URL, str(id)), "{}/groups/{}".format(APP_URL, str(group["_id"])),
APP_NAME, APP_NAME,
), ),
} }
) )
push.send_multiple( push.send_multiple(
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": id})), list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
"{} posted in {}".format(user["username"], group["name"]), "{} posted in {}".format(user["username"], group["name"]),
data["content"][:30] + "...", entry["content"][:30] + "...",
) )
return entry
def get_entries(user, id): def get_entries(user, id):
@ -194,8 +219,14 @@ def get_entries(user, id):
raise util.errors.BadRequest("You're not a member of this group") raise util.errors.BadRequest("You're not a member of this group")
if not has_group_permission(user, group, "viewNoticeboard"): if not has_group_permission(user, group, "viewNoticeboard"):
raise util.errors.Forbidden("You don't have permission to view the feed") raise util.errors.Forbidden("You don't have permission to view the feed")
# Only return entries that have been moderated or are owned by the user
entries = list( entries = list(
db.groupEntries.find({"group": id}).sort("createdAt", pymongo.DESCENDING) db.groupEntries.find(
{
"group": id,
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
}
).sort("createdAt", pymongo.DESCENDING)
) )
authors = list( authors = list(
db.users.find( db.users.find(
@ -258,6 +289,7 @@ def create_entry_reply(user, id, entry_id, data):
"inReplyTo": entry_id, "inReplyTo": entry_id,
"user": user["_id"], "user": user["_id"],
"content": data["content"], "content": data["content"],
"moderationRequired": True,
} }
if "attachments" in data: if "attachments" in data:
reply["attachments"] = data["attachments"] reply["attachments"] = data["attachments"]
@ -282,9 +314,22 @@ def create_entry_reply(user, id, entry_id, data):
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url( reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(user["_id"], user["avatar"]) "users/{0}/{1}".format(user["_id"], user["avatar"])
) )
util.send_moderation_request(user, "groupEntries", entry)
return reply
def send_entry_reply_notification(id):
db = database.get_db()
reply = db.groupEntries.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": reply["user"]})
original_entry = db.groupEntries.find_one({"_id": reply["inReplyTo"]})
group = db.groups.find_one({"_id": original_entry["group"]})
op = db.users.find_one( op = db.users.find_one(
{ {
"$and": [{"_id": entry.get("user")}, {"_id": {"$ne": user["_id"]}}], "$and": [
{"_id": original_entry.get("user")},
{"_id": {"$ne": user["_id"]}},
],
"subscriptions.email": "messages.replied", "subscriptions.email": "messages.replied",
} }
) )
@ -297,13 +342,12 @@ def create_entry_reply(user, id, entry_id, data):
op["username"], op["username"],
user["username"], user["username"],
group["name"], group["name"],
data["content"], reply["content"],
"{}/groups/{}".format(APP_URL, str(id)), "{}/groups/{}".format(APP_URL, str(group["_id"])),
APP_NAME, APP_NAME,
), ),
} }
) )
return reply
def delete_entry_reply(user, id, entry_id, reply_id): def delete_entry_reply(user, id, entry_id, reply_id):
@ -586,6 +630,7 @@ def create_forum_topic_reply(user, id, topic_id, data):
"user": user["_id"], "user": user["_id"],
"content": data["content"], "content": data["content"],
"attachments": data.get("attachments", []), "attachments": data.get("attachments", []),
"moderationRequired": True,
} }
result = db.groupForumTopicReplies.insert_one(reply) result = db.groupForumTopicReplies.insert_one(reply)
db.groupForumTopics.update_one( db.groupForumTopics.update_one(
@ -623,11 +668,21 @@ def create_forum_topic_reply(user, id, topic_id, data):
) )
) )
util.send_moderation_request(user, "groupForumTopicReplies", reply)
return reply
def send_forum_topic_reply_notification(id):
db = database.get_db()
reply = db.groupForumTopicReplies.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": reply["user"]})
topic = db.groupForumTopics.find_one({"_id": reply["topic"]})
group = db.groups.find_one({"_id": topic["group"]})
for u in db.users.find( for u in db.users.find(
{ {
"_id": {"$ne": user["_id"]}, "_id": {"$ne": reply["user"]},
"groups": id, "groups": topic["group"],
"subscriptions.email": "groupForumTopic-" + str(topic_id), "subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
}, },
{"email": 1, "username": 1}, {"email": 1, "username": 1},
): ):
@ -640,17 +695,15 @@ def create_forum_topic_reply(user, id, topic_id, data):
user["username"], user["username"],
topic["title"], topic["title"],
group["name"], group["name"],
data["content"], reply["content"],
"{}/groups/{}/forum/topics/{}".format( "{}/groups/{}/forum/topics/{}".format(
APP_URL, str(id), str(topic_id) APP_URL, str(group["_id"]), str(topic["_id"])
), ),
APP_NAME, APP_NAME,
), ),
} }
) )
return reply
def get_forum_topic_replies(user, id, topic_id, data): def get_forum_topic_replies(user, id, topic_id, data):
REPLIES_PER_PAGE = 20 REPLIES_PER_PAGE = 20
@ -670,7 +723,12 @@ def get_forum_topic_replies(user, id, topic_id, data):
) )
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id}) total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
replies = list( replies = list(
db.groupForumTopicReplies.find({"topic": topic_id}) db.groupForumTopicReplies.find(
{
"topic": topic_id,
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
}
)
.sort("createdAt", pymongo.ASCENDING) .sort("createdAt", pymongo.ASCENDING)
.skip((page - 1) * REPLIES_PER_PAGE) .skip((page - 1) * REPLIES_PER_PAGE)
.limit(REPLIES_PER_PAGE) .limit(REPLIES_PER_PAGE)

View File

@ -12,7 +12,7 @@ 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, "storedName": 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})
@ -21,6 +21,8 @@ def delete(user, id):
if not util.can_edit_project(user, project): if not util.can_edit_project(user, project):
raise util.errors.Forbidden("Forbidden", 403) raise util.errors.Forbidden("Forbidden", 403)
db.objects.delete_one({"_id": ObjectId(id)}) db.objects.delete_one({"_id": ObjectId(id)})
db.comments.delete_many({"object": ObjectId(id)})
uploads.delete_file(f"projects/{project['_id']}/{obj.get('storedName')}")
return {"deletedObject": id} return {"deletedObject": id}
@ -34,7 +36,9 @@ def get(user, id):
raise util.errors.NotFound("Project not found") raise util.errors.NotFound("Project not found")
is_owner = user and (user.get("_id") == proj["user"]) is_owner = user and (user.get("_id") == proj["user"])
if not is_owner and proj["visibility"] != "public": if not is_owner and proj["visibility"] != "public":
raise util.errors.BadRequest("Forbidden") 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}) owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
if obj["type"] == "file" and "storedName" in obj: if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url( obj["url"] = uploads.get_presigned_url(
@ -169,12 +173,12 @@ def create_comment(user, id, data):
obj = db.objects.find_one({"_id": ObjectId(id)}) obj = db.objects.find_one({"_id": ObjectId(id)})
if not obj: if not obj:
raise util.errors.NotFound("We could not find the specified object") raise util.errors.NotFound("We could not find the specified object")
project = db.projects.find_one({"_id": obj["project"]})
comment = { comment = {
"content": data.get("content", ""), "content": data.get("content", ""),
"object": ObjectId(id), "object": ObjectId(id),
"user": user["_id"], "user": user["_id"],
"createdAt": datetime.datetime.now(), "createdAt": datetime.datetime.now(),
"moderationRequired": True,
} }
result = db.comments.insert_one(comment) result = db.comments.insert_one(comment)
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}}) db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
@ -186,6 +190,16 @@ def create_comment(user, id, data):
"users/{0}/{1}".format(user["_id"], user.get("avatar")) "users/{0}/{1}".format(user["_id"], user.get("avatar"))
), ),
} }
util.send_moderation_request(user, "comments", 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( project_owner = db.users.find_one(
{"_id": project["user"], "subscriptions.email": "projects.commented"} {"_id": project["user"], "subscriptions.email": "projects.commented"}
) )
@ -209,7 +223,6 @@ def create_comment(user, id, data):
), ),
} }
) )
return comment
def get_comments(user, id): def get_comments(user, id):
@ -224,7 +237,14 @@ def get_comments(user, id):
is_owner = user and (user.get("_id") == proj["user"]) is_owner = user and (user.get("_id") == proj["user"])
if not is_owner and proj["visibility"] != "public": if not is_owner and proj["visibility"] != "public":
raise util.errors.Forbidden("This project is private") raise util.errors.Forbidden("This project is private")
comments = list(db.comments.find({"object": id})) 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)) user_ids = list(map(lambda c: c["user"], comments))
users = list( users = list(
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1}) db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})

View File

@ -1,7 +1,8 @@
import datetime import datetime
import re 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, objects from api import uploads, objects
default_pattern = { default_pattern = {
@ -11,6 +12,7 @@ default_pattern = {
"defaultColour": "178,53,111", "defaultColour": "178,53,111",
"defaultSpacing": 1, "defaultSpacing": 1,
"defaultThickness": 1, "defaultThickness": 1,
"guideFrequency": 8,
}, },
"weft": { "weft": {
"treadles": 8, "treadles": 8,
@ -18,6 +20,7 @@ default_pattern = {
"defaultColour": "53,69,178", "defaultColour": "53,69,178",
"defaultSpacing": 1, "defaultSpacing": 1,
"defaultThickness": 1, "defaultThickness": 1,
"guideFrequency": 8,
}, },
"tieups": [[]] * 8, "tieups": [[]] * 8,
"colours": [ "colours": [
@ -227,8 +230,12 @@ def delete(user, username, project_path):
project = get_by_username(username, project_path) project = get_by_username(username, project_path)
if not util.can_edit_project(user, project): if not util.can_edit_project(user, project):
raise util.errors.Forbidden("Forbidden") raise util.errors.Forbidden("Forbidden")
objects = list(db.objects.find({"project": project["_id"]}, {"_id": 1}))
db.projects.delete_one({"_id": project["_id"]}) db.projects.delete_one({"_id": project["_id"]})
db.objects.delete_many({"project": project["_id"]}) db.objects.delete_many({"project": project["_id"]})
db.comments.delete_many({"object": {"$in": [o["_id"] for o in objects]}})
uploads.delete_folder("projects/" + str(project["_id"]))
return {"deletedProject": project["_id"]} return {"deletedProject": project["_id"]}
@ -240,9 +247,12 @@ def get_objects(user, username, path):
if not util.can_view_project(user, project): if not util.can_view_project(user, project):
raise util.errors.Forbidden("This project is private") 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( objs = list(
db.objects.find( db.objects.find(
{"project": project["_id"]}, query,
{ {
"createdAt": 1, "createdAt": 1,
"name": 1, "name": 1,
@ -294,6 +304,7 @@ def create_object(user, username, path, data):
"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()): if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
obj["isImage"] = True obj["isImage"] = True
@ -312,6 +323,7 @@ def create_object(user, username, path, data):
uploads.blur_image( uploads.blur_image(
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb "projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
) )
util.send_moderation_request(user, "object", obj)
return obj return obj
if data["type"] == "pattern": if data["type"] == "pattern":
obj = { obj = {
@ -325,7 +337,16 @@ def create_object(user, username, path, data):
if pattern: if pattern:
obj["name"] = pattern["name"] obj["name"] = pattern["name"]
obj["pattern"] = pattern obj["pattern"] = pattern
except Exception: 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( raise util.errors.BadRequest(
"Unable to load WIF file. It is either invalid or in a format we cannot understand." "Unable to load WIF file. It is either invalid or in a format we cannot understand."
) )

View File

@ -1,5 +1,7 @@
import datetime
from bson.objectid import ObjectId
from util import database, util from util import database, util
from api import uploads from api import uploads, objects, groups
def get_users(user): def get_users(user):
@ -52,3 +54,82 @@ def get_groups(user):
for group in groups: for group in groups:
group["memberCount"] = db.users.count_documents({"groups": group["_id"]}) group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
return {"groups": groups} 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

@ -105,6 +105,7 @@ def discover(user, count=3):
db = database.get_db() db = database.get_db()
projects = [] projects = []
users = [] users = []
groups = []
all_projects_query = { all_projects_query = {
"name": {"$not": re.compile("my new project", re.IGNORECASE)}, "name": {"$not": re.compile("my new project", re.IGNORECASE)},
@ -165,9 +166,25 @@ def discover(user, count=3):
if len(users) >= count: if len(users) >= count:
break break
all_groups = list(
db.groups.find(
{"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 { return {
"highlightProjects": projects, "highlightProjects": projects,
"highlightUsers": users, "highlightUsers": users,
"highlightGroups": groups,
} }

View File

@ -8,6 +8,8 @@ import blurhash
from util import database, util from util import database, util
from api.groups import has_group_permission from api.groups import has_group_permission
s3_client = None
def sanitise_filename(s): def sanitise_filename(s):
bad_chars = re.compile("[^a-zA-Z0-9_.]") bad_chars = re.compile("[^a-zA-Z0-9_.]")
@ -16,6 +18,9 @@ def sanitise_filename(s):
def get_s3(): def get_s3():
global s3_client
if s3_client:
return s3_client
session = boto3.session.Session() session = boto3.session.Session()
s3_client = session.client( s3_client = session.client(
@ -28,7 +33,6 @@ def get_s3():
def get_presigned_url(path): def get_presigned_url(path):
return os.environ["AWS_S3_ENDPOINT"] + os.environ["AWS_S3_BUCKET"] + "/" + path
s3 = get_s3() s3 = get_s3()
return s3.generate_presigned_url( return s3.generate_presigned_url(
"get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path} "get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
@ -45,10 +49,38 @@ def upload_file(path, data):
def get_file(key): def get_file(key):
if not key:
return None
s3 = get_s3() s3 = get_s3()
return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key) return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
def delete_file(key):
if not key:
return
s3 = get_s3()
s3.delete_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
def delete_folder(path):
bucket_name = os.environ["AWS_S3_BUCKET"]
if not path:
return
s3 = get_s3()
response = s3.list_objects_v2(Bucket=bucket_name, Prefix=path)
if "Contents" in response:
files_in_folder = response["Contents"]
files_to_delete = []
for f in files_in_folder:
files_to_delete.append({"Key": f["Key"]})
response = s3.delete_objects(
Bucket=bucket_name, Delete={"Objects": files_to_delete}
)
def generate_file_upload_request( def generate_file_upload_request(
user, file_name, file_size, file_type, for_type, for_id user, file_name, file_size, file_type, for_type, for_id
): ):

View File

@ -12,6 +12,7 @@ def me(user):
"username": user["username"], "username": user["username"],
"bio": user.get("bio"), "bio": user.get("bio"),
"email": user.get("email"), "email": user.get("email"),
"emailVerified": user.get("emailVerified"),
"avatar": user.get("avatar"), "avatar": user.get("avatar"),
"avatarUrl": user.get("avatar") "avatarUrl": user.get("avatar")
and uploads.get_presigned_url( and uploads.get_presigned_url(
@ -115,12 +116,19 @@ def update(user, username, data):
uploads.blur_image( uploads.blur_image(
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb "users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
) )
if "avatar" in data and user.get("avatar") and user["avatar"] != data["avatar"]:
uploads.delete_file("users/" + str(user["_id"]) + "/" + user["avatar"])
updater = util.build_updater(data, allowed_keys) updater = util.build_updater(data, allowed_keys)
if updater: if updater:
if "avatar" in updater.get( if "avatar" in updater.get(
"$unset", {} "$unset", {}
): # Also unset blurhash if removing avatar ): # Also unset blurhash if removing avatar
updater["$unset"]["avatarBlurHash"] = "" 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) db.users.update_one({"username": username}, updater)
return get(user, data.get("username", username)) return get(user, data.get("username", username))

View File

@ -77,7 +77,7 @@ def handle_unprocessable_entity(e):
message += f"""{str(key)}: """ message += f"""{str(key)}: """
return build_message(message, d[key]) return build_message(message, d[key])
elif isinstance(d[key], list): elif isinstance(d[key], list):
message += f"""{str(key)}: {',\n '.join(d[key])}\n""" message += f"""{str(key)}: {",\n ".join(d[key])}\n"""
return message return message
if validation_errors: if validation_errors:
@ -156,6 +156,21 @@ def email_address(args):
return util.jsonify(accounts.update_email(util.get_user(), args)) return util.jsonify(accounts.update_email(util.get_user(), args))
@app.route("/accounts/email/verificationRequests", methods=["POST"])
def email_verification_request():
return util.jsonify(accounts.send_verification_email(util.get_user()))
@app.route("/accounts/email/verified", methods=["PUT"])
@use_args(
{
"token": fields.Str(required=True),
}
)
def email_verify(args):
return util.jsonify(accounts.verify_email(args.get("token")))
@limiter.limit("5 per minute", key_func=util.limit_by_user, methods=["POST"]) @limiter.limit("5 per minute", key_func=util.limit_by_user, methods=["POST"])
@app.route("/accounts/password", methods=["PUT"]) @app.route("/accounts/password", methods=["PUT"])
@use_args( @use_args(
@ -234,7 +249,7 @@ def users_username_get(username):
@use_args( @use_args(
{ {
"username": fields.Str(validate=validate.Length(min=3)), "username": fields.Str(validate=validate.Length(min=3)),
"avatar": fields.Str(), "avatar": fields.Str(allow_none=True),
"bio": fields.Str(), "bio": fields.Str(),
"location": fields.Str(), "location": fields.Str(),
"website": fields.Str(), "website": fields.Str(),
@ -458,6 +473,7 @@ def groups_route_get():
"name": fields.Str(required=True, validate=validate.Length(min=3)), "name": fields.Str(required=True, validate=validate.Length(min=3)),
"description": fields.Str(), "description": fields.Str(),
"closed": fields.Bool(), "closed": fields.Bool(),
"advertised": fields.Bool(),
} }
) )
def groups_route_post(args): def groups_route_post(args):
@ -478,6 +494,7 @@ def group_route(id):
"name": fields.Str(), "name": fields.Str(),
"description": fields.Str(), "description": fields.Str(),
"closed": fields.Bool(), "closed": fields.Bool(),
"advertised": fields.Bool(),
"memberPermissions": fields.List(fields.Str()), "memberPermissions": fields.List(fields.Str()),
"image": fields.Str(allow_none=True), "image": fields.Str(allow_none=True),
} }
@ -756,6 +773,37 @@ def root_groups():
return util.jsonify(root.get_groups(util.get_user(required=True))) return util.jsonify(root.get_groups(util.get_user(required=True)))
@app.route("/root/moderation", methods=["GET"])
def root_moderation():
return util.jsonify(root.get_moderation(util.get_user(required=True)))
@app.route("/root/moderation/<item_type>/<id>", methods=["PUT", "DELETE"])
def root_moderation_item(item_type, id):
return util.jsonify(
root.moderate(
util.get_user(required=True), item_type, id, request.method == "PUT"
)
)
## REPORTS
@app.route("/reports", methods=["POST"])
@use_args(
{
"referrer": fields.Str(),
"url": fields.Str(required=True, validate=validate.Length(min=5)),
"description": fields.Str(required=True, validate=validate.Length(min=5)),
"email": fields.Email(allow_none=True),
}
)
def reports(args):
util.send_report_email(args)
return {"success": True}
## ActivityPub Support ## ActivityPub Support

View File

@ -1,4 +0,0 @@
#!/bin/bash
ruff format .
ruff check --fix .

1836
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,32 @@
[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.12" python = "^3.12"
flask = "^3.0.3" flask = "^3.1.0"
bcrypt = "^4.2.0" bcrypt = "^4.3.0"
pyjwt = "^2.9.0" pyjwt = "^2.10.0"
boto3 = "^1.35.34" boto3 = "^1.37.4"
flask-cors = "^5.0.0" flask-cors = "^5.0.1"
dnspython = "^2.6.1" dnspython = "^2.7.0"
requests = "^2.32.3" requests = "^2.32.3"
pymongo = "^4.10.1" pymongo = "^4.11.1"
flask_limiter = "^3.8.0" flask_limiter = "^3.10.1"
firebase-admin = "^6.5.0" firebase-admin = "^6.6.0"
blurhash-python = "^1.2.2" blurhash-python = "^1.2.2"
gunicorn = "^23.0.0" gunicorn = "^23.0.0"
sentry-sdk = {extras = ["flask"], version = "^2.15.0"} sentry-sdk = {extras = ["flask"], version = "^2.22.0"}
pyOpenSSL = "^24.2.1" pyOpenSSL = "^25.0.0"
webargs = "^8.6.0" webargs = "^8.6.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.6.9" ruff = "^0.9.9"
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]

View File

@ -36,5 +36,8 @@ def handle_send(data):
def send(data): def send(data):
if data.get("to_user"):
if not data["to_user"].get("emailVerified") and not data.get("force"):
return
thr = Thread(target=handle_send, args=[data]) thr = Thread(target=handle_send, args=[data])
thr.start() thr.start()

View File

@ -1,3 +1,4 @@
import os
import json import json
import datetime import datetime
from flask import request, Response from flask import request, Response
@ -7,7 +8,7 @@ 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
@ -92,6 +93,34 @@ def build_updater(obj, allowed_keys):
return updater 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(public_exponent=65537, key_size=4096) private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
private_pem = private_key.private_bytes( private_pem = private_key.private_bytes(

View File

@ -12,7 +12,7 @@ def normalise_colour(max_color, triplet):
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)
@ -152,11 +152,35 @@ def dumps(obj):
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:
raise Exception("Invalid file: null or empty or not string")
# Some user-uploaded files (Quickdraw?) start with strange HTTP header info.
# Remove all preceding non-section lines:
wif_file = "[" + wif_file.split("[", 1)[1]
# Make all section names lowercase
normalized_lines = []
for line in wif_file.splitlines():
if line.strip().startswith("[") and line.strip().endswith("]"):
section_name = line.strip()[1:-1].lower()
normalized_lines.append(f"[{section_name}]")
else:
normalized_lines.append(line)
wif_file = "\n".join(normalized_lines)
# Load config
config = configparser.ConfigParser(
allow_no_value=True, strict=False, inline_comment_prefixes=("#", ";")
)
config.read_string(wif_file)
DEFAULT_TITLE = "Untitled Pattern" DEFAULT_TITLE = "Untitled Pattern"
draft = {} draft = {}
if "wif" in config:
draft["wifInfo"] = dict(config["wif"])
draft["wifInfo"]["importedFile"] = wif_file
if "text" in config: if "text" in config:
text = config["text"] text = config["text"]
draft["name"] = text.get("title") or DEFAULT_TITLE draft["name"] = text.get("title") or DEFAULT_TITLE
@ -182,30 +206,36 @@ def loads(wif_file):
normalise_colour(255, "0,0,255"), normalise_colour(255, "0,0,255"),
] ]
weaving = config["weaving"] weaving = config["weaving"] if "weaving" in config else None
threading = config["threading"] threading = config["threading"] if "threading" in config else []
warp = config["warp"] warp = config["warp"] if "warp" in config else None
draft["warp"] = {} draft["warp"] = {}
draft["warp"]["shafts"] = weaving.getint("shafts") draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
draft["warp"]["threading"] = [] draft["warp"]["threading"] = []
if warp.get("color"): # Work out default warp colour
if warp and warp.get("color"):
warp_colour_index = warp.getint("color") - 1 warp_colour_index = warp.getint("color") - 1
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index] if warp_colour_index < len(draft["colours"]):
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
else: if not draft.get("warp").get("defaultColour"):
# In case of no color table or colour index out of bounds # In case of no color table or colour index out of bounds
draft["warp"]["defaultColour"] = draft["colours"][0] draft["warp"]["defaultColour"] = draft["colours"][0]
for x in threading: for x in threading:
shaft = threading[x] shaft = threading[x].strip()
if "," in shaft: if "," in shaft:
shaft = shaft.split(",")[0] shaft = shaft.split(",")[0]
shaft = int(shaft) shaft = int(shaft) if shaft else 0
while int(x) >= len(draft["warp"]["threading"]) - 1: while int(x) > len(
draft["warp"]["threading"]
): # grow threading array to current x
draft["warp"]["threading"].append({"shaft": 0}) draft["warp"]["threading"].append({"shaft": 0})
draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft} draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
if shaft > draft["warp"]["shafts"]:
draft["warp"]["shafts"] = shaft
draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
try: try:
warp_colours = config["warp colors"] warp_colours = config["warp colors"]
for x in warp_colours: for x in warp_colours:
@ -214,28 +244,37 @@ def loads(wif_file):
] ]
except Exception: except Exception:
pass pass
if not draft["warp"]["threading"]: # Make a bunch of empty threads
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
treadling = config["treadling"] treadling = config["treadling"] if "treadling" in config else []
weft = config["weft"] weft = config["weft"] if "weft" in config else None
draft["weft"] = {} draft["weft"] = {}
draft["weft"]["treadles"] = weaving.getint("treadles") draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
draft["weft"]["treadling"] = [] draft["weft"]["treadling"] = []
if weft.get("color"): # Work out default weft colour
if weft and weft.get("color"):
weft_colour_index = weft.getint("color") - 1 weft_colour_index = weft.getint("color") - 1
draft["weft"]["defaultColour"] = draft["colours"][weft_colour_index] if weft_colour_index < len(draft["colours"]):
else: 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 # In case of no color table or colour index out of bounds
draft["weft"]["defaultColour"] = draft["colours"][1] draft["weft"]["defaultColour"] = draft["colours"][1]
for x in treadling: for x in treadling:
shaft = treadling[x] treadle = treadling[x].strip()
if "," in shaft: if "," in treadle:
shaft = shaft.split(",")[0] treadle = treadle.split(",")[0]
shaft = int(shaft) treadle = int(treadle) if treadle else 0
while int(x) >= len(draft["weft"]["treadling"]) - 1: while int(x) > len(
draft["weft"]["treadling"]
): # grow treadling array to current x
draft["weft"]["treadling"].append({"treadle": 0}) draft["weft"]["treadling"].append({"treadle": 0})
draft["weft"]["treadling"][int(x) - 1] = {"treadle": shaft} draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
if treadle > draft["weft"]["treadles"]:
draft["weft"]["treadles"] = treadle
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
try: try:
weft_colours = config["weft colors"] weft_colours = config["weft colors"]
for x in weft_colours: for x in weft_colours:
@ -244,17 +283,20 @@ def loads(wif_file):
] ]
except Exception: except Exception:
pass 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"] tieup = config["tieup"] if "tieup" in config else None
draft["tieups"] = [] # [0]*len(tieup) draft["tieups"] = []
for x in tieup: if tieup:
while int(x) >= len(draft["tieups"]) - 1: for x in tieup:
draft["tieups"].append([]) while int(x) >= len(draft["tieups"]) - 1:
split = tieup[x].split(",") draft["tieups"].append([])
try: try:
draft["tieups"][int(x) - 1] = [int(i) for i in split] split = tieup[x].split(",")
except Exception: draft["tieups"][int(x) - 1] = [int(i) for i in split]
draft["tieups"][int(x) - 1] = [] except Exception:
draft["tieups"][int(x) - 1] = []
return draft return draft
@ -323,6 +365,9 @@ def draw_image(obj, with_plan=False):
drawdown_left = warp_left if with_plan else 0 drawdown_left = warp_left if with_plan else 0
drawdown_bottom = weft_bottom if with_plan else full_height 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) WHITE = (255, 255, 255)
GREY = (150, 150, 150) GREY = (150, 150, 150)
BLACK = (0, 0, 0) BLACK = (0, 0, 0)
@ -348,7 +393,10 @@ def draw_image(obj, with_plan=False):
width=1, width=1,
joint=None, joint=None,
) )
col_index = 1
for i, x in enumerate(range(len(warp["threading"]) - 1, 0, -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] thread = warp["threading"][i]
xcoord = x * BASE_SIZE xcoord = x * BASE_SIZE
draw.line( draw.line(
@ -356,8 +404,8 @@ def draw_image(obj, with_plan=False):
(xcoord, warp_top), (xcoord, warp_top),
(xcoord, warp_bottom), (xcoord, warp_bottom),
], ],
fill=GREY, fill=BLACK if is_guide else GREY,
width=1, width=2 if is_guide else 1,
joint=None, joint=None,
) )
if thread.get("shaft", 0) > 0: if thread.get("shaft", 0) > 0:
@ -397,7 +445,10 @@ def draw_image(obj, with_plan=False):
width=1, width=1,
joint=None, joint=None,
) )
row_index = 0
for i, y in enumerate(range(0, len(weft["treadling"]))): 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] thread = weft["treadling"][i]
ycoord = weft_top + y * BASE_SIZE ycoord = weft_top + y * BASE_SIZE
draw.line( draw.line(
@ -405,8 +456,8 @@ def draw_image(obj, with_plan=False):
(weft_left, ycoord), (weft_left, ycoord),
(weft_right, ycoord), (weft_right, ycoord),
], ],
fill=GREY, fill=BLACK if is_guide else GREY,
width=1, width=2 if is_guide else 1,
joint=None, joint=None,
) )
if thread.get("treadle", 0) > 0: if thread.get("treadle", 0) > 0:
@ -485,7 +536,9 @@ def draw_image(obj, with_plan=False):
shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"] shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"]
# Work out if should be warp or weft in "front" # Work out if should be warp or weft in "front"
tieup = tieups[treadle - 1] if treadle > 0 else [] tieup = (
tieups[treadle - 1] if (treadle > 0 and treadle <= len(tieups)) else []
)
tieup = [t for t in tieup if t <= warp["shafts"]] tieup = [t for t in tieup if t <= warp["shafts"]]
thread_type = "warp" if shaft in tieup else "weft" thread_type = "warp" if shaft in tieup else "weft"
@ -528,9 +581,13 @@ def draw_image(obj, with_plan=False):
in_mem_file = io.BytesIO() in_mem_file = io.BytesIO()
img.save(in_mem_file, "PNG") img.save(in_mem_file, "PNG")
in_mem_file.seek(0) in_mem_file.seek(0)
file_name = "preview-{0}_{1}-{2}.png".format( file_name_prefix = "preview-{0}_{1}".format(
"full" if with_plan else "base", obj["_id"], int(time.time()) "full" if with_plan else "base", obj["_id"]
) )
path = "projects/{}/{}".format(obj["project"], file_name) file_name = "{0}-{1}.png".format(file_name_prefix, int(time.time()))
uploads.upload_file(path, in_mem_file) folder = "projects/{}".format(obj["project"])
# Delete existing preview images of this type
uploads.delete_folder("{}/{}".format(folder, file_name_prefix))
# Upload the new preview image
uploads.upload_file("{}/{}".format(folder, file_name), in_mem_file)
return file_name 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

@ -1,3 +1,10 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@ -21,10 +23,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties') def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@ -32,20 +30,18 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
compileSdkVersion 33 compileSdkVersion flutter.compileSdkVersion
namespace 'com.treadl'
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
lintOptions {
disable 'InvalidPackage'
}
defaultConfig { defaultConfig {
applicationId "com.treadl" applicationId "com.treadl"
minSdkVersion 29 minSdk = flutter.minSdkVersion
targetSdkVersion 34 targetSdk = flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@ -64,14 +60,19 @@ android {
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
} }
lint {
disable 'InvalidPackage'
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
} }
flutter { flutter {
source '../..' source '../..'
} }
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
apply plugin: 'com.google.gms.google-services'

View File

@ -1,25 +0,0 @@
// Generated 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;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

View File

@ -1,17 +1,3 @@
buildscript {
ext.kotlin_version = '1.8.20'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.3'
}
}
allprojects { allprojects {
repositories { repositories {
google() google()

View File

@ -1,5 +1,3 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
android.bundle.enableUncompressedNativeLibs=false

View File

@ -1,5 +1,6 @@
#Sun Apr 06 21:07:46 BST 2025
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 distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -1,15 +1,26 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def plugins = new Properties() repositories {
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') google()
if (pluginsFile.exists()) { mavenCentral()
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } gradlePluginPortal()
}
} }
plugins.each { name, path -> plugins {
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() id "dev.flutter.flutter-plugin-loader" version "1.0.0"
include ":$name" id "com.android.application" version '8.9.1' apply false
project(":$name").projectDir = pluginDirectory id "org.jetbrains.kotlin.android" version "2.1.10" apply false
id "com.google.gms.google-services" version "4.3.3" apply false
} }
include ":app"

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>11.0</string> <string>12.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, '11.0' # platform :ios, '12.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,112 +1,120 @@
PODS: PODS:
- DKImagePickerController/Core (4.3.4): - DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager - DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource - DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.4) - DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.4): - DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core - DKImagePickerController/Core
- DKPhotoGallery - DKPhotoGallery
- DKImagePickerController/Resource (4.3.4) - DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.17): - DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.17) - DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.17) - DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.17) - DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.17) - DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- DKPhotoGallery/Core (0.0.17): - DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model - DKPhotoGallery/Model
- DKPhotoGallery/Preview - DKPhotoGallery/Preview
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- DKPhotoGallery/Model (0.0.17): - DKPhotoGallery/Model (0.0.19):
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- DKPhotoGallery/Preview (0.0.17): - DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model - DKPhotoGallery/Model
- DKPhotoGallery/Resource - DKPhotoGallery/Resource
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- DKPhotoGallery/Resource (0.0.17): - DKPhotoGallery/Resource (0.0.19):
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Firebase/CoreOnly (10.9.0): - Firebase/CoreOnly (11.10.0):
- FirebaseCore (= 10.9.0) - FirebaseCore (~> 11.10.0)
- Firebase/Messaging (10.9.0): - Firebase/Messaging (11.10.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 10.9.0) - FirebaseMessaging (~> 11.10.0)
- firebase_core (2.13.1): - firebase_core (3.13.0):
- Firebase/CoreOnly (= 10.9.0) - Firebase/CoreOnly (= 11.10.0)
- Flutter - Flutter
- firebase_messaging (14.6.2): - firebase_messaging (15.2.5):
- Firebase/Messaging (= 10.9.0) - Firebase/Messaging (= 11.10.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseCore (10.9.0): - FirebaseCore (11.10.0):
- FirebaseCoreInternal (~> 10.0) - FirebaseCoreInternal (~> 11.10.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 7.8) - GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (10.10.0): - FirebaseCoreInternal (11.10.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (10.10.0): - FirebaseInstallations (11.10.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 11.10.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.1) - PromisesObjC (~> 2.4)
- FirebaseMessaging (10.9.0): - FirebaseMessaging (11.10.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 9.2) - GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Reachability (~> 7.8) - GoogleUtilities/Reachability (~> 8.0)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- GoogleDataTransport (9.2.3): - fluttertoast (0.0.2):
- GoogleUtilities/Environment (~> 7.7) - Flutter
- nanopb (< 2.30910.0, >= 2.30908.0) - GoogleDataTransport (10.1.0):
- PromisesObjC (< 3.0, >= 1.2) - nanopb (~> 3.30910.0)
- GoogleUtilities/AppDelegateSwizzler (7.11.1): - PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.0.2):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Network - GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1): - GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2) - GoogleUtilities/Environment (8.0.2):
- GoogleUtilities/Logger (7.11.1): - GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.0.2):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.1): - GoogleUtilities/Privacy
- GoogleUtilities/Network (8.0.2):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib" - "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability - GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)" - "GoogleUtilities/NSData+zlib (8.0.2)":
- GoogleUtilities/Reachability (7.11.1): - GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.0.2)
- GoogleUtilities/Reachability (8.0.2):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.1): - GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- nanopb (2.30909.0): - nanopb (3.30910.0):
- nanopb/decode (= 2.30909.0) - nanopb/decode (= 3.30910.0)
- nanopb/encode (= 2.30909.0) - nanopb/encode (= 3.30910.0)
- nanopb/decode (2.30909.0) - nanopb/decode (3.30910.0)
- nanopb/encode (2.30909.0) - nanopb/encode (3.30910.0)
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- PromisesObjC (2.2.0) - PromisesObjC (2.4.0)
- SDWebImage (5.18.8): - SDWebImage (5.21.0):
- SDWebImage/Core (= 5.18.8) - SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.18.8) - SDWebImage/Core (5.21.0)
- share_plus (0.0.1): - share_plus (0.0.1):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SwiftyGif (5.4.4) - SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
@ -115,6 +123,7 @@ DEPENDENCIES:
- 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_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@ -146,6 +155,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
path_provider_foundation: path_provider_foundation:
@ -158,29 +169,30 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2 Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
firebase_core: ce64b0941c6d87c6ef5022ae9116a158236c8c94 firebase_core: 2d4534e7b489907dcede540c835b48981d890943
firebase_messaging: 42912365e62efc1ea3e00724e5eecba6068ddb88 firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557 FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893 FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
FirebaseInstallations: 52153982b057d3afcb4e1fbb3eb0b6d00611e681 FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
COCOAPODS: 1.14.2 COCOAPODS: 1.16.2

View File

@ -156,6 +156,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
2341743D762090318428F35C /* [CP] Embed Pods Frameworks */, 2341743D762090318428F35C /* [CP] Embed Pods Frameworks */,
4777130FB39D1044BC14FC9C /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -172,7 +173,7 @@
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1430; LastUpgradeCheck = 1510;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
@ -248,6 +249,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
4777130FB39D1044BC14FC9C /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1430" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -1,7 +1,7 @@
import UIKit import UIKit
import Flutter import Flutter
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,

162
mobile/lib/account.dart Normal file
View File

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart';
import 'model.dart';
class _AccountScreenState extends State<AccountScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
bool loading = false;
Api api = Api();
@override
initState() {
super.initState();
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user != null) {
_emailController.text = model.user!.email ?? '';
_usernameController.text = model.user!.username;
}
}
void _saveEmail(BuildContext context) async {
setState(() {
loading = true;
});
AppModel model = Provider.of<AppModel>(context, listen: false);
var data = await api.request('PUT', '/accounts/email', {'email': _emailController.text});
if (data['success'] == true) {
model.setUserEmail(_emailController.text);
setState(() {
loading = false;
});
Fluttertoast.showToast(
msg: "Email updated successfully",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 10,
backgroundColor: Colors.green[800],
textColor: Colors.white,
);
} else {
setState(() {
loading = false;
});
Fluttertoast.showToast(
msg: data['message'],
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 10,
backgroundColor: Colors.red[800],
textColor: Colors.white,
);
}
}
void _saveUsername(BuildContext context) async {
setState(() {
loading = true;
});
AppModel model = Provider.of<AppModel>(context, listen: false);
var data = await api.request('PUT', '/users/${model.user?.username}', {'username': _usernameController.text});
if (data['success'] == true) {
model.setUserUsername(_usernameController.text);
setState(() {
loading = false;
});
Fluttertoast.showToast(
msg: "Username updated successfully",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 10,
backgroundColor: Colors.green[800],
textColor: Colors.white,
);
} else {
setState(() {
loading = false;
});
Fluttertoast.showToast(
msg: data['message'],
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 10,
backgroundColor: Colors.red[800],
textColor: Colors.white,
);
}
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
return Scaffold(
appBar: AppBar(
title: Text('Edit Account'),
),
body: ListView(
padding: const EdgeInsets.all(8),
children: <Widget>[
Text('Account email address', style: Theme.of(context).textTheme.titleLarge),
SizedBox(height: 10),
Row(
children: [
Expanded(child: TextField(
controller: _emailController,
decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Account email address',
border: OutlineInputBorder(),
),
)),
TextButton(
child: Text('Save'),
onPressed: () => _saveEmail(context),
),
],
),
SizedBox(height: 20),
Text('Account username', style: Theme.of(context).textTheme.titleLarge),
SizedBox(height: 10),
Row(
children: [
Expanded(child: TextField(
controller: _usernameController,
decoration: InputDecoration(
hintText: 'sam', labelText: 'Account username',
border: OutlineInputBorder(),
),
)),
TextButton(
child: Text('Save'),
onPressed: () {
_saveUsername(context);
},
),
],
),
SizedBox(height: 20),
Text('To edit your profile or change your password, please use the Treadl website.', style: Theme.of(context).textTheme.bodyMedium),
SizedBox(height: 20),
ElevatedButton(
child: Text('Open Treadl website'),
onPressed: () {
launchUrl(Uri.parse('https://treadl.com/${model.user?.username}'));
},
),
]
),
);
}
}
class AccountScreen extends StatefulWidget {
@override
const AccountScreen({super.key});
@override
State<AccountScreen> createState() => _AccountScreenState();
}

View File

@ -1,19 +1,16 @@
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 '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://192.168.5.134:2001'; //final String apiBase = 'http://localhost:2001';
Api({token: null}) { Api({token}) {
if (token != null) _token = token; if (token != null) _token = token;
} }
@ -29,7 +26,7 @@ class Api {
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';
@ -42,17 +39,17 @@ class Api {
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 = null; String? json;
if (data != null) { if (data != null) {
json = jsonEncode(data!); 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 = null; String? json;
if (data != null) { if (data != null) {
json = jsonEncode(data!); 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);
@ -86,15 +83,13 @@ class Api {
if (response == null) { if (response == null) {
return {'success': false, 'message': 'No response for your request'}; return {'success': false, 'message': 'No response for your request'};
} }
int status = response!.statusCode; int status = response.statusCode;
if (status == 200) { if (status == 200) {
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'); Map<String, dynamic> respData = jsonDecode(response.body);
Map<String, dynamic> respData = jsonDecode(response!.body);
return {'success': false, 'code': status, 'message': respData['message']}; return {'success': false, 'code': status, 'message': respData['message']};
} }
} }

View File

@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'lib.dart'; import 'lib.dart';
@ -22,7 +20,7 @@ class _ExploreTabState extends State<ExploreTab> {
void getExploreData() async { void getExploreData() async {
if (explorePage == -1) return; if (explorePage == -1) return;
var data = await api.request('GET', '/search/explore?page=${explorePage}'); var data = await api.request('GET', '/search/explore?page=$explorePage');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
loading = false; loading = false;
@ -56,7 +54,7 @@ class _ExploreTabState extends State<ExploreTab> {
borderRadius: BorderRadius.all(Radius.circular(10)), borderRadius: BorderRadius.all(Radius.circular(10)),
), ),
child:Center( child:Center(
child: CupertinoButton( child: TextButton(
child: Text('Load more'), child: Text('Load more'),
onPressed: () => getExploreData(), onPressed: () => getExploreData(),
) )
@ -66,7 +64,8 @@ class _ExploreTabState extends State<ExploreTab> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Explore'), title: Text('Explore Treadl'),
forceMaterialTransparency: true,
), ),
body: loading ? body: loading ?
Container( Container(
@ -74,42 +73,42 @@ class _ExploreTabState extends State<ExploreTab> {
alignment: Alignment.center, alignment: Alignment.center,
child: CircularProgressIndicator() child: CircularProgressIndicator()
) )
: Container( : Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ SizedBox(height: 10),
SizedBox(height: 10), CustomText('Discover projects', 'h1', margin: 5),
CustomText('Discover projects', 'h1', margin: 5), SizedBox(height: 5),
SizedBox(height: 5), SizedBox(
Container( height: 130,
height: 130, child: ListView(
child: ListView( scrollDirection: Axis.horizontal,
scrollDirection: Axis.horizontal, children: projects.map((p) => ProjectCard(p)).toList()
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,
), ),
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 { class ExploreTab extends StatefulWidget {
const ExploreTab({super.key});
@override @override
_ExploreTabState createState() => _ExploreTabState(); State<ExploreTab> createState() => _ExploreTabState();
} }

View File

@ -1,17 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'api.dart'; import 'api.dart';
import 'group_noticeboard.dart'; 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; Map<String, dynamic>? _group;
int _selectedIndex = 0; int _selectedIndex = 0;
_GroupScreenState(this.id) { }
@override @override
void initState() { void initState() {
fetchGroup(); fetchGroup();
@ -20,7 +15,7 @@ class _GroupScreenState extends State<GroupScreen> {
void fetchGroup() async { void fetchGroup() async {
Api api = Api(); Api api = Api();
var data = await api.request('GET', '/groups/' + id); var data = await api.request('GET', '/groups/${widget.id}');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_group = data['payload']; _group = data['payload'];
@ -65,7 +60,7 @@ class _GroupScreenState extends State<GroupScreen> {
class GroupScreen extends StatefulWidget { class GroupScreen extends StatefulWidget {
final String id; final String id;
GroupScreen(this.id) { } const GroupScreen(this.id, {super.key});
@override @override
_GroupScreenState createState() => _GroupScreenState(id); State<GroupScreen> createState() => _GroupScreenState();
} }

View File

@ -1,27 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'user.dart';
class _GroupMembersTabState extends State<GroupMembersTab> { class _GroupMembersTabState extends State<GroupMembersTab> {
final Map<String,dynamic> _group;
final Api api = Api(); final Api api = Api();
List<dynamic> _members = []; List<dynamic> _members = [];
bool _loading = false; bool _loading = false;
_GroupMembersTabState(this._group) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
getMembers(_group['_id']); getMembers(widget.group['_id']);
} }
void getMembers(String id) async { void getMembers(String id) async {
setState(() => _loading = true); setState(() => _loading = true);
var data = await api.request('GET', '/groups/' + id + '/members'); var data = await api.request('GET', '/groups/$id/members');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_members = data['payload']['members']; _members = data['payload']['members'];
@ -31,8 +26,8 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
} }
Widget getMemberCard(member) { Widget getMemberCard(member) {
return new ListTile( return ListTile(
onTap: () => context.push('/' + member['username']), onTap: () => context.push('/${member["username"]}'),
leading: Util.avatarImage(Util.avatarUrl(member), size: 40), 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'])
@ -49,17 +44,15 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
) )
:Column( :Column(
children: [ children: [
Container( Expanded(
child: Expanded( child: ListView.builder(
child: ListView.builder( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8), itemCount: _members.length,
itemCount: _members.length, itemBuilder: (BuildContext context, int index) {
itemBuilder: (BuildContext context, int index) { return getMemberCard(_members[index]);
return getMemberCard(_members[index]); },
},
),
), ),
) ),
] ]
); );
} }
@ -67,7 +60,7 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
class GroupMembersTab extends StatefulWidget { class GroupMembersTab extends StatefulWidget {
final Map<String,dynamic> group; final Map<String,dynamic> group;
GroupMembersTab(this.group) { } const GroupMembersTab(this.group, {super.key});
@override @override
_GroupMembersTabState createState() => _GroupMembersTabState(group); State<GroupMembersTab> createState() => _GroupMembersTabState();
} }

View File

@ -1,34 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'api.dart'; import 'api.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 Api api = Api(); final Api api = Api();
Map<String,dynamic> _group;
List<dynamic> _entries = []; List<dynamic> _entries = [];
bool showPostButton = false; bool showPostButton = false;
bool _loading = false; bool _loading = false;
bool _posting = false; bool _posting = false;
_GroupNoticeBoardTabState(this._group) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
getEntries(_group['_id']); getEntries(widget.group['_id']);
_newEntryController.addListener(() { _newEntryController.addListener(() {
setState(() { setState(() {
showPostButton = _newEntryController.text.length > 0 ? true : false; showPostButton = _newEntryController.text.isNotEmpty ? true : false;
}); });
}); });
} }
void getEntries(String id) async { void getEntries(String id) async {
setState(() => _loading = true); setState(() => _loading = true);
var data = await api.request('GET', '/groups/' + id + '/entries'); var data = await api.request('GET', '/groups/$id/entries');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_entries = data['payload']['entries']; _entries = data['payload']['entries'];
@ -39,9 +34,9 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
void _sendPost(context) async { void _sendPost(context) async {
String text = _newEntryController.text; String text = _newEntryController.text;
if (text.length == 0) return; if (text.isEmpty) return;
setState(() => _posting = true); setState(() => _posting = true);
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text}); var data = await api.request('POST', '/groups/${widget.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());
@ -83,28 +78,26 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
return Column( return Column(
children: <Widget>[ children: <Widget>[
NoticeboardInput(_newEntryController, () => _sendPost(context), _posting, label: 'Write a new post to the group'), NoticeboardInput(_newEntryController, () => _sendPost(context), _posting, label: 'Write a new post to the group'),
Container( Expanded(
child: Expanded( child: _loading ?
child: _loading ? Container(
Container( margin: const EdgeInsets.all(10.0),
margin: const EdgeInsets.all(10.0), alignment: Alignment.center,
alignment: Alignment.center, child: CircularProgressIndicator()
child: CircularProgressIndicator() )
) :
: ListView.builder(
ListView.builder( padding: const EdgeInsets.all(0),
padding: const EdgeInsets.all(0), itemCount: entries.length,
itemCount: entries.length, itemBuilder: (BuildContext context, int index) {
itemBuilder: (BuildContext context, int index) { return Container(
return Container( key: Key(entries[index]['_id']),
key: Key(entries[index]['_id']), child: NoticeboardPost(entries[index],
child: NoticeboardPost(entries[index], onDelete: _onDelete,
onDelete: _onDelete, onReply: _onReply,
onReply: _onReply, ));
)); },
}, ),
),
)
) )
] ]
); );
@ -113,7 +106,7 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
class GroupNoticeBoardTab extends StatefulWidget { class GroupNoticeBoardTab extends StatefulWidget {
final Map<String,dynamic> group; final Map<String,dynamic> group;
GroupNoticeBoardTab(this.group) { } const GroupNoticeBoardTab(this.group, {super.key});
@override @override
_GroupNoticeBoardTabState createState() => _GroupNoticeBoardTabState(group); State<GroupNoticeBoardTab> createState() => _GroupNoticeBoardTabState();
} }

View File

@ -32,13 +32,13 @@ 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 if (description == null) { } else {
description = 'This group doesn\'t have a description.'; description ??= 'This group doesn\'t have a description.';
} }
return Card( return Card(
child: InkWell( child: InkWell(
onTap: () => context.push('/groups/' + group['_id']), onTap: () => context.push('/groups/${group["_id"]}'),
child: ListTile( child: ListTile(
leading: Icon(Icons.people, size: 40, color: Colors.pink[300]), leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
trailing: Icon(Icons.keyboard_arrow_right), trailing: Icon(Icons.keyboard_arrow_right),
@ -51,18 +51,18 @@ class _GroupsTabState extends State<GroupsTab> {
Widget getBody() { Widget getBody() {
AppModel model = Provider.of<AppModel>(context); AppModel model = Provider.of<AppModel>(context);
if (model.user == null) if (model.user == null) {
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.'); return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
else if (_loading) } else if (_loading) {
return CircularProgressIndicator(); return CircularProgressIndicator();
else if (_groups != null && _groups.length > 0) } else if (_groups.isNotEmpty) {
return ListView.builder( return ListView.builder(
itemCount: _groups.length, itemCount: _groups.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]); return buildGroupCard(_groups[index]);
}, },
); );
else } else {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -72,6 +72,7 @@ class _GroupsTabState extends State<GroupsTab> {
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center), 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), Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
]); ]);
}
} }
@override @override
@ -90,6 +91,8 @@ class _GroupsTabState extends State<GroupsTab> {
} }
class GroupsTab extends StatefulWidget { class GroupsTab extends StatefulWidget {
const GroupsTab({super.key});
@override @override
_GroupsTabState createState() => _GroupsTabState(); State<GroupsTab> createState() => _GroupsTabState();
} }

View File

@ -1,19 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'explore.dart'; import 'explore.dart';
import 'projects.dart'; import 'projects.dart';
import 'groups.dart'; import 'groups.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override @override
State<HomeScreen> createState() => _MyStatefulWidgetState(); State<HomeScreen> createState() => _MyStatefulWidgetState();
} }
class _MyStatefulWidgetState extends State<HomeScreen> { class _MyStatefulWidgetState extends State<HomeScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [ final List<Widget> _widgetOptions = <Widget> [
ExploreTab(), ExploreTab(),
ProjectsTab(), ProjectsTab(),
GroupsTab() GroupsTab()

View File

@ -1,5 +1,4 @@
import 'dart:core'; import 'dart:core';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart'; 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';
@ -7,9 +6,6 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'user.dart';
import 'object.dart';
import 'project.dart';
class Alert extends StatelessWidget { class Alert extends StatelessWidget {
final String type; final String type;
@ -18,7 +14,7 @@ class Alert extends StatelessWidget {
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}) {} const Alert({super.key, this.type = 'info', this.title = '', this.description = '', this.descriptionWidget, this.actionText = 'Click here', this.action});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -38,9 +34,9 @@ class Alert extends StatelessWidget {
return Container( return Container(
padding: EdgeInsets.all(15), padding: EdgeInsets.all(15),
margin: EdgeInsets.all(15), margin: EdgeInsets.all(15),
decoration: new BoxDecoration( decoration: BoxDecoration(
color: accentColor, color: accentColor,
borderRadius: new BorderRadius.all(Radius.circular(10.0)), borderRadius: BorderRadius.all(Radius.circular(10.0)),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.grey[50]!, spreadRadius: 5), BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
], ],
@ -51,11 +47,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 != null ? descriptionWidget! : Text(""), if (descriptionWidget != null) descriptionWidget!,
action != null ? CupertinoButton( if (action != null)
child: Text(actionText), ElevatedButton(
onPressed: () => action!(), child: Text(actionText),
) : Text("") onPressed: () => action!(),
)
] ]
) )
); );
@ -66,28 +63,25 @@ 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}); const NoticeboardPost(this._entry, {super.key, this.onDelete, this.onReply});
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply); @override
State<NoticeboardPost> createState() => _NoticeboardPostState();
} }
class _NoticeboardPostState extends State<NoticeboardPost> { class _NoticeboardPostState extends State<NoticeboardPost> {
final Map<String,dynamic> _entry; final Api api = Api();
final Api api = new Api();
final Function? onDelete;
final Function? onReply;
final TextEditingController _replyController = TextEditingController(); final TextEditingController _replyController = TextEditingController();
bool _isReplying = false; bool _isReplying = false;
bool _replying = false; bool _replying = false;
_NoticeboardPostState(this._entry, {this.onDelete = null, this.onReply = null}) { }
void _sendReply() async { void _sendReply() async {
setState(() => _replying = true); setState(() => _replying = true);
var data = await api.request('POST', '/groups/' + _entry['group'] + '/entries/' + _entry['_id'] + '/replies', {'content': _replyController.text}); var data = await api.request('POST', '/groups/${widget._entry["group"]}/entries/${widget._entry["_id"]}/replies', {'content': _replyController.text});
if (data['success'] == true) { if (data['success'] == true) {
_replyController.value = TextEditingValue(text: ''); _replyController.value = TextEditingValue(text: '');
if (!mounted) return;
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());
if (onReply != null) { if (widget.onReply != null) {
onReply!(data['payload']); widget.onReply!(data['payload']);
} }
setState(() { setState(() {
_replying = false; _replying = false;
@ -97,38 +91,40 @@ 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/${widget._entry["group"]}/entries/${widget._entry["_id"]}');
if (data['success'] == true) { if (data['success'] == true) {
if (onDelete != null) { if (widget.onDelete != null) {
onDelete!(_entry); widget.onDelete!(widget._entry);
}
if (mounted) {
context.pop();
} }
context.pop();
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var createdAt = DateTime.parse(_entry['createdAt']); var createdAt = DateTime.parse(widget._entry['createdAt']);
bool isReply = _entry['inReplyTo'] != null; bool isReply = widget._entry['inReplyTo'] != null;
int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length; int replyCount = widget._entry['replies'] == null ? 0 : widget._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 replies';
if (_isReplying) replyText = 'Cancel reply'; if (_isReplying) replyText = 'Cancel reply';
List<Widget> replyWidgets = []; List<Widget> replyWidgets = [];
if (_entry['replies'] != null) { if (widget._entry['replies'] != null) {
for (int i = 0; i < _entry['replies']!.length; i++) { for (int i = 0; i < widget._entry['replies']!.length; i++) {
replyWidgets.add(new Container( replyWidgets.add(Container(
key: Key(_entry['replies']![i]['_id']), key: Key(widget._entry['replies']![i]['_id']),
child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete) child: NoticeboardPost(widget._entry['replies']![i], onDelete: widget.onDelete)
)); ));
} }
} }
return new GestureDetector( return GestureDetector(
key: Key(_entry['_id']), key: Key(widget._entry['_id']),
onLongPress: () async { onLongPress: () async {
Dialog simpleDialog = Dialog( Dialog simpleDialog = Dialog(
child: Container( child: SizedBox(
height: 160.0, height: 160.0,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -136,7 +132,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
ElevatedButton( ElevatedButton(
//color: Colors.orange, //color: Colors.orange,
onPressed: () { onPressed: () {
launch('https://www.treadl.com'); launchUrl(Uri.parse('https://www.treadl.com/report'));
}, },
child: Text('Report this post'), child: Text('Report this post'),
), ),
@ -167,11 +163,11 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Row( Row(
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onTap: () => context.push('/' + _entry['authorUser']['username']), onTap: () => context.push('/${widget._entry["authorUser"]["username"]}'),
child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40) child: Util.avatarImage(Util.avatarUrl(widget._entry['authorUser']), size: isReply ? 30 : 40)
), ),
SizedBox(width: 5), SizedBox(width: 5),
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)), Text(widget._entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
SizedBox(width: 5), SizedBox(width: 5),
Text(DateFormat('kk:mm on MMMM d y').format(createdAt), style: TextStyle(color: Colors.grey, fontSize: 10)), Text(DateFormat('kk:mm on MMMM d y').format(createdAt), style: TextStyle(color: Colors.grey, fontSize: 10)),
SizedBox(width: 10), SizedBox(width: 10),
@ -183,7 +179,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
), ),
Row(children: [ Row(children: [
SizedBox(width: 45), SizedBox(width: 45),
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left)) Expanded(child: Text(widget._entry['content'], textAlign: TextAlign.left))
]), ]),
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0), _isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
Column( Column(
@ -200,7 +196,7 @@ class NoticeboardInput extends StatelessWidget {
final Function _onPost; final Function _onPost;
final bool _posting; final bool _posting;
final String label; final String label;
NoticeboardInput(this._controller, this._onPost, this._posting, {this.label = 'Write a new post'}) {} const NoticeboardInput(this._controller, this._onPost, this._posting, {super.key, this.label = 'Write a new post'});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -217,7 +213,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),
) )
@ -229,13 +225,13 @@ class NoticeboardInput extends StatelessWidget {
class UserChip extends StatelessWidget { class UserChip extends StatelessWidget {
final Map<String,dynamic> user; final Map<String,dynamic> user;
UserChip(this.user) {} const UserChip(this.user, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ImageProvider? avatar = Util.avatarUrl(user); ImageProvider? avatar = Util.avatarUrl(user);
return GestureDetector( return GestureDetector(
onTap: () => context.push('/' + user['username']), onTap: () => context.push('/${user["username"]}'),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -250,7 +246,7 @@ class UserChip extends StatelessWidget {
class PatternCard extends StatelessWidget { class PatternCard extends StatelessWidget {
final Map<String,dynamic> object; final Map<String,dynamic> object;
PatternCard(this.object) {} const PatternCard(this.object, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -262,7 +258,7 @@ class PatternCard extends StatelessWidget {
), ),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']); context.push('/${object["projectObject"]["owner"]["username"]}/${object["projectObject"]["path"]}/${object["_id"]}');
}, },
child: Column( child: Column(
children: [ children: [
@ -295,7 +291,7 @@ class PatternCard extends StatelessWidget {
class ProjectCard extends StatelessWidget { class ProjectCard extends StatelessWidget {
final Map<String,dynamic> project; final Map<String,dynamic> project;
ProjectCard(this.project) {} const ProjectCard(this.project, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -307,7 +303,7 @@ class ProjectCard extends StatelessWidget {
), ),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']); context.push('/${project["owner"]["username"]}/${project["path"]}');
}, },
child: Column( child: Column(
children: [ children: [
@ -336,10 +332,10 @@ class CustomText extends StatelessWidget {
final String text; final String text;
final String type; final String type;
final double margin; final double margin;
TextStyle? style; late final TextStyle style;
CustomText(this.text, this.type, {this.margin = 0}) { CustomText(this.text, this.type, {super.key, this.margin = 0}) {
if (this.type == 'h1') { if (type == 'h1') {
style = TextStyle(fontSize: 25, fontWeight: FontWeight.bold); style = TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
} }
else { else {
style = TextStyle(); style = TextStyle();
@ -349,7 +345,7 @@ class CustomText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
margin: EdgeInsets.all(this.margin), margin: EdgeInsets.all(margin),
child: Text(text, style: style) child: Text(text, style: style)
); );
} }
@ -357,7 +353,7 @@ class CustomText extends StatelessWidget {
class LoginNeeded extends StatelessWidget { class LoginNeeded extends StatelessWidget {
final String? text; final String? text;
LoginNeeded({this.text}) {} const LoginNeeded({super.key, this.text});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -367,11 +363,12 @@ class LoginNeeded extends StatelessWidget {
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center), Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/login.png'), width: 300), Image(image: AssetImage('assets/login.png'), width: 300),
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10), text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
CupertinoButton( SizedBox(height: 20),
ElevatedButton(
onPressed: () { onPressed: () {
context.push('/welcome'); context.push('/welcome');
}, },
child: new Text("Login or register", child: Text("Login or register",
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )
) )
@ -384,7 +381,7 @@ class EmptyBox extends StatelessWidget {
final String title; final String title;
final String? description; final String? description;
EmptyBox(this.title, {this.description}) {} const EmptyBox(this.title, {super.key, this.description});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,6 +1,5 @@
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:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -18,16 +17,19 @@ class _LoginScreenState extends State<LoginScreen> {
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) {
if (!context.mounted) return;
AppModel model = Provider.of<AppModel>(context, listen: false); AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(data['payload']['token']); await model.setToken(data['payload']['token']);
if (!context.mounted) return;
context.go('/onboarding'); context.go('/onboarding');
} }
else { else {
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text("There was a problem logging you in"), title: Text("There was a problem logging you in"),
content: new Text(data['message']), content: Text(data['message']),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
@ -50,7 +52,7 @@ class _LoginScreenState extends State<LoginScreen> {
margin: const EdgeInsets.only(top: 40, left: 10, right: 10), margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: ListView( child: ListView(
children: <Widget>[ children: <Widget>[
Text('Login with your Treadl account', style: TextStyle(fontSize: 20)), Text('Login with your Treadl account', style: Theme.of(context).textTheme.titleLarge),
SizedBox(height: 30), SizedBox(height: 30),
TextField( TextField(
autofocus: true, autofocus: true,
@ -74,7 +76,7 @@ class _LoginScreenState extends State<LoginScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [GestureDetector( children: [GestureDetector(
onTap: () => launch('https://treadl.com/password/forgotten'), onTap: () => launchUrl(Uri.parse('https://treadl.com/password/forgotten')),
child: Text('Forgotten your password?'), child: Text('Forgotten your password?'),
)] )]
), ),
@ -83,7 +85,6 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () => _submit(context), 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)
) )
), ),
] ]
@ -94,6 +95,8 @@ class _LoginScreenState extends State<LoginScreen> {
} }
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override @override
_LoginScreenState createState() => _LoginScreenState(); State<LoginScreen> createState() => _LoginScreenState();
} }

View File

@ -4,7 +4,6 @@ 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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
//import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart'; import 'model.dart';
import 'welcome.dart'; import 'welcome.dart';
@ -15,8 +14,10 @@ import 'home.dart';
import 'project.dart'; import 'project.dart';
import 'object.dart'; import 'object.dart';
import 'settings.dart'; import 'settings.dart';
import 'verify_email.dart';
import 'group.dart'; import 'group.dart';
import 'user.dart'; import 'user.dart';
import 'account.dart';
final router = GoRouter( final router = GoRouter(
routes: [ routes: [
@ -41,6 +42,8 @@ final router = GoRouter(
GoRoute(path: '/home', builder: (context, state) => HomeScreen()), GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()), GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)), GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
GoRoute(path: '/email/verify', builder: (context, state) => VerifyEmailScreen(token: state.uri.queryParameters['token'])),
GoRoute(path: '/account', builder: (context, state) => AccountScreen()),
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)), 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', 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']!)), GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
@ -57,9 +60,11 @@ void main() {
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {
const MyApp({super.key});
// Create the initialization Future outside of `build`: // Create the initialization Future outside of `build`:
@override @override
_AppState createState() => _AppState(); State<MyApp> createState() => _AppState();
} }
class _AppState extends State<MyApp> { class _AppState extends State<MyApp> {
@ -78,7 +83,6 @@ class _AppState extends State<MyApp> {
title: 'Treadl', title: 'Treadl',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.pink, primarySwatch: Colors.pink,
scaffoldBackgroundColor: Color.fromRGBO(255, 251, 248, 1),
), ),
); );
}, },
@ -87,17 +91,16 @@ class _AppState extends State<MyApp> {
} }
class Startup extends StatelessWidget { class Startup extends StatelessWidget {
bool _handled = false;
Startup() { Startup({super.key}) {
FirebaseMessaging.onMessage.listen((RemoteMessage message) { /*FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.notification != null) { if (message.notification != null) {
print(message.notification!); 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,
@ -105,21 +108,20 @@ class Startup extends StatelessWidget {
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
textColor: Colors.black, textColor: Colors.black,
fontSize: 16.0 fontSize: 16.0
);*/ );
} }
}); });*/
} }
void checkToken(BuildContext context) async { void checkToken(BuildContext context) async {
if (_handled) return;
_handled = true;
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
String? token = prefs.getString('apiToken'); String? token = prefs.getString('apiToken');
if (token != null) { if (token != null) {
if (!context.mounted) return;
AppModel model = Provider.of<AppModel>(context, listen: false); AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(token!); await model.setToken(token);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; FirebaseMessaging firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission( await firebaseMessaging.requestPermission(
alert: true, alert: true,
announcement: false, announcement: false,
badge: true, badge: true,
@ -128,13 +130,13 @@ 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");
Api api = Api(); Api api = Api();
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!}); api.request('PUT', '/accounts/pushToken', {'pushToken': pushToken});
} }
} }
if (!context.mounted) return;
context.go('/home'); context.go('/home');
} }

View File

@ -4,14 +4,19 @@ import 'api.dart';
class User { class User {
final String id; final String id;
final String username; String username;
String? email;
String? avatar; String? avatar;
String? avatarUrl; String? avatarUrl;
bool? emailVerified;
User(this.id, this.username, {this.avatar, this.avatarUrl}) {} User(this.id, this.username, {this.avatar, this.avatarUrl});
static User loadJSON(Map<String,dynamic> input) { static User loadJSON(Map<String,dynamic> input) {
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']); User newUser = User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
newUser.email = input['email'];
newUser.emailVerified = input['emailVerified'];
return newUser;
} }
} }
@ -22,6 +27,27 @@ class AppModel extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void verifyEmail() {
if (user != null) {
user!.emailVerified = true;
notifyListeners();
}
}
void setUserEmail(String? email) {
if (user != null) {
user!.email = email;
notifyListeners();
}
}
void setUserUsername(String username) {
if (user != null) {
user!.username = username;
notifyListeners();
}
}
String? apiToken; String? apiToken;
Future<void> setToken(String? newToken) async { Future<void> setToken(String? newToken) async {
apiToken = newToken; apiToken = newToken;
@ -32,34 +58,9 @@ class AppModel extends ChangeNotifier {
var data = await api.request('GET', '/users/me'); var data = await api.request('GET', '/users/me');
if (data['success'] == true) { if (data['success'] == true) {
setUser(User.loadJSON(data['payload'])); setUser(User.loadJSON(data['payload']));
print(data);
} }
} else { } else {
prefs.remove('apiToken'); 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

@ -2,26 +2,19 @@ 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:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:io'; import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'model.dart'; import 'model.dart';
import 'patterns/pattern.dart';
import 'patterns/viewer.dart'; import 'patterns/viewer.dart';
class _ObjectScreenState extends State<ObjectScreen> { class _ObjectScreenState extends State<ObjectScreen> {
final String username;
final String projectPath;
final String id;
Map<String,dynamic>? object; Map<String,dynamic>? object;
Map<String,dynamic>? pattern; Map<String,dynamic>? pattern;
bool _isLoading = false; bool _isLoading = false;
final Api api = Api(); final Api api = Api();
_ObjectScreenState(this.username, this.projectPath, this.id) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
@ -29,7 +22,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
void fetchObject() async { void fetchObject() async {
var data = await api.request('GET', '/objects/' + id); var data = await api.request('GET', '/objects/${widget.id}');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
object = data['payload']; object = data['payload'];
@ -42,24 +35,26 @@ class _ObjectScreenState extends State<ObjectScreen> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
File? file; File? file;
if (object!['type'] == 'pattern') { if (object!['type'] == 'pattern') {
var data = await api.request('GET', '/objects/' + id + '/wif'); var data = await api.request('GET', '/objects/${widget.id}/wif');
if (data['success'] == true) { if (data['success'] == true) {
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']); file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
} }
} else { } else {
String fileName = Uri.file(object!['url']).pathSegments.last; String fileName = Uri.file(object!['url']).pathSegments.last;
fileName = fileName.split('?').first;
file = await api.downloadFile(object!['url'], fileName); file = await api.downloadFile(object!['url'], fileName);
} }
if (file != null) { if (file != null) {
Util.shareFile(file!, withDelete: true); Util.shareFile(file, withDelete: true);
} }
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
void _deleteObject(BuildContext context, BuildContext modalContext) async { void _deleteObject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/objects/' + id); var data = await api.request('DELETE', '/objects/${widget.id}');
if (data['success']) { if (data['success']) {
if (!context.mounted) return;
context.go('/home'); context.go('/home');
} }
} }
@ -68,8 +63,8 @@ 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 item?'), title: Text('Really delete this item?'),
content: new Text('This action cannot be undone.'), content: Text('This action cannot be undone.'),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
@ -108,7 +103,8 @@ class _ObjectScreenState extends State<ObjectScreen> {
TextButton( TextButton(
child: Text('OK'), child: Text('OK'),
onPressed: () async { onPressed: () async {
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text}); var data = await api.request('PUT', '/objects/${widget.id}', {'name': renameController.text});
if (!context.mounted) return;
if (data['success']) { if (data['success']) {
context.pop(); context.pop();
object!['name'] = data['payload']['name']; object!['name'] = data['payload']['name'];
@ -142,8 +138,8 @@ class _ObjectScreenState extends State<ObjectScreen> {
), ),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () => _confirmDeleteObject(modalContext), onPressed: () => _confirmDeleteObject(modalContext),
child: Text('Delete item'),
isDestructiveAction: true, isDestructiveAction: true,
child: Text('Delete item'),
), ),
] ]
); );
@ -159,7 +155,6 @@ class _ObjectScreenState extends State<ObjectScreen> {
)); ));
} }
else if (object!['isImage'] == true && object!['url'] != null) { else if (object!['isImage'] == true && object!['url'] != null) {
print(object!['url']);
return Image.network(object!['url']); return Image.network(object!['url']);
} }
else if (object!['type'] == 'pattern') { else if (object!['type'] == 'pattern') {
@ -167,7 +162,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
return PatternViewer(pattern!, withEditor: true); return PatternViewer(pattern!, withEditor: true);
} }
else if (object!['previewUrl'] != null) { else if (object!['previewUrl'] != null) {
return Image.network(object!['previewUrl']!);; return Image.network(object!['previewUrl']!);
} }
else { else {
return Column( return Column(
@ -187,7 +182,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
Text('Treadl cannot display this type of item.'), Text('Treadl cannot display this type of item.'),
SizedBox(height: 20), SizedBox(height: 20),
ElevatedButton(child: Text('View file'), onPressed: () { ElevatedButton(child: Text('View file'), onPressed: () {
launch(object!['url']); launchUrl(object!['url']);
}), }),
], ],
)); ));
@ -198,9 +193,6 @@ class _ObjectScreenState extends State<ObjectScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context); AppModel model = Provider.of<AppModel>(context);
User? user = model.user; User? user = model.user;
String description = '';
if (object?['description'] != null)
description = object!['description']!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(object?['name'] ?? 'Object'), title: Text(object?['name'] ?? 'Object'),
@ -236,8 +228,8 @@ class ObjectScreen extends StatefulWidget {
final String username; final String username;
final String projectPath; final String projectPath;
final String id; final String id;
ObjectScreen(this.username, this.projectPath, this.id, ) { } const ObjectScreen(this.username, this.projectPath, this.id, {super.key});
@override @override
_ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id); State<ObjectScreen> createState() => _ObjectScreenState();
} }

View File

@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.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 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
@ -22,8 +20,8 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
void _requestPushPermissions() async { void _requestPushPermissions() async {
try { try {
setState(() => _loading = true); setState(() => _loading = true);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; FirebaseMessaging firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission( await firebaseMessaging.requestPermission(
alert: true, alert: true,
announcement: false, announcement: false,
badge: true, badge: true,
@ -32,12 +30,11 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
provisional: false, provisional: false,
sound: true, sound: true,
); );
_pushToken = await _firebaseMessaging.getToken(); _pushToken = await firebaseMessaging.getToken();
if (_pushToken != null) { if (_pushToken != null) {
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!}); api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
} }
} } catch (_) { }
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);
} }
@ -63,8 +60,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
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 or 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: 20), SizedBox(height: 20),
CupertinoButton( ElevatedButton(
color: Colors.white,
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)), 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),
) )
@ -85,14 +81,13 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
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: 20), SizedBox(height: 20),
CupertinoButton( ElevatedButton(
color: Colors.white, onPressed: _requestPushPermissions,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_loading ? CircularProgressIndicator() : SizedBox(width: 0), _loading ? CircularProgressIndicator() : SizedBox(width: 0),
_loading ? SizedBox(width: 10) : SizedBox(width: 0), _loading ? SizedBox(width: 10) : SizedBox(width: 0),
Text('Continue', style: TextStyle(color: Colors.pink)), Text('Continue', style: TextStyle(color: Colors.pink)),
]), ]),
onPressed: _requestPushPermissions,
) )
] ]
) )
@ -109,8 +104,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
SizedBox(height: 10), SizedBox(height: 10),
Text('You\'re ready to get started. We hope you enjoy using Treadl.', 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: 20), SizedBox(height: 20),
CupertinoButton( ElevatedButton(
color: Colors.white,
child: Text('Get started', style: TextStyle(color: Colors.pink)), child: Text('Get started', style: TextStyle(color: Colors.pink)),
onPressed: () => context.go('/home'), onPressed: () => context.go('/home'),
), ),
@ -124,6 +118,8 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
} }
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override @override
_OnboardingScreenState createState() => _OnboardingScreenState(); State<OnboardingScreen> createState() => _OnboardingScreenState();
} }

View File

@ -4,10 +4,10 @@ import '../util.dart';
class DrawdownPainter extends CustomPainter { class DrawdownPainter extends CustomPainter {
final Map<String,dynamic> pattern; final Map<String,dynamic> pattern;
final double BASE_SIZE; final double baseSize;
@override @override
DrawdownPainter(this.BASE_SIZE, this.pattern) {} DrawdownPainter(this.baseSize, this.pattern);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@ -20,10 +20,10 @@ class DrawdownPainter extends CustomPainter {
..strokeWidth = 1; ..strokeWidth = 1;
// Draw grid // Draw grid
for (double i = 0; i <= size.width; i += BASE_SIZE) { for (double i = 0; i <= size.width; i += baseSize) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint); canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
} }
for (double y = 0; y <= size.height; y += BASE_SIZE) { for (double y = 0; y <= size.height; y += baseSize) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint); canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
} }
@ -38,13 +38,13 @@ class DrawdownPainter extends CustomPainter {
// Only capture valid tie-ups (e.g. in case there is data for more shafts, which are then reduced) // 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 // Dart throws error if index < 0 so check fiest
List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : []; List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : [];
List<dynamic> filteredTieup = tieup.where((t) => t< warp['shafts']).toList(); List<dynamic> filteredTieup = tieup.where((t) => t <= warp['shafts']).toList();
String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft'; String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft';
Rect rect = Offset( Rect rect = Offset(
size.width - BASE_SIZE * (thread + 1), size.width - baseSize * (thread + 1),
tread * BASE_SIZE tread * baseSize
) & Size(BASE_SIZE, BASE_SIZE); ) & Size(baseSize, baseSize);
canvas.drawRect( canvas.drawRect(
rect, rect,
Paint() Paint()

View File

@ -7,40 +7,40 @@ import 'drawdown.dart';
class Pattern extends StatelessWidget { class Pattern extends StatelessWidget {
final Map<String,dynamic> pattern; final Map<String,dynamic> pattern;
final Function? onUpdate; final Function? onUpdate;
final double BASE_SIZE = 5; final double baseSize = 5;
@override @override
Pattern(this.pattern, {this.onUpdate}) {} const Pattern(this.pattern, {super.key, this.onUpdate});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var warp = pattern['warp']; var warp = pattern['warp'];
var weft = pattern['weft']; var weft = pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE; double draftWidth = warp['threading']?.length * baseSize + weft['treadles'] * baseSize + baseSize;
double draftHeight = warp['shafts'] * BASE_SIZE + weft['treadling']?.length * BASE_SIZE + BASE_SIZE; double draftHeight = warp['shafts'] * baseSize + weft['treadling']?.length * baseSize + baseSize;
double tieupTop = BASE_SIZE; double tieupTop = baseSize;
double tieupRight = BASE_SIZE; double tieupRight = baseSize;
double tieupWidth = weft['treadles'] * BASE_SIZE; double tieupWidth = weft['treadles'] * baseSize;
double tieupHeight = warp['shafts'] * BASE_SIZE; double tieupHeight = warp['shafts'] * baseSize;
double warpTop = 0; double warpTop = 0;
double warpRight = weft['treadles'] * BASE_SIZE + BASE_SIZE * 2; double warpRight = weft['treadles'] * baseSize + baseSize * 2;
double warpWidth = warp['threading']?.length * BASE_SIZE; double warpWidth = warp['threading']?.length * baseSize;
double warpHeight = warp['shafts'] * BASE_SIZE + BASE_SIZE; double warpHeight = warp['shafts'] * baseSize + baseSize;
double weftRight = 0; double weftRight = 0;
double weftTop = warp['shafts'] * BASE_SIZE + BASE_SIZE * 2; double weftTop = warp['shafts'] * baseSize + baseSize * 2;
double weftWidth = weft['treadles'] * BASE_SIZE + BASE_SIZE; double weftWidth = weft['treadles'] * baseSize + baseSize;
double weftHeight = weft['treadling'].length * BASE_SIZE; double weftHeight = weft['treadling'].length * baseSize;
double drawdownTop = warpHeight + BASE_SIZE; double drawdownTop = warpHeight + baseSize;
double drawdownRight = weftWidth + BASE_SIZE; double drawdownRight = weftWidth + baseSize;
double drawdownWidth = warpWidth; double drawdownWidth = warpWidth;
double drawdownHeight = weftHeight; double drawdownHeight = weftHeight;
return Container( return SizedBox(
width: draftWidth, width: draftWidth,
height: draftHeight, height: draftHeight,
child: Stack( child: Stack(
@ -53,14 +53,13 @@ class Pattern extends StatelessWidget {
var tieups = pattern['tieups']; var tieups = pattern['tieups'];
double dx = details.localPosition.dx; double dx = details.localPosition.dx;
double dy = details.localPosition.dy; double dy = details.localPosition.dy;
int tie = (dx / BASE_SIZE).toInt(); int tie = (dx / baseSize).toInt();
int shaft = ((tieupHeight - dy) / BASE_SIZE).toInt() + 1; int shaft = ((tieupHeight - dy) / baseSize).toInt() + 1;
if (tieups[tie].contains(shaft)) { if (tieups[tie].contains(shaft)) {
tieups[tie].remove(shaft); tieups[tie].remove(shaft);
} else { } else {
tieups[tie].add(shaft); tieups[tie].add(shaft);
} }
print(tieups);
if (onUpdate != null) { if (onUpdate != null) {
onUpdate!({'tieups': tieups}); onUpdate!({'tieups': tieups});
} }
@ -68,7 +67,7 @@ class Pattern extends StatelessWidget {
}, },
child: CustomPaint( child: CustomPaint(
size: Size(tieupWidth, tieupHeight), size: Size(tieupWidth, tieupHeight),
painter: TieupPainter(BASE_SIZE, this.pattern), painter: TieupPainter(baseSize, pattern),
)), )),
), ),
Positioned( Positioned(
@ -76,7 +75,7 @@ class Pattern extends StatelessWidget {
top: warpTop, top: warpTop,
child: CustomPaint( child: CustomPaint(
size: Size(warpWidth, warpHeight), size: Size(warpWidth, warpHeight),
painter: WarpPainter(BASE_SIZE, this.pattern), painter: WarpPainter(baseSize, pattern),
), ),
), ),
Positioned( Positioned(
@ -84,7 +83,7 @@ class Pattern extends StatelessWidget {
top: weftTop, top: weftTop,
child: CustomPaint( child: CustomPaint(
size: Size(weftWidth, weftHeight), size: Size(weftWidth, weftHeight),
painter: WeftPainter(BASE_SIZE, this.pattern), painter: WeftPainter(baseSize, pattern),
), ),
), ),
Positioned( Positioned(
@ -92,7 +91,7 @@ class Pattern extends StatelessWidget {
top: drawdownTop, top: drawdownTop,
child: CustomPaint( child: CustomPaint(
size: Size(drawdownWidth, drawdownHeight), size: Size(drawdownWidth, drawdownHeight),
painter: DrawdownPainter(BASE_SIZE, this.pattern), painter: DrawdownPainter(baseSize, pattern),
), ),
) )
] ]

View File

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

View File

@ -4,33 +4,29 @@ import 'pattern.dart';
class PatternViewer extends StatefulWidget { class PatternViewer extends StatefulWidget {
final Map<String,dynamic> pattern; final Map<String,dynamic> pattern;
final bool withEditor; final bool withEditor;
PatternViewer(this.pattern, {this.withEditor = false}) {} const PatternViewer(this.pattern, {super.key, this.withEditor = false});
@override @override
State<PatternViewer> createState() => _PatternViewerState(this.pattern, this.withEditor); State<PatternViewer> createState() => _PatternViewerState();
} }
class _PatternViewerState extends State<PatternViewer> { class _PatternViewerState extends State<PatternViewer> {
Map<String,dynamic> pattern;
final bool withEditor;
bool controllerInitialised = false; bool controllerInitialised = false;
final controller = TransformationController(); final controller = TransformationController();
final double BASE_SIZE = 5; final double baseSize = 5;
_PatternViewerState(this.pattern, this.withEditor) {}
void updatePattern(update) { void updatePattern(update) {
setState(() { setState(() {
pattern!.addAll(update); widget.pattern.addAll(update);
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!controllerInitialised) { if (!controllerInitialised) {
var warp = pattern['warp']; var warp = widget.pattern['warp'];
var weft = pattern['weft']; var weft = widget.pattern['weft'];
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE; double draftWidth = warp['threading']?.length * baseSize + weft['treadles'] * baseSize + baseSize;
final zoomFactor = 1.0; final zoomFactor = 1.0;
final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0; final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0;
final yTranslate = 0.0; final yTranslate = 0.0;
@ -47,22 +43,7 @@ class _PatternViewerState extends State<PatternViewer> {
maxScale: 5, maxScale: 5,
constrained: false, constrained: false,
transformationController: controller, transformationController: controller,
child: RepaintBoundary(child: Pattern(pattern)) child: RepaintBoundary(child: Pattern(widget.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

@ -3,10 +3,10 @@ import '../util.dart';
class WarpPainter extends CustomPainter { class WarpPainter extends CustomPainter {
final Map<String,dynamic> pattern; final Map<String,dynamic> pattern;
final double BASE_SIZE; final double baseSize;
@override @override
WarpPainter(this.BASE_SIZE, this.pattern) {} WarpPainter(this.baseSize, this.pattern);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@ -15,17 +15,12 @@ class WarpPainter extends CustomPainter {
var paint = Paint() var paint = Paint()
..color = Colors.black ..color = Colors.black
..strokeWidth = 0.5; ..strokeWidth = 0.5;
var thickPaint = Paint()
..color = Colors.black
..strokeWidth = 1.5;
// Draw grid // Draw grid
int columnsPainted = 0; for (double i = size.width; i >= 0; i -= baseSize) {
for (double i = size.width; i >= 0; i -= BASE_SIZE) {
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint); canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
columnsPainted += 1;
} }
for (double y = 0; y <= size.height; y += BASE_SIZE) { for (double y = 0; y <= size.height; y += baseSize) {
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint); canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
} }
@ -34,12 +29,12 @@ class WarpPainter extends CustomPainter {
var thread = warp['threading'][i]; var thread = warp['threading'][i];
int? shaft = thread?['shaft']; int? shaft = thread?['shaft'];
String? colour = warp['defaultColour']; String? colour = warp['defaultColour'];
double x = size.width - (i+1)*BASE_SIZE; double x = size.width - (i+1)*baseSize;
if (shaft != null) { if (shaft != null) {
if (shaft! > 0) { if (shaft > 0) {
canvas.drawRect( canvas.drawRect(
Offset(x, size.height - shaft!*BASE_SIZE) & Offset(x, size.height - shaft*baseSize) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()), Size(baseSize.toDouble(), baseSize.toDouble()),
paint paint
); );
} }
@ -51,9 +46,9 @@ class WarpPainter extends CustomPainter {
if (colour != null) { if (colour != null) {
canvas.drawRect( canvas.drawRect(
Offset(x, 0) & Offset(x, 0) &
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()), Size(baseSize.toDouble(), baseSize.toDouble()),
Paint() Paint()
..color = Util.rgb(colour!) ..color = Util.rgb(colour)
); );
} }
} }

View File

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

View File

@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'dart:io'; import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
@ -13,11 +14,7 @@ import 'model.dart';
import 'lib.dart'; import 'lib.dart';
class _ProjectScreenState extends State<ProjectScreen> { class _ProjectScreenState extends State<ProjectScreen> {
final String username; late final String fullPath;
final String projectPath;
final String fullPath;
final Function? onUpdate;
final Function? onDelete;
final picker = ImagePicker(); final picker = ImagePicker();
final Api api = Api(); final Api api = Api();
Map<String,dynamic>? project; Map<String,dynamic>? project;
@ -25,19 +22,17 @@ class _ProjectScreenState extends State<ProjectScreen> {
bool _loading = false; bool _loading = false;
Map<String,dynamic>? _creatingObject; Map<String,dynamic>? _creatingObject;
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
fullPath = username + '/' + projectPath;
@override @override
initState() { initState() {
super.initState(); super.initState();
fullPath = '${widget.username}/${widget.projectPath}';
getProject(fullPath); getProject(fullPath);
getObjects(fullPath); getObjects(fullPath);
} }
void getProject(String fullName) async { void getProject(String fullName) async {
setState(() => _loading = true); setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName); var data = await api.request('GET', '/projects/$fullName');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
project = data['payload']; project = data['payload'];
@ -48,7 +43,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
void getObjects(String fullName) async { void getObjects(String fullName) async {
setState(() => _loading = true); setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName + '/objects'); var data = await api.request('GET', '/projects/$fullName/objects');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_objects = data['payload']['objects']; _objects = data['payload']['objects'];
@ -62,31 +57,11 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void _onDeleteProject() { void _onDeleteProject() {
context.pop(); context.go('/');
onDelete!(project!['_id']);
} }
void _onUpdateProject(project) { void _onUpdateProject(p) {
setState(() { setState(() {
project = project; project = p;
});
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;
}); });
} }
@ -145,7 +120,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
PlatformFile file = result.files.single; PlatformFile file = result.files.single;
XFile xFile = XFile(file.path!); XFile xFile = XFile(file.path!);
String? ext = file.extension; String? ext = file.extension;
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) { if (ext != null && ext.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
final String contents = await xFile.readAsString(); final String contents = await xFile.readAsString();
_createObjectFromWif(file.name, contents); _createObjectFromWif(file.name, contents);
} else { } else {
@ -155,16 +130,16 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void _chooseImage() async { void _chooseImage() async {
File file;
try { try {
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery); final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
if (imageFile == null) return; if (imageFile == null) return;
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss'); final f = DateFormat('yyyy-MM-dd_hh-mm-ss');
String time = f.format(new DateTime.now()); String time = f.format(DateTime.now());
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last; String name = '${project!["name"]} $time.${imageFile.name.split(".").last}';
_createObjectFromFile(name, imageFile); _createObjectFromFile(name, imageFile);
} }
on Exception { on Exception {
if (!mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
@ -183,30 +158,30 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void showSettingsModal() { void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject); Widget settingsDialog = _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog); showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
} }
Widget getNetworkImageBox(String url) { Widget getNetworkImageBox(String url) {
return new AspectRatio( return AspectRatio(
aspectRatio: 1 / 1, aspectRatio: 1 / 1,
child: new Container( child: Container(
decoration: new BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0), borderRadius: BorderRadius.circular(10.0),
image: new DecorationImage( image: DecorationImage(
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: FractionalOffset.topCenter, alignment: FractionalOffset.topCenter,
image: new NetworkImage(url), image: NetworkImage(url),
) )
), ),
), ),
); );
} }
Widget getIconBox(Icon icon) { Widget getIconBox(Icon icon) {
return new AspectRatio( return AspectRatio(
aspectRatio: 1 / 1, aspectRatio: 1 / 1,
child: new Container( child: Container(
decoration: new BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0), borderRadius: BorderRadius.circular(10.0),
), ),
@ -257,10 +232,10 @@ class _ProjectScreenState extends State<ProjectScreen> {
leader = CircularProgressIndicator(); leader = CircularProgressIndicator();
} }
return new Card( return Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.push('/' + username + '/' + projectPath + '/' + object['_id']); context.push('/${widget.username}/${widget.projectPath}/${object["_id"]}');
}, },
child: ListTile( child: ListTile(
leading: leader, leading: leader,
@ -273,17 +248,18 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
Widget getBody() { Widget getBody() {
if (_loading || project == null) if (_loading || project == null) {
return CircularProgressIndicator(); return CircularProgressIndicator();
else if ((_objects != null && _objects.length > 0) || _creatingObject != null) } else if ((_objects.isNotEmpty) || _creatingObject != null) {
return ListView.builder( return ListView.builder(
itemCount: _objects.length + (_creatingObject != null ? 1 : 0), itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return getObjectCard(index); return getObjectCard(index);
}, },
); );
else } 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.'); 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 @override
@ -293,6 +269,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(project?['name'] ?? 'Project'), title: Text(project?['name'] ?? 'Project'),
forceMaterialTransparency: true,
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.ios_share), icon: Icon(Icons.ios_share),
@ -300,12 +277,12 @@ class _ProjectScreenState extends State<ProjectScreen> {
_shareProject(); _shareProject();
}, },
), ),
onUpdate != null ? IconButton( IconButton(
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
onPressed: () { onPressed: () {
showSettingsModal(); showSettingsModal();
}, },
) : SizedBox(width: 0), ),
] ]
), ),
body: Container( body: Container(
@ -349,8 +326,8 @@ class _ProjectScreenState extends State<ProjectScreen> {
SizedBox(width: 10), SizedBox(width: 10),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
child: const Icon(Icons.insert_drive_file_outlined),
onPressed: _chooseFile, onPressed: _chooseFile,
child: const Icon(Icons.insert_drive_file_outlined),
), ),
]), ]),
], ],
@ -362,12 +339,9 @@ class _ProjectScreenState extends State<ProjectScreen> {
class ProjectScreen extends StatefulWidget { class ProjectScreen extends StatefulWidget {
final String username; final String username;
final String projectPath; final String projectPath;
final Map<String,dynamic>? project; const ProjectScreen(this.username, this.projectPath, {super.key});
final Function? onUpdate;
final Function? onDelete;
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
@override @override
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete); State<ProjectScreen> createState() => _ProjectScreenState();
} }
class _ProjectSettingsDialog extends StatelessWidget { class _ProjectSettingsDialog extends StatelessWidget {
@ -377,7 +351,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
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']; fullPath = '${project["owner"]["username"]}/${project["path"]}';
void _renameProject(BuildContext context) async { void _renameProject(BuildContext context) async {
TextEditingController renameController = TextEditingController(); TextEditingController renameController = TextEditingController();
@ -401,11 +375,22 @@ class _ProjectSettingsDialog extends StatelessWidget {
TextButton( TextButton(
child: Text('OK'), child: Text('OK'),
onPressed: () async { onPressed: () async {
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text}); var data = await api.request('PUT', '/projects/$fullPath', {'name': renameController.text});
if (data['success']) { if (data['success']) {
if (!context.mounted) return;
context.pop(); context.pop();
_onUpdateProject(data['payload']); _onUpdateProject(data['payload']);
} else {
Fluttertoast.showToast(
msg: data['message'],
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 10,
backgroundColor: Colors.red[800],
textColor: Colors.white,
);
} }
if (!context.mounted) return;
context.pop(); context.pop();
}, },
), ),
@ -416,18 +401,17 @@ class _ProjectSettingsDialog extends StatelessWidget {
} }
void _toggleVisibility(BuildContext context, bool checked) async { void _toggleVisibility(BuildContext context, bool checked) async {
var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'}); var data = await api.request('PUT', '/projects/$fullPath', {'visibility': checked ? 'private': 'public'});
if (data['success']) { if (data['success']) {
if (!context.mounted) return;
context.pop(); 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/' + fullPath); var data = await api.request('DELETE', '/projects/$fullPath');
if (data['success']) { if (data['success']) {
context.pop();
context.pop();
_onDelete(); _onDelete();
} }
} }
@ -436,8 +420,8 @@ class _ProjectSettingsDialog extends StatelessWidget {
showDialog( showDialog(
context: modalContext, context: modalContext,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text('Really delete this project?'), title: Text('Really delete this project?'),
content: new Text('This will remove any files and objects inside the project. This action cannot be undone.'), content: Text('This will remove any files and objects inside the project. This action cannot be undone.'),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
@ -469,11 +453,11 @@ 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),
Text('Private project', style: Theme.of(context).textTheme.bodyText1), Text('Private project', style: Theme.of(context).textTheme.bodyMedium),
] ]
) )
), ),
@ -483,8 +467,8 @@ class _ProjectSettingsDialog extends StatelessWidget {
), ),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () { _confirmDeleteProject(context); }, onPressed: () { _confirmDeleteProject(context); },
child: Text('Delete project'),
isDestructiveAction: true, isDestructiveAction: true,
child: Text('Delete project'),
), ),
] ]
); );

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
@ -39,59 +38,42 @@ class _ProjectsTabState extends State<ProjectsTab> {
}); });
} }
void _onCreateProject(newProject) { void _onCreateProject(newProject) {
List<dynamic> _newProjects = _projects; List<dynamic> newProjects = _projects;
_newProjects.insert(0, newProject); newProjects.insert(0, newProject);
setState(() { setState(() {
_projects = _newProjects; _projects = newProjects;
_creatingProject = false; _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 { void showNewProjectDialog() async {
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject); Widget simpleDialog = _NewProjectDialog(_onCreatingProject, _onCreateProject);
showDialog(context: context, builder: (BuildContext context) => simpleDialog); showDialog(context: context, builder: (BuildContext context) => simpleDialog);
} }
Widget buildProjectCard(Map<String,dynamic> project) { Widget buildProjectCard(Map<String,dynamic> project) {
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : ''; String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
if (description != null && description.length > 80) { if (description.length > 80) {
description = description.substring(0, 77) + '...'; description = '${description.substring(0, 77)}...';
} }
if (project['visibility'] == 'public') { if (project['visibility'] == 'public') {
description = "PUBLIC PROJECT\n" + description; description = "PUBLIC PROJECT\n$description";
} }
else description = "PRIVATE PROJECT\n" + description; else {
return new Card( description = "PRIVATE PROJECT\n$description";
}
return Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.push('/' + project['owner']['username'] + '/' + project['path']); context.push('/${project["owner"]["username"]}/${project["path"]}');
}, },
child: Container( child: Container(
padding: EdgeInsets.all(5), padding: EdgeInsets.all(5),
child: ListTile( child: ListTile(
leading: new AspectRatio( leading: AspectRatio(
aspectRatio: 1 / 1, aspectRatio: 1 / 1,
child: new Container( child: Container(
decoration: new BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: Colors.grey[100],
borderRadius: BorderRadius.circular(10.0), borderRadius: BorderRadius.circular(10.0),
), ),
@ -99,7 +81,7 @@ class _ProjectsTabState extends State<ProjectsTab> {
), ),
), ),
trailing: Icon(Icons.keyboard_arrow_right), trailing: Icon(Icons.keyboard_arrow_right),
title: Text(project['name'] != null ? project['name'] : 'Untitled project'), title: Text(project['name'] ?? 'Untitled project'),
subtitle: Text(description), subtitle: Text(description),
), ),
)) ))
@ -109,26 +91,29 @@ class _ProjectsTabState extends State<ProjectsTab> {
Widget getBody() { Widget getBody() {
AppModel model = Provider.of<AppModel>(context); AppModel model = Provider.of<AppModel>(context);
if (model.user == null) if (model.user == null) {
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.'); return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
if (_loading) }
if (_loading) {
return CircularProgressIndicator(); return CircularProgressIndicator();
else if (_projects != null && _projects.length > 0) } else if (_projects.isNotEmpty) {
return ListView.builder( return ListView.builder(
itemCount: _projects.length, itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]); return buildProjectCard(_projects[index]);
}, },
); );
else return Column( } else {
crossAxisAlignment: CrossAxisAlignment.center, return Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ mainAxisAlignment: MainAxisAlignment.center,
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center), children: [
Image(image: AssetImage('assets/reading.png'), width: 300), Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', 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 @override
@ -139,6 +124,13 @@ class _ProjectsTabState extends State<ProjectsTab> {
appBar: AppBar( appBar: AppBar(
title: Text('My Projects'), title: Text('My Projects'),
actions: <Widget>[ actions: <Widget>[
if (user != null && user.emailVerified != true) IconButton(
onPressed: () {
context.push('/email/verify');
},
icon: Icon(Icons.warning),
color: Colors.red,
),
IconButton( IconButton(
icon: Icon(Icons.info_outline), icon: Icon(Icons.info_outline),
onPressed: () { onPressed: () {
@ -155,27 +147,23 @@ class _ProjectsTabState extends State<ProjectsTab> {
floatingActionButton: user != null ? FloatingActionButton( floatingActionButton: user != null ? FloatingActionButton(
onPressed: showNewProjectDialog, onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add), child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500],
) : null, ) : null,
); );
} }
} }
class ProjectsTab extends StatefulWidget { class ProjectsTab extends StatefulWidget {
const ProjectsTab({super.key});
@override @override
_ProjectsTabState createState() => _ProjectsTabState(); State<ProjectsTab> createState() => _ProjectsTabState();
} }
class _NewProjectDialogState extends State<_NewProjectDialog> { class _NewProjectDialogState extends State<_NewProjectDialog> {
final TextEditingController _newProjectNameController = TextEditingController(); final TextEditingController _newProjectNameController = TextEditingController();
final Function _onStart;
final Function _onComplete;
String _newProjectName = '';
bool _newProjectPrivate = false; bool _newProjectPrivate = false;
final Api api = Api(); final Api api = Api();
_NewProjectDialogState(this._onStart, this._onComplete) {}
void _onToggleProjectVisibility(checked) { void _onToggleProjectVisibility(checked) {
setState(() { setState(() {
_newProjectPrivate = checked; _newProjectPrivate = checked;
@ -183,13 +171,15 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
} }
void _createProject() async { void _createProject() async {
_onStart(); widget._onStart();
String name = _newProjectNameController.text; String name = _newProjectNameController.text;
bool priv = _newProjectPrivate; bool priv = _newProjectPrivate;
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']); widget._onComplete(data['payload']);
context.pop(); if (mounted) {
context.pop();
}
} }
} }
@ -217,13 +207,12 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
title: Text('Make this project private') title: Text('Make this project private')
), ),
SizedBox(height: 20), SizedBox(height: 20),
CupertinoButton( ElevatedButton(
color: Colors.pink,
onPressed: _createProject, onPressed: _createProject,
child: Text('Create'), child: Text('Create'),
), ),
SizedBox(height: 10), SizedBox(height: 10),
CupertinoButton( TextButton(
onPressed: () { onPressed: () {
context.pop(); context.pop();
}, },
@ -238,7 +227,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
class _NewProjectDialog extends StatefulWidget { class _NewProjectDialog extends StatefulWidget {
final Function _onComplete; final Function _onComplete;
final Function _onStart; final Function _onStart;
_NewProjectDialog(this._onStart, this._onComplete) {} const _NewProjectDialog(this._onStart, this._onComplete);
@override @override
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete); State<_NewProjectDialog> createState() => _NewProjectDialogState();
} }

View File

@ -14,21 +14,24 @@ class _RegisterScreenState extends State<RegisterScreen> {
final Api api = Api(); final Api api = Api();
bool _registering = false; bool _registering = false;
void _submit(context) async { void _submit(BuildContext context) async {
setState(() => _registering = true); setState(() => _registering = true);
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) {
if (!context.mounted) return;
AppModel model = Provider.of<AppModel>(context, listen: false); AppModel model = Provider.of<AppModel>(context, listen: false);
model.setToken(data['payload']['token']); await model.setToken(data['payload']['token']);
if (!context.mounted) return;
context.go('/onboarding'); context.go('/onboarding');
} }
else { else {
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text("There was a problem registering your account"), title: Text("There was a problem registering your account"),
content: new Text(data['message']), content: Text(data['message']),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
@ -82,11 +85,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan( text: TextSpan(
text: 'By registering you agree to Treadl\'s ', text: 'By registering you agree to Treadl\'s ',
style: Theme.of(context).textTheme.bodyText1, style: Theme.of(context).textTheme.bodyMedium,
children: <TextSpan>[ 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: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri.parse('https://treadl.com/terms-of-use'))),
TextSpan(text: ' and '), 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: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri.parse('https://treadl.com/privacy'))),
TextSpan(text: '.'), TextSpan(text: '.'),
], ],
), ),
@ -94,10 +97,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
SizedBox(height: 20), SizedBox(height: 20),
ElevatedButton( ElevatedButton(
onPressed: () => _submit(context), onPressed: () => _submit(context),
//color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register", child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
) )
), ),
] ]
@ -108,6 +109,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
} }
class RegisterScreen extends StatefulWidget { class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override @override
_RegisterScreenState createState() => _RegisterScreenState(); State<RegisterScreen> createState() => _RegisterScreenState();
} }

View File

@ -9,6 +9,8 @@ import 'model.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
SettingsScreen({super.key});
void _logout(BuildContext context) async { void _logout(BuildContext context) async {
AppModel model = Provider.of<AppModel>(context, listen: false); AppModel model = Provider.of<AppModel>(context, listen: false);
Api api = Api(); Api api = Api();
@ -44,16 +46,18 @@ class SettingsScreen extends StatelessWidget {
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) {
if (!context.mounted) return;
AppModel model = Provider.of<AppModel>(context, listen: false); AppModel model = Provider.of<AppModel>(context, listen: false);
model.setToken(null); model.setToken(null);
model.setUser(null); model.setUser(null);
context.go('/home'); context.go('/home');
} else { } else {
if (!context.mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) => CupertinoAlertDialog( builder: (BuildContext context) => CupertinoAlertDialog(
title: new Text('There was a problem with deleting your account'), title: Text('There was a problem with deleting your account'),
content: new Text(data['message']), content: Text(data['message']),
actions: <Widget>[ actions: <Widget>[
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
@ -86,16 +90,14 @@ class SettingsScreen extends StatelessWidget {
title: Text('About Treadl'), title: Text('About Treadl'),
), ),
body:ListView( body:ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(10),
children: <Widget>[ children: <Widget>[
Container( Container(
margin: const EdgeInsets.only(top: 10.0, bottom: 10.0), margin: const EdgeInsets.only(top: 10.0, bottom: 10.0),
child: child:
Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleLarge), Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleLarge),
), ),
Container( Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyMedium),
child: Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyText1)
),
SizedBox(height: 30), SizedBox(height: 30),
@ -106,37 +108,56 @@ class SettingsScreen extends StatelessWidget {
title: Text('Logout'), title: Text('Logout'),
onTap: () => _logout(context), onTap: () => _logout(context),
), ),
ListTile(
leading: Icon(Icons.mode_edit),
title: Text('Edit Account'),
onTap: () => context.push('/account'),
),
ListTile( ListTile(
leading: Icon(Icons.delete), leading: Icon(Icons.delete),
title: Text('Delete Account'), title: Text('Delete Account'),
onTap: () => _deleteAccount(context), onTap: () => _deleteAccount(context),
), ),
] ]
) : CupertinoButton( ) : ElevatedButton(
color: Colors.pink, child: Text('Join Treadl'),
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
onPressed: () => context.push('/welcome'), onPressed: () => context.push('/welcome'),
), ),
SizedBox(height: 30), SizedBox(height: 30),
ListTile(
leading: Icon(Icons.email),
title: Text('Email Us'),
onTap: () {
Uri emailLaunchUri = Uri(
scheme: 'mailto',
path: 'hello@treadl.com',
queryParameters: {
'subject': 'Mobile App Contact',
'body': ''
},
);
launchUrl(Uri.parse(emailLaunchUri.toString()));
}
),
ListTile( ListTile(
leading: Icon(Icons.link), leading: Icon(Icons.link),
trailing: Icon(Icons.explore), trailing: Icon(Icons.explore),
title: Text('Visit Our Website'), title: Text('Visit Our Website'),
onTap: () => launch('https://treadl.com'), onTap: () => launchUrl(Uri.parse('https://treadl.com')),
), ),
ListTile( ListTile(
leading: Icon(Icons.insert_drive_file), leading: Icon(Icons.insert_drive_file),
trailing: Icon(Icons.explore), trailing: Icon(Icons.explore),
title: Text('Terms of Use'), title: Text('Terms of Use'),
onTap: () => launch('https://treadl.com/terms-of-use'), onTap: () => launchUrl(Uri.parse('https://treadl.com/terms-of-use')),
), ),
ListTile( ListTile(
leading: Icon(Icons.insert_drive_file), leading: Icon(Icons.insert_drive_file),
trailing: Icon(Icons.explore), trailing: Icon(Icons.explore),
title: Text('Privacy Policy'), title: Text('Privacy Policy'),
onTap: () => launch('https://treadl.com/privacy'), onTap: () => launchUrl(Uri.parse('https://treadl.com/privacy')),
), ),
] ]
), ),

View File

@ -1,6 +1,5 @@
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:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'util.dart'; import 'util.dart';
@ -8,24 +7,21 @@ import 'api.dart';
import 'lib.dart'; import 'lib.dart';
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin { class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
final String username;
final Api api = Api(); final Api api = Api();
TabController? _tabController; TabController? _tabController;
Map<String,dynamic>? _user; Map<String,dynamic>? _user;
bool _loading = false; bool _loading = false;
_UserScreenState(this.username) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
_tabController = new TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
getUser(username); getUser(widget.username);
} }
void getUser(String username) async { void getUser(String username) async {
if (username == null) return;
setState(() => _loading = true); setState(() => _loading = true);
var data = await api.request('GET', '/users/' + username); var data = await api.request('GET', '/users/$username');
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_user = data['payload']; _user = data['payload'];
@ -35,9 +31,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
} }
Widget getBody() { Widget getBody() {
if (_loading) if (_loading) {
return CircularProgressIndicator(); return CircularProgressIndicator();
else if (_user != null && _tabController != null) { } else if (_user != null && _tabController != null) {
var u = _user!; var u = _user!;
String? created; String? created;
if (u['createdAt'] != null) { if (u['createdAt'] != null) {
@ -63,7 +59,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Text(u['location']) Text(u['location'])
]) : SizedBox(height: 1), ]) : SizedBox(height: 1),
SizedBox(height: 10), SizedBox(height: 10),
Text('Member' + (created != null ? (' since ' + created!) : ''), Text('Member${created != null ? (' since $created') : ''}',
style: TextStyle(color: Colors.grey[500]) style: TextStyle(color: Colors.grey[500])
), ),
SizedBox(height: 10), SizedBox(height: 10),
@ -72,9 +68,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
onTap: () { onTap: () {
String url = u['website']; String url = u['website'];
if (!url.startsWith('http')) { if (!url.startsWith('http')) {
url = 'http://' + url; url = 'http://$url';
} }
launch(url); launchUrl(Uri.parse(url));
}, },
child: Text(u['website'], child: Text(u['website'],
style: TextStyle(color: Colors.pink)) style: TextStyle(color: Colors.pink))
@ -134,8 +130,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
) )
]); ]);
} }
else else {
return Text('User not found'); return Text('User not found');
}
} }
@override @override
@ -143,12 +140,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(username), title: Text(widget.username),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.person), icon: Icon(Icons.person),
onPressed: () { onPressed: () {
launch('https://www.treadl.com/' + username); launchUrl(Uri.parse('https://www.treadl.com/${widget.username}'));
}, },
), ),
] ]
@ -164,7 +161,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
class UserScreen extends StatefulWidget { class UserScreen extends StatefulWidget {
final String username; final String username;
UserScreen(this.username) { } const UserScreen(this.username, {super.key});
@override @override
_UserScreenState createState() => _UserScreenState(username); State<UserScreen> createState() => _UserScreenState();
} }

View File

@ -2,15 +2,14 @@ import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert';
import 'model.dart'; import 'model.dart';
String APP_URL = 'https://www.treadl.com'; String appBaseUrl = 'https://www.treadl.com';
class Util { class Util {
static ImageProvider? avatarUrl(Map<String,dynamic> user) { static ImageProvider? avatarUrl(Map<String,dynamic> user) {
if (user != null && user['avatar'] != null) { if (user['avatar'] != null) {
if (user['avatar'].length < 3) { if (user['avatar'].length < 3) {
return AssetImage('assets/avatars/${user['avatar']}.png'); return AssetImage('assets/avatars/${user['avatar']}.png');
} }
@ -23,19 +22,19 @@ class Util {
static Widget avatarImage(ImageProvider? image, {double size=30}) { static Widget avatarImage(ImageProvider? image, {double size=30}) {
if (image != null) { if (image != null) {
return new Container( return Container(
width: size, width: size,
height: size, height: size,
decoration: new BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
image: new DecorationImage( image: DecorationImage(
fit: BoxFit.fill, fit: BoxFit.fill,
image: image image: image
) )
) )
); );
} }
return new Container( return Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -54,7 +53,7 @@ class Util {
} }
static String appUrl(String path) { static String appUrl(String path) {
return APP_URL + '/' + path; return '$appBaseUrl/$path';
} }
static Future<String> storagePath() async { static Future<String> storagePath() async {

View File

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart';
import 'model.dart';
import 'lib.dart';
class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
String? token;
bool loading = false;
String? error;
String? success;
Api api = Api();
_VerifyEmailScreenState();
@override
initState() {
super.initState();
_verify(context);
token = widget.token;
}
void _verify(BuildContext context) async {
if (token == null) {
return;
}
setState(() {
loading = true;
error = null;
success = null;
});
AppModel model = Provider.of<AppModel>(context, listen: false);
var data = await api.request('PUT', '/accounts/email/verified', {'token': token});
if (data['success'] == true) {
model.verifyEmail();
setState(() {
loading = false;
success = 'Email verified successfully';
});
Fluttertoast.showToast(
msg: "Email verified successfully",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 10,
backgroundColor: Colors.green[800],
textColor: Colors.white,
fontSize: 20.0
);
if (!context.mounted) return;
context.go('/');
} else {
setState(() {
loading = false;
error = data['message'];
});
}
}
void _resend(BuildContext context) async {
setState(() {
loading = true;
error = null;
success = null;
});
var data = await api.request('POST', '/accounts/email/verificationRequests', null);
if (data['success'] == true) {
setState(() {
success = 'Verification email sent. Remember to check your spam folder.';
});
} else {
setState(() {
error = data['message'];
loading = false;
});
}
}
@override
Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold(
appBar: AppBar(
title: Text('Email Verification'),
),
body: ListView(
padding: const EdgeInsets.all(8),
children: <Widget>[
Text('Please verify your email address', style: Theme.of(context).textTheme.titleLarge),
if (loading) Column(
children: [
SizedBox(height: 20),
CircularProgressIndicator(),
]
),
SizedBox(height: 20),
Text('We have sent you an email with a verification link. Please tap the link in the email to verify your email address.', style: Theme.of(context).textTheme.bodyMedium),
SizedBox(height: 20),
if (user != null && user.email != null)
Row(
children: [
Expanded(
child: TextField(
enabled: false,
controller: TextEditingController()..text = user.email!,
decoration: InputDecoration(
labelText: 'Email address to verify',
hintText: user.email,
border: OutlineInputBorder(),
),
),
),
TextButton(
onPressed: () => context.push('/account'),
child: Text('Edit Email'),
),
]
),
SizedBox(height: 20),
if (success != null) Alert(
title: 'Success',
description: success!,
type: 'success',
),
if (error != null) Alert(
title: 'There was a problem',
description: error!,
type: 'failure',
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _resend(context),
child: Text('Resend Verification Email'),
),
SizedBox(height: 20),
TextButton(
onPressed: () => context.go('/'),
child: Text('Cancel'),
),
]
),
);
}
}
class VerifyEmailScreen extends StatefulWidget {
final String? token;
@override
const VerifyEmailScreen({super.key, required this.token});
@override
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
}

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'login.dart';
class WelcomeScreen extends StatelessWidget { class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
void _login(BuildContext context) { void _login(BuildContext context) {
context.push('/login'); context.push('/login');
} }
@ -25,27 +25,24 @@ class WelcomeScreen extends StatelessWidget {
SizedBox(height: 10), SizedBox(height: 10),
Text('Treadl is a place for weavers to connect and manage their portfolios.', style: TextStyle(color: Colors.white), textAlign: TextAlign.center), Text('Treadl is a place for weavers to connect and manage their portfolios.', style: TextStyle(color: Colors.white), textAlign: TextAlign.center),
SizedBox(height: 30), SizedBox(height: 30),
CupertinoButton( ElevatedButton(
onPressed: () => _login(context), onPressed: () => _login(context),
color: Colors.white, child: Text("Login",
child: new Text("Login",
style: TextStyle(color: Colors.pink), style: TextStyle(color: Colors.pink),
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )
), ),
SizedBox(height: 15), SizedBox(height: 15),
CupertinoButton( ElevatedButton(
onPressed: () => _register(context), onPressed: () => _register(context),
color: Colors.pink[400], child: Text("Register",
child: new Text("Register",
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )
), ),
SizedBox(height: 35), SizedBox(height: 35),
CupertinoButton( TextButton(
onPressed: () => context.pop(), onPressed: () => context.pop(),
child: new Text("Cancel", child: Text("Cancel",
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )

View File

@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import file_picker
import file_selector_macos import file_selector_macos
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
@ -14,6 +15,7 @@ import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))

File diff suppressed because it is too large Load Diff

View File

@ -15,35 +15,36 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.1+11 version: 1.3.0+12
environment: environment:
sdk: '>=2.17.0 <3.0.0' sdk: '>=2.17.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.4 cupertino_icons: ^1.0.8
http: ^0.13.4 http: ^1.3.0
shared_preferences: ^2.0.15 shared_preferences: ^2.5.2
provider: ^6.0.3 provider: ^6.1.2
url_launcher: ^6.1.2 url_launcher: ^6.3.1
flutter_html: ^3.0.0-alpha.3 flutter_html: ^3.0.0
intl: ^0.17.0 intl: ^0.20.2
image_picker: ^1.0.6 image_picker: ^1.1.2
file_picker: ^6.1.1 file_picker: ^9.2.1
flutter_launcher_icons: ^0.9.0 flutter_launcher_icons: ^0.14.3
firebase_messaging: ^14.4.0 firebase_messaging: ^15.2.4
path_provider: ^2.1.1 path_provider: ^2.1.5
share_plus: ^7.2.1 share_plus: ^10.1.4
flutter_expandable_fab: ^2.0.0 flutter_expandable_fab: ^2.4.0
go_router: ^13.0.1 go_router: ^14.8.1
fluttertoast: ^8.2.12
#fluttertoast: ^8.0.9
firebase_core: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0
flutter_icons: flutter_icons:
android: "launcher_icon" android: "launcher_icon"

21
web/.env Normal file
View File

@ -0,0 +1,21 @@
VITE_API_URL="https://api.treadl.com"
VITE_IMAGINARY_URL="https://images.treadl.com"
VITE_SENTRY_DSN="https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320"
VITE_SOURCE_REPO_URL="https://git.wilw.dev/wilw/treadl"
VITE_PATREON_URL="https://www.patreon.com/treadl"
VITE_KOFI_URL="https://ko-fi.com/wilw88"
VITE_STATUS_URL="https://status.wilw.dev/status/treadl"
VITE_STATUS_BADGE_URL="https://status.wilw.dev/api/badge/1/uptime?labelPrefix=API+"
VITE_IOS_APP_URL="https://apps.apple.com/gb/app/treadl/id1525094357"
VITE_ANDROID_APP_URL="https://play.google.com/store/apps/details/Treadl?id=com.treadl"
VITE_CONTACT_EMAIL="hello@treadl.com"
VITE_APP_NAME="Treadl"
VITE_APP_DOMAIN="treadl.com"
VITE_TERMS_OF_USE_URL="https://git.wilw.dev/wilw/treadl/wiki/Terms-of-Use"
VITE_PRIVACY_POLICY_URL="https://git.wilw.dev/wilw/treadl/wiki/Privacy-Policy"
VITE_ONLINE_SAFETY_URL="https://git.wilw.dev/wilw/treadl/wiki/Online-Safety"
VITE_ONLINE_SAFETY_POLICY_URL="https://git.wilw.dev/wilw/treadl/wiki/Online-Safety-Policy"
VITE_FOLLOWING_ENABLED="false"
VITE_GROUP_DISCOVERY_ENABLED="false"
VITE_USER_DISCOVERY_ENABLED="false"
VITE_GROUPS_ENABLED="false"

View File

@ -1,10 +1 @@
VITE_API_URL="http://localhost:2001" VITE_API_URL="http://localhost:2001"
VITE_IMAGINARY_URL=""
VITE_SENTRY_DSN=""
VITE_SOURCE_REPO_URL="https://git.wilw.dev/wilw/treadl"
VITE_PATREON_URL="https://www.patreon.com/treadl"
VITE_KOFI_URL="https://ko-fi.com/wilw88"
VITE_IOS_APP_URL="https://apps.apple.com/gb/app/treadl/id1525094357"
VITE_ANDROID_APP_URL="https://play.google.com/store/apps/details/Treadl?id=com.treadl"
VITE_CONTACT_EMAIL="hello@treadl.com"
VITE_APP_NAME="Treadl"

View File

@ -1,2 +0,0 @@
node_modules/
build/

View File

@ -1,17 +0,0 @@
{
"env": {
"browser": true
},
"extends": ["react-app", "airbnb"],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"rules": {
"no-underscore-dangle": [2, { "allow": ["_id"] }],
"max-len": 0
}
}

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