Compare commits
263 Commits
Author | SHA1 | Date | |
---|---|---|---|
bdd62cbcfd | |||
4f4a71bf72 | |||
239adc4816 | |||
15bfb43965 | |||
384cf75400 | |||
89ffa94553 | |||
e03ceed668 | |||
31d6f41276 | |||
fe24bcef1e | |||
29d0af6e5b | |||
045a0af4a2 | |||
8446c209b3 | |||
c6fdc1d537 | |||
397ec5072b | |||
82f0a1eb6d | |||
e174abce33 | |||
d72038212f | |||
957cbebdd2 | |||
fdb363abe4 | |||
859d78cf5d | |||
f0a0a55bce | |||
0019f4e019 | |||
af07226227 | |||
46965c0040 | |||
97584a8d91 | |||
bed153b5f8 | |||
a1d05684ed | |||
5f903d61b1 | |||
0d942dc864 | |||
870a53e956 | |||
ff4f48ba00 | |||
e9fb964b51 | |||
1bb38a8e09 | |||
b8f7622b9f | |||
dc9b388465 | |||
e866895a84 | |||
72d164f394 | |||
210a984a07 | |||
3927cc6d67 | |||
92513715bd | |||
b5b86d599a | |||
8a69a1b21d | |||
0f47a25529 | |||
716ca31a60 | |||
e3fd2c8f27 | |||
d56c201ec7 | |||
65e059655f | |||
dd7af64508 | |||
06bd0fb8ac | |||
8c1145e54f | |||
5692258cc1 | |||
7dac76558d | |||
229eec89ea | |||
81bed97d42 | |||
402a25d980 | |||
f021914089 | |||
a8a000ae55 | |||
980a5bb14b | |||
8afd7c5694 | |||
17806d410b | |||
9cd1ae4628 | |||
d037aa6a9f | |||
a7f87e0b76 | |||
6ddf2d4ae7 | |||
059fc0d966 | |||
f8f06f8b68 | |||
e74a7461fa | |||
22ebf35382 | |||
2398ef5cf9 | |||
c0a5f32060 | |||
849ff0a1e9 | |||
f3b3ce3d57 | |||
032e737ab9 | |||
1428c83050 | |||
6ad9105c82 | |||
c060a6fc41 | |||
48db95ff6e | |||
ddb723ab88 | |||
933e601572 | |||
fddfa5df0b | |||
39b65dd806 | |||
047d0b25d8 | |||
d08e7826b9 | |||
41493c3534 | |||
845178997d | |||
0d6febbde6 | |||
ccc6fbe13a | |||
40f7e25d8f | |||
e85ad4f4bc | |||
934086251b | |||
ba9713e3eb | |||
c25a2c5fe2 | |||
d22dbd7629 | |||
2e5d25c6f2 | |||
116224d784 | |||
9920a0a596 | |||
ec32489de5 | |||
78a197d5e4 | |||
fad3bc835b | |||
9d3ed248b3 | |||
14ca22f3e9 | |||
2fe05b2118 | |||
f8808bde3d | |||
2a55cfcc2e | |||
007e4822a6 | |||
3df577e666 | |||
a85f0de85a | |||
25ed716849 | |||
350ab15306 | |||
a22c2d7d16 | |||
0b041b04ad | |||
b1d9a41f9d | |||
33241747cd | |||
12f985b7aa | |||
9eff558ebf | |||
bad485ac1d | |||
bfd828f520 | |||
7647542421 | |||
2b37756567 | |||
8abdf00ef8 | |||
062d5f94e4 | |||
9d2574bcd6 | |||
d4ccd62a34 | |||
522c13cd75 | |||
1179d8859f | |||
6e15952ffc | |||
20b94e553d | |||
572d39e947 | |||
dcf44f6b1d | |||
4b410ec31e | |||
f63866a04c | |||
104879ee27 | |||
f94769e228 | |||
88c0b44444 | |||
3403134072 | |||
e6178c8a72 | |||
58bf8ca74e | |||
6cfcf0c5a1 | |||
65b379f162 | |||
4bf03c7c67 | |||
aeb60dd840 | |||
3b2c1e7f4c | |||
a2cde7de81 | |||
b14f438597 | |||
ac97481e6e | |||
1129a9df48 | |||
afc32578cf | |||
f44d56182b | |||
14f930af13 | |||
9ed84493cc | |||
ee11984a00 | |||
dcb9453ccd | |||
45725f52c1 | |||
5e108cf8c8 | |||
1406f9c4c8 | |||
3e79d950b1 | |||
ea792bd75d | |||
46ed954e53 | |||
2f92b3b883 | |||
e51f3af984 | |||
1e85112f6d | |||
c3598dfa5c | |||
3aa70c48e7 | |||
c368e675b4 | |||
68351f84e3 | |||
e46d1c63a7 | |||
29aba03ba8 | |||
e387c0e05a | |||
164ca73913 | |||
d70858ffb7 | |||
073335a322 | |||
450efe8b36 | |||
ba1ba5ed94 | |||
80cf4d3b4c | |||
79299ab978 | |||
46e2f76778 | |||
a00de971ae | |||
8eb4eb4bd9 | |||
78c3908bf9 | |||
c394de8286 | |||
3b691fed81 | |||
00f4e7a8b9 | |||
dd6d5ab18a | |||
aed7813d62 | |||
b0fb8af468 | |||
17d0ae31a8 | |||
822f2cef84 | |||
ca300ced7a | |||
4c61879883 | |||
aa6afec3b7 | |||
e780dff8cc | |||
a0b655d69a | |||
b3a44e17bc | |||
ee4b3ef439 | |||
cda8874547 | |||
4f53120d11 | |||
fed94fa543 | |||
b93e048509 | |||
9f09905f31 | |||
20d9d3391c | |||
961ca473c7 | |||
64297a2d28 | |||
84c965b175 | |||
5e528d2a21 | |||
7be01d0955 | |||
2812bb3a2d | |||
835777e562 | |||
a3a04fa8e9 | |||
d77dbc12ab | |||
cf9601de90 | |||
f36694c54e | |||
8473d2c480 | |||
00946f92d9 | |||
41f8dee19e | |||
4a6c96edb5 | |||
46be02067f | |||
8f498cfe1c | |||
369fd67101 | |||
a2128c7b35 | |||
0b697d22dc | |||
9fd8ea9755 | |||
4c899c2309 | |||
77fc0a502b | |||
781d3e23dd | |||
92860ea082 | |||
9f75631d58 | |||
bdb4db100e | |||
e68ebface7 | |||
27e7a11eba | |||
ee5cd5dea5 | |||
55f21bd18e | |||
be5cb2cb12 | |||
d3b9142e4f | |||
21ee690409 | |||
0a56844784 | |||
ee6584a396 | |||
ac6020cb8e | |||
958edd7556 | |||
2d680d7142 | |||
ecbfb2d9ca | |||
ec38525a43 | |||
d90b40d6f2 | |||
edd9c1e3ee | |||
f701bc46d8 | |||
9e9491e064 | |||
a6de05a0ca | |||
d4f56345c6 | |||
447f76e807 | |||
a583234743 | |||
30ebc7d22d | |||
49ccdbd8ab | |||
57de689815 | |||
55325dfe8b | |||
214b80b72c | |||
084ae20664 | |||
4b656d31e1 | |||
2f21d8fe2c | |||
196587616a | |||
22c80781d4 | |||
57032a60f0 | |||
d7a737e814 | |||
be97df6331 | |||
87e53b42a6 |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
api/.venv
|
||||
web/node_modules
|
||||
*.pyc
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.swp
|
||||
.DS_Store
|
BIN
.nova/Artwork
BIN
.nova/Artwork
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
@ -1,5 +0,0 @@
|
||||
{
|
||||
"workspace.art_style" : 1,
|
||||
"workspace.color" : 10,
|
||||
"workspace.name" : "Treadl"
|
||||
}
|
@ -1,38 +1,35 @@
|
||||
pipeline:
|
||||
steps:
|
||||
buildweb:
|
||||
group: build
|
||||
image: node
|
||||
when:
|
||||
path: "web/*"
|
||||
path: "web/**/*"
|
||||
environment:
|
||||
- VITE_API_URL=https://api.treadl.com
|
||||
- VITE_IMAGINARY_URL=https://images.treadl.com
|
||||
- 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_CONTACT_EMAIL=hello@treadl.com
|
||||
- VITE_APP_NAME=Treadl
|
||||
- VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
|
||||
commands:
|
||||
- cd web
|
||||
- yarn install
|
||||
- yarn build
|
||||
- npm install
|
||||
- npx vite build
|
||||
|
||||
buildapi:
|
||||
group: build
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
secrets: [docker_username, docker_password]
|
||||
when:
|
||||
path: "api/*"
|
||||
path: "api/**/*"
|
||||
settings:
|
||||
repo: wilw/treadl-api
|
||||
dockerfile: api/Dockerfile
|
||||
context: api
|
||||
platforms: linux/amd64
|
||||
|
||||
deployweb:
|
||||
image: alpine
|
||||
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
||||
when:
|
||||
path: "web/*"
|
||||
path: "web/**/*"
|
||||
commands:
|
||||
- cd web
|
||||
- apk update
|
||||
@ -41,4 +38,5 @@ pipeline:
|
||||
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
||||
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||
|
||||
branches: main
|
||||
when:
|
||||
branch: main
|
||||
|
104
README.md
104
README.md
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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)
|
||||
* [DigitalOcean managed MongoDB](https://www.digitalocean.com/products/managed-databases-mongodb)
|
||||
### Alternative deployment
|
||||
|
||||
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)
|
||||
* [MongoDB official Docker Image](https://hub.docker.com/_/mongo)
|
||||
In this case you'll need to:
|
||||
- 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`
|
||||
* Database: The name of the database, within your cluster/instance, where you want Treadl to store the data.
|
||||
### S3-compatible object storage
|
||||
|
||||
### 2. Provision an S3-compatible bucket
|
||||
|
||||
Treadl uses S3-compatible object storage for storing assets (e.g. uploaded files). You should create and configure a bucket for Treadl to use.
|
||||
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.
|
||||
|
||||
Hosted options:
|
||||
|
||||
* [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)
|
||||
|
||||
Self-hosted options:
|
||||
|
||||
* [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
|
||||
* 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._
|
||||
|
||||
### 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
|
||||
* Add the S3 detais into the relevant parts
|
||||
* Add Mailgun connection details (for sending outbound mail)
|
||||
* Change the app's URL and email addresses
|
||||
To begin, clone this repository to your computer:
|
||||
|
||||
Once ready, you can launch the API by passing in this envfile (assuming you built the image with a name of `treadl-api`):
|
||||
|
||||
```shell
|
||||
$ docker run --env-file envfile -d treadl-api
|
||||
```bash
|
||||
git clone https://git.wilw.dev/wilw/treadl.git
|
||||
```
|
||||
|
||||
_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
|
||||
|
||||
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
|
||||
```bash
|
||||
task init
|
||||
```
|
||||
|
||||
_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:
|
||||
|
||||
```shell
|
||||
$ yarn build
|
||||
$ s3cmd cp build/ s3://my-treadl-ui # Example
|
||||
```bash
|
||||
task
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
|
118
Taskfile.yml
Normal file
118
Taskfile.yml
Normal file
@ -0,0 +1,118 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
VENV: ".venv/bin/activate"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Run web bundler and API
|
||||
deps:
|
||||
- start-db
|
||||
- run-api
|
||||
- run-web
|
||||
|
||||
run-web:
|
||||
desc: Run web frontend
|
||||
dir: 'web'
|
||||
cmds:
|
||||
- echo "[Web] Starting React app..."
|
||||
- npx vite --port 8002
|
||||
|
||||
run-api:
|
||||
desc: Run API server
|
||||
dir: 'api'
|
||||
dotenv: ['envfile']
|
||||
cmds:
|
||||
- echo "[FLASK] Starting Flask app..."
|
||||
- bash -c "source {{.VENV}} && flask run --debug"
|
||||
|
||||
start-db:
|
||||
desc: Start database
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- echo "[DB] Starting database..."
|
||||
- docker run --rm -d --name mongo -v ~/.mongo:/data/db -p 27017:27017 mongo:6
|
||||
|
||||
init:
|
||||
desc: Initialize project
|
||||
cmds:
|
||||
- task: install-deps
|
||||
- cp api/envfile.template api/envfile
|
||||
|
||||
install-deps:
|
||||
desc: Install all dependencies
|
||||
deps:
|
||||
- install-deps-web
|
||||
- install-deps-api
|
||||
|
||||
install-deps-web:
|
||||
desc: Install web dependencies
|
||||
dir: 'web'
|
||||
cmds:
|
||||
- echo "[Web] Installing dependencies..."
|
||||
- npm install
|
||||
|
||||
install-deps-api:
|
||||
desc: Install API dependencies
|
||||
dir: 'api'
|
||||
cmds:
|
||||
- echo "[FLASK] Installing dependencies..."
|
||||
- cmd: python3.12 -m venv .venv
|
||||
ignore_error: true
|
||||
- bash -c "source {{.VENV}} && pip install poetry"
|
||||
- bash -c "source {{.VENV}} && poetry install"
|
||||
|
||||
lint:
|
||||
desc: Lint all
|
||||
deps:
|
||||
- lint-web
|
||||
- lint-api
|
||||
|
||||
lint-web:
|
||||
desc: Lint web frontend
|
||||
dir: 'web'
|
||||
cmds:
|
||||
- echo "[Web] Linting React app..."
|
||||
- npx standard --fix
|
||||
|
||||
lint-api:
|
||||
desc: Lint API server
|
||||
dir: 'api'
|
||||
cmds:
|
||||
- echo "[FLASK] Linting Flask app..."
|
||||
- bash -c "source {{.VENV}} && ruff format ."
|
||||
- bash -c "source {{.VENV}} && ruff check --fix ."
|
||||
|
||||
clean:
|
||||
desc: Remove all dependencies
|
||||
cmds:
|
||||
- rm -rf web/node_modules
|
||||
- rm -rf api/.venv
|
||||
|
||||
build-docker:
|
||||
desc: Build all-in-one Docker image
|
||||
cmds:
|
||||
- echo "Building Docker image..."
|
||||
- docker build -f docker/Dockerfile -t wilw/treadl --platform linux/amd64,linux/arm64 .
|
||||
|
||||
deploy:
|
||||
desc: Deploy all
|
||||
deps:
|
||||
- deploy-web
|
||||
- deploy-api
|
||||
|
||||
deploy-web:
|
||||
desc: Deploy web front-end
|
||||
dir: 'web'
|
||||
cmds:
|
||||
- npm install
|
||||
- npx vite build
|
||||
- aws --profile personal s3 sync dist s3://treadl.com
|
||||
- 'curl -X POST -H "AccessKey: $BUNNY_PERSONAL" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||
|
||||
deploy-api:
|
||||
desc: Deploy API
|
||||
dir: 'api'
|
||||
cmds:
|
||||
- docker build -t wilw/treadl-api --platform linux/amd64 .
|
||||
- docker push wilw/treadl-api
|
BIN
api/.DS_Store
vendored
BIN
api/.DS_Store
vendored
Binary file not shown.
2
api/.gitignore
vendored
2
api/.gitignore
vendored
@ -7,3 +7,5 @@ __pycache__/
|
||||
config-prod.yml
|
||||
envfile
|
||||
firebase.json
|
||||
.DS_Store
|
||||
migration_projects/
|
@ -1,19 +1,16 @@
|
||||
FROM python:3.9-slim-buster
|
||||
FROM amd64/python:3.12-slim
|
||||
|
||||
# set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install poetry
|
||||
|
||||
COPY poetry.lock .
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN poetry export --without-hashes -f requirements.txt | pip install -r /dev/stdin
|
||||
RUN poetry config virtualenvs.create false --local
|
||||
RUN poetry install
|
||||
|
||||
# Add remaining files
|
||||
COPY app.py .
|
||||
COPY api/ .
|
||||
COPY util/ .
|
||||
COPY . /app/
|
||||
|
||||
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
||||
|
@ -1,67 +1,3 @@
|
||||
# Treadl web 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.
|
@ -1,37 +1,72 @@
|
||||
import datetime, jwt, bcrypt, re, os
|
||||
import datetime
|
||||
import jwt
|
||||
import bcrypt
|
||||
import re
|
||||
import os
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, mail, util
|
||||
from api import uploads
|
||||
|
||||
jwt_secret = os.environ['JWT_SECRET']
|
||||
jwt_secret = os.environ["JWT_SECRET"]
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
def register(username, email, password):
|
||||
|
||||
def register(username, email, password, how_find_us):
|
||||
if not username or len(username) < 4 or not email or len(email) < 6:
|
||||
raise util.errors.BadRequest('Your username or email is too short or invalid.')
|
||||
raise util.errors.BadRequest("Your username or email is too short or invalid.")
|
||||
username = username.lower()
|
||||
email = email.lower()
|
||||
if not re.match("^[a-z0-9_]+$", username):
|
||||
raise util.errors.BadRequest('Usernames can only contain letters, numbers, and underscores')
|
||||
raise util.errors.BadRequest(
|
||||
"Usernames can only contain letters, numbers, and underscores"
|
||||
)
|
||||
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise util.errors.BadRequest('Your password should be at least {0} characters.'.format(MIN_PASSWORD_LENGTH))
|
||||
raise util.errors.BadRequest(
|
||||
"Your password should be at least {0} characters.".format(
|
||||
MIN_PASSWORD_LENGTH
|
||||
)
|
||||
)
|
||||
db = database.get_db()
|
||||
existingUser = db.users.find_one({'$or': [{'username': username}, {'email': email}]})
|
||||
existingUser = db.users.find_one(
|
||||
{"$or": [{"username": username}, {"email": email}]}
|
||||
)
|
||||
if existingUser:
|
||||
raise util.errors.BadRequest('An account with this username or email already exists.')
|
||||
raise util.errors.BadRequest(
|
||||
"An account with this username or email already exists."
|
||||
)
|
||||
|
||||
try:
|
||||
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||
result = db.users.insert_one({ 'username': username, 'email': email, 'password': hashed_password, 'createdAt': datetime.datetime.now(), 'subscriptions': {'email': ['groups.invited', 'groups.joinRequested', 'groups.joined', 'messages.replied', 'projects.commented']}})
|
||||
mail.send({
|
||||
'to': os.environ.get('ADMIN_EMAIL'),
|
||||
'subject': '{} signup'.format(os.environ.get('APP_NAME')),
|
||||
'text': 'A new user signed up with username {0} and email {1}'.format(username, email)
|
||||
})
|
||||
mail.send({
|
||||
'to': email,
|
||||
'subject': 'Welcome to {}!'.format(os.environ.get('APP_NAME')),
|
||||
'text': '''Dear {0},
|
||||
result = db.users.insert_one(
|
||||
{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": hashed_password,
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"subscriptions": {
|
||||
"email": [
|
||||
"groups.invited",
|
||||
"groups.joinRequested",
|
||||
"groups.joined",
|
||||
"messages.replied",
|
||||
"projects.commented",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
mail.send(
|
||||
{
|
||||
"to": os.environ.get("ADMIN_EMAIL"),
|
||||
"subject": "{} signup".format(os.environ.get("APP_NAME")),
|
||||
"text": "A new user signed up with username {0} and email {1}, discovered from {2}".format(
|
||||
username, email, how_find_us
|
||||
),
|
||||
}
|
||||
)
|
||||
mail.send(
|
||||
{
|
||||
"to": email,
|
||||
"subject": "Welcome to {}!".format(os.environ.get("APP_NAME")),
|
||||
"text": """Dear {0},
|
||||
|
||||
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
||||
|
||||
@ -61,155 +96,226 @@ We hope you enjoy using {3} and if you have any comments or feedback please tell
|
||||
Best wishes,
|
||||
|
||||
The {3} Team
|
||||
'''.format(
|
||||
""".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)}
|
||||
os.environ.get("APP_URL"),
|
||||
os.environ.get("CONTACT_EMAIL"),
|
||||
os.environ.get("APP_NAME"),
|
||||
),
|
||||
}
|
||||
)
|
||||
return {"token": generate_access_token(result.inserted_id)}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise util.errors.BadRequest('Unable to register your account. Please try again later')
|
||||
raise util.errors.BadRequest(
|
||||
"Unable to register your account. Please try again later"
|
||||
)
|
||||
|
||||
|
||||
def login(email, password):
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'$or': [{'username': email.lower()}, {'email': email.lower()}]})
|
||||
user = db.users.find_one(
|
||||
{"$or": [{"username": email.lower()}, {"email": email.lower()}]}
|
||||
)
|
||||
try:
|
||||
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']):
|
||||
return {'token': generate_access_token(user['_id'])}
|
||||
if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||
return {"token": generate_access_token(user["_id"])}
|
||||
else:
|
||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
||||
except Exception as e:
|
||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
||||
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||
except Exception:
|
||||
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||
|
||||
|
||||
def logout(user):
|
||||
db = database.get_db()
|
||||
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}})
|
||||
return {'loggedOut': True}
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
|
||||
)
|
||||
return {"loggedOut": True}
|
||||
|
||||
|
||||
def update_email(user, data):
|
||||
if not data: raise util.errors.BadRequest('Invalid request')
|
||||
if 'email' not in data: raise util.errors.BadRequest('Invalid request')
|
||||
if len(data['email']) < 4: raise util.errors.BadRequest('New email is too short')
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if "email" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if len(data["email"]) < 4:
|
||||
raise util.errors.BadRequest("New email is too short")
|
||||
db = database.get_db()
|
||||
db.users.update_one({'_id': user['_id']}, {'$set': {'email': data['email']}})
|
||||
mail.send({
|
||||
'to': user['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'),
|
||||
db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}})
|
||||
mail.send(
|
||||
{
|
||||
"to": user["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"),
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
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'),
|
||||
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"),
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
return {'email': data['email']}
|
||||
return {"email": data["email"]}
|
||||
|
||||
|
||||
def update_password(user, data):
|
||||
if not data: raise util.errors.BadRequest('Invalid request')
|
||||
if 'newPassword' not in data: raise util.errors.BadRequest('Invalid request')
|
||||
if len(data['newPassword']) < MIN_PASSWORD_LENGTH: raise util.errors.BadRequest('New password should be at least {0} characters long'.format(MIN_PASSWORD_LENGTH))
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if "newPassword" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if len(data["newPassword"]) < MIN_PASSWORD_LENGTH:
|
||||
raise util.errors.BadRequest(
|
||||
"New password should be at least {0} characters long".format(
|
||||
MIN_PASSWORD_LENGTH
|
||||
)
|
||||
)
|
||||
|
||||
db = database.get_db()
|
||||
if 'currentPassword' in data:
|
||||
if not user: raise util.errors.BadRequest('User context is required')
|
||||
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']):
|
||||
raise util.errors.BadRequest('Incorrect password')
|
||||
elif 'token' in data:
|
||||
if "currentPassword" in data:
|
||||
if not user:
|
||||
raise util.errors.BadRequest("User context is required")
|
||||
if not bcrypt.checkpw(
|
||||
data["currentPassword"].encode("utf-8"), user["password"]
|
||||
):
|
||||
raise util.errors.BadRequest("Incorrect password")
|
||||
elif "token" in data:
|
||||
try:
|
||||
id = jwt.decode(data['token'], jwt_secret)['sub']
|
||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']})
|
||||
if not user: raise Exception
|
||||
except Exception as e:
|
||||
raise util.errors.BadRequest('There was a problem updating your password. Your token may be invalid or out of date')
|
||||
else:
|
||||
raise util.errors.BadRequest('Current password or reset token is required')
|
||||
if not user: raise util.errors.BadRequest('Unable to change your password')
|
||||
|
||||
hashed_password = bcrypt.hashpw(data['newPassword'].encode("utf-8"), bcrypt.gensalt())
|
||||
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.passwordReset': ''}})
|
||||
|
||||
mail.send({
|
||||
'to_user': user,
|
||||
'subject': 'Your {} password has changed'.format(os.environ.get('APP_NAME')),
|
||||
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.'.format(
|
||||
user['username'],
|
||||
os.environ.get('APP_NAME'),
|
||||
id = jwt.decode(data["token"], jwt_secret, algorithms="HS256")["sub"]
|
||||
user = db.users.find_one(
|
||||
{"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
|
||||
)
|
||||
})
|
||||
return {'passwordUpdated': True}
|
||||
if not user:
|
||||
raise Exception
|
||||
except Exception:
|
||||
raise util.errors.BadRequest(
|
||||
"There was a problem updating your password. Your token may be invalid or out of date"
|
||||
)
|
||||
else:
|
||||
raise util.errors.BadRequest("Current password or reset token is required")
|
||||
if not user:
|
||||
raise util.errors.BadRequest("Unable to change your password")
|
||||
|
||||
hashed_password = bcrypt.hashpw(
|
||||
data["newPassword"].encode("utf-8"), bcrypt.gensalt()
|
||||
)
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]},
|
||||
{"$set": {"password": hashed_password}, "$unset": {"tokens.passwordReset": ""}},
|
||||
)
|
||||
|
||||
mail.send(
|
||||
{
|
||||
"to_user": user,
|
||||
"subject": "Your {} password has changed".format(
|
||||
os.environ.get("APP_NAME")
|
||||
),
|
||||
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.".format(
|
||||
user["username"],
|
||||
os.environ.get("APP_NAME"),
|
||||
),
|
||||
}
|
||||
)
|
||||
return {"passwordUpdated": True}
|
||||
|
||||
|
||||
def delete(user, password):
|
||||
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']):
|
||||
raise util.errors.BadRequest('Incorrect password')
|
||||
if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||
raise util.errors.BadRequest("Incorrect password")
|
||||
db = database.get_db()
|
||||
for project in db.projects.find({'user': user['_id']}):
|
||||
db.objects.remove({'project': project['_id']})
|
||||
db.projects.remove({'_id': project['_id']})
|
||||
db.users.remove({'_id': user['_id']})
|
||||
return {'deletedUser': user['_id']}
|
||||
for project in db.projects.find({"user": user["_id"]}):
|
||||
db.objects.delete_many({"project": project["_id"]})
|
||||
db.projects.delete_one({"_id": project["_id"]})
|
||||
db.comments.delete_many({"user": user["_id"]})
|
||||
db.users.update_many(
|
||||
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
|
||||
)
|
||||
db.users.delete_one({"_id": user["_id"]})
|
||||
return {"deletedUser": user["_id"]}
|
||||
|
||||
|
||||
def generate_access_token(user_id):
|
||||
payload = {
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||
'iat': datetime.datetime.utcnow(),
|
||||
'sub': str(user_id)
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||
"iat": datetime.datetime.utcnow(),
|
||||
"sub": str(user_id),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode("utf-8")
|
||||
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
db = database.get_db()
|
||||
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}})
|
||||
db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
|
||||
return token
|
||||
|
||||
|
||||
def get_user_context(token):
|
||||
if not token: return None
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_secret)
|
||||
id = payload['sub']
|
||||
payload = jwt.decode(token, jwt_secret, algorithms="HS256")
|
||||
id = payload["sub"]
|
||||
if id:
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token})
|
||||
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}})
|
||||
user['currentToken'] = token
|
||||
user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
|
||||
)
|
||||
user["currentToken"] = token
|
||||
return user
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
||||
|
||||
def reset_password(data):
|
||||
if not data or not 'email' in data: raise util.errors.BadRequest('Invalid request')
|
||||
if len(data['email']) < 5: raise util.errors.BadRequest('Your email is too short')
|
||||
if not data or "email" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if len(data["email"]) < 5:
|
||||
raise util.errors.BadRequest("Your email is too short")
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'email': data['email'].lower()})
|
||||
user = db.users.find_one({"email": data["email"].lower()})
|
||||
if user:
|
||||
payload = {
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
||||
'iat': datetime.datetime.utcnow(),
|
||||
'sub': str(user['_id'])
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
||||
"iat": datetime.datetime.utcnow(),
|
||||
"sub": str(user["_id"]),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
mail.send(
|
||||
{
|
||||
"to_user": user,
|
||||
"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(
|
||||
user["username"],
|
||||
"{}/password/reset?token={}".format(
|
||||
os.environ.get("APP_URL"), token
|
||||
),
|
||||
os.environ.get("APP_NAME"),
|
||||
),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode('utf-8')
|
||||
mail.send({
|
||||
'to_user': user,
|
||||
'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(
|
||||
user['username'],
|
||||
'{}/password/reset?token={}'.format(os.environ.get('APP_URL'), token),
|
||||
os.environ.get('APP_NAME'),
|
||||
)
|
||||
})
|
||||
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}})
|
||||
return {'passwordResetEmailSent': True}
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
|
||||
)
|
||||
return {"passwordResetEmailSent": True}
|
||||
|
||||
|
||||
def update_push_token(user, data):
|
||||
if not data or 'pushToken' not in data: raise util.errors.BadRequest('Push token is required')
|
||||
if not data or "pushToken" not in data:
|
||||
raise util.errors.BadRequest("Push token is required")
|
||||
db = database.get_db()
|
||||
db.users.update_one({'_id': user['_id']}, {'$set': {'pushToken': data['pushToken']}})
|
||||
return {'addedPushToken': data['pushToken']}
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$set": {"pushToken": data["pushToken"]}}
|
||||
)
|
||||
return {"addedPushToken": data["pushToken"]}
|
||||
|
@ -1,64 +1,82 @@
|
||||
import os, re
|
||||
import os
|
||||
import re
|
||||
from util import database, util
|
||||
from api import uploads
|
||||
|
||||
DOMAIN = os.environ.get('APP_DOMAIN')
|
||||
DOMAIN = os.environ.get("APP_DOMAIN")
|
||||
|
||||
|
||||
def webfinger(resource):
|
||||
if not resource: raise util.errors.BadRequest('Resource required')
|
||||
if not resource:
|
||||
raise util.errors.BadRequest("Resource required")
|
||||
resource = resource.lower()
|
||||
exp = re.compile('acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)', re.IGNORECASE)
|
||||
exp = re.compile("acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)", re.IGNORECASE)
|
||||
matches = exp.findall(resource)
|
||||
if not matches or not matches[0]: raise util.errors.BadRequest('Resource invalid')
|
||||
if not matches or not matches[0]:
|
||||
raise util.errors.BadRequest("Resource invalid")
|
||||
username, host = matches[0]
|
||||
if not username or not host: raise util.errors.BadRequest('Resource invalid')
|
||||
if host != DOMAIN: raise util.errors.NotFound('Host unknown')
|
||||
if not username or not host:
|
||||
raise util.errors.BadRequest("Resource invalid")
|
||||
if host != DOMAIN:
|
||||
raise util.errors.NotFound("Host unknown")
|
||||
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'username': username})
|
||||
if not user: raise util.errors.NotFound('User unknown')
|
||||
user = db.users.find_one({"username": username})
|
||||
if not user:
|
||||
raise util.errors.NotFound("User unknown")
|
||||
|
||||
return {
|
||||
"subject": resource,
|
||||
"aliases": [
|
||||
"https://{}/{}".format(DOMAIN, username),
|
||||
"https://{}/u/{}".format(DOMAIN, username)
|
||||
"https://{}/u/{}".format(DOMAIN, username),
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://webfinger.net/rel/profile-page",
|
||||
"type": "text/html",
|
||||
"href": "https://{}/{}".format(DOMAIN, username)
|
||||
"href": "https://{}/{}".format(DOMAIN, username),
|
||||
},
|
||||
{
|
||||
"rel": "self",
|
||||
"type": "application/activity+json",
|
||||
"href": "https://{}/u/{}".format(DOMAIN, username)
|
||||
"href": "https://{}/u/{}".format(DOMAIN, username),
|
||||
},
|
||||
{
|
||||
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
||||
"template": "https://{}/authorize_interaction".format(DOMAIN) + "?uri={uri}"
|
||||
}
|
||||
]
|
||||
"template": "https://{}/authorize_interaction".format(DOMAIN)
|
||||
+ "?uri={uri}",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def user(username):
|
||||
if not username: raise util.errors.BadRequest('Username required')
|
||||
if not username:
|
||||
raise util.errors.BadRequest("Username required")
|
||||
username = username.lower()
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'username': username})
|
||||
if not user: raise util.errors.NotFound('User unknown')
|
||||
avatar_url = user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||
user = db.users.find_one({"username": username})
|
||||
if not user:
|
||||
raise util.errors.NotFound("User unknown")
|
||||
avatar_url = user.get("avatar") and uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||
)
|
||||
|
||||
pub_key = None
|
||||
if user.get('services', {}).get('activityPub', {}).get('publicKey'):
|
||||
pub_key = user['services']['activityPub']['publicKey']
|
||||
if user.get("services", {}).get("activityPub", {}).get("publicKey"):
|
||||
pub_key = user["services"]["activityPub"]["publicKey"]
|
||||
else:
|
||||
priv_key, pub_key = util.generate_rsa_keypair()
|
||||
db.users.update_one({'_id': user['_id']}, {'$set': {
|
||||
'services.activityPub.publicKey': pub_key,
|
||||
'services.activityPub.privateKey': priv_key,
|
||||
}})
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"services.activityPub.publicKey": pub_key,
|
||||
"services.activityPub.privateKey": priv_key,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
resp = {
|
||||
"@context": [
|
||||
@ -73,93 +91,100 @@ def user(username):
|
||||
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||
"preferredUsername": username,
|
||||
"name": username,
|
||||
"summary": user.get('bio', ''),
|
||||
"summary": user.get("bio", ""),
|
||||
"url": "https://{}/{}".format(DOMAIN, username),
|
||||
"discoverable": True,
|
||||
"published": "2021-01-27T00:00:00Z",
|
||||
"publicKey": {
|
||||
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
||||
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
||||
"publicKeyPem": pub_key.decode('utf-8')
|
||||
"publicKeyPem": pub_key.decode("utf-8"),
|
||||
},
|
||||
"attachment": [],
|
||||
"endpoints": {
|
||||
"sharedInbox": "https://{}/inbox".format(DOMAIN)
|
||||
},
|
||||
"icon": {
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": avatar_url
|
||||
},
|
||||
"image": {
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": avatar_url
|
||||
}
|
||||
"endpoints": {"sharedInbox": "https://{}/inbox".format(DOMAIN)},
|
||||
"icon": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||
"image": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||
}
|
||||
|
||||
if user.get('website'):
|
||||
resp['attachment'].append({
|
||||
if user.get("website"):
|
||||
resp["attachment"].append(
|
||||
{
|
||||
"type": "PropertyValue",
|
||||
"name": "Website",
|
||||
"value": "<a href=\"https://{}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">{}</span><span class=\"invisible\"></span></a>".format(user['website'], user['website'])
|
||||
})
|
||||
"value": '<a href="https://{}" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">{}</span><span class="invisible"></span></a>'.format(
|
||||
user["website"], user["website"]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def outbox(username, page, min_id, max_id):
|
||||
if not username: raise util.errors.BadRequest('Username required')
|
||||
if not username:
|
||||
raise util.errors.BadRequest("Username required")
|
||||
username = username.lower()
|
||||
db = database.get_db()
|
||||
user = db.users.find_one({'username': username})
|
||||
if not user: raise util.errors.NotFound('User unknown')
|
||||
user = db.users.find_one({"username": username})
|
||||
if not user:
|
||||
raise util.errors.NotFound("User unknown")
|
||||
|
||||
if not page or page != 'true':
|
||||
if not page or page != "true":
|
||||
return {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||
"type": "OrderedCollection",
|
||||
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username)
|
||||
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username),
|
||||
}
|
||||
if page == 'true':
|
||||
min_string = '&min_id={}'.format(min_id) if min_id else ''
|
||||
max_string = '&max_id={}'.format(max_id) if max_id else ''
|
||||
if page == "true":
|
||||
min_string = "&min_id={}".format(min_id) if min_id else ""
|
||||
max_string = "&max_id={}".format(max_id) if max_id else ""
|
||||
ret = {
|
||||
"id": "https://{}/u/{}/outbox?page=true{}{}".format(DOMAIN, username, min_string, max_string),
|
||||
"id": "https://{}/u/{}/outbox?page=true{}{}".format(
|
||||
DOMAIN, username, min_string, max_string
|
||||
),
|
||||
"type": "OrderedCollectionPage",
|
||||
# "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
# "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||
"orderedItems": []
|
||||
"orderedItems": [],
|
||||
}
|
||||
|
||||
project_list = list(db.projects.find({'user': user['_id'], 'visibility': 'public'}))
|
||||
project_list = list(
|
||||
db.projects.find({"user": user["_id"], "visibility": "public"})
|
||||
)
|
||||
for p in project_list:
|
||||
ret['orderedItems'].append({
|
||||
"id": "https://{}/{}/{}/activity".format(DOMAIN, username, p['path']),
|
||||
ret["orderedItems"].append(
|
||||
{
|
||||
"id": "https://{}/{}/{}/activity".format(
|
||||
DOMAIN, username, p["path"]
|
||||
),
|
||||
"type": "Create",
|
||||
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
||||
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2021-10-18T20:06:18Z",
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"published": p["createdAt"].strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
), # "2021-10-18T20:06:18Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"object": {
|
||||
"id": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
||||
"id": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||
"type": "Note",
|
||||
"summary": None,
|
||||
# "inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
||||
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2022-08-03T15:43:30Z",
|
||||
"url": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
||||
"published": p["createdAt"].strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
), # "2022-08-03T15:43:30Z",
|
||||
"url": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"cc": [
|
||||
"https://{}/u/{}/followers".format(DOMAIN, username),
|
||||
],
|
||||
"sensitive": False,
|
||||
"content": "{} created a project: {}".format(username, p['name']),
|
||||
"content": "{} created a project: {}".format(
|
||||
username, p["name"]
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return ret
|
@ -1,268 +1,804 @@
|
||||
import datetime, re, os
|
||||
import datetime
|
||||
import re
|
||||
import os
|
||||
import math
|
||||
import pymongo
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util, mail, push
|
||||
from api import uploads
|
||||
|
||||
APP_NAME = os.environ.get('APP_NAME')
|
||||
APP_URL = os.environ.get('APP_URL')
|
||||
APP_NAME = os.environ.get("APP_NAME")
|
||||
APP_URL = os.environ.get("APP_URL")
|
||||
|
||||
|
||||
def has_group_permission(user, group, permission=None):
|
||||
if not user or not group:
|
||||
return False
|
||||
if user["_id"] in group.get("admins", []):
|
||||
return True
|
||||
if group["_id"] not in user.get("groups", []):
|
||||
return False
|
||||
if permission:
|
||||
return permission in group.get("memberPermissions", [])
|
||||
return False
|
||||
|
||||
|
||||
def create(user, data):
|
||||
if not data: raise util.errors.BadRequest('Invalid request')
|
||||
if len(data.get('name')) < 3: raise util.errors.BadRequest('A longer name is required')
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if len(data.get("name")) < 3:
|
||||
raise util.errors.BadRequest("A longer name is required")
|
||||
db = database.get_db()
|
||||
|
||||
group = {
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'user': user['_id'],
|
||||
'admins': [user['_id']],
|
||||
'name': data['name'],
|
||||
'description': data.get('description', ''),
|
||||
'closed': data.get('closed', False),
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"user": user["_id"],
|
||||
"admins": [user["_id"]],
|
||||
"name": data["name"],
|
||||
"description": data.get("description", ""),
|
||||
"closed": data.get("closed", False),
|
||||
"advertised": data.get("advertised", False),
|
||||
"memberPermissions": [
|
||||
"viewMembers",
|
||||
"viewNoticeboard",
|
||||
"postNoticeboard",
|
||||
"viewProjects",
|
||||
"postProjects",
|
||||
"viewForumTopics",
|
||||
"postForumTopics",
|
||||
"postForumTopicReplies",
|
||||
],
|
||||
}
|
||||
result = db.groups.insert_one(group)
|
||||
group['_id'] = result.inserted_id
|
||||
create_member(user, group['_id'], user['_id'])
|
||||
group["_id"] = result.inserted_id
|
||||
create_member(user, group["_id"], user["_id"])
|
||||
return group
|
||||
|
||||
|
||||
def get(user):
|
||||
db = database.get_db()
|
||||
groups = list(db.groups.find({'_id': {'$in': user.get('groups', [])}}))
|
||||
return {'groups': groups}
|
||||
groups = list(db.groups.find({"_id": {"$in": user.get("groups", [])}}))
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
def get_one(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
group['adminUsers'] = list(db.users.find({'_id': {'$in': group.get('admins', [])}}, {'username': 1, 'avatar': 1}))
|
||||
for u in group['adminUsers']:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if group.get("image"):
|
||||
group["imageUrl"] = uploads.get_presigned_url(
|
||||
"groups/{0}/{1}".format(id, group["image"])
|
||||
)
|
||||
group["adminUsers"] = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": group.get("admins", [])}}, {"username": 1, "avatar": 1}
|
||||
)
|
||||
)
|
||||
for u in group["adminUsers"]:
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
def update(user, id, update):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
||||
allowed_keys = ['name', 'description', 'closed']
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You're not a group admin")
|
||||
allowed_keys = [
|
||||
"name",
|
||||
"description",
|
||||
"closed",
|
||||
"advertised",
|
||||
"memberPermissions",
|
||||
"image",
|
||||
]
|
||||
updater = util.build_updater(update, allowed_keys)
|
||||
if updater: db.groups.update({'_id': id}, 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)
|
||||
return get_one(user, id)
|
||||
|
||||
|
||||
def delete(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
||||
db.groups.remove({'_id': id})
|
||||
db.groupEntries.remove({'group': id})
|
||||
db.users.update({'groups': id}, {'$pull': {'groups': id}}, multi = True)
|
||||
return {'deletedGroup': id}
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You're not a group admin")
|
||||
db.groups.delete_one({"_id": id})
|
||||
db.groupEntries.delete_many({"group": id})
|
||||
db.users.update_many({"groups": id}, {"$pull": {"groups": id}})
|
||||
return {"deletedGroup": id}
|
||||
|
||||
|
||||
def create_entry(user, id, data):
|
||||
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
||||
if not data or "content" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if group["_id"] not in user.get("groups", []):
|
||||
raise util.errors.Forbidden("You must be a member to write in the feed")
|
||||
if not has_group_permission(user, group, "postNoticeboard"):
|
||||
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
||||
entry = {
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'group': id,
|
||||
'user': user['_id'],
|
||||
'content': data['content'],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if 'attachments' in data:
|
||||
entry['attachments'] = data['attachments']
|
||||
for attachment in entry['attachments']:
|
||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
||||
attachment['isImage'] = True
|
||||
if attachment['type'] == 'file':
|
||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||
if "attachments" in data:
|
||||
entry["attachments"] = data["attachments"]
|
||||
for attachment in entry["attachments"]:
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
if attachment["type"] == "file":
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||
)
|
||||
|
||||
result = db.groupEntries.insert_one(entry)
|
||||
entry['_id'] = result.inserted_id
|
||||
entry['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
||||
if 'avatar' in user:
|
||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||
|
||||
for u in db.users.find({'_id': {'$ne': user['_id']}, 'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}, {'email': 1, 'username': 1}):
|
||||
mail.send({
|
||||
'to_user': u,
|
||||
'subject': 'New message in ' + group['name'],
|
||||
'text': 'Dear {0},\n\n{1} posted a message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(
|
||||
u['username'],
|
||||
user['username'],
|
||||
group['name'],
|
||||
data['content'],
|
||||
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||
APP_NAME,
|
||||
entry["_id"] = result.inserted_id
|
||||
entry["authorUser"] = {
|
||||
"_id": user["_id"],
|
||||
"username": user["username"],
|
||||
"avatar": user.get("avatar"),
|
||||
}
|
||||
if "avatar" in user:
|
||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||
)
|
||||
})
|
||||
push.send_multiple(list(db.users.find({'_id': {'$ne': user['_id']}, 'groups': id})), '{} posted in {}'.format(user['username'], group['name']), data['content'][:30] + '...')
|
||||
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(
|
||||
{
|
||||
"_id": {"$ne": user["_id"]},
|
||||
"groups": group["_id"],
|
||||
"subscriptions.email": "groupFeed-" + str(group["_id"]),
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": u,
|
||||
"subject": "New message in " + group["name"],
|
||||
"text": "Dear {0},\n\n{1} posted a message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
||||
u["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
entry["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
push.send_multiple(
|
||||
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
|
||||
"{} posted in {}".format(user["username"], group["name"]),
|
||||
entry["content"][:30] + "...",
|
||||
)
|
||||
|
||||
|
||||
def get_entries(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if id not in user.get('groups', []): raise util.errors.BadRequest('You\'re not a member of this group')
|
||||
entries = list(db.groupEntries.find({'group': id}).sort('createdAt', pymongo.DESCENDING))
|
||||
authors = list(db.users.find({'_id': {'$in': [e['user'] for e in entries]}}, {'username': 1, 'avatar': 1}))
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if id not in user.get("groups", []):
|
||||
raise util.errors.BadRequest("You're not a member of this group")
|
||||
if not has_group_permission(user, group, "viewNoticeboard"):
|
||||
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(
|
||||
db.groupEntries.find(
|
||||
{
|
||||
"group": id,
|
||||
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
|
||||
}
|
||||
).sort("createdAt", pymongo.DESCENDING)
|
||||
)
|
||||
authors = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": [e["user"] for e in entries]}}, {"username": 1, "avatar": 1}
|
||||
)
|
||||
)
|
||||
for entry in entries:
|
||||
if 'attachments' in entry:
|
||||
for attachment in entry['attachments']:
|
||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||
if "attachments" in entry:
|
||||
for attachment in entry["attachments"]:
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||
)
|
||||
for author in authors:
|
||||
if entry['user'] == author['_id']:
|
||||
entry['authorUser'] = author
|
||||
if 'avatar' in author:
|
||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(author['_id'], author['avatar']))
|
||||
return {'entries': entries}
|
||||
if entry["user"] == author["_id"]:
|
||||
entry["authorUser"] = author
|
||||
if "avatar" in author:
|
||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||
)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
def delete_entry(user, id, entry_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
entry_id = ObjectId(entry_id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
||||
if entry['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the entry or be an admin of the group')
|
||||
db.groupEntries.remove({'$or': [{'_id': entry_id}, {'inReplyTo': entry_id}]})
|
||||
return {'deletedEntry': entry_id}
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
||||
if not entry or entry["group"] != id:
|
||||
raise util.errors.NotFound("Entry not found")
|
||||
if entry["user"] != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden(
|
||||
"You must own the entry or be an admin of the group"
|
||||
)
|
||||
db.groupEntries.delete_one({"$or": [{"_id": entry_id}, {"inReplyTo": entry_id}]})
|
||||
return {"deletedEntry": entry_id}
|
||||
|
||||
|
||||
def create_entry_reply(user, id, entry_id, data):
|
||||
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
||||
if not data or "content" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
entry_id = ObjectId(entry_id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
entry = db.groupEntries.find_one({'_id': entry_id})
|
||||
if not entry or entry.get('group') != group['_id']: raise util.errors.NotFound('Entry to reply to not found')
|
||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
entry = db.groupEntries.find_one({"_id": entry_id})
|
||||
if not entry or entry.get("group") != group["_id"]:
|
||||
raise util.errors.NotFound("Entry to reply to not found")
|
||||
if group["_id"] not in user.get("groups", []):
|
||||
raise util.errors.Forbidden("You must be a member to write in the feed")
|
||||
if not has_group_permission(user, group, "postNoticeboard"):
|
||||
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
||||
reply = {
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'group': id,
|
||||
'inReplyTo': entry_id,
|
||||
'user': user['_id'],
|
||||
'content': data['content'],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"inReplyTo": entry_id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if 'attachments' in data:
|
||||
reply['attachments'] = data['attachments']
|
||||
for attachment in reply['attachments']:
|
||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
||||
attachment['isImage'] = True
|
||||
if attachment['type'] == 'file':
|
||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||
if "attachments" in data:
|
||||
reply["attachments"] = data["attachments"]
|
||||
for attachment in reply["attachments"]:
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
if attachment["type"] == "file":
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||
)
|
||||
|
||||
result = db.groupEntries.insert_one(reply)
|
||||
reply['_id'] = result.inserted_id
|
||||
reply['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
||||
if 'avatar' in user:
|
||||
reply['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||
op = db.users.find_one({'$and': [{'_id': entry.get('user')}, {'_id': {'$ne': user['_id']}}], 'subscriptions.email': 'messages.replied'})
|
||||
if op:
|
||||
mail.send({
|
||||
'to_user': op,
|
||||
'subject': user['username'] + ' replied to your post',
|
||||
'text': 'Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(
|
||||
op['username'],
|
||||
user['username'],
|
||||
group['name'],
|
||||
data['content'],
|
||||
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||
APP_NAME,
|
||||
reply["_id"] = result.inserted_id
|
||||
reply["authorUser"] = {
|
||||
"_id": user["_id"],
|
||||
"username": user["username"],
|
||||
"avatar": user.get("avatar"),
|
||||
}
|
||||
if "avatar" in user:
|
||||
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"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(
|
||||
{
|
||||
"$and": [
|
||||
{"_id": original_entry.get("user")},
|
||||
{"_id": {"$ne": user["_id"]}},
|
||||
],
|
||||
"subscriptions.email": "messages.replied",
|
||||
}
|
||||
)
|
||||
if op:
|
||||
mail.send(
|
||||
{
|
||||
"to_user": op,
|
||||
"subject": user["username"] + " replied to your post",
|
||||
"text": "Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
||||
op["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
reply["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def delete_entry_reply(user, id, entry_id, reply_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
entry_id = ObjectId(entry_id)
|
||||
reply_id = ObjectId(reply_id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
||||
if not entry or entry["group"] != id:
|
||||
raise util.errors.NotFound("Entry not found")
|
||||
reply = db.groupEntries.find_one(reply_id)
|
||||
if not reply or reply.get('inReplyTo') != entry_id: raise util.errors.NotFound('Reply not found')
|
||||
if entry['user'] != user['_id'] and reply['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the reply or entry or be an admin of the group')
|
||||
db.groupEntries.remove({'_id': entry_id})
|
||||
return {'deletedEntry': entry_id}
|
||||
if not reply or reply.get("inReplyTo") != entry_id:
|
||||
raise util.errors.NotFound("Reply not found")
|
||||
if (
|
||||
entry["user"] != user["_id"]
|
||||
and reply["user"] != user["_id"]
|
||||
and user["_id"] not in group.get("admins", [])
|
||||
):
|
||||
raise util.errors.Forbidden(
|
||||
"You must own the reply or entry or be an admin of the group"
|
||||
)
|
||||
db.groupEntries.delete_one({"_id": entry_id})
|
||||
return {"deletedEntry": entry_id}
|
||||
|
||||
|
||||
def create_member(user, id, user_id, invited=False):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
user_id = ObjectId(user_id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1, 'closed': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user_id != user['_id']: raise util.errors.Forbidden('Not allowed to add someone else to the group')
|
||||
if group.get('closed') and not invited and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('Not allowed to join a closed group')
|
||||
db.users.update({'_id': user_id}, {'$addToSet': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
||||
db.invitations.remove({'type': 'group', 'typeId': id, 'recipient': user_id})
|
||||
for admin in db.users.find({'_id': {'$in': group.get('admins', []), '$ne': user_id}, 'subscriptions.email': 'groups.joined'}, {'email': 1, 'username': 1}):
|
||||
mail.send({
|
||||
'to_user': admin,
|
||||
'subject': 'Someone joined your group',
|
||||
'text': 'Dear {0},\n\n{1} recently joined your group {2} on {4}!\n\nFollow the link below to manage your group:\n\n{3}'.format(
|
||||
admin['username'],
|
||||
user['username'],
|
||||
group['name'],
|
||||
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||
APP_NAME,
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1, "closed": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user_id != user["_id"]:
|
||||
raise util.errors.Forbidden("Not allowed to add someone else to the group")
|
||||
if (
|
||||
group.get("closed")
|
||||
and not invited
|
||||
and user["_id"] not in group.get("admins", [])
|
||||
):
|
||||
raise util.errors.Forbidden("Not allowed to join a closed group")
|
||||
db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$addToSet": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
||||
)
|
||||
db.invitations.delete_many({"type": "group", "typeId": id, "recipient": user_id})
|
||||
for admin in db.users.find(
|
||||
{
|
||||
"_id": {"$in": group.get("admins", []), "$ne": user_id},
|
||||
"subscriptions.email": "groups.joined",
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": admin,
|
||||
"subject": "Someone joined your group",
|
||||
"text": "Dear {0},\n\n{1} recently joined your group {2} on {4}!\n\nFollow the link below to manage your group:\n\n{3}".format(
|
||||
admin["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
"{}/groups/{}".format(APP_URL, str(id)),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return {'newMember': user_id}
|
||||
return {"newMember": user_id}
|
||||
|
||||
|
||||
def get_members(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if id not in user.get('groups', []) and not 'root' in user.get('roles', []): raise util.errors.Forbidden('You need to be a member to see the member list')
|
||||
members = list(db.users.find({'groups': id}, {'username': 1, 'avatar': 1, 'bio': 1, 'groups': 1}))
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if id not in user.get("groups", []) and "root" not in user.get("roles", []):
|
||||
raise util.errors.Forbidden("You need to be a member to see the member list")
|
||||
if not has_group_permission(user, group, "viewMembers"):
|
||||
raise util.errors.Forbidden("You don't have permission to view the member list")
|
||||
members = list(
|
||||
db.users.find(
|
||||
{"groups": id}, {"username": 1, "avatar": 1, "bio": 1, "groups": 1}
|
||||
)
|
||||
)
|
||||
for m in members:
|
||||
if 'avatar' in m:
|
||||
m['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(m['_id'], m['avatar']))
|
||||
return {'members': members}
|
||||
if "avatar" in m:
|
||||
m["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(m["_id"], m["avatar"])
|
||||
)
|
||||
return {"members": members}
|
||||
|
||||
|
||||
def delete_member(user, id, user_id):
|
||||
id = ObjectId(id)
|
||||
user_id = ObjectId(user_id)
|
||||
db = database.get_db()
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user_id != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You can\'t remove this user')
|
||||
if user_id in group.get('admins', []) and len(group['admins']) == 1:
|
||||
raise util.errors.Forbidden('There needs to be at least one admin in this group')
|
||||
db.users.update({'_id': user_id}, {'$pull': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
||||
db.groups.update({'_id': id}, {'$pull': {'admins': user_id}})
|
||||
return {'deletedMember': user_id}
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You can't remove this user")
|
||||
if user_id in group.get("admins", []) and len(group["admins"]) == 1:
|
||||
raise util.errors.Forbidden(
|
||||
"There needs to be at least one admin in this group"
|
||||
)
|
||||
db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$pull": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
||||
)
|
||||
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
||||
return {"deletedMember": user_id}
|
||||
|
||||
|
||||
def create_admin(user, id, user_id):
|
||||
id = ObjectId(id)
|
||||
user_id = ObjectId(user_id)
|
||||
db = database.get_db()
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You can't add this admin")
|
||||
if user_id in group.get("admins", []):
|
||||
raise util.errors.Forbidden("This user is already an admin")
|
||||
db.groups.update_one({"_id": id}, {"$addToSet": {"admins": user_id}})
|
||||
return {"createdAdmin": user_id}
|
||||
|
||||
|
||||
def delete_admin(user, id, user_id):
|
||||
id = ObjectId(id)
|
||||
user_id = ObjectId(user_id)
|
||||
db = database.get_db()
|
||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You can't remove this admin")
|
||||
if user_id not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("This user is not an admin")
|
||||
if len(group["admins"]) == 1:
|
||||
raise util.errors.Forbidden(
|
||||
"There needs to be at least one admin in this group"
|
||||
)
|
||||
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
||||
return {"deletedAdmin": user_id}
|
||||
|
||||
|
||||
def get_projects(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if id not in user.get('groups', []): raise util.errors.Forbidden('You need to be a member to see the project list')
|
||||
projects = list(db.projects.find({'groupVisibility': id}, {'name': 1, 'path': 1, 'user': 1, 'description': 1, 'visibility': 1}))
|
||||
authors = list(db.users.find({'groups': id, '_id': {'$in': list(map(lambda p: p['user'], projects))}}, {'username': 1, 'avatar': 1, 'bio': 1}))
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if id not in user.get("groups", []):
|
||||
raise util.errors.Forbidden("You need to be a member to see the project list")
|
||||
if not has_group_permission(user, group, "viewProjects"):
|
||||
raise util.errors.Forbidden(
|
||||
"You don't have permission to view the project list"
|
||||
)
|
||||
projects = list(
|
||||
db.projects.find(
|
||||
{"groupVisibility": id},
|
||||
{"name": 1, "path": 1, "user": 1, "description": 1, "visibility": 1},
|
||||
)
|
||||
)
|
||||
authors = list(
|
||||
db.users.find(
|
||||
{"groups": id, "_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
||||
{"username": 1, "avatar": 1, "bio": 1},
|
||||
)
|
||||
)
|
||||
for a in authors:
|
||||
if 'avatar' in a:
|
||||
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))
|
||||
if "avatar" in a:
|
||||
a["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
||||
)
|
||||
for project in projects:
|
||||
for a in authors:
|
||||
if project['user'] == a['_id']:
|
||||
project['owner'] = a
|
||||
project['fullName'] = a['username'] + '/' + project['path']
|
||||
if project["user"] == a["_id"]:
|
||||
project["owner"] = a
|
||||
project["fullName"] = a["username"] + "/" + project["path"]
|
||||
break
|
||||
return {'projects': projects}
|
||||
return {"projects": projects}
|
||||
|
||||
|
||||
def create_forum_topic(user, id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if not has_group_permission(user, group, "postForumTopics"):
|
||||
raise util.errors.Forbidden("You don't have permission to create a topic")
|
||||
topic = {
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"user": user["_id"],
|
||||
"title": data["title"],
|
||||
"description": data.get("description", ""),
|
||||
}
|
||||
result = db.groupForumTopics.insert_one(topic)
|
||||
topic["_id"] = result.inserted_id
|
||||
return topic
|
||||
|
||||
|
||||
def update_forum_topic(user, id, topic_id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to edit the topic")
|
||||
allowed_keys = ["title", "description"]
|
||||
updater = util.build_updater(data, allowed_keys)
|
||||
if updater:
|
||||
db.groupForumTopics.update_one({"_id": topic_id}, updater)
|
||||
return db.groupForumTopics.find_one({"_id": topic_id})
|
||||
|
||||
|
||||
def delete_forum_topic(user, id, topic_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to delete the topic")
|
||||
db.groupForumTopics.delete_one({"_id": topic_id})
|
||||
db.groupForumTopicReplies.delete_many({"topic": topic_id})
|
||||
return {"deletedTopic": topic_id}
|
||||
|
||||
|
||||
def get_forum_topics(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if not has_group_permission(user, group, "viewForumTopics"):
|
||||
raise util.errors.Forbidden(
|
||||
"You don't have permission to view the forum topics"
|
||||
)
|
||||
return {
|
||||
"topics": list(
|
||||
db.groupForumTopics.find({"group": id}).sort(
|
||||
"createdAt", pymongo.DESCENDING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def create_forum_topic_reply(user, id, topic_id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not has_group_permission(user, group, "postForumTopicReplies"):
|
||||
raise util.errors.Forbidden("You don't have permission to create a reply")
|
||||
reply = {
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"topic": topic_id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"attachments": data.get("attachments", []),
|
||||
"moderationRequired": True,
|
||||
}
|
||||
result = db.groupForumTopicReplies.insert_one(reply)
|
||||
db.groupForumTopics.update_one(
|
||||
{"_id": topic_id},
|
||||
{
|
||||
"$set": {
|
||||
"lastReplyAt": reply["createdAt"],
|
||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||
{"topic": topic_id}
|
||||
),
|
||||
"lastReply": result.inserted_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
reply["_id"] = result.inserted_id
|
||||
reply["author"] = {
|
||||
"_id": user["_id"],
|
||||
"username": user["username"],
|
||||
"avatar": user.get("avatar"),
|
||||
}
|
||||
if "avatar" in user:
|
||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||
)
|
||||
for attachment in reply["attachments"]:
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
if attachment["type"] == "file":
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/topics/{1}/{2}".format(
|
||||
id, topic_id, attachment["storedName"]
|
||||
)
|
||||
)
|
||||
|
||||
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(
|
||||
{
|
||||
"_id": {"$ne": reply["user"]},
|
||||
"groups": topic["group"],
|
||||
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": u,
|
||||
"subject": "A new reply was posted to " + topic["title"],
|
||||
"text": "Dear {0},\n\n{1} posted a new reply in {2} (in the group {3}) on {6}:\n\n{4}\n\nFollow the link below to visit the group:\n\n{5}".format(
|
||||
u["username"],
|
||||
user["username"],
|
||||
topic["title"],
|
||||
group["name"],
|
||||
reply["content"],
|
||||
"{}/groups/{}/forum/topics/{}".format(
|
||||
APP_URL, str(group["_id"]), str(topic["_id"])
|
||||
),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_forum_topic_replies(user, id, topic_id, data):
|
||||
REPLIES_PER_PAGE = 20
|
||||
page = int(data.get("page", 1))
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not has_group_permission(user, group, "viewForumTopics"):
|
||||
raise util.errors.Forbidden(
|
||||
"You don't have permission to view the forum topics"
|
||||
)
|
||||
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
||||
replies = list(
|
||||
db.groupForumTopicReplies.find(
|
||||
{
|
||||
"topic": topic_id,
|
||||
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
|
||||
}
|
||||
)
|
||||
.sort("createdAt", pymongo.ASCENDING)
|
||||
.skip((page - 1) * REPLIES_PER_PAGE)
|
||||
.limit(REPLIES_PER_PAGE)
|
||||
)
|
||||
authors = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": [r["user"] for r in replies]}}, {"username": 1, "avatar": 1}
|
||||
)
|
||||
)
|
||||
for reply in replies:
|
||||
author = next((a for a in authors if a["_id"] == reply["user"]), None)
|
||||
if author:
|
||||
reply["author"] = author
|
||||
if "avatar" in author:
|
||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||
)
|
||||
if "attachments" in reply:
|
||||
for attachment in reply["attachments"]:
|
||||
if attachment["type"] == "file":
|
||||
attachment["isImage"] = False
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$",
|
||||
attachment["storedName"].lower(),
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/topics/{1}/{2}".format(
|
||||
id, topic_id, attachment["storedName"]
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"topic": topic,
|
||||
"replies": replies,
|
||||
"totalReplies": total_replies,
|
||||
"page": page,
|
||||
"totalPages": math.ceil(total_replies / REPLIES_PER_PAGE),
|
||||
}
|
||||
|
||||
|
||||
def delete_forum_topic_reply(user, id, topic_id, reply_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
reply_id = ObjectId(reply_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
reply = db.groupForumTopicReplies.find_one({"_id": reply_id})
|
||||
if not reply or reply.get("topic") != topic_id:
|
||||
raise util.errors.NotFound("Reply not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == reply.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to delete the reply")
|
||||
db.groupForumTopicReplies.delete_one({"_id": reply_id})
|
||||
last_reply = db.groupForumTopicReplies.find_one(
|
||||
{"topic": topic_id}, sort=[("createdAt", pymongo.DESCENDING)]
|
||||
)
|
||||
db.groupForumTopics.update_one(
|
||||
{"_id": topic_id},
|
||||
{
|
||||
"$set": {
|
||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||
{"topic": topic_id}
|
||||
),
|
||||
"lastReply": last_reply["_id"] if last_reply else None,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"deletedReply": reply_id}
|
||||
|
@ -1,171 +1,252 @@
|
||||
import re, datetime, os
|
||||
import pymongo
|
||||
import datetime
|
||||
import os
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util, mail
|
||||
from api import uploads, groups
|
||||
|
||||
APP_NAME = os.environ.get('APP_NAME')
|
||||
APP_URL = os.environ.get('APP_URL')
|
||||
APP_NAME = os.environ.get("APP_NAME")
|
||||
APP_URL = os.environ.get("APP_URL")
|
||||
|
||||
|
||||
def get(user):
|
||||
db = database.get_db()
|
||||
admin_groups = list(db.groups.find({'admins': user['_id']}))
|
||||
invites = list(db.invitations.find({'$or': [{'recipient': user['_id']}, {'recipientGroup': {'$in': list(map(lambda g: g['_id'], admin_groups))}}]}))
|
||||
inviters = list(db.users.find({'_id': {'$in': [i['user'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
||||
admin_groups = list(db.groups.find({"admins": user["_id"]}))
|
||||
invites = list(
|
||||
db.invitations.find(
|
||||
{
|
||||
"$or": [
|
||||
{"recipient": user["_id"]},
|
||||
{
|
||||
"recipientGroup": {
|
||||
"$in": list(map(lambda g: g["_id"], admin_groups))
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
inviters = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": [i["user"] for i in invites]}}, {"username": 1, "avatar": 1}
|
||||
)
|
||||
)
|
||||
for invite in invites:
|
||||
invite['recipient'] = user['_id']
|
||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||
for u in inviters:
|
||||
if u['_id'] == invite['user']:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
invite['invitedBy'] = u
|
||||
break
|
||||
sent_invites = list(db.invitations.find({'user': user['_id']}))
|
||||
recipients = list(db.users.find({'_id': {'$in': list(map(lambda i: i.get('recipient'), sent_invites))}}, {'username': 1, 'avatar': 1}))
|
||||
invite["recipient"] = user["_id"]
|
||||
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||
inviter = next((u for u in inviters if u["_id"] == invite["user"]), None)
|
||||
if inviter:
|
||||
if "avatar" in inviter:
|
||||
inviter["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(inviter["_id"], inviter["avatar"])
|
||||
)
|
||||
invite["invitedBy"] = inviter
|
||||
sent_invites = list(db.invitations.find({"user": user["_id"]}))
|
||||
recipients = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": list(map(lambda i: i.get("recipient"), sent_invites))}},
|
||||
{"username": 1, "avatar": 1},
|
||||
)
|
||||
)
|
||||
for invite in sent_invites:
|
||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||
for u in recipients:
|
||||
if u['_id'] == invite.get('recipient'):
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
invite['invitedBy'] = u
|
||||
break
|
||||
return {'invitations': invites, 'sentInvitations': sent_invites}
|
||||
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||
recipient = next(
|
||||
(u for u in recipients if u["_id"] == invite.get("recipient")), None
|
||||
)
|
||||
if recipient:
|
||||
if "avatar" in recipient:
|
||||
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||
)
|
||||
invite["invitedBy"] = recipient
|
||||
return {"invitations": invites, "sentInvitations": sent_invites}
|
||||
|
||||
|
||||
def accept(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
invite = db.invitations.find_one({'_id': id})
|
||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
||||
if invite['type'] == 'group':
|
||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to accept')
|
||||
group = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||
invite = db.invitations.find_one({"_id": id})
|
||||
if not invite:
|
||||
raise util.errors.NotFound("Invitation not found")
|
||||
if invite["type"] == "group":
|
||||
if invite["recipient"] != user["_id"]:
|
||||
raise util.errors.Forbidden("This invitation is not yours to accept")
|
||||
group = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||
if not group:
|
||||
db.invitations.remove({'_id': id})
|
||||
return {'acceptedInvitation': id}
|
||||
groups.create_member(user, group['_id'], user['_id'], invited = True)
|
||||
db.invitations.remove({'_id': id})
|
||||
return {'acceptedInvitation': id, 'group': group}
|
||||
if invite['type'] == 'groupJoinRequest':
|
||||
group = db.groups.find_one({'_id': invite['typeId']})
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to accept this request')
|
||||
requester = db.users.find_one({'_id': invite['user']})
|
||||
db.invitations.delete_one({"_id": id})
|
||||
return {"acceptedInvitation": id}
|
||||
groups.create_member(user, group["_id"], user["_id"], invited=True)
|
||||
db.invitations.delete_one({"_id": id})
|
||||
return {"acceptedInvitation": id, "group": group}
|
||||
if invite["type"] == "groupJoinRequest":
|
||||
group = db.groups.find_one({"_id": invite["typeId"]})
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden(
|
||||
"You need to be an admin of this group to accept this request"
|
||||
)
|
||||
requester = db.users.find_one({"_id": invite["user"]})
|
||||
if not group or not requester:
|
||||
db.invitations.remove({'_id': id})
|
||||
return {'acceptedInvitation': id}
|
||||
groups.create_member(requester, group['_id'], requester['_id'], invited = True)
|
||||
db.invitations.remove({'_id': id})
|
||||
return {'acceptedInvitation': id, 'group': group}
|
||||
db.invitations.delete_one({"_id": id})
|
||||
return {"acceptedInvitation": id}
|
||||
groups.create_member(requester, group["_id"], requester["_id"], invited=True)
|
||||
db.invitations.delete_one({"_id": id})
|
||||
return {"acceptedInvitation": id, "group": group}
|
||||
|
||||
|
||||
def delete(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
invite = db.invitations.find_one({'_id': id})
|
||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
||||
if invite['type'] == 'group':
|
||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to decline')
|
||||
if invite['type'] == 'groupJoinRequest':
|
||||
group = db.groups.find_one({'_id': invite['typeId']})
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to manage this request')
|
||||
db.invitations.remove({'_id': id})
|
||||
return {'deletedInvitation': id}
|
||||
invite = db.invitations.find_one({"_id": id})
|
||||
if not invite:
|
||||
raise util.errors.NotFound("Invitation not found")
|
||||
if invite["type"] == "group":
|
||||
if invite["recipient"] != user["_id"]:
|
||||
raise util.errors.Forbidden("This invitation is not yours to decline")
|
||||
if invite["type"] == "groupJoinRequest":
|
||||
group = db.groups.find_one({"_id": invite["typeId"]})
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden(
|
||||
"You need to be an admin of this group to manage this request"
|
||||
)
|
||||
db.invitations.delete_one({"_id": id})
|
||||
return {"deletedInvitation": id}
|
||||
|
||||
|
||||
def create_group_invitation(user, group_id, data):
|
||||
if not data or 'user' not in data: raise util.errors.BadRequest('Invalid request')
|
||||
if not data or "user" not in data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
db = database.get_db()
|
||||
recipient_id = ObjectId(data['user'])
|
||||
recipient_id = ObjectId(data["user"])
|
||||
group_id = ObjectId(group_id)
|
||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to invite users')
|
||||
recipient = db.users.find_one({'_id': recipient_id}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
||||
if not recipient: raise util.errors.NotFound('User not found')
|
||||
if group_id in recipient.get('groups', []): raise util.errors.BadRequest('This user is already in this group')
|
||||
if db.invitations.find_one({'recipient': recipient_id, 'typeId': group_id, 'type': 'group'}):
|
||||
raise util.errors.BadRequest('This user has already been invited to this group')
|
||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You need to be a group admin to invite users")
|
||||
recipient = db.users.find_one(
|
||||
{"_id": recipient_id},
|
||||
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
||||
)
|
||||
if not recipient:
|
||||
raise util.errors.NotFound("User not found")
|
||||
if group_id in recipient.get("groups", []):
|
||||
raise util.errors.BadRequest("This user is already in this group")
|
||||
if db.invitations.find_one(
|
||||
{"recipient": recipient_id, "typeId": group_id, "type": "group"}
|
||||
):
|
||||
raise util.errors.BadRequest("This user has already been invited to this group")
|
||||
invite = {
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'user': user['_id'],
|
||||
'recipient': recipient_id,
|
||||
'type': 'group',
|
||||
'typeId': group_id
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"user": user["_id"],
|
||||
"recipient": recipient_id,
|
||||
"type": "group",
|
||||
"typeId": group_id,
|
||||
}
|
||||
result = db.invitations.insert_one(invite)
|
||||
if 'groups.invited' in recipient.get('subscriptions', {}).get('email', []):
|
||||
mail.send({
|
||||
'to_user': recipient,
|
||||
'subject': 'You\'ve been invited to a group on {}!'.format(APP_NAME),
|
||||
'text': 'Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.'.format(
|
||||
recipient['username'],
|
||||
group['name'],
|
||||
if "groups.invited" in recipient.get("subscriptions", {}).get("email", []):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": recipient,
|
||||
"subject": "You've been invited to a group on {}!".format(APP_NAME),
|
||||
"text": "Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.".format(
|
||||
recipient["username"],
|
||||
group["name"],
|
||||
APP_URL,
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
invite['_id'] = result.inserted_id
|
||||
invite["_id"] = result.inserted_id
|
||||
return invite
|
||||
|
||||
|
||||
def create_group_request(user, group_id):
|
||||
db = database.get_db()
|
||||
group_id = ObjectId(group_id)
|
||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if group_id in user.get('groups'): raise util.errors.BadRequest('You are already a member of this group')
|
||||
admin = db.users.find_one({'_id': {'$in': group.get('admins', [])}}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
||||
if not admin: raise util.errors.NotFound('No users can approve you to join this group')
|
||||
if db.invitations.find_one({'recipient': user['_id'], 'typeId': group_id, 'type': 'group'}):
|
||||
raise util.errors.BadRequest('You have already been invited to this group')
|
||||
if db.invitations.find_one({'user': user['_id'], 'typeId': group_id, 'type': 'groupJoinRequest'}):
|
||||
raise util.errors.BadRequest('You have already requested access to this group')
|
||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if group_id in user.get("groups", []):
|
||||
raise util.errors.BadRequest("You are already a member of this group")
|
||||
admin = db.users.find_one(
|
||||
{"_id": {"$in": group.get("admins", [])}},
|
||||
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
||||
)
|
||||
if not admin:
|
||||
raise util.errors.NotFound("No users can approve you to join this group")
|
||||
if db.invitations.find_one(
|
||||
{"recipient": user["_id"], "typeId": group_id, "type": "group"}
|
||||
):
|
||||
raise util.errors.BadRequest("You have already been invited to this group")
|
||||
if db.invitations.find_one(
|
||||
{"user": user["_id"], "typeId": group_id, "type": "groupJoinRequest"}
|
||||
):
|
||||
raise util.errors.BadRequest("You have already requested access to this group")
|
||||
invite = {
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'user': user['_id'],
|
||||
'recipientGroup': group['_id'],
|
||||
'type': 'groupJoinRequest',
|
||||
'typeId': group_id
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"user": user["_id"],
|
||||
"recipientGroup": group["_id"],
|
||||
"type": "groupJoinRequest",
|
||||
"typeId": group_id,
|
||||
}
|
||||
result = db.invitations.insert_one(invite)
|
||||
if 'groups.joinRequested' in admin.get('subscriptions', {}).get('email', []):
|
||||
mail.send({
|
||||
'to_user': admin,
|
||||
'subject': 'Someone wants to join your group',
|
||||
'text': 'Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.'.format(
|
||||
admin['username'],
|
||||
user['username'],
|
||||
group['name'],
|
||||
if "groups.joinRequested" in admin.get("subscriptions", {}).get("email", []):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": admin,
|
||||
"subject": "Someone wants to join your group",
|
||||
"text": "Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.".format(
|
||||
admin["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
APP_URL,
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
invite['_id'] = result.inserted_id
|
||||
invite["_id"] = result.inserted_id
|
||||
return invite
|
||||
|
||||
|
||||
def get_group_invitations(user, id):
|
||||
db = database.get_db()
|
||||
group_id = ObjectId(id)
|
||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
||||
invites = list(db.invitations.find({'type': 'group', 'typeId': group_id}))
|
||||
recipients = list(db.users.find({'_id': {'$in': [i['recipient'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||
invites = list(db.invitations.find({"type": "group", "typeId": group_id}))
|
||||
recipients = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": [i["recipient"] for i in invites]}},
|
||||
{"username": 1, "avatar": 1},
|
||||
)
|
||||
)
|
||||
for invite in invites:
|
||||
for recipient in recipients:
|
||||
if invite['recipient'] == recipient['_id']:
|
||||
if 'avatar' in recipient:
|
||||
recipient['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(recipient['_id'], recipient['avatar']))
|
||||
invite['recipientUser'] = recipient
|
||||
if invite["recipient"] == recipient["_id"]:
|
||||
if "avatar" in recipient:
|
||||
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||
)
|
||||
invite["recipientUser"] = recipient
|
||||
break
|
||||
return {'invitations': invites}
|
||||
return {"invitations": invites}
|
||||
|
||||
|
||||
def delete_group_invitation(user, id, invite_id):
|
||||
db = database.get_db()
|
||||
group_id = ObjectId(id)
|
||||
invite_id = ObjectId(invite_id)
|
||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
||||
if not group: raise util.errors.NotFound('Group not found')
|
||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
||||
invite = db.invitations.find_one({'_id': invite_id})
|
||||
if not invite or invite['typeId'] != group_id: raise util.errors.NotFound('This invite could not be found')
|
||||
db.invitations.remove({'_id': invite_id})
|
||||
return {'deletedInvite': invite_id}
|
||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if user["_id"] not in group.get("admins", []):
|
||||
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||
invite = db.invitations.find_one({"_id": invite_id})
|
||||
if not invite or invite["typeId"] != group_id:
|
||||
raise util.errors.NotFound("This invite could not be found")
|
||||
db.invitations.delete_one({"_id": invite_id})
|
||||
return {"deletedInvite": invite_id}
|
||||
|
@ -1,154 +1,272 @@
|
||||
import datetime, base64, os
|
||||
import datetime
|
||||
import base64
|
||||
import os
|
||||
from bson.objectid import ObjectId
|
||||
import requests
|
||||
from util import database, wif, util, mail
|
||||
from api import uploads
|
||||
|
||||
APP_NAME = os.environ.get('APP_NAME')
|
||||
APP_NAME = os.environ.get("APP_NAME")
|
||||
APP_URL = os.environ.get("APP_URL")
|
||||
|
||||
|
||||
def delete(user, id):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
||||
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||
if not obj:
|
||||
raise util.errors.NotFound('Object not found')
|
||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
||||
raise util.errors.NotFound("Object not found")
|
||||
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||
if not project:
|
||||
raise util.errors.NotFound('Project not found')
|
||||
if project['user'] != user['_id']:
|
||||
raise util.errors.Forbidden('Forbidden', 403)
|
||||
db.objects.remove(ObjectId(id))
|
||||
return {'deletedObject': id}
|
||||
raise util.errors.NotFound("Project not found")
|
||||
if not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("Forbidden", 403)
|
||||
db.objects.delete_one({"_id": ObjectId(id)})
|
||||
return {"deletedObject": id}
|
||||
|
||||
|
||||
def get(user, id):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id))
|
||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||
if not obj:
|
||||
raise util.errors.NotFound('Object not found')
|
||||
raise util.errors.NotFound("Object not found")
|
||||
proj = db.projects.find_one({"_id": obj["project"]})
|
||||
if not proj:
|
||||
raise util.errors.NotFound("Project not found")
|
||||
is_owner = user and (user.get("_id") == proj["user"])
|
||||
if not is_owner and proj["visibility"] != "public":
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
|
||||
raise util.errors.Forbidden("Awaiting moderation")
|
||||
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
|
||||
if obj["type"] == "file" and "storedName" in obj:
|
||||
obj["url"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(proj["_id"], obj["storedName"])
|
||||
)
|
||||
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
||||
obj["previewUrl"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(proj["_id"], obj["preview"])
|
||||
)
|
||||
del obj["preview"]
|
||||
if obj.get("fullPreview"):
|
||||
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(proj["_id"], obj["fullPreview"])
|
||||
)
|
||||
obj["projectObject"] = proj
|
||||
if owner:
|
||||
if "avatar" in owner:
|
||||
owner["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(str(owner["_id"]), owner["avatar"])
|
||||
)
|
||||
obj["projectObject"]["owner"] = owner
|
||||
return obj
|
||||
|
||||
|
||||
def copy_to_project(user, id, project_id):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id))
|
||||
if not obj: raise util.errors.NotFound('This object could not be found')
|
||||
original_project = db.projects.find_one(obj['project'])
|
||||
if not obj:
|
||||
raise util.errors.NotFound("This object could not be found")
|
||||
original_project = db.projects.find_one(obj["project"])
|
||||
if not original_project:
|
||||
raise util.errors.NotFound('Project not found')
|
||||
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']):
|
||||
raise util.errors.Forbidden('This project is not open-source')
|
||||
raise util.errors.NotFound("Project not found")
|
||||
if not original_project.get("openSource") and not util.can_edit_project(
|
||||
user, original_project
|
||||
):
|
||||
raise util.errors.Forbidden("This project is not open-source")
|
||||
if original_project.get("visibility") != "public" and not util.can_edit_project(
|
||||
user, original_project
|
||||
):
|
||||
raise util.errors.Forbidden("This project is not public")
|
||||
target_project = db.projects.find_one(ObjectId(project_id))
|
||||
if not target_project or target_project['user'] != user['_id']:
|
||||
raise util.errors.Forbidden('You don\'t own the target project')
|
||||
if not target_project or not util.can_edit_project(user, target_project):
|
||||
raise util.errors.Forbidden("You don't own the target project")
|
||||
|
||||
obj['_id'] = ObjectId()
|
||||
obj['project'] = target_project['_id']
|
||||
obj['createdAt'] = datetime.datetime.now()
|
||||
obj['commentCount'] = 0
|
||||
obj["_id"] = ObjectId()
|
||||
obj["project"] = target_project["_id"]
|
||||
obj["createdAt"] = datetime.datetime.now()
|
||||
obj["commentCount"] = 0
|
||||
if "preview" in obj:
|
||||
del obj["preview"]
|
||||
if obj.get("pattern"):
|
||||
images = wif.generate_images(obj)
|
||||
if images:
|
||||
obj.update(images)
|
||||
db.objects.insert_one(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def get_wif(user, id):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id))
|
||||
if not obj: raise util.errors.NotFound('Object not found')
|
||||
project = db.projects.find_one(obj['project'])
|
||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
||||
raise util.errors.Forbidden('This project is not open-source')
|
||||
if not obj:
|
||||
raise util.errors.NotFound("Object not found")
|
||||
project = db.projects.find_one(obj["project"])
|
||||
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("This project is not open-source")
|
||||
if project.get("visibility") != "public" and not util.can_edit_project(
|
||||
user, project
|
||||
):
|
||||
raise util.errors.Forbidden("This project is not public")
|
||||
try:
|
||||
output = wif.dumps(obj).replace('\n', '\\n')
|
||||
return {'wif': output}
|
||||
except Exception as e:
|
||||
raise util.errors.BadRequest('Unable to create WIF file')
|
||||
output = wif.dumps(obj).replace("\n", "\\n")
|
||||
return {"wif": output}
|
||||
except Exception:
|
||||
raise util.errors.BadRequest("Unable to create WIF file")
|
||||
|
||||
|
||||
def get_pdf(user, id):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id))
|
||||
if not obj: raise util.errors.NotFound('Object not found')
|
||||
project = db.projects.find_one(obj['project'])
|
||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
||||
raise util.errors.Forbidden('This project is not open-source')
|
||||
if not obj:
|
||||
raise util.errors.NotFound("Object not found")
|
||||
project = db.projects.find_one(obj["project"])
|
||||
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("This project is not open-source")
|
||||
if project.get("visibility") != "public" and not util.can_edit_project(
|
||||
user, project
|
||||
):
|
||||
raise util.errors.Forbidden("This project is not public")
|
||||
try:
|
||||
response = requests.get('https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object=' + id + '&landscape=true&paperWidth=23.39&paperHeight=33.11')
|
||||
response = requests.get(
|
||||
"https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object="
|
||||
+ id
|
||||
+ "&landscape=true&paperWidth=23.39&paperHeight=33.11"
|
||||
)
|
||||
response.raise_for_status()
|
||||
pdf = uploads.get_file('objects/' + id + '/export.pdf')
|
||||
body64 = base64.b64encode(pdf['Body'].read())
|
||||
bytes_str = str(body64).replace("b'", '')[:-1]
|
||||
return {'pdf': body64.decode('ascii')}
|
||||
pdf = uploads.get_file("objects/" + id + "/export.pdf")
|
||||
body64 = base64.b64encode(pdf["Body"].read())
|
||||
return {"pdf": body64.decode("ascii")}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise util.errors.BadRequest('Unable to export PDF')
|
||||
raise util.errors.BadRequest("Unable to export PDF")
|
||||
|
||||
|
||||
def update(user, id, data):
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
||||
if not obj: raise util.errors.NotFound('Object not found')
|
||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
||||
if not project: raise util.errors.NotFound('Project not found')
|
||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||
allowed_keys = ['name', 'description', 'pattern', 'preview']
|
||||
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||
if not obj:
|
||||
raise util.errors.NotFound("Object not found")
|
||||
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||
if not project:
|
||||
raise util.errors.NotFound("Project not found")
|
||||
if not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
allowed_keys = ["name", "description", "pattern"]
|
||||
|
||||
updater = util.build_updater(data, allowed_keys)
|
||||
if updater:
|
||||
db.objects.update({'_id': ObjectId(id)}, updater)
|
||||
db.objects.update_one({"_id": ObjectId(id)}, updater)
|
||||
|
||||
if data.get("pattern"):
|
||||
obj.update(data)
|
||||
wif.generate_images(obj)
|
||||
|
||||
return get(user, id)
|
||||
|
||||
|
||||
def create_comment(user, id, data):
|
||||
if not data or not data.get('content'): raise util.errors.BadRequest('Comment data is required')
|
||||
if not data or not data.get("content"):
|
||||
raise util.errors.BadRequest("Comment data is required")
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
||||
if not obj: raise util.errors.NotFound('We could not find the specified object')
|
||||
project = db.projects.find_one({'_id': obj['project']})
|
||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||
if not obj:
|
||||
raise util.errors.NotFound("We could not find the specified object")
|
||||
comment = {
|
||||
'content': data.get('content', ''),
|
||||
'object': ObjectId(id),
|
||||
'user': user['_id'],
|
||||
'createdAt': datetime.datetime.now()
|
||||
"content": data.get("content", ""),
|
||||
"object": ObjectId(id),
|
||||
"user": user["_id"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"moderationRequired": True,
|
||||
}
|
||||
result = db.comments.insert_one(comment)
|
||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': 1}})
|
||||
comment['_id'] = result.inserted_id
|
||||
comment['authorUser'] = {
|
||||
'username': user['username'],
|
||||
'avatar': user.get('avatar'),
|
||||
'avatarUrl': uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user.get('avatar')))
|
||||
}
|
||||
project_owner = db.users.find_one({'_id': project['user'], 'subscriptions.email': 'projects.commented'})
|
||||
if project_owner and project_owner['_id'] != user['_id']:
|
||||
mail.send({
|
||||
'to_user': project_owner,
|
||||
'subject': '{} commented on {}'.format(user['username'], project['name']),
|
||||
'text': 'Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}'.format(
|
||||
project_owner['username'],
|
||||
user['username'],
|
||||
obj['name'],
|
||||
project['name'],
|
||||
comment['content'],
|
||||
'{}/{}/{}/{}'.format(
|
||||
APP_URL, project_owner['username'], project['path'], str(id)
|
||||
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
||||
comment["_id"] = result.inserted_id
|
||||
comment["authorUser"] = {
|
||||
"username": user["username"],
|
||||
"avatar": user.get("avatar"),
|
||||
"avatarUrl": uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
|
||||
),
|
||||
APP_NAME,
|
||||
)
|
||||
})
|
||||
}
|
||||
util.send_moderation_request(user, "comments", comment)
|
||||
return comment
|
||||
|
||||
def get_comments(user, id):
|
||||
|
||||
def send_comment_notification(id):
|
||||
db = database.get_db()
|
||||
comments = list(db.comments.find({'object': ObjectId(id)}))
|
||||
user_ids = list(map(lambda c:c['user'], comments))
|
||||
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
||||
comment = db.comments.find_one({"_id": ObjectId(id)})
|
||||
user = db.users.find_one({"_id": comment["user"]})
|
||||
obj = db.objects.find_one({"_id": comment["object"]})
|
||||
project = db.projects.find_one({"_id": obj["project"]})
|
||||
project_owner = db.users.find_one(
|
||||
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
||||
)
|
||||
if project_owner and project_owner["_id"] != user["_id"]:
|
||||
mail.send(
|
||||
{
|
||||
"to_user": project_owner,
|
||||
"subject": "{} commented on {}".format(
|
||||
user["username"], project["name"]
|
||||
),
|
||||
"text": "Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}".format(
|
||||
project_owner["username"],
|
||||
user["username"],
|
||||
obj["name"],
|
||||
project["name"],
|
||||
comment["content"],
|
||||
"{}/{}/{}/{}".format(
|
||||
APP_URL, project_owner["username"], project["path"], str(id)
|
||||
),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_comments(user, id):
|
||||
id = ObjectId(id)
|
||||
db = database.get_db()
|
||||
obj = db.objects.find_one({"_id": id}, {"project": 1})
|
||||
if not obj:
|
||||
raise util.errors.NotFound("Object not found")
|
||||
proj = db.projects.find_one({"_id": obj["project"]}, {"user": 1, "visibility": 1})
|
||||
if not proj:
|
||||
raise util.errors.NotFound("Project not found")
|
||||
is_owner = user and (user.get("_id") == proj["user"])
|
||||
if not is_owner and proj["visibility"] != "public":
|
||||
raise util.errors.Forbidden("This project is private")
|
||||
query = {
|
||||
"object": id,
|
||||
"$or": [
|
||||
{"moderationRequired": {"$ne": True}},
|
||||
{"user": user["_id"] if user else None},
|
||||
],
|
||||
}
|
||||
comments = list(db.comments.find(query))
|
||||
user_ids = list(map(lambda c: c["user"], comments))
|
||||
users = list(
|
||||
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
|
||||
)
|
||||
for comment in comments:
|
||||
for u in users:
|
||||
if comment['user'] == u['_id']:
|
||||
comment['authorUser'] = u
|
||||
if 'avatar' in u:
|
||||
comment['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
return {'comments': comments}
|
||||
if comment["user"] == u["_id"]:
|
||||
comment["authorUser"] = u
|
||||
if "avatar" in u:
|
||||
comment["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||
)
|
||||
return {"comments": comments}
|
||||
|
||||
|
||||
def delete_comment(user, id, comment_id):
|
||||
db = database.get_db()
|
||||
comment = db.comments.find_one({'_id': ObjectId(comment_id)})
|
||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
||||
if not comment or not obj or obj['_id'] != comment['object']: raise util.errors.NotFound('Comment not found')
|
||||
project = db.projects.find_one({'_id': obj['project']})
|
||||
if comment['user'] != user['_id'] and comment['user'] != project['user']: raise util.errors.Forbidden('You can\'t delete this comment')
|
||||
db.comments.remove({'_id': comment['_id']})
|
||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': -1}})
|
||||
return {'deletedComment': comment['_id']}
|
||||
comment = db.comments.find_one({"_id": ObjectId(comment_id)})
|
||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||
if not comment or not obj or obj["_id"] != comment["object"]:
|
||||
raise util.errors.NotFound("Comment not found")
|
||||
project = db.projects.find_one({"_id": obj["project"]})
|
||||
if comment["user"] != user["_id"] and not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("You can't delete this comment")
|
||||
db.comments.delete_one({"_id": comment["_id"]})
|
||||
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": -1}})
|
||||
return {"deletedComment": comment["_id"]}
|
||||
|
@ -1,190 +1,362 @@
|
||||
import datetime, re
|
||||
import datetime
|
||||
import re
|
||||
import os
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, wif, util
|
||||
from api import uploads
|
||||
from util import database, wif, util, mail
|
||||
from api import uploads, objects
|
||||
|
||||
default_pattern = {
|
||||
'warp': {
|
||||
'shafts': 8,
|
||||
'threads': 100,
|
||||
'threading': [{'shaft': 0}] * 100,
|
||||
'defaultColour': '178,53,111',
|
||||
'defaultSpacing': 1,
|
||||
'defaultThickness': 1,
|
||||
"warp": {
|
||||
"shafts": 8,
|
||||
"threading": [{"shaft": 0}] * 100,
|
||||
"defaultColour": "178,53,111",
|
||||
"defaultSpacing": 1,
|
||||
"defaultThickness": 1,
|
||||
"guideFrequency": 8,
|
||||
},
|
||||
'weft': {
|
||||
'treadles': 8,
|
||||
'threads': 50,
|
||||
'treadling': [{'treadle': 0}] * 50,
|
||||
'defaultColour': '53,69,178',
|
||||
'defaultSpacing': 1,
|
||||
'defaultThickness': 1
|
||||
"weft": {
|
||||
"treadles": 8,
|
||||
"treadling": [{"treadle": 0}] * 50,
|
||||
"defaultColour": "53,69,178",
|
||||
"defaultSpacing": 1,
|
||||
"defaultThickness": 1,
|
||||
"guideFrequency": 8,
|
||||
},
|
||||
'tieups': [[]] * 8,
|
||||
'colours': ['256,256,256', '0,0,0', '50,0,256', '0,68,256', '0,256,256', '0,256,0', '119,256,0', '256,256,0', '256,136,0', '256,0,0', '256,0,153', '204,0,256', '132,102,256', '102,155,256', '102,256,256', '102,256,102', '201,256,102', '256,256,102', '256,173,102', '256,102,102', '256,102,194', '224,102,256', '31,0,153', '0,41,153', '0,153,153', '0,153,0', '71,153,0', '153,153,0', '153,82,0', '153,0,0', '153,0,92', '122,0,153', '94,68,204', '68,102,204', '68,204,204', '68,204,68', '153,204,68', '204,204,68', '204,136,68', '204,68,68', '204,68,153', '170,68,204', '37,0,204', '0,50,204', '0,204,204', '0,204,0', '89,204,0', '204,204,0', '204,102,0', '204,0,0', '204,0,115', '153,0,204', '168,136,256', '136,170,256', '136,256,256', '136,256,136', '230,256,136', '256,256,136', '256,178,136', '256,136,136', '256,136,204', '240,136,256', '49,34,238', '34,68,238', '34,238,238', '34,238,34', '71,238,34', '238,238,34', '238,82,34', '238,34,34', '238,34,92', '122,34,238', '128,102,238', '102,136,238', '102,238,238', '102,238,102', '187,238,102', '238,238,102', '238,170,102', '238,102,102', '238,102,187', '204,102,238', '178,53,111', '53,69,178'],
|
||||
"tieups": [[]] * 8,
|
||||
"colours": [
|
||||
"256,256,256",
|
||||
"0,0,0",
|
||||
"50,0,256",
|
||||
"0,68,256",
|
||||
"0,256,256",
|
||||
"0,256,0",
|
||||
"119,256,0",
|
||||
"256,256,0",
|
||||
"256,136,0",
|
||||
"256,0,0",
|
||||
"256,0,153",
|
||||
"204,0,256",
|
||||
"132,102,256",
|
||||
"102,155,256",
|
||||
"102,256,256",
|
||||
"102,256,102",
|
||||
"201,256,102",
|
||||
"256,256,102",
|
||||
"256,173,102",
|
||||
"256,102,102",
|
||||
"256,102,194",
|
||||
"224,102,256",
|
||||
"31,0,153",
|
||||
"0,41,153",
|
||||
"0,153,153",
|
||||
"0,153,0",
|
||||
"71,153,0",
|
||||
"153,153,0",
|
||||
"153,82,0",
|
||||
"153,0,0",
|
||||
"153,0,92",
|
||||
"122,0,153",
|
||||
"94,68,204",
|
||||
"68,102,204",
|
||||
"68,204,204",
|
||||
"68,204,68",
|
||||
"153,204,68",
|
||||
"204,204,68",
|
||||
"204,136,68",
|
||||
"204,68,68",
|
||||
"204,68,153",
|
||||
"170,68,204",
|
||||
"37,0,204",
|
||||
"0,50,204",
|
||||
"0,204,204",
|
||||
"0,204,0",
|
||||
"89,204,0",
|
||||
"204,204,0",
|
||||
"204,102,0",
|
||||
"204,0,0",
|
||||
"204,0,115",
|
||||
"153,0,204",
|
||||
"168,136,256",
|
||||
"136,170,256",
|
||||
"136,256,256",
|
||||
"136,256,136",
|
||||
"230,256,136",
|
||||
"256,256,136",
|
||||
"256,178,136",
|
||||
"256,136,136",
|
||||
"256,136,204",
|
||||
"240,136,256",
|
||||
"49,34,238",
|
||||
"34,68,238",
|
||||
"34,238,238",
|
||||
"34,238,34",
|
||||
"71,238,34",
|
||||
"238,238,34",
|
||||
"238,82,34",
|
||||
"238,34,34",
|
||||
"238,34,92",
|
||||
"122,34,238",
|
||||
"128,102,238",
|
||||
"102,136,238",
|
||||
"102,238,238",
|
||||
"102,238,102",
|
||||
"187,238,102",
|
||||
"238,238,102",
|
||||
"238,170,102",
|
||||
"238,102,102",
|
||||
"238,102,187",
|
||||
"204,102,238",
|
||||
"178,53,111",
|
||||
"53,69,178",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def derive_path(name):
|
||||
path = name.replace(' ', '-').lower()
|
||||
return re.sub('[^0-9a-z\-]+', '', path)
|
||||
path = name.replace(" ", "-").lower()
|
||||
return re.sub("[^0-9a-z\-]+", "", path)
|
||||
|
||||
|
||||
def get_by_username(username, project_path):
|
||||
db = database.get_db()
|
||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1})
|
||||
owner = db.users.find_one({"username": username}, {"_id": 1, "username": 1})
|
||||
if not owner:
|
||||
raise util.errors.BadRequest('User not found')
|
||||
project = db.projects.find_one({'user': owner['_id'], 'path': project_path})
|
||||
raise util.errors.BadRequest("User not found")
|
||||
project = db.projects.find_one({"user": owner["_id"], "path": project_path})
|
||||
if not project:
|
||||
raise util.errors.NotFound('Project not found')
|
||||
project['owner'] = owner
|
||||
project['fullName'] = owner['username'] + '/' + project['path']
|
||||
raise util.errors.NotFound("Project not found")
|
||||
project["owner"] = owner
|
||||
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||
return project
|
||||
|
||||
|
||||
def create(user, data):
|
||||
if not data: raise util.errors.BadRequest('Invalid request')
|
||||
name = data.get('name', '')
|
||||
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
name = data.get("name", "")
|
||||
if len(name) < 3:
|
||||
raise util.errors.BadRequest("A longer name is required")
|
||||
db = database.get_db()
|
||||
|
||||
path = derive_path(name)
|
||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
||||
raise util.errors.BadRequest('Bad Name')
|
||||
groups = data.get('groupVisibility', [])
|
||||
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||
raise util.errors.BadRequest("Bad Name")
|
||||
groups = data.get("groupVisibility", [])
|
||||
group_visibility = []
|
||||
for group in groups:
|
||||
group_visibility.append(ObjectId(group))
|
||||
proj = {
|
||||
'name': name,
|
||||
'description': data.get('description', ''),
|
||||
'visibility': data.get('visibility', 'public'),
|
||||
'openSource': data.get('openSource', True),
|
||||
'groupVisibility': group_visibility,
|
||||
'path': path,
|
||||
'user': user['_id'],
|
||||
'createdAt': datetime.datetime.now()
|
||||
"name": name,
|
||||
"description": data.get("description", ""),
|
||||
"visibility": data.get("visibility", "public"),
|
||||
"openSource": data.get("openSource", True),
|
||||
"groupVisibility": group_visibility,
|
||||
"path": path,
|
||||
"user": user["_id"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
}
|
||||
result = db.projects.insert_one(proj)
|
||||
proj['_id'] = result.inserted_id
|
||||
proj['owner'] = {'_id': user['_id'], 'username': user['username']}
|
||||
proj['fullName'] = user['username'] + '/' + proj['path']
|
||||
proj["_id"] = result.inserted_id
|
||||
proj["owner"] = {"_id": user["_id"], "username": user["username"]}
|
||||
proj["fullName"] = user["username"] + "/" + proj["path"]
|
||||
return proj
|
||||
|
||||
|
||||
def get(user, username, path):
|
||||
db = database.get_db()
|
||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
||||
if not owner: raise util.errors.NotFound('User not found')
|
||||
project = db.projects.find_one({'user': owner['_id'], 'path': path})
|
||||
if not project: raise util.errors.NotFound('Project not found')
|
||||
owner = db.users.find_one(
|
||||
{"username": username},
|
||||
{
|
||||
"_id": 1,
|
||||
"username": 1,
|
||||
"avatar": 1,
|
||||
"isSilverSupporter": 1,
|
||||
"isGoldSupporter": 1,
|
||||
},
|
||||
)
|
||||
if not owner:
|
||||
raise util.errors.NotFound("User not found")
|
||||
project = db.projects.find_one({"user": owner["_id"], "path": path})
|
||||
if not project:
|
||||
raise util.errors.NotFound("Project not found")
|
||||
if not util.can_view_project(user, project):
|
||||
raise util.errors.Forbidden('This project is private')
|
||||
raise util.errors.Forbidden("This project is private")
|
||||
|
||||
if 'avatar' in owner:
|
||||
owner['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(owner['_id'], owner['avatar']))
|
||||
project['owner'] = owner
|
||||
project['fullName'] = owner['username'] + '/' + project['path']
|
||||
if "avatar" in owner:
|
||||
owner["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(owner["_id"], owner["avatar"])
|
||||
)
|
||||
project["owner"] = owner
|
||||
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||
return project
|
||||
|
||||
|
||||
def update(user, username, project_path, update):
|
||||
db = database.get_db()
|
||||
project = get_by_username(username, project_path)
|
||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||
if not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
|
||||
current_path = project_path
|
||||
if 'name' in update:
|
||||
if len(update['name']) < 3: raise util.errors.BadRequest('The name is too short.')
|
||||
path = derive_path(update['name'])
|
||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
||||
raise util.errors.BadRequest('You already have a project with a similar name')
|
||||
update['path'] = path
|
||||
if "name" in update:
|
||||
if len(update["name"]) < 3:
|
||||
raise util.errors.BadRequest("The name is too short.")
|
||||
path = derive_path(update["name"])
|
||||
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||
raise util.errors.BadRequest(
|
||||
"You already have a project with a similar name"
|
||||
)
|
||||
update["path"] = path
|
||||
current_path = path
|
||||
update['groupVisibility'] = list(map(lambda g: ObjectId(g), update.get('groupVisibility', [])))
|
||||
allowed_keys = ['name', 'description', 'path', 'visibility', 'openSource', 'groupVisibility']
|
||||
update["groupVisibility"] = list(
|
||||
map(lambda g: ObjectId(g), update.get("groupVisibility", []))
|
||||
)
|
||||
allowed_keys = [
|
||||
"name",
|
||||
"description",
|
||||
"path",
|
||||
"visibility",
|
||||
"openSource",
|
||||
"groupVisibility",
|
||||
]
|
||||
updater = util.build_updater(update, allowed_keys)
|
||||
if updater:
|
||||
db.projects.update({'_id': project['_id']}, updater)
|
||||
db.projects.update_one({"_id": project["_id"]}, updater)
|
||||
return get(user, username, current_path)
|
||||
|
||||
|
||||
def delete(user, username, project_path):
|
||||
db = database.get_db()
|
||||
project = get_by_username(username, project_path)
|
||||
if project['user'] != user['_id']:
|
||||
raise util.errors.Forbidden('Forbidden')
|
||||
db.projects.remove({'_id': project['_id']})
|
||||
db.objects.remove({'project': project['_id']})
|
||||
return {'deletedProject': project['_id'] }
|
||||
if not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
db.projects.delete_one({"_id": project["_id"]})
|
||||
db.objects.delete_many({"project": project["_id"]})
|
||||
return {"deletedProject": project["_id"]}
|
||||
|
||||
|
||||
def get_objects(user, username, path):
|
||||
db = database.get_db()
|
||||
project = get_by_username(username, path)
|
||||
if not project: raise util.errors.NotFound('Project not found')
|
||||
if not project:
|
||||
raise util.errors.NotFound("Project not found")
|
||||
if not util.can_view_project(user, project):
|
||||
raise util.errors.Forbidden('This project is private')
|
||||
raise util.errors.Forbidden("This project is private")
|
||||
|
||||
objs = list(db.objects.find({'project': project['_id']}, {'createdAt': 1, 'name': 1, 'description': 1, 'project': 1, 'preview': 1, 'type': 1, 'storedName': 1, 'isImage': 1, 'imageBlurHash': 1, 'commentCount': 1}))
|
||||
query = {"project": project["_id"]}
|
||||
if not util.can_edit_project(user, project):
|
||||
query["moderationRequired"] = {"$ne": True}
|
||||
objs = list(
|
||||
db.objects.find(
|
||||
query,
|
||||
{
|
||||
"createdAt": 1,
|
||||
"name": 1,
|
||||
"description": 1,
|
||||
"project": 1,
|
||||
"preview": 1,
|
||||
"fullPreview": 1,
|
||||
"type": 1,
|
||||
"storedName": 1,
|
||||
"isImage": 1,
|
||||
"imageBlurHash": 1,
|
||||
"commentCount": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
for obj in objs:
|
||||
if obj['type'] == 'file' and 'storedName' in obj:
|
||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
||||
if obj["type"] == "file" and "storedName" in obj:
|
||||
obj["url"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
||||
)
|
||||
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
||||
obj["previewUrl"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(project["_id"], obj["preview"])
|
||||
)
|
||||
del obj["preview"]
|
||||
if obj.get("fullPreview"):
|
||||
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(project["_id"], obj["fullPreview"])
|
||||
)
|
||||
return objs
|
||||
|
||||
|
||||
def create_object(user, username, path, data):
|
||||
if not data and not data.get('type'): raise util.errors.BadRequest('Invalid request')
|
||||
if not data.get('type'): raise util.errors.BadRequest('Object type is required.')
|
||||
if not data and not data.get("type"):
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
if not data.get("type"):
|
||||
raise util.errors.BadRequest("Object type is required.")
|
||||
db = database.get_db()
|
||||
project = get_by_username(username, path)
|
||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||
file_count = db.objects.find({'project': project['_id']}).count()
|
||||
if not util.can_edit_project(user, project):
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
|
||||
if data['type'] == 'file':
|
||||
if not 'storedName' in data:
|
||||
raise util.errors.BadRequest('File stored name must be included')
|
||||
if data["type"] == "file":
|
||||
if "storedName" not in data:
|
||||
raise util.errors.BadRequest("File stored name must be included")
|
||||
obj = {
|
||||
'project': project['_id'],
|
||||
'name': data.get('name', 'Untitled file'),
|
||||
'storedName': data['storedName'],
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'type': 'file',
|
||||
"project": project["_id"],
|
||||
"name": data.get("name", "Untitled file"),
|
||||
"storedName": data["storedName"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"type": "file",
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', data['storedName'].lower()):
|
||||
obj['isImage'] = True
|
||||
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
||||
obj["isImage"] = True
|
||||
result = db.objects.insert_one(obj)
|
||||
obj['_id'] = result.inserted_id
|
||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
||||
if obj.get('isImage'):
|
||||
obj["_id"] = result.inserted_id
|
||||
obj["url"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
||||
)
|
||||
if obj.get("isImage"):
|
||||
|
||||
def handle_cb(h):
|
||||
db.objects.update_one({'_id': obj['_id']}, {'$set': {'imageBlurHash': h}})
|
||||
uploads.blur_image('projects/' + str(project['_id']) + '/' + data['storedName'], handle_cb)
|
||||
db.objects.update_one(
|
||||
{"_id": obj["_id"]}, {"$set": {"imageBlurHash": h}}
|
||||
)
|
||||
|
||||
uploads.blur_image(
|
||||
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
||||
)
|
||||
util.send_moderation_request(user, "object", obj)
|
||||
return obj
|
||||
if data['type'] == 'pattern':
|
||||
if data.get('wif'):
|
||||
if data["type"] == "pattern":
|
||||
obj = {
|
||||
"project": project["_id"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"type": "pattern",
|
||||
}
|
||||
if data.get("wif"):
|
||||
try:
|
||||
pattern = wif.loads(data['wif'])
|
||||
pattern = wif.loads(data["wif"])
|
||||
if pattern:
|
||||
obj = {
|
||||
'project': project['_id'],
|
||||
'name': pattern['name'],
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'type': 'pattern',
|
||||
'pattern': pattern
|
||||
}
|
||||
result = db.objects.insert_one(obj)
|
||||
obj['_id'] = result.inserted_id
|
||||
return obj
|
||||
obj["name"] = pattern["name"]
|
||||
obj["pattern"] = pattern
|
||||
except Exception as e:
|
||||
raise util.errors.BadRequest('Unable to load WIF file. It is either invalid or in a format we cannot understand.')
|
||||
elif data.get('name'):
|
||||
pattern = default_pattern.copy()
|
||||
pattern['warp'].update({'shafts': data.get('shafts', 8)})
|
||||
pattern['weft'].update({'treadles': data.get('treadles', 8)})
|
||||
obj = {
|
||||
'project': project['_id'],
|
||||
'name': data['name'],
|
||||
'createdAt': datetime.datetime.now(),
|
||||
'type': 'pattern',
|
||||
'pattern': pattern
|
||||
mail.send(
|
||||
{
|
||||
"to": os.environ.get("ADMIN_EMAIL"),
|
||||
"subject": "Error loading WIF file",
|
||||
"text": "A WIF file failed to parse with error: {}. The contents are below:\n\n{}".format(
|
||||
e, data["wif"]
|
||||
),
|
||||
}
|
||||
)
|
||||
raise util.errors.BadRequest(
|
||||
"Unable to load WIF file. It is either invalid or in a format we cannot understand."
|
||||
)
|
||||
else:
|
||||
pattern = default_pattern.copy()
|
||||
pattern["warp"].update({"shafts": data.get("shafts", 8)})
|
||||
pattern["weft"].update({"treadles": data.get("treadles", 8)})
|
||||
obj["name"] = data.get("name") or "Untitled Pattern"
|
||||
obj["pattern"] = pattern
|
||||
result = db.objects.insert_one(obj)
|
||||
obj['_id'] = result.inserted_id
|
||||
return obj
|
||||
raise util.errors.BadRequest('Unable to create object')
|
||||
|
||||
obj["_id"] = result.inserted_id
|
||||
images = wif.generate_images(obj)
|
||||
if images:
|
||||
db.objects.update_one({"_id": obj["_id"]}, {"$set": images})
|
||||
|
||||
return objects.get(user, obj["_id"])
|
||||
raise util.errors.BadRequest("Unable to create object")
|
||||
|
144
api/api/root.py
144
api/api/root.py
@ -1,35 +1,135 @@
|
||||
import re, datetime
|
||||
import pymongo
|
||||
import datetime
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util, mail
|
||||
from api import uploads, groups
|
||||
from util import database, util
|
||||
from api import uploads, objects, groups
|
||||
|
||||
|
||||
def get_users(user):
|
||||
db = database.get_db()
|
||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
||||
users = list(db.users.find({}, {'username': 1, 'avatar': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1, 'roles': 1, 'groups': 1}).sort('lastSeenAt', -1))
|
||||
if not util.is_root(user):
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
users = list(
|
||||
db.users.find(
|
||||
{},
|
||||
{
|
||||
"username": 1,
|
||||
"avatar": 1,
|
||||
"email": 1,
|
||||
"createdAt": 1,
|
||||
"lastSeenAt": 1,
|
||||
"roles": 1,
|
||||
"groups": 1,
|
||||
},
|
||||
)
|
||||
.sort("lastSeenAt", -1)
|
||||
.limit(200)
|
||||
)
|
||||
group_ids = []
|
||||
for u in users: group_ids += u.get('groups', [])
|
||||
groups = list(db.groups.find({'_id': {'$in': group_ids}}))
|
||||
projects = list(db.projects.find({}, {'name': 1, 'path': 1, 'user': 1}))
|
||||
for u in users:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
||||
u['projects'] = []
|
||||
group_ids += u.get("groups", [])
|
||||
groups = list(db.groups.find({"_id": {"$in": group_ids}}, {"name": 1}))
|
||||
projects = list(db.projects.find({}, {"name": 1, "path": 1, "user": 1}))
|
||||
for u in users:
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||
)
|
||||
u["projects"] = []
|
||||
for p in projects:
|
||||
if p['user'] == u['_id']:
|
||||
u['projects'].append(p)
|
||||
u['groupMemberships'] = []
|
||||
if u.get('groups'):
|
||||
if p["user"] == u["_id"]:
|
||||
u["projects"].append(p)
|
||||
u["groupMemberships"] = []
|
||||
if u.get("groups"):
|
||||
for g in groups:
|
||||
if g['_id'] in u.get('groups', []):
|
||||
u['groupMemberships'].append(g)
|
||||
return {'users': users}
|
||||
if g["_id"] in u.get("groups", []):
|
||||
u["groupMemberships"].append(g)
|
||||
return {"users": users}
|
||||
|
||||
|
||||
def get_groups(user):
|
||||
db = database.get_db()
|
||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
||||
if not util.is_root(user):
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
groups = list(db.groups.find({}))
|
||||
for group in groups:
|
||||
group['memberCount'] = db.users.find({'groups': group['_id']}).count()
|
||||
return {'groups': groups}
|
||||
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
def get_moderation(user):
|
||||
db = database.get_db()
|
||||
if not util.is_root(user):
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
object_list = list(db.objects.find({"moderationRequired": True}))
|
||||
for obj in object_list:
|
||||
if obj["type"] == "file" and "storedName" in obj:
|
||||
obj["url"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
|
||||
)
|
||||
comment_list = list(db.comments.find({"moderationRequired": True}))
|
||||
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
|
||||
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
|
||||
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
|
||||
for entry in group_entry_list:
|
||||
for a in entry.get("attachments", []):
|
||||
if a["type"] == "file" and "storedName" in a:
|
||||
a["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/{1}".format(entry["group"], a["storedName"])
|
||||
)
|
||||
group_topic_reply_list = list(
|
||||
db.groupForumTopicReplies.find({"moderationRequired": True})
|
||||
)
|
||||
for reply in group_topic_reply_list:
|
||||
for a in reply.get("attachments", []):
|
||||
if a["type"] == "file" and "storedName" in a:
|
||||
a["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/topics/{1}/{2}".format(
|
||||
reply["group"], reply["topic"], a["storedName"]
|
||||
)
|
||||
)
|
||||
return {
|
||||
"objects": object_list,
|
||||
"comments": comment_list,
|
||||
"users": user_list,
|
||||
"groups": group_list,
|
||||
"groupEntries": group_entry_list,
|
||||
"groupForumTopicReplies": group_topic_reply_list,
|
||||
}
|
||||
|
||||
|
||||
def moderate(user, item_type, item_id, allowed):
|
||||
db = database.get_db()
|
||||
if not util.is_root(user):
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
if item_type not in [
|
||||
"objects",
|
||||
"comments",
|
||||
"users",
|
||||
"groups",
|
||||
"groupEntries",
|
||||
"groupForumTopicReplies",
|
||||
]:
|
||||
raise util.errors.BadRequest("Invalid item type")
|
||||
item_id = ObjectId(item_id)
|
||||
item = db[item_type].find_one({"_id": item_id})
|
||||
# For now, handle only allowed moderations.
|
||||
# Disallowed will be manually managed.
|
||||
if item and allowed:
|
||||
db[item_type].update_one(
|
||||
{"_id": item_id},
|
||||
{
|
||||
"$set": {
|
||||
"moderationRequired": False,
|
||||
"moderated": True,
|
||||
"moderatedAt": datetime.datetime.now(),
|
||||
"moderatedBy": user["_id"],
|
||||
}
|
||||
},
|
||||
)
|
||||
if item_type == "comments":
|
||||
objects.send_comment_notification(item_id)
|
||||
if item_type == "groupEntries":
|
||||
groups.send_entry_notification(item_id)
|
||||
if item_type == "groupForumTopicReplies":
|
||||
groups.send_forum_topic_reply_notification(item_id)
|
||||
return {"success": True}
|
||||
|
@ -1,74 +1,253 @@
|
||||
import re, random
|
||||
import re
|
||||
import random
|
||||
import pymongo
|
||||
from util import database, util
|
||||
from api import uploads
|
||||
|
||||
|
||||
def all(user, params):
|
||||
if not params or 'query' not in params: raise util.errors.BadRequest('Username parameter needed')
|
||||
expression = re.compile(params['query'], re.IGNORECASE)
|
||||
if not params or "query" not in params:
|
||||
raise util.errors.BadRequest("Query parameter needed")
|
||||
expression = re.compile(params["query"], re.IGNORECASE)
|
||||
db = database.get_db()
|
||||
|
||||
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(10).sort('username', pymongo.ASCENDING))
|
||||
users = list(
|
||||
db.users.find(
|
||||
{"username": expression},
|
||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||
)
|
||||
.limit(10)
|
||||
.sort("username", pymongo.ASCENDING)
|
||||
)
|
||||
for u in users:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||
)
|
||||
|
||||
projects = list(db.projects.find({'name': expression, '$or': [
|
||||
{'user': user['_id']},
|
||||
{'groupVisibility': {'$in': user.get('groups', [])}},
|
||||
{'visibility': 'public'}
|
||||
]}, {'name': 1, 'path': 1, 'user': 1}).limit(5))
|
||||
proj_users = list(db.users.find({'_id': {'$in': list(map(lambda p:p['user'], projects))}}, {'username': 1, 'avatar': 1}))
|
||||
my_projects = list(db.projects.find({"user": user["_id"]}, {"name": 1, "path": 1}))
|
||||
objects = list(
|
||||
db.objects.find(
|
||||
{
|
||||
"project": {"$in": list(map(lambda p: p["_id"], my_projects))},
|
||||
"name": expression,
|
||||
},
|
||||
{"name": 1, "type": 1, "isImage": 1, "project": 1},
|
||||
)
|
||||
)
|
||||
for o in objects:
|
||||
proj = next(p for p in my_projects if p["_id"] == o["project"])
|
||||
if proj:
|
||||
o["path"] = user["username"] + "/" + proj["path"] + "/" + str(o["_id"])
|
||||
|
||||
projects = list(
|
||||
db.projects.find(
|
||||
{
|
||||
"name": expression,
|
||||
"$or": [
|
||||
{"user": user["_id"]},
|
||||
{"groupVisibility": {"$in": user.get("groups", [])}},
|
||||
{"visibility": "public"},
|
||||
],
|
||||
},
|
||||
{"name": 1, "path": 1, "user": 1},
|
||||
).limit(10)
|
||||
)
|
||||
proj_users = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
||||
{"username": 1, "avatar": 1},
|
||||
)
|
||||
)
|
||||
for proj in projects:
|
||||
for proj_user in proj_users:
|
||||
if proj['user'] == proj_user['_id']:
|
||||
proj['owner'] = proj_user
|
||||
proj['fullName'] = proj_user['username'] + '/' + proj['path']
|
||||
if 'avatar' in proj_user:
|
||||
proj['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(proj_user['_id'], proj_user['avatar']))
|
||||
if proj["user"] == proj_user["_id"]:
|
||||
proj["owner"] = proj_user
|
||||
proj["fullName"] = proj_user["username"] + "/" + proj["path"]
|
||||
if "avatar" in proj_user:
|
||||
proj["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(proj_user["_id"], proj_user["avatar"])
|
||||
)
|
||||
|
||||
groups = list(db.groups.find({'name': expression, 'unlisted': {'$ne': True}}, {'name': 1, 'closed': 1}).limit(5))
|
||||
groups = list(
|
||||
db.groups.find(
|
||||
{"name": expression, "unlisted": {"$ne": True}}, {"name": 1, "closed": 1}
|
||||
).limit(5)
|
||||
)
|
||||
|
||||
return {"users": users, "projects": projects, "groups": groups, "objects": objects}
|
||||
|
||||
return {'users': users, 'projects': projects, 'groups': groups}
|
||||
|
||||
def users(user, params):
|
||||
if not user: raise util.errors.Forbidden('You need to be logged in')
|
||||
if not params or 'username' not in params: raise util.errors.BadRequest('Username parameter needed')
|
||||
expression = re.compile(params['username'], re.IGNORECASE)
|
||||
if not user:
|
||||
raise util.errors.Forbidden("You need to be logged in")
|
||||
if not params or "username" not in params:
|
||||
raise util.errors.BadRequest("Username parameter needed")
|
||||
expression = re.compile(params["username"], re.IGNORECASE)
|
||||
db = database.get_db()
|
||||
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(5).sort('username', pymongo.ASCENDING))
|
||||
users = list(
|
||||
db.users.find(
|
||||
{"username": expression},
|
||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||
)
|
||||
.limit(5)
|
||||
.sort("username", pymongo.ASCENDING)
|
||||
)
|
||||
for u in users:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
return {'users': users}
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||
)
|
||||
return {"users": users}
|
||||
|
||||
def discover(user):
|
||||
if not user: raise util.errors.Forbidden('You need to be logged in')
|
||||
|
||||
def discover(user, count=3):
|
||||
db = database.get_db()
|
||||
projects = []
|
||||
users = []
|
||||
count = 3
|
||||
groups = []
|
||||
|
||||
all_projects = list(db.projects.find({'name': {'$not': re.compile('my new project', re.IGNORECASE)}, 'visibility': 'public', 'user': {'$ne': user['_id']}}, {'name': 1, 'path': 1, 'user': 1}))
|
||||
all_projects_query = {
|
||||
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
||||
"visibility": "public",
|
||||
}
|
||||
if user and user.get("_id"):
|
||||
all_projects_query["user"] = {"$ne": user["_id"]}
|
||||
all_projects = list(
|
||||
db.projects.find(all_projects_query, {"name": 1, "path": 1, "user": 1})
|
||||
)
|
||||
random.shuffle(all_projects)
|
||||
for p in all_projects:
|
||||
if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}):
|
||||
owner = db.users.find_one({'_id': p['user']}, {'username': 1})
|
||||
p['fullName'] = owner['username'] + '/' + p['path']
|
||||
if db.objects.find_one(
|
||||
{"project": p["_id"], "name": {"$ne": "Untitled pattern"}}
|
||||
):
|
||||
owner = db.users.find_one({"_id": p["user"]}, {"username": 1, "avatar": 1})
|
||||
p["fullName"] = owner["username"] + "/" + p["path"]
|
||||
p["owner"] = owner
|
||||
if "avatar" in p["owner"]:
|
||||
p["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(p["owner"]["_id"], p["owner"]["avatar"])
|
||||
)
|
||||
projects.append(p)
|
||||
if len(projects) >= count: break
|
||||
if len(projects) >= count:
|
||||
break
|
||||
|
||||
interest_fields = ['bio', 'avatar', 'website', 'facebook', 'twitter', 'instagram', 'location']
|
||||
all_users = list(db.users.find({'_id': {'$ne': user['_id']}, '$or': list(map(lambda f: {f: {'$exists': True}}, interest_fields))}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}))
|
||||
interest_fields = [
|
||||
"bio",
|
||||
"avatar",
|
||||
"website",
|
||||
"facebook",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"location",
|
||||
]
|
||||
all_users_query = {
|
||||
"$or": list(map(lambda f: {f: {"$exists": True}}, interest_fields))
|
||||
}
|
||||
if user and user.get("_id"):
|
||||
all_users_query["_id"] = {"$ne": user["_id"]}
|
||||
all_users = list(
|
||||
db.users.find(
|
||||
all_users_query,
|
||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||
)
|
||||
)
|
||||
random.shuffle(all_users)
|
||||
for u in all_users:
|
||||
if 'avatar' in u:
|
||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||
)
|
||||
if user:
|
||||
u["following"] = u["_id"] in list(
|
||||
map(lambda f: f["user"], user.get("following", []))
|
||||
)
|
||||
users.append(u)
|
||||
if len(users) >= count: break
|
||||
if len(users) >= count:
|
||||
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 {
|
||||
'highlightProjects': projects,
|
||||
'highlightUsers': users,
|
||||
"highlightProjects": projects,
|
||||
"highlightUsers": users,
|
||||
"highlightGroups": groups,
|
||||
}
|
||||
|
||||
|
||||
def explore(page=1):
|
||||
db = database.get_db()
|
||||
per_page = 10
|
||||
|
||||
project_map = {}
|
||||
user_map = {}
|
||||
all_public_projects = list(
|
||||
db.projects.find(
|
||||
{
|
||||
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
||||
"visibility": "public",
|
||||
},
|
||||
{"name": 1, "path": 1, "user": 1},
|
||||
)
|
||||
)
|
||||
all_public_project_ids = list(map(lambda p: p["_id"], all_public_projects))
|
||||
for project in all_public_projects:
|
||||
project_map[project["_id"]] = project
|
||||
objects = list(
|
||||
db.objects.find(
|
||||
{
|
||||
"project": {"$in": all_public_project_ids},
|
||||
"name": {"$not": re.compile("untitled pattern", re.IGNORECASE)},
|
||||
"preview": {"$exists": True},
|
||||
},
|
||||
{"project": 1, "name": 1, "createdAt": 1, "type": 1, "preview": 1},
|
||||
)
|
||||
.sort("createdAt", pymongo.DESCENDING)
|
||||
.skip((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
)
|
||||
for object in objects:
|
||||
object["projectObject"] = project_map.get(object["project"])
|
||||
if "preview" in object and ".png" in object["preview"]:
|
||||
object["previewUrl"] = uploads.get_presigned_url(
|
||||
"projects/{0}/{1}".format(object["project"], object["preview"])
|
||||
)
|
||||
del object["preview"]
|
||||
authors = list(
|
||||
db.users.find(
|
||||
{
|
||||
"_id": {
|
||||
"$in": list(
|
||||
map(lambda o: o.get("projectObject", {}).get("user"), objects)
|
||||
)
|
||||
}
|
||||
},
|
||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||
)
|
||||
)
|
||||
for a in authors:
|
||||
if "avatar" in a:
|
||||
a["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
||||
)
|
||||
user_map[a["_id"]] = a
|
||||
for object in objects:
|
||||
object["userObject"] = user_map.get(object.get("projectObject", {}).get("user"))
|
||||
object["projectObject"]["owner"] = user_map.get(
|
||||
object.get("projectObject", {}).get("user")
|
||||
)
|
||||
|
||||
return {"objects": objects}
|
||||
|
41
api/api/snippets.py
Normal file
41
api/api/snippets.py
Normal file
@ -0,0 +1,41 @@
|
||||
import datetime
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util
|
||||
|
||||
|
||||
def list_for_user(user):
|
||||
db = database.get_db()
|
||||
snippets = db.snippets.find({"user": user["_id"]}).sort("createdAt", -1)
|
||||
return {"snippets": list(snippets)}
|
||||
|
||||
|
||||
def create(user, data):
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
name = data.get("name", "")
|
||||
snippet_type = data.get("type", "")
|
||||
if len(name) < 3:
|
||||
raise util.errors.BadRequest("A longer name is required")
|
||||
if snippet_type not in ["warp", "weft"]:
|
||||
raise util.errors.BadRequest("Invalid snippet type")
|
||||
db = database.get_db()
|
||||
snippet = {
|
||||
"name": name,
|
||||
"user": user["_id"],
|
||||
"createdAt": datetime.datetime.utcnow(),
|
||||
"type": snippet_type,
|
||||
"threading": data.get("threading", []),
|
||||
"treadling": data.get("treadling", []),
|
||||
}
|
||||
result = db.snippets.insert_one(snippet)
|
||||
snippet["_id"] = result.inserted_id
|
||||
return snippet
|
||||
|
||||
|
||||
def delete(user, id):
|
||||
db = database.get_db()
|
||||
snippet = db.snippets.find_one({"_id": ObjectId(id), "user": user["_id"]})
|
||||
if not snippet:
|
||||
raise util.errors.NotFound("Snippet not found")
|
||||
db.snippets.delete_one({"_id": snippet["_id"]})
|
||||
return {"deletedSnippet": snippet["_id"]}
|
@ -1,83 +1,108 @@
|
||||
import os, time, re
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
from threading import Thread
|
||||
from bson.objectid import ObjectId
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
import blurhash
|
||||
from util import database
|
||||
from util import database, util
|
||||
from api.groups import has_group_permission
|
||||
|
||||
|
||||
def sanitise_filename(s):
|
||||
bad_chars = re.compile('[^a-zA-Z0-9_.]')
|
||||
s = bad_chars.sub('_', s)
|
||||
bad_chars = re.compile("[^a-zA-Z0-9_.]")
|
||||
s = bad_chars.sub("_", s)
|
||||
return s
|
||||
|
||||
|
||||
def get_s3():
|
||||
session = boto3.session.Session()
|
||||
|
||||
s3_client = session.client(
|
||||
service_name='s3',
|
||||
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
||||
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
|
||||
endpoint_url=os.environ['AWS_S3_ENDPOINT'],
|
||||
service_name="s3",
|
||||
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||
endpoint_url=os.environ["AWS_S3_ENDPOINT"],
|
||||
)
|
||||
return s3_client
|
||||
|
||||
|
||||
def get_presigned_url(path):
|
||||
return os.environ['AWS_S3_ENDPOINT'] + os.environ['AWS_S3_BUCKET'] + '/' + path
|
||||
return os.environ["AWS_S3_ENDPOINT"] + os.environ["AWS_S3_BUCKET"] + "/" + path
|
||||
s3 = get_s3()
|
||||
return s3.generate_presigned_url('get_object',
|
||||
Params = {
|
||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
||||
'Key': path
|
||||
}
|
||||
return s3.generate_presigned_url(
|
||||
"get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
|
||||
)
|
||||
|
||||
|
||||
def upload_file(path, data):
|
||||
s3 = get_s3()
|
||||
s3.upload_fileobj(
|
||||
data,
|
||||
os.environ["AWS_S3_BUCKET"],
|
||||
path,
|
||||
)
|
||||
|
||||
|
||||
def get_file(key):
|
||||
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 generate_file_upload_request(user, file_name, file_size, file_type, for_type, for_id):
|
||||
|
||||
def generate_file_upload_request(
|
||||
user, file_name, file_size, file_type, for_type, for_id
|
||||
):
|
||||
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
||||
raise util.errors.BadRequest('File size is too big')
|
||||
raise util.errors.BadRequest("File size is too big")
|
||||
db = database.get_db()
|
||||
allowed = False
|
||||
path = ''
|
||||
if for_type == 'project':
|
||||
path = ""
|
||||
if for_type == "project":
|
||||
project = db.projects.find_one(ObjectId(for_id))
|
||||
allowed = project and project.get('user') == user['_id']
|
||||
path = 'projects/' + for_id + '/'
|
||||
if for_type == 'user':
|
||||
allowed = for_id == str(user['_id'])
|
||||
path = 'users/' + for_id + '/'
|
||||
if for_type == 'group':
|
||||
allowed = ObjectId(for_id) in user.get('groups', [])
|
||||
path = 'groups/' + for_id + '/'
|
||||
allowed = project and util.can_edit_project(user, project)
|
||||
path = "projects/" + for_id + "/"
|
||||
if for_type == "user":
|
||||
allowed = for_id == str(user["_id"])
|
||||
path = "users/" + for_id + "/"
|
||||
if for_type == "group":
|
||||
allowed = ObjectId(for_id) in user.get("groups", [])
|
||||
path = "groups/" + for_id + "/"
|
||||
if for_type == "groupForum":
|
||||
topic = db.groupForumTopics.find_one(ObjectId(for_id))
|
||||
if not topic:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
group = db.groups.find_one(topic["group"])
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
allowed = has_group_permission(user, group, "postForumTopicReplies")
|
||||
path = "groups/" + str(group["_id"]) + "/topics/" + for_id + "/"
|
||||
if not allowed:
|
||||
raise util.errors.Forbidden('You\'re not allowed to upload this file')
|
||||
raise util.errors.Forbidden("You're not allowed to upload this file")
|
||||
|
||||
file_body, file_extension = os.path.splitext(file_name)
|
||||
new_name = sanitise_filename('{0}_{1}{2}'.format(file_body or file_name, int(time.time()), file_extension or ''))
|
||||
s3 = get_s3()
|
||||
signed_url = s3.generate_presigned_url('put_object',
|
||||
Params = {
|
||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
||||
'Key': path + new_name,
|
||||
'ContentType': file_type
|
||||
}
|
||||
new_name = sanitise_filename(
|
||||
"{0}_{1}{2}".format(
|
||||
file_body or file_name, int(time.time()), file_extension or ""
|
||||
)
|
||||
return {
|
||||
'signedRequest': signed_url,
|
||||
'fileName': new_name
|
||||
}
|
||||
)
|
||||
s3 = get_s3()
|
||||
signed_url = s3.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": os.environ["AWS_S3_BUCKET"],
|
||||
"Key": path + new_name,
|
||||
"ContentType": file_type,
|
||||
},
|
||||
)
|
||||
return {"signedRequest": signed_url, "fileName": new_name}
|
||||
|
||||
|
||||
def handle_blur_image(key, func):
|
||||
f = get_file(key)['Body']
|
||||
f = get_file(key)["Body"]
|
||||
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
||||
func(bhash)
|
||||
|
||||
|
||||
def blur_image(key, func):
|
||||
thr = Thread(target=handle_blur_image, args=[key, func])
|
||||
thr.start()
|
||||
|
378
api/api/users.py
378
api/api/users.py
@ -1,93 +1,355 @@
|
||||
import datetime
|
||||
import re
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util
|
||||
from api import uploads
|
||||
|
||||
|
||||
def me(user):
|
||||
db = database.get_db()
|
||||
return {
|
||||
'_id': user['_id'],
|
||||
'username': user['username'],
|
||||
'bio': user.get('bio'),
|
||||
'email': user.get('email'),
|
||||
'avatar': user.get('avatar'),
|
||||
'avatarUrl': user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])),
|
||||
'roles': user.get('roles', []),
|
||||
'groups': user.get('groups', []),
|
||||
'subscriptions': user.get('subscriptions'),
|
||||
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
|
||||
'isSilverSupporter': user.get('isSilverSupporter'),
|
||||
'isGoldSupporter': user.get('isGoldSupporter'),
|
||||
"_id": user["_id"],
|
||||
"username": user["username"],
|
||||
"bio": user.get("bio"),
|
||||
"email": user.get("email"),
|
||||
"avatar": user.get("avatar"),
|
||||
"avatarUrl": user.get("avatar")
|
||||
and uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||
),
|
||||
"roles": user.get("roles", []),
|
||||
"groups": user.get("groups", []),
|
||||
"subscriptions": user.get("subscriptions"),
|
||||
"finishedTours": user.get("completedTours", []) + user.get("skippedTours", []),
|
||||
"isSilverSupporter": user.get("isSilverSupporter"),
|
||||
"isGoldSupporter": user.get("isGoldSupporter"),
|
||||
"followerCount": db.users.count_documents({"following.user": user["_id"]}),
|
||||
}
|
||||
|
||||
|
||||
def get(user, username):
|
||||
db = database.get_db()
|
||||
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
||||
fetch_user = db.users.find_one(
|
||||
{"username": username},
|
||||
{
|
||||
"username": 1,
|
||||
"createdAt": 1,
|
||||
"avatar": 1,
|
||||
"avatarBlurHash": 1,
|
||||
"bio": 1,
|
||||
"location": 1,
|
||||
"website": 1,
|
||||
"twitter": 1,
|
||||
"facebook": 1,
|
||||
"linkedIn": 1,
|
||||
"instagram": 1,
|
||||
"isSilverSupporter": 1,
|
||||
"isGoldSupporter": 1,
|
||||
},
|
||||
)
|
||||
if not fetch_user:
|
||||
raise util.errors.NotFound('User not found')
|
||||
project_query = {'user': fetch_user['_id']}
|
||||
if not user or not user['_id'] == fetch_user['_id']:
|
||||
project_query['visibility'] = 'public'
|
||||
raise util.errors.NotFound("User not found")
|
||||
project_query = {"user": fetch_user["_id"]}
|
||||
if not user or not user["_id"] == fetch_user["_id"]:
|
||||
project_query["visibility"] = "public"
|
||||
|
||||
if "avatar" in fetch_user:
|
||||
fetch_user["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(str(fetch_user["_id"]), fetch_user["avatar"])
|
||||
)
|
||||
if user:
|
||||
fetch_user["following"] = fetch_user["_id"] in list(
|
||||
map(lambda f: f["user"], user.get("following", []))
|
||||
)
|
||||
|
||||
user_projects = list(
|
||||
db.projects.find(
|
||||
project_query, {"name": 1, "path": 1, "description": 1, "visibility": 1}
|
||||
).limit(15)
|
||||
)
|
||||
for project in user_projects:
|
||||
project["fullName"] = fetch_user["username"] + "/" + project["path"]
|
||||
project["owner"] = {
|
||||
"_id": fetch_user["_id"],
|
||||
"username": fetch_user["username"],
|
||||
"avatar": fetch_user.get("avatar"),
|
||||
"avatarUrl": fetch_user.get("avatarUrl"),
|
||||
}
|
||||
fetch_user["projects"] = user_projects
|
||||
|
||||
fetch_user['projects'] = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
|
||||
for project in fetch_user['projects']:
|
||||
project['fullName'] = fetch_user['username'] + '/' + project['path']
|
||||
if 'avatar' in fetch_user:
|
||||
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
|
||||
return fetch_user
|
||||
|
||||
|
||||
def update(user, username, data):
|
||||
if not data: raise util.errors.BadRequest('Invalid request')
|
||||
if not data:
|
||||
raise util.errors.BadRequest("Invalid request")
|
||||
db = database.get_db()
|
||||
if user['username'] != username:
|
||||
raise util.errors.Forbidden('Not allowed')
|
||||
allowed_keys = ['username', 'avatar', 'bio', 'location', 'website', 'twitter', 'facebook', 'linkedIn', 'instagram']
|
||||
if 'username' in data:
|
||||
if not data.get('username') or len(data['username']) < 3:
|
||||
raise util.errors.BadRequest('New username is not valid')
|
||||
if db.users.find({'username': data['username'].lower()}).count():
|
||||
raise util.errors.BadRequest('A user with this username already exists')
|
||||
data['username'] = data['username'].lower()
|
||||
if 'avatar' in data and len(data['avatar']) > 3: # Not a default avatar
|
||||
if user["username"] != username:
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
allowed_keys = [
|
||||
"username",
|
||||
"avatar",
|
||||
"bio",
|
||||
"location",
|
||||
"website",
|
||||
"twitter",
|
||||
"facebook",
|
||||
"linkedIn",
|
||||
"instagram",
|
||||
]
|
||||
if "username" in data:
|
||||
if not data.get("username") or len(data["username"]) < 3:
|
||||
raise util.errors.BadRequest("New username is not valid")
|
||||
if not re.match("^[a-z0-9_]+$", data["username"]):
|
||||
raise util.errors.BadRequest(
|
||||
"Usernames can only contain letters, numbers, and underscores"
|
||||
)
|
||||
if db.users.count_documents({"username": data["username"].lower()}):
|
||||
raise util.errors.BadRequest("A user with this username already exists")
|
||||
data["username"] = data["username"].lower()
|
||||
if data.get("avatar") and len(data["avatar"]) > 3: # Not a default avatar
|
||||
|
||||
def handle_cb(h):
|
||||
db.users.update_one({'_id': user['_id']}, {'$set': {'avatarBlurHash': h}})
|
||||
uploads.blur_image('users/' + str(user['_id']) + '/' + data['avatar'], handle_cb)
|
||||
db.users.update_one({"_id": user["_id"]}, {"$set": {"avatarBlurHash": h}})
|
||||
|
||||
uploads.blur_image(
|
||||
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
|
||||
)
|
||||
updater = util.build_updater(data, allowed_keys)
|
||||
if updater:
|
||||
db.users.update({'username': username}, updater)
|
||||
return get(user, data.get('username', username))
|
||||
if "avatar" in updater.get(
|
||||
"$unset", {}
|
||||
): # Also unset blurhash if removing avatar
|
||||
updater["$unset"]["avatarBlurHash"] = ""
|
||||
if "$set" in updater and (
|
||||
"avatar" in data or "bio" in data or "website" in data or "username" in data
|
||||
):
|
||||
updater["$set"]["moderationRequired"] = True
|
||||
util.send_moderation_request(user, "users", user)
|
||||
db.users.update_one({"username": username}, updater)
|
||||
return get(user, data.get("username", username))
|
||||
|
||||
|
||||
def finish_tour(user, username, tour, status):
|
||||
db = database.get_db()
|
||||
if user['username'] != username:
|
||||
raise util.errors.Forbidden('Not allowed')
|
||||
key = 'completedTours' if status == 'completed' else 'skippedTours'
|
||||
db.users.update_one({'_id': user['_id']}, {'$addToSet': {key: tour}})
|
||||
return {'finishedTour': tour}
|
||||
if user["username"] != username:
|
||||
raise util.errors.Forbidden("Not allowed")
|
||||
key = "completedTours" if status == "completed" else "skippedTours"
|
||||
db.users.update_one({"_id": user["_id"]}, {"$addToSet": {key: tour}})
|
||||
return {"finishedTour": tour}
|
||||
|
||||
|
||||
def get_projects(user, id):
|
||||
db = database.get_db()
|
||||
u = db.users.find_one(id, {'username': 1, 'avatar': 1})
|
||||
if not u: raise util.errors.NotFound('User not found')
|
||||
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
||||
u = db.users.find_one(id, {"username": 1, "avatar": 1})
|
||||
if not u:
|
||||
raise util.errors.NotFound("User not found")
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||
)
|
||||
projects = []
|
||||
for project in db.projects.find({'user': ObjectId(id)}):
|
||||
project['owner'] = u
|
||||
project['fullName'] = u['username'] + '/' + project['path']
|
||||
project_query = {"user": ObjectId(id)}
|
||||
if not user or not user["_id"] == ObjectId(id):
|
||||
project_query["visibility"] = "public"
|
||||
for project in db.projects.find(project_query):
|
||||
project["owner"] = u
|
||||
project["fullName"] = u["username"] + "/" + project["path"]
|
||||
projects.append(project)
|
||||
return projects
|
||||
|
||||
|
||||
def create_email_subscription(user, username, subscription):
|
||||
db = database.get_db()
|
||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
||||
u = db.users.find_one({'username': username})
|
||||
db.users.update({'_id': u['_id']}, {'$addToSet': {'subscriptions.email': subscription}})
|
||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
||||
return {'subscriptions': subs.get('subscriptions', {})}
|
||||
if user["username"] != username:
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
u = db.users.find_one({"username": username})
|
||||
db.users.update_one(
|
||||
{"_id": u["_id"]}, {"$addToSet": {"subscriptions.email": subscription}}
|
||||
)
|
||||
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||
return {"subscriptions": subs.get("subscriptions", {})}
|
||||
|
||||
|
||||
def delete_email_subscription(user, username, subscription):
|
||||
db = database.get_db()
|
||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
||||
u = db.users.find_one({'username': username})
|
||||
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
||||
return {'subscriptions': subs.get('subscriptions', {})}
|
||||
if user["username"] != username:
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
u = db.users.find_one({"username": username})
|
||||
db.users.update_one(
|
||||
{"_id": u["_id"]}, {"$pull": {"subscriptions.email": subscription}}
|
||||
)
|
||||
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||
return {"subscriptions": subs.get("subscriptions", {})}
|
||||
|
||||
|
||||
def create_follower(user, username):
|
||||
db = database.get_db()
|
||||
target_user = db.users.find_one({"username": username.lower()})
|
||||
if not target_user:
|
||||
raise util.errors.NotFound("User not found")
|
||||
if target_user["_id"] == user["_id"]:
|
||||
raise util.errors.BadRequest("Cannot follow yourself")
|
||||
follow_object = {
|
||||
"user": target_user["_id"],
|
||||
"followedAt": datetime.datetime.utcnow(),
|
||||
}
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$addToSet": {"following": follow_object}}
|
||||
)
|
||||
return follow_object
|
||||
|
||||
|
||||
def delete_follower(user, username):
|
||||
db = database.get_db()
|
||||
target_user = db.users.find_one({"username": username.lower()})
|
||||
if not target_user:
|
||||
raise util.errors.NotFound("User not found")
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]}, {"$pull": {"following": {"user": target_user["_id"]}}}
|
||||
)
|
||||
return {"unfollowed": True}
|
||||
|
||||
|
||||
def get_feed(user, username):
|
||||
db = database.get_db()
|
||||
if user["username"] != username:
|
||||
raise util.errors.Forbidden("Forbidden")
|
||||
following_user_ids = list(map(lambda f: f["user"], user.get("following", [])))
|
||||
following_project_ids = list(
|
||||
map(
|
||||
lambda p: p["_id"],
|
||||
db.projects.find(
|
||||
{"user": {"$in": following_user_ids}, "visibility": "public"},
|
||||
{"_id": 1},
|
||||
),
|
||||
)
|
||||
)
|
||||
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days=365)
|
||||
|
||||
# Fetch the items for the feed
|
||||
recent_projects = list(
|
||||
db.projects.find(
|
||||
{
|
||||
"_id": {"$in": following_project_ids},
|
||||
"createdAt": {"$gt": one_year_ago},
|
||||
"visibility": "public",
|
||||
},
|
||||
{"user": 1, "createdAt": 1, "name": 1, "path": 1, "visibility": 1},
|
||||
)
|
||||
.sort("createdAt", -1)
|
||||
.limit(20)
|
||||
)
|
||||
recent_objects = list(
|
||||
db.objects.find(
|
||||
{
|
||||
"project": {"$in": following_project_ids},
|
||||
"createdAt": {"$gt": one_year_ago},
|
||||
},
|
||||
{"project": 1, "createdAt": 1, "name": 1},
|
||||
)
|
||||
.sort("createdAt", -1)
|
||||
.limit(30)
|
||||
)
|
||||
recent_comments = list(
|
||||
db.comments.find(
|
||||
{"user": {"$in": following_user_ids}, "createdAt": {"$gt": one_year_ago}},
|
||||
{"user": 1, "createdAt": 1, "object": 1, "content": 1},
|
||||
)
|
||||
.sort("createdAt", -1)
|
||||
.limit(30)
|
||||
)
|
||||
|
||||
# Process objects (as don't know the user)
|
||||
object_project_ids = list(map(lambda o: o["project"], recent_objects))
|
||||
object_projects = list(
|
||||
db.projects.find(
|
||||
{"_id": {"$in": object_project_ids}, "visibility": "public"}, {"user": 1}
|
||||
)
|
||||
)
|
||||
for obj in recent_objects:
|
||||
for proj in object_projects:
|
||||
if obj["project"] == proj["_id"]:
|
||||
obj["user"] = proj.get("user")
|
||||
|
||||
# Process comments as don't know the project
|
||||
comment_object_ids = list(map(lambda c: c["object"], recent_comments))
|
||||
comment_objects = list(
|
||||
db.objects.find({"_id": {"$in": comment_object_ids}}, {"project": 1})
|
||||
)
|
||||
for com in recent_comments:
|
||||
for obj in comment_objects:
|
||||
if com["object"] == obj["_id"]:
|
||||
com["project"] = obj.get("project")
|
||||
|
||||
# Prepare the feed items, and sort it
|
||||
feed_items = []
|
||||
for p in recent_projects:
|
||||
p["feedType"] = "project"
|
||||
feed_items.append(p)
|
||||
for o in recent_objects:
|
||||
o["feedType"] = "object"
|
||||
feed_items.append(o)
|
||||
for c in recent_comments:
|
||||
c["feedType"] = "comment"
|
||||
feed_items.append(c)
|
||||
feed_items.sort(key=lambda d: d["createdAt"], reverse=True)
|
||||
feed_items = feed_items[:20]
|
||||
|
||||
# Post-process the feed, adding user/project objects
|
||||
feed_user_ids = set()
|
||||
feed_project_ids = set()
|
||||
for f in feed_items:
|
||||
feed_user_ids.add(f.get("user"))
|
||||
feed_project_ids.add(f.get("project"))
|
||||
feed_projects = list(
|
||||
db.projects.find(
|
||||
{"_id": {"$in": list(feed_project_ids)}, "visibility": "public"},
|
||||
{"name": 1, "path": 1, "user": 1, "visibility": 1},
|
||||
)
|
||||
)
|
||||
feed_users = list(
|
||||
db.users.find(
|
||||
{
|
||||
"$or": [
|
||||
{"_id": {"$in": list(feed_user_ids)}},
|
||||
{"_id": {"$in": list(map(lambda p: p["user"], feed_projects))}},
|
||||
]
|
||||
},
|
||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||
)
|
||||
)
|
||||
for u in feed_users:
|
||||
if "avatar" in u:
|
||||
u["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||
)
|
||||
feed_user_map = {}
|
||||
feed_project_map = {}
|
||||
for u in feed_users:
|
||||
feed_user_map[str(u["_id"])] = u
|
||||
for p in feed_projects:
|
||||
feed_project_map[str(p["_id"])] = p
|
||||
for f in feed_items:
|
||||
if f.get("user") and feed_user_map.get(str(f["user"])):
|
||||
f["userObject"] = feed_user_map.get(str(f["user"]))
|
||||
if f.get("project") and feed_project_map.get(str(f["project"])):
|
||||
f["projectObject"] = feed_project_map.get(str(f["project"]))
|
||||
if f.get("projectObject", {}).get("user") and feed_user_map.get(
|
||||
str(f["projectObject"]["user"])
|
||||
):
|
||||
f["projectObject"]["userObject"] = feed_user_map.get(
|
||||
str(f["projectObject"]["user"])
|
||||
)
|
||||
|
||||
# Filter out orphaned or non-public comments/objects
|
||||
def filter_func(f):
|
||||
if f["feedType"] == "comment" and not f.get("projectObject"):
|
||||
return False
|
||||
if f["feedType"] == "object" and not f.get("projectObject"):
|
||||
return False
|
||||
return True
|
||||
|
||||
feed_items = list(filter(filter_func, feed_items))
|
||||
|
||||
return {"feed": feed_items}
|
||||
|
875
api/app.py
875
api/app.py
File diff suppressed because it is too large
Load Diff
18
api/bucket-policy-dev.json
Normal file
18
api/bucket-policy-dev.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"Action": [
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::treadl-dev/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3::treadl/*"
|
||||
"arn:aws:s3:::treadl/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
34
api/migrations/object_previews.py
Normal file
34
api/migrations/object_previews.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Script to migrate from the old data: string URLs for images to image files directly on S3.
|
||||
|
||||
from pymongo import MongoClient
|
||||
import base64
|
||||
import os
|
||||
|
||||
db = MongoClient("mongodb://USER:PASS@db/admin")["treadl"]
|
||||
|
||||
os.makedirs("migration_projects/projects", exist_ok=True)
|
||||
|
||||
for obj in db.objects.find(
|
||||
{"preview": {"$regex": "^data:"}}, {"preview": 1, "project": 1}
|
||||
):
|
||||
preview = obj["preview"]
|
||||
preview = preview.replace("data:image/png;base64,", "")
|
||||
|
||||
imgdata = base64.b64decode(preview)
|
||||
filename = "some_image.png"
|
||||
|
||||
os.makedirs("migration_projects/projects/" + str(obj["project"]), exist_ok=True)
|
||||
with open(
|
||||
"migration_projects/projects/"
|
||||
+ str(obj["project"])
|
||||
+ "/preview_"
|
||||
+ str(obj["_id"])
|
||||
+ ".png",
|
||||
"wb",
|
||||
) as f:
|
||||
f.write(imgdata)
|
||||
db.objects.update_one(
|
||||
{"_id": obj["_id"]},
|
||||
{"$set": {"previewNew": "preview_" + str(obj["_id"]) + ".png"}},
|
||||
)
|
||||
# exit()
|
2384
api/poetry.lock
generated
2384
api/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,30 +1,33 @@
|
||||
[tool.poetry]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
package-mode = false
|
||||
description = "Treadl API"
|
||||
authors = ["Will <will@treadl.com>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
flask = "^1.1.1"
|
||||
bcrypt = "^3.1.7"
|
||||
pyjwt = "^1.7.1"
|
||||
boto3 = "^1.10.50"
|
||||
flask-cors = "^3.0.8"
|
||||
dnspython = "^1.16.0"
|
||||
requests = "^2.22.0"
|
||||
botocore = "^1.13.50"
|
||||
pymongo = "^3.10.1"
|
||||
flask_limiter = "^1.3.1"
|
||||
werkzeug = "^1.0.1"
|
||||
firebase-admin = "^4.3.0"
|
||||
blurhash-python = "^1.0.2"
|
||||
gunicorn = "^20.0.4"
|
||||
sentry-sdk = {extras = ["flask"], version = "^1.5.10"}
|
||||
pyOpenSSL = "^22.0.0"
|
||||
python = "^3.12"
|
||||
flask = "^3.0.3"
|
||||
bcrypt = "^4.2.0"
|
||||
pyjwt = "^2.9.0"
|
||||
boto3 = "^1.35.34"
|
||||
flask-cors = "^5.0.0"
|
||||
dnspython = "^2.6.1"
|
||||
requests = "^2.32.3"
|
||||
pymongo = "^4.10.1"
|
||||
flask_limiter = "^3.8.0"
|
||||
firebase-admin = "^6.5.0"
|
||||
blurhash-python = "^1.2.2"
|
||||
gunicorn = "^23.0.0"
|
||||
sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
|
||||
pyOpenSSL = "^24.2.1"
|
||||
webargs = "^8.6.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.6.9"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
@ -1,12 +1,12 @@
|
||||
import os
|
||||
from pymongo import MongoClient
|
||||
from flask import g
|
||||
|
||||
db = None
|
||||
|
||||
|
||||
def get_db():
|
||||
global db
|
||||
|
||||
if db is None:
|
||||
db = MongoClient(os.environ['MONGO_URL'])[os.environ['MONGO_DATABASE']]
|
||||
db = MongoClient(os.environ["MONGO_URL"])[os.environ["MONGO_DATABASE"]]
|
||||
return db
|
||||
|
@ -2,33 +2,39 @@ import os
|
||||
from threading import Thread
|
||||
import requests
|
||||
|
||||
def handle_send(data):
|
||||
if 'from' not in data:
|
||||
data['from'] = '{} <{}>'.format(os.environ.get('APP_NAME'), os.environ.get('FROM_EMAIL'))
|
||||
if 'to_user' in data:
|
||||
user = data['to_user']
|
||||
data['to'] = user['username'] + ' <' + user['email'] + '>'
|
||||
del data['to_user']
|
||||
data['text'] += '\n\nFrom the team at {0}\n\n\n\n--\n\nDon\'t like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}'.format(
|
||||
os.environ.get('APP_NAME'),
|
||||
os.environ.get('APP_URL'),
|
||||
os.environ.get('CONTACT_EMAIL')
|
||||
)
|
||||
data['reply-to'] = os.environ.get('CONTACT_EMAIL')
|
||||
|
||||
base_url = os.environ.get('MAILGUN_URL')
|
||||
api_key = os.environ.get('MAILGUN_KEY')
|
||||
def handle_send(data):
|
||||
if "from" not in data:
|
||||
data["from"] = "{} <{}>".format(
|
||||
os.environ.get("APP_NAME"), os.environ.get("FROM_EMAIL")
|
||||
)
|
||||
if "to_user" in data:
|
||||
user = data["to_user"]
|
||||
data["to"] = user["username"] + " <" + user["email"] + ">"
|
||||
del data["to_user"]
|
||||
data["text"] += (
|
||||
"\n\nFrom the team at {0}\n\n\n\n--\n\nDon't like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}".format(
|
||||
os.environ.get("APP_NAME"),
|
||||
os.environ.get("APP_URL"),
|
||||
os.environ.get("CONTACT_EMAIL"),
|
||||
)
|
||||
)
|
||||
data["reply-to"] = os.environ.get("CONTACT_EMAIL")
|
||||
|
||||
base_url = os.environ.get("MAILGUN_URL")
|
||||
api_key = os.environ.get("MAILGUN_KEY")
|
||||
if base_url and api_key:
|
||||
auth = ('api', api_key)
|
||||
auth = ("api", api_key)
|
||||
try:
|
||||
response = requests.post(base_url, auth=auth, data=data)
|
||||
response.raise_for_status()
|
||||
except:
|
||||
print('Unable to send email')
|
||||
except Exception:
|
||||
print("Unable to send email")
|
||||
else:
|
||||
print('Not sending email. Message pasted below.')
|
||||
print("Not sending email. Message pasted below.")
|
||||
print(data)
|
||||
|
||||
|
||||
def send(data):
|
||||
thr = Thread(target=handle_send, args=[data])
|
||||
thr.start()
|
||||
|
@ -4,36 +4,47 @@ from firebase_admin import messaging
|
||||
|
||||
default_app = firebase_admin.initialize_app()
|
||||
|
||||
|
||||
def handle_send_multiple(users, title, body, extra={}):
|
||||
tokens = []
|
||||
for user in users:
|
||||
if user.get('pushToken'): tokens.append(user['pushToken'])
|
||||
if not tokens: return
|
||||
if user.get("pushToken"):
|
||||
tokens.append(user["pushToken"])
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
# Create a list containing up to 500 messages.
|
||||
messages = list(map(lambda t: messaging.Message(
|
||||
messages = list(
|
||||
map(
|
||||
lambda t: messaging.Message(
|
||||
notification=messaging.Notification(title, body),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(badge=1, sound='default'),
|
||||
aps=messaging.Aps(badge=1, sound="default"),
|
||||
),
|
||||
),
|
||||
token=t,
|
||||
data=extra,
|
||||
), tokens))
|
||||
),
|
||||
tokens,
|
||||
)
|
||||
)
|
||||
try:
|
||||
response = messaging.send_all(messages)
|
||||
print('{0} messages were sent successfully'.format(response.success_count))
|
||||
print("{0} messages were sent successfully".format(response.success_count))
|
||||
except Exception as e:
|
||||
print('Error sending notification', str(e))
|
||||
print("Error sending notification", str(e))
|
||||
|
||||
|
||||
def send_multiple(users, title, body, extra={}):
|
||||
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
||||
thr.start()
|
||||
|
||||
|
||||
def send_single(user, title, body, extra={}):
|
||||
token = user.get('pushToken')
|
||||
if not token: return
|
||||
token = user.get("pushToken")
|
||||
if not token:
|
||||
return
|
||||
message = messaging.Message(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
@ -41,7 +52,7 @@ def send_single(user, title, body, extra = {}):
|
||||
),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(badge=1, sound='default'),
|
||||
aps=messaging.Aps(badge=1, sound="default"),
|
||||
),
|
||||
),
|
||||
data=extra,
|
||||
@ -50,6 +61,6 @@ def send_single(user, title, body, extra = {}):
|
||||
try:
|
||||
response = messaging.send(message)
|
||||
# Response is a message ID string.
|
||||
print('Successfully sent message:', response)
|
||||
print("Successfully sent message:", response)
|
||||
except Exception as e:
|
||||
print('Error sending notification', str(e))
|
||||
print("Error sending notification", str(e))
|
||||
|
119
api/util/util.py
119
api/util/util.py
@ -1,4 +1,6 @@
|
||||
import json, datetime
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
from flask import request, Response
|
||||
import werkzeug
|
||||
from flask_limiter.util import get_remote_address
|
||||
@ -6,43 +8,65 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from bson.objectid import ObjectId
|
||||
from api import accounts
|
||||
from util import util
|
||||
from util import util, mail
|
||||
|
||||
errors = werkzeug.exceptions
|
||||
|
||||
|
||||
def get_user(required=True):
|
||||
headers = request.headers
|
||||
if not headers.get('Authorization') and required:
|
||||
raise util.errors.Unauthorized('This resource requires authentication')
|
||||
if headers.get('Authorization'):
|
||||
user = accounts.get_user_context(headers.get('Authorization').replace('Bearer ', ''))
|
||||
if not headers.get("Authorization") and required:
|
||||
raise util.errors.Unauthorized("This resource requires authentication")
|
||||
if headers.get("Authorization"):
|
||||
user = accounts.get_user_context(
|
||||
headers.get("Authorization").replace("Bearer ", "")
|
||||
)
|
||||
if user is None and required:
|
||||
raise util.errors.Unauthorized('Invalid token')
|
||||
raise util.errors.Unauthorized("Invalid token")
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def limit_by_client():
|
||||
data = request.get_json()
|
||||
if data:
|
||||
if data.get('email'): return data.get('email')
|
||||
if data.get('token'): return data.get('token')
|
||||
if data.get("email"):
|
||||
return data.get("email")
|
||||
if data.get("token"):
|
||||
return data.get("token")
|
||||
return get_remote_address()
|
||||
|
||||
|
||||
def limit_by_user():
|
||||
user = util.get_user(required=False)
|
||||
return user['_id'] if user else get_remote_address()
|
||||
return user["_id"] if user else get_remote_address()
|
||||
|
||||
|
||||
def is_root(user):
|
||||
return user and "root" in user.get("roles", [])
|
||||
|
||||
|
||||
def can_view_project(user, project):
|
||||
if not project: return False
|
||||
if project.get('visibility') == 'public':
|
||||
return True
|
||||
if not user: return False
|
||||
if project.get('visibility') == 'private' and user['_id'] == project['user']:
|
||||
return True
|
||||
if set(user.get('groups', [])).intersection(project.get('groupVisibility', [])):
|
||||
return True
|
||||
if 'root' in user.get('roles', []): return True
|
||||
if not project:
|
||||
return False
|
||||
if project.get("visibility") == "public":
|
||||
return True
|
||||
if not user:
|
||||
return False
|
||||
if project.get("visibility") == "private" and can_edit_project(user, project):
|
||||
return True
|
||||
if set(user.get("groups", [])).intersection(project.get("groupVisibility", [])):
|
||||
return True
|
||||
if "root" in user.get("roles", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_edit_project(user, project):
|
||||
if not user or not project:
|
||||
return False
|
||||
return project.get("user") == user["_id"] or is_root(user)
|
||||
|
||||
|
||||
def filter_keys(obj, allowed_keys):
|
||||
filtered = {}
|
||||
@ -51,35 +75,67 @@ def filter_keys(obj, allowed_keys):
|
||||
filtered[key] = obj[key]
|
||||
return filtered
|
||||
|
||||
|
||||
def build_updater(obj, allowed_keys):
|
||||
if not obj: return {}
|
||||
if not obj:
|
||||
return {}
|
||||
allowed = filter_keys(obj, allowed_keys)
|
||||
updater = {}
|
||||
for key in allowed:
|
||||
if not allowed[key]:
|
||||
if '$unset' not in updater: updater['$unset'] = {}
|
||||
updater['$unset'][key] = ''
|
||||
if "$unset" not in updater:
|
||||
updater["$unset"] = {}
|
||||
updater["$unset"][key] = ""
|
||||
else:
|
||||
if '$set' not in updater: updater['$set'] = {}
|
||||
updater['$set'][key] = allowed[key]
|
||||
if "$set" not in updater:
|
||||
updater["$set"] = {}
|
||||
updater["$set"][key] = allowed[key]
|
||||
return updater
|
||||
|
||||
def generate_rsa_keypair():
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=4096
|
||||
|
||||
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():
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_key = private_key.public_key()
|
||||
public_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
class MongoJsonEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime.datetime, datetime.date)):
|
||||
@ -88,8 +144,9 @@ class MongoJsonEncoder(json.JSONEncoder):
|
||||
return str(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def jsonify(*args, **kwargs):
|
||||
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
||||
resp = Response(resp_data)
|
||||
resp.headers['Content-Type'] = 'application/json'
|
||||
resp.headers["Content-Type"] = "application/json"
|
||||
return resp
|
||||
|
656
api/util/wif.py
656
api/util/wif.py
@ -1,197 +1,585 @@
|
||||
import io
|
||||
import configparser
|
||||
import time
|
||||
from threading import Thread
|
||||
from PIL import Image, ImageDraw
|
||||
from api import uploads
|
||||
from util import database
|
||||
|
||||
|
||||
def normalise_colour(max_color, triplet):
|
||||
color_factor = 256 / max_color
|
||||
components = triplet.split(',')
|
||||
components = triplet.split(",")
|
||||
new_components = []
|
||||
for component in components:
|
||||
new_components.append(str(int(float(color_factor) * int(component))))
|
||||
return ','.join(new_components)
|
||||
new_components.append(str(int(float(color_factor) * int(float(component)))))
|
||||
return ",".join(new_components)
|
||||
|
||||
|
||||
def denormalise_colour(max_color, triplet):
|
||||
color_factor = max_color / 256
|
||||
components = triplet.split(',')
|
||||
components = triplet.split(",")
|
||||
new_components = []
|
||||
for component in components:
|
||||
new_components.append(str(int(float(color_factor) * int(component))))
|
||||
return ','.join(new_components)
|
||||
return ",".join(new_components)
|
||||
|
||||
|
||||
def colour_tuple(triplet):
|
||||
if not triplet:
|
||||
return None
|
||||
components = triplet.split(",")
|
||||
return tuple(map(lambda c: int(c), components))
|
||||
|
||||
|
||||
def darken_colour(c_tuple, val):
|
||||
def darken(c):
|
||||
c = c * val
|
||||
if c < 0:
|
||||
c = 0
|
||||
if c > 255:
|
||||
c = 255
|
||||
return int(c)
|
||||
|
||||
return tuple(map(darken, c_tuple))
|
||||
|
||||
|
||||
def get_colour_index(colours, colour):
|
||||
for (index, c) in enumerate(colours):
|
||||
if c == colour: return index + 1
|
||||
for index, c in enumerate(colours):
|
||||
if c == colour:
|
||||
return index + 1
|
||||
return 1
|
||||
|
||||
|
||||
def dumps(obj):
|
||||
if not obj or not obj['pattern']: raise Exception('Invalid pattern')
|
||||
if not obj or not obj["pattern"]:
|
||||
raise Exception("Invalid pattern")
|
||||
wif = []
|
||||
|
||||
wif.append('[WIF]')
|
||||
wif.append('Version=1.1')
|
||||
wif.append('Source Program=Treadl')
|
||||
wif.append('Source Version=1')
|
||||
wif.append("[WIF]")
|
||||
wif.append("Version=1.1")
|
||||
wif.append("Source Program=Treadl")
|
||||
wif.append("Source Version=1")
|
||||
|
||||
wif.append('\n[CONTENTS]')
|
||||
wif.append('COLOR PALETTE=true')
|
||||
wif.append('TEXT=true')
|
||||
wif.append('WEAVING=true')
|
||||
wif.append('WARP=true')
|
||||
wif.append('WARP COLORS=true')
|
||||
wif.append('WEFT COLORS=true')
|
||||
wif.append('WEFT=true')
|
||||
wif.append('COLOR TABLE=true')
|
||||
wif.append('THREADING=true')
|
||||
wif.append('TIEUP=true')
|
||||
wif.append('TREADLING=true')
|
||||
wif.append("\n[CONTENTS]")
|
||||
wif.append("COLOR PALETTE=true")
|
||||
wif.append("TEXT=true")
|
||||
wif.append("WEAVING=true")
|
||||
wif.append("WARP=true")
|
||||
wif.append("WARP COLORS=true")
|
||||
wif.append("WEFT COLORS=true")
|
||||
wif.append("WEFT=true")
|
||||
wif.append("COLOR TABLE=true")
|
||||
wif.append("THREADING=true")
|
||||
wif.append("TIEUP=true")
|
||||
wif.append("TREADLING=true")
|
||||
|
||||
wif.append('\n[TEXT]')
|
||||
wif.append('Title={0}'.format(obj['name']))
|
||||
wif.append("\n[TEXT]")
|
||||
wif.append("Title={0}".format(obj["name"]))
|
||||
|
||||
wif.append('\n[COLOR TABLE]')
|
||||
for (index, colour) in enumerate(obj['pattern']['colours']):
|
||||
wif.append('{0}={1}'.format(index + 1, denormalise_colour(999, colour)))
|
||||
wif.append("\n[COLOR TABLE]")
|
||||
for index, colour in enumerate(obj["pattern"]["colours"]):
|
||||
wif.append("{0}={1}".format(index + 1, denormalise_colour(999, colour)))
|
||||
|
||||
wif.append('\n[COLOR PALETTE]')
|
||||
wif.append('Range=0,999')
|
||||
wif.append('Entries={0}'.format(len(obj['pattern']['colours'])))
|
||||
wif.append("\n[COLOR PALETTE]")
|
||||
wif.append("Range=0,999")
|
||||
wif.append("Entries={0}".format(len(obj["pattern"]["colours"])))
|
||||
|
||||
wif.append('\n[WEAVING]')
|
||||
wif.append('Rising Shed=true')
|
||||
wif.append('Treadles={0}'.format(obj['pattern']['weft']['treadles']))
|
||||
wif.append('Shafts={0}'.format(obj['pattern']['warp']['shafts']))
|
||||
wif.append("\n[WEAVING]")
|
||||
wif.append("Rising Shed=true")
|
||||
wif.append("Treadles={0}".format(obj["pattern"]["weft"]["treadles"]))
|
||||
wif.append("Shafts={0}".format(obj["pattern"]["warp"]["shafts"]))
|
||||
|
||||
wif.append('\n[WARP]')
|
||||
wif.append('Units=centimeters')
|
||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['warp']['defaultColour'])))
|
||||
wif.append('Threads={0}'.format(obj['pattern']['warp']['threads']))
|
||||
wif.append('Spacing=0.212')
|
||||
wif.append('Thickness=0.212')
|
||||
wif.append("\n[WARP]")
|
||||
wif.append("Units=centimeters")
|
||||
wif.append(
|
||||
"Color={0}".format(
|
||||
get_colour_index(
|
||||
obj["pattern"]["colours"], obj["pattern"]["warp"]["defaultColour"]
|
||||
)
|
||||
)
|
||||
)
|
||||
wif.append("Threads={0}".format(len(obj["pattern"]["warp"]["threading"])))
|
||||
wif.append("Spacing=0.212")
|
||||
wif.append("Thickness=0.212")
|
||||
|
||||
wif.append('\n[WARP COLORS]')
|
||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
||||
if 'colour' in thread:
|
||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
||||
wif.append("\n[WARP COLORS]")
|
||||
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||
if "colour" in thread:
|
||||
wif.append(
|
||||
"{0}={1}".format(
|
||||
index + 1,
|
||||
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||
)
|
||||
)
|
||||
|
||||
wif.append('\n[THREADING]')
|
||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
||||
wif.append('{0}={1}'.format(index + 1, thread['shaft']))
|
||||
wif.append("\n[THREADING]")
|
||||
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||
wif.append("{0}={1}".format(index + 1, thread["shaft"]))
|
||||
|
||||
wif.append('\n[WEFT]')
|
||||
wif.append('Units=centimeters')
|
||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['weft']['defaultColour'])))
|
||||
wif.append('Threads={0}'.format(obj['pattern']['weft']['threads']))
|
||||
wif.append('Spacing=0.212')
|
||||
wif.append('Thickness=0.212')
|
||||
wif.append("\n[WEFT]")
|
||||
wif.append("Units=centimeters")
|
||||
wif.append(
|
||||
"Color={0}".format(
|
||||
get_colour_index(
|
||||
obj["pattern"]["colours"], obj["pattern"]["weft"]["defaultColour"]
|
||||
)
|
||||
)
|
||||
)
|
||||
wif.append("Threads={0}".format(len(obj["pattern"]["weft"]["treadling"])))
|
||||
wif.append("Spacing=0.212")
|
||||
wif.append("Thickness=0.212")
|
||||
|
||||
wif.append('\n[WEFT COLORS]')
|
||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
||||
if 'colour' in thread:
|
||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
||||
wif.append("\n[WEFT COLORS]")
|
||||
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||
if "colour" in thread:
|
||||
wif.append(
|
||||
"{0}={1}".format(
|
||||
index + 1,
|
||||
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||
)
|
||||
)
|
||||
|
||||
wif.append('\n[TREADLING]')
|
||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
||||
wif.append('{0}={1}'.format(index + 1, thread['treadle']))
|
||||
wif.append("\n[TREADLING]")
|
||||
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||
wif.append("{0}={1}".format(index + 1, thread["treadle"]))
|
||||
|
||||
wif.append('\n[TIEUP]')
|
||||
for (index, tieup) in enumerate(obj['pattern']['tieups']):
|
||||
wif.append('{0}={1}'.format(str(index + 1), ','.join(str(x) for x in tieup)))
|
||||
wif.append("\n[TIEUP]")
|
||||
for index, tieup in enumerate(obj["pattern"]["tieups"]):
|
||||
wif.append("{0}={1}".format(str(index + 1), ",".join(str(x) for x in tieup)))
|
||||
|
||||
return "\n".join(wif)
|
||||
|
||||
return '\n'.join(wif)
|
||||
|
||||
def loads(wif_file):
|
||||
config = configparser.ConfigParser(allow_no_value=True, strict=False)
|
||||
config.read_string(wif_file.lower())
|
||||
# Ensure file exists:
|
||||
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"
|
||||
draft = {}
|
||||
|
||||
text = config['text']
|
||||
draft['name'] = text.get('title')
|
||||
if "wif" in config:
|
||||
draft["wifInfo"] = dict(config["wif"])
|
||||
draft["wifInfo"]["importedFile"] = wif_file
|
||||
if "text" in config:
|
||||
text = config["text"]
|
||||
draft["name"] = text.get("title") or DEFAULT_TITLE
|
||||
if not draft.get("name"):
|
||||
draft["name"] = DEFAULT_TITLE
|
||||
|
||||
min_color = 0
|
||||
max_color = 255
|
||||
if 'color palette' in config:
|
||||
color_palette = config['color palette']
|
||||
color_range = color_palette.get('range').split(',')
|
||||
min_color = int(color_range[0])
|
||||
if "color palette" in config:
|
||||
color_palette = config["color palette"]
|
||||
color_range = color_palette.get("range").split(",")
|
||||
max_color = int(color_range[1])
|
||||
|
||||
if 'color table' in config:
|
||||
color_table = config['color table']
|
||||
draft['colours'] = [None]*len(color_table)
|
||||
if "color table" in config:
|
||||
color_table = config["color table"]
|
||||
draft["colours"] = [None] * len(color_table)
|
||||
for x in color_table:
|
||||
draft['colours'][int(x)-1] = normalise_colour(max_color, color_table[x])
|
||||
if not draft.get('colours'): draft['colours'] = []
|
||||
if len(draft['colours']) < 2:
|
||||
draft['colours'] += [normalise_colour(255, '255,255,255'), normalise_colour(255, '0,0,255')]
|
||||
draft["colours"][int(x) - 1] = normalise_colour(max_color, color_table[x])
|
||||
if not draft.get("colours"):
|
||||
draft["colours"] = []
|
||||
if len(draft["colours"]) < 2:
|
||||
draft["colours"] += [
|
||||
normalise_colour(255, "255,255,255"),
|
||||
normalise_colour(255, "0,0,255"),
|
||||
]
|
||||
|
||||
weaving = config['weaving']
|
||||
weaving = config["weaving"] if "weaving" in config else None
|
||||
|
||||
threading = config['threading']
|
||||
warp = config['warp']
|
||||
draft['warp'] = {}
|
||||
draft['warp']['shafts'] = weaving.getint('shafts')
|
||||
draft['warp']['threading'] = []
|
||||
threading = config["threading"] if "threading" in config else []
|
||||
warp = config["warp"] if "warp" in config else None
|
||||
draft["warp"] = {}
|
||||
draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
|
||||
draft["warp"]["threading"] = []
|
||||
|
||||
|
||||
if warp.get('color'):
|
||||
warp_colour_index = warp.getint('color') - 1
|
||||
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index]
|
||||
|
||||
else:
|
||||
# Work out default warp colour
|
||||
if warp and warp.get("color"):
|
||||
warp_colour_index = warp.getint("color") - 1
|
||||
if warp_colour_index < len(draft["colours"]):
|
||||
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
|
||||
if not draft.get("warp").get("defaultColour"):
|
||||
# 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:
|
||||
shaft = threading[x]
|
||||
if ',' in shaft:
|
||||
shaft = threading[x].strip()
|
||||
if "," in shaft:
|
||||
shaft = shaft.split(",")[0]
|
||||
shaft = int(shaft)
|
||||
while int(x) >= len(draft['warp']['threading']) - 1:
|
||||
draft['warp']['threading'].append({'shaft': 0})
|
||||
draft['warp']['threading'][int(x) - 1] = {'shaft': shaft}
|
||||
draft['warp']['threads'] = len(draft['warp']['threading'])
|
||||
shaft = int(shaft) if shaft else 0
|
||||
while int(x) >= len(draft["warp"]["threading"]) - 1:
|
||||
draft["warp"]["threading"].append({"shaft": 0})
|
||||
draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
|
||||
if shaft > draft["warp"]["shafts"]:
|
||||
draft["warp"]["shafts"] = shaft
|
||||
draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
|
||||
try:
|
||||
warp_colours = config['warp colors']
|
||||
warp_colours = config["warp colors"]
|
||||
for x in warp_colours:
|
||||
draft['warp']['threading'][int(x) - 1]['colour'] = draft['colours'][warp_colours.getint(x)-1]
|
||||
except Exception as e:
|
||||
draft["warp"]["threading"][int(x) - 1]["colour"] = draft["colours"][
|
||||
warp_colours.getint(x) - 1
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
if not draft["warp"]["threading"]: # Make a bunch of empty threads
|
||||
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
|
||||
|
||||
treadling = config['treadling']
|
||||
weft = config['weft']
|
||||
draft['weft'] = {}
|
||||
draft['weft']['treadles'] = weaving.getint('treadles')
|
||||
draft['weft']['treadling'] = []
|
||||
treadling = config["treadling"] if "treadling" in config else []
|
||||
weft = config["weft"] if "weft" in config else None
|
||||
draft["weft"] = {}
|
||||
draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
|
||||
draft["weft"]["treadling"] = []
|
||||
|
||||
if weft.get('color'):
|
||||
weft_colour_index = weft.getint('color') - 1
|
||||
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index]
|
||||
else:
|
||||
# Work out default weft colour
|
||||
if weft and weft.get("color"):
|
||||
weft_colour_index = weft.getint("color") - 1
|
||||
if weft_colour_index < len(draft["colours"]):
|
||||
draft["weft"]["defaultColour"] = draft["colours"][weft_colour_index]
|
||||
if not draft.get("weft").get("defaultColour"):
|
||||
# In case of no color table or colour index out of bounds
|
||||
draft['weft']['defaultColour'] = draft['colours'][1]
|
||||
draft["weft"]["defaultColour"] = draft["colours"][1]
|
||||
|
||||
for x in treadling:
|
||||
shaft = treadling[x]
|
||||
if ',' in shaft:
|
||||
shaft = shaft.split(",")[0]
|
||||
shaft = int(shaft)
|
||||
while int(x) >= len(draft['weft']['treadling']) - 1:
|
||||
draft['weft']['treadling'].append({'treadle': 0})
|
||||
draft['weft']['treadling'][int(x) - 1] = {'treadle': shaft}
|
||||
draft['weft']['threads'] = len(draft['weft']['treadling'])
|
||||
treadle = treadling[x].strip()
|
||||
if "," in treadle:
|
||||
treadle = treadle.split(",")[0]
|
||||
treadle = int(treadle) if treadle else 0
|
||||
while int(x) >= len(draft["weft"]["treadling"]) - 1:
|
||||
draft["weft"]["treadling"].append({"treadle": 0})
|
||||
draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
|
||||
if treadle > draft["weft"]["treadles"]:
|
||||
draft["weft"]["treadles"] = treadle
|
||||
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
|
||||
try:
|
||||
weft_colours = config['weft colors']
|
||||
weft_colours = config["weft colors"]
|
||||
for x in weft_colours:
|
||||
draft['weft']['treadling'][int(x) - 1]['colour'] = draft['colours'][weft_colours.getint(x)-1]
|
||||
except: pass
|
||||
draft["weft"]["treadling"][int(x) - 1]["colour"] = draft["colours"][
|
||||
weft_colours.getint(x) - 1
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
if not draft["weft"]["treadling"]: # Make a bunch of empty threads
|
||||
draft["weft"]["treadling"] = [{"treadle": 0} for i in range(20)]
|
||||
|
||||
tieup = config['tieup']
|
||||
draft['tieups'] = []#[0]*len(tieup)
|
||||
tieup = config["tieup"] if "tieup" in config else None
|
||||
draft["tieups"] = []
|
||||
if tieup:
|
||||
for x in tieup:
|
||||
while int(x) >= len(draft['tieups']) - 1:
|
||||
draft['tieups'].append([])
|
||||
split = tieup[x].split(',')
|
||||
while int(x) >= len(draft["tieups"]) - 1:
|
||||
draft["tieups"].append([])
|
||||
try:
|
||||
draft['tieups'][int(x)-1] = [int(i) for i in split]
|
||||
except:
|
||||
draft['tieups'][int(x)-1] = []
|
||||
split = tieup[x].split(",")
|
||||
draft["tieups"][int(x) - 1] = [int(i) for i in split]
|
||||
except Exception:
|
||||
draft["tieups"][int(x) - 1] = []
|
||||
|
||||
return draft
|
||||
|
||||
|
||||
def generate_images_thread(obj):
|
||||
preview_image = draw_image(obj)
|
||||
full_preview_image = draw_image(obj, with_plan=True)
|
||||
if preview_image or full_preview_image:
|
||||
db = database.get_db()
|
||||
db.objects.update_one(
|
||||
{"_id": obj["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"preview": preview_image,
|
||||
"fullPreview": full_preview_image,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_images(obj):
|
||||
thr = Thread(target=generate_images_thread, args=[obj])
|
||||
thr.start()
|
||||
|
||||
|
||||
def draw_image(obj, with_plan=False):
|
||||
if not obj or not obj["pattern"]:
|
||||
raise Exception("Invalid pattern")
|
||||
BASE_SIZE = 10
|
||||
pattern = obj["pattern"]
|
||||
warp = pattern["warp"]
|
||||
weft = pattern["weft"]
|
||||
tieups = pattern["tieups"]
|
||||
|
||||
full_width = (
|
||||
len(warp["threading"]) * BASE_SIZE
|
||||
+ BASE_SIZE
|
||||
+ weft["treadles"] * BASE_SIZE
|
||||
+ BASE_SIZE
|
||||
if with_plan
|
||||
else len(warp["threading"]) * BASE_SIZE
|
||||
)
|
||||
full_height = (
|
||||
warp["shafts"] * BASE_SIZE + len(weft["treadling"]) * BASE_SIZE + BASE_SIZE * 2
|
||||
if with_plan
|
||||
else len(weft["treadling"]) * BASE_SIZE
|
||||
)
|
||||
|
||||
warp_top = 0
|
||||
warp_left = 0
|
||||
warp_right = len(warp["threading"]) * BASE_SIZE
|
||||
warp_bottom = warp["shafts"] * BASE_SIZE + BASE_SIZE
|
||||
|
||||
weft_left = warp_right + BASE_SIZE
|
||||
weft_top = warp["shafts"] * BASE_SIZE + BASE_SIZE * 2
|
||||
weft_right = warp_right + BASE_SIZE + weft["treadles"] * BASE_SIZE + BASE_SIZE
|
||||
weft_bottom = weft_top + len(weft["treadling"]) * BASE_SIZE
|
||||
|
||||
tieup_left = warp_right + BASE_SIZE
|
||||
tieup_top = BASE_SIZE
|
||||
tieup_right = tieup_left + weft["treadles"] * BASE_SIZE
|
||||
tieup_bottom = warp_bottom
|
||||
|
||||
drawdown_top = warp_bottom + BASE_SIZE if with_plan else 0
|
||||
drawdown_right = warp_right if with_plan else full_width
|
||||
drawdown_left = warp_left if with_plan else 0
|
||||
drawdown_bottom = weft_bottom if with_plan else full_height
|
||||
|
||||
warp_guides = warp.get("guideFrequency") or 0
|
||||
weft_guides = weft.get("guideFrequency") or 0
|
||||
|
||||
WHITE = (255, 255, 255)
|
||||
GREY = (150, 150, 150)
|
||||
BLACK = (0, 0, 0)
|
||||
img = Image.new("RGBA", (full_width, full_height), WHITE)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw warp
|
||||
if with_plan:
|
||||
draw.rectangle(
|
||||
[(warp_left, warp_top), (warp_right, warp_bottom)],
|
||||
fill=None,
|
||||
outline=GREY,
|
||||
width=1,
|
||||
)
|
||||
for y in range(1, warp["shafts"] + 1):
|
||||
ycoord = y * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(warp_left, ycoord),
|
||||
(warp_right, ycoord),
|
||||
],
|
||||
fill=GREY,
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
col_index = 1
|
||||
for i, x in enumerate(range(len(warp["threading"]) - 1, 0, -1)):
|
||||
is_guide = warp_guides and col_index % warp_guides == 0
|
||||
col_index += 1
|
||||
thread = warp["threading"][i]
|
||||
xcoord = x * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(xcoord, warp_top),
|
||||
(xcoord, warp_bottom),
|
||||
],
|
||||
fill=BLACK if is_guide else GREY,
|
||||
width=2 if is_guide else 1,
|
||||
joint=None,
|
||||
)
|
||||
if thread.get("shaft", 0) > 0:
|
||||
ycoord = warp_bottom - (thread["shaft"] * BASE_SIZE)
|
||||
draw.rectangle(
|
||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||
fill=BLACK,
|
||||
outline=None,
|
||||
width=1,
|
||||
)
|
||||
colour = warp["defaultColour"]
|
||||
if thread and thread.get("colour"):
|
||||
colour = thread["colour"]
|
||||
draw.rectangle(
|
||||
[
|
||||
(xcoord, warp_top),
|
||||
(xcoord + BASE_SIZE, warp_top + BASE_SIZE),
|
||||
],
|
||||
fill=colour_tuple(colour),
|
||||
)
|
||||
|
||||
# Draw weft
|
||||
draw.rectangle(
|
||||
[(weft_left, weft_top), (weft_right, weft_bottom)],
|
||||
fill=None,
|
||||
outline=GREY,
|
||||
width=1,
|
||||
)
|
||||
for x in range(1, weft["treadles"] + 1):
|
||||
xcoord = weft_left + x * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(xcoord, weft_top),
|
||||
(xcoord, weft_bottom),
|
||||
],
|
||||
fill=GREY,
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
row_index = 0
|
||||
for i, y in enumerate(range(0, len(weft["treadling"]))):
|
||||
is_guide = weft_guides and row_index % weft_guides == 0
|
||||
row_index += 1
|
||||
thread = weft["treadling"][i]
|
||||
ycoord = weft_top + y * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(weft_left, ycoord),
|
||||
(weft_right, ycoord),
|
||||
],
|
||||
fill=BLACK if is_guide else GREY,
|
||||
width=2 if is_guide else 1,
|
||||
joint=None,
|
||||
)
|
||||
if thread.get("treadle", 0) > 0:
|
||||
xcoord = weft_left + (thread["treadle"] - 1) * BASE_SIZE
|
||||
draw.rectangle(
|
||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||
fill=BLACK,
|
||||
outline=None,
|
||||
width=1,
|
||||
)
|
||||
colour = weft["defaultColour"]
|
||||
if thread and thread.get("colour"):
|
||||
colour = thread["colour"]
|
||||
draw.rectangle(
|
||||
[
|
||||
(weft_right - BASE_SIZE, ycoord),
|
||||
(weft_right, ycoord + BASE_SIZE),
|
||||
],
|
||||
fill=colour_tuple(colour),
|
||||
)
|
||||
|
||||
# Draw tieups
|
||||
draw.rectangle(
|
||||
[(tieup_left, tieup_top), (tieup_right, tieup_bottom)],
|
||||
fill=None,
|
||||
outline=GREY,
|
||||
width=1,
|
||||
)
|
||||
for y in range(1, warp["shafts"] + 1):
|
||||
ycoord = y * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(tieup_left, ycoord),
|
||||
(tieup_right, ycoord),
|
||||
],
|
||||
fill=GREY,
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
for x, tieup in enumerate(tieups):
|
||||
xcoord = tieup_left + x * BASE_SIZE
|
||||
draw.line(
|
||||
[
|
||||
(xcoord, tieup_top),
|
||||
(xcoord, tieup_bottom),
|
||||
],
|
||||
fill=GREY,
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
for entry in tieup:
|
||||
if entry > 0:
|
||||
ycoord = tieup_bottom - (entry * BASE_SIZE)
|
||||
draw.rectangle(
|
||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
||||
fill=BLACK,
|
||||
outline=None,
|
||||
width=1,
|
||||
)
|
||||
|
||||
# Draw drawdown
|
||||
draw.rectangle(
|
||||
[(drawdown_left, drawdown_top), (drawdown_right, drawdown_bottom)],
|
||||
fill=None,
|
||||
outline=(0, 0, 0),
|
||||
width=1,
|
||||
)
|
||||
for y, weft_thread in enumerate(weft["treadling"]):
|
||||
for x, warp_thread in enumerate(warp["threading"]):
|
||||
# Ensure selected treadle and shaft is within configured pattern range
|
||||
treadle = (
|
||||
0
|
||||
if weft_thread["treadle"] > weft["treadles"]
|
||||
else weft_thread["treadle"]
|
||||
)
|
||||
shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"]
|
||||
|
||||
# Work out if should be warp or weft in "front"
|
||||
tieup = (
|
||||
tieups[treadle - 1] if (treadle > 0 and treadle <= len(tieups)) else []
|
||||
)
|
||||
tieup = [t for t in tieup if t <= warp["shafts"]]
|
||||
thread_type = "warp" if shaft in tieup else "weft"
|
||||
|
||||
# Calculate current colour
|
||||
weft_colour = weft_thread.get("colour") or weft.get("defaultColour")
|
||||
warp_colour = warp_thread.get("colour") or warp.get("defaultColour")
|
||||
colour = colour_tuple(warp_colour if thread_type == "warp" else weft_colour)
|
||||
|
||||
# Calculate drawdown coordinates
|
||||
x1 = drawdown_right - (x + 1) * BASE_SIZE
|
||||
x2 = drawdown_right - x * BASE_SIZE
|
||||
y1 = drawdown_top + y * BASE_SIZE
|
||||
y2 = drawdown_top + (y + 1) * BASE_SIZE
|
||||
|
||||
# Draw the thread, with shadow
|
||||
d = [0.6, 0.8, 0.9, 1.1, 1.3, 1.3, 1.1, 0.9, 0.8, 0.6, 0.5]
|
||||
if thread_type == "warp":
|
||||
for i, grad_x in enumerate(range(x1, x2)):
|
||||
draw.line(
|
||||
[
|
||||
(grad_x, y1),
|
||||
(grad_x, y2),
|
||||
],
|
||||
fill=(darken_colour(colour, d[i])),
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
else:
|
||||
for i, grad_y in enumerate(range(y1, y2)):
|
||||
draw.line(
|
||||
[
|
||||
(x1, grad_y),
|
||||
(x2, grad_y),
|
||||
],
|
||||
fill=(darken_colour(colour, d[i])),
|
||||
width=1,
|
||||
joint=None,
|
||||
)
|
||||
|
||||
in_mem_file = io.BytesIO()
|
||||
img.save(in_mem_file, "PNG")
|
||||
in_mem_file.seek(0)
|
||||
file_name = "preview-{0}_{1}-{2}.png".format(
|
||||
"full" if with_plan else "base", obj["_id"], int(time.time())
|
||||
)
|
||||
path = "projects/{}/{}".format(obj["project"], file_name)
|
||||
uploads.upload_file(path, in_mem_file)
|
||||
return file_name
|
||||
|
40
docker/Dockerfile
Normal file
40
docker/Dockerfile
Normal 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
33
docker/docker-compose.yml
Normal 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
22
docker/nginx.conf
Normal 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
8
docker/start.sh
Normal 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;"
|
||||
|
@ -4,7 +4,33 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f
|
||||
channel: stable
|
||||
revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
- platform: linux
|
||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
- platform: macos
|
||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
- platform: windows
|
||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
@ -6,7 +6,7 @@ The source code for Treadl's iOS and Android application.
|
||||
|
||||
The application is written in Dart using the Flutter framework.
|
||||
|
||||
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality.
|
||||
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality and for adding images to projects when out and about.
|
||||
|
||||
## Start
|
||||
|
||||
|
28
mobile/analysis_options.yaml
Normal file
28
mobile/analysis_options.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
compileSdkVersion 33
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
@ -44,8 +44,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.treadl"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 31
|
||||
minSdkVersion 29
|
||||
targetSdkVersion 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
@ -43,6 +43,14 @@
|
||||
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="http" android:host="treadl.com" />
|
||||
<data android:scheme="https" android:host="treadl.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
@ -1,16 +1,21 @@
|
||||
// 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 io.flutter.app.FlutterApplication}, adding multidex support.
|
||||
* Extension of {@link android.app.Application}, adding multidex support.
|
||||
*/
|
||||
public class FlutterMultiDexApplication extends FlutterApplication {
|
||||
public class FlutterMultiDexApplication extends Application {
|
||||
@Override
|
||||
@CallSuper
|
||||
protected void attachBaseContext(Context base) {
|
||||
|
@ -1,12 +1,12 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.8.20'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.0'
|
||||
classpath 'com.android.tools.build:gradle:7.4.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.3'
|
||||
}
|
||||
@ -27,6 +27,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
#Fri Jun 23 08:50:38 CEST 2017
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
||||
|
BIN
mobile/assets/login.png
Normal file
BIN
mobile/assets/login.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>9.0</string>
|
||||
<string>11.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '9.0'
|
||||
# platform :ios, '11.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
@ -1,153 +1,186 @@
|
||||
PODS:
|
||||
- Firebase/CoreOnly (8.11.0):
|
||||
- FirebaseCore (= 8.11.0)
|
||||
- Firebase/Messaging (8.11.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 8.11.0)
|
||||
- firebase_core (1.13.1):
|
||||
- Firebase/CoreOnly (= 8.11.0)
|
||||
- DKImagePickerController/Core (4.3.4):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
- DKImagePickerController/ImageDataManager (4.3.4)
|
||||
- DKImagePickerController/PhotoGallery (4.3.4):
|
||||
- DKImagePickerController/Core
|
||||
- DKPhotoGallery
|
||||
- DKImagePickerController/Resource (4.3.4)
|
||||
- DKPhotoGallery (0.0.17):
|
||||
- DKPhotoGallery/Core (= 0.0.17)
|
||||
- DKPhotoGallery/Model (= 0.0.17)
|
||||
- DKPhotoGallery/Preview (= 0.0.17)
|
||||
- DKPhotoGallery/Resource (= 0.0.17)
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Core (0.0.17):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Preview
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Model (0.0.17):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Preview (0.0.17):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Resource
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Resource (0.0.17):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- firebase_messaging (11.2.8):
|
||||
- Firebase/Messaging (= 8.11.0)
|
||||
- Firebase/CoreOnly (10.9.0):
|
||||
- FirebaseCore (= 10.9.0)
|
||||
- Firebase/Messaging (10.9.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 10.9.0)
|
||||
- firebase_core (2.13.1):
|
||||
- Firebase/CoreOnly (= 10.9.0)
|
||||
- Flutter
|
||||
- firebase_messaging (14.6.2):
|
||||
- Firebase/Messaging (= 10.9.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseCore (8.11.0):
|
||||
- FirebaseCoreDiagnostics (~> 8.0)
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- GoogleUtilities/Logger (~> 7.7)
|
||||
- FirebaseCoreDiagnostics (8.12.0):
|
||||
- GoogleDataTransport (~> 9.1)
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- GoogleUtilities/Logger (~> 7.7)
|
||||
- nanopb (~> 2.30908.0)
|
||||
- FirebaseInstallations (8.12.0):
|
||||
- FirebaseCore (~> 8.0)
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- GoogleUtilities/UserDefaults (~> 7.7)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- FirebaseMessaging (8.11.0):
|
||||
- FirebaseCore (~> 8.0)
|
||||
- FirebaseInstallations (~> 8.0)
|
||||
- GoogleDataTransport (~> 9.1)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- GoogleUtilities/Reachability (~> 7.7)
|
||||
- GoogleUtilities/UserDefaults (~> 7.7)
|
||||
- nanopb (~> 2.30908.0)
|
||||
- FirebaseCore (10.9.0):
|
||||
- FirebaseCoreInternal (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/Logger (~> 7.8)
|
||||
- FirebaseCoreInternal (10.10.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||
- FirebaseInstallations (10.10.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- PromisesObjC (~> 2.1)
|
||||
- FirebaseMessaging (10.9.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- GoogleDataTransport (~> 9.2)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/Reachability (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- Flutter (1.0.0)
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- Toast
|
||||
- GoogleDataTransport (9.1.2):
|
||||
- GoogleUtilities/Environment (~> 7.2)
|
||||
- nanopb (~> 2.30908.0)
|
||||
- GoogleDataTransport (9.2.3):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/AppDelegateSwizzler (7.7.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Environment (7.7.0):
|
||||
- GoogleUtilities/Environment (7.11.1):
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.7.0):
|
||||
- GoogleUtilities/Logger (7.11.1):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Network (7.7.0):
|
||||
- GoogleUtilities/Network (7.11.1):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (7.7.0)"
|
||||
- GoogleUtilities/Reachability (7.7.0):
|
||||
- "GoogleUtilities/NSData+zlib (7.11.1)"
|
||||
- GoogleUtilities/Reachability (7.11.1):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/UserDefaults (7.7.0):
|
||||
- GoogleUtilities/UserDefaults (7.11.1):
|
||||
- GoogleUtilities/Logger
|
||||
- image_picker (0.0.1):
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- nanopb (2.30908.0):
|
||||
- nanopb/decode (= 2.30908.0)
|
||||
- nanopb/encode (= 2.30908.0)
|
||||
- nanopb/decode (2.30908.0)
|
||||
- nanopb/encode (2.30908.0)
|
||||
- PromisesObjC (2.0.0)
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- nanopb (2.30909.0):
|
||||
- nanopb/decode (= 2.30909.0)
|
||||
- nanopb/encode (= 2.30909.0)
|
||||
- nanopb/decode (2.30909.0)
|
||||
- nanopb/encode (2.30909.0)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- Toast (4.0.0)
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.2.0)
|
||||
- SDWebImage (5.18.8):
|
||||
- SDWebImage/Core (= 5.18.8)
|
||||
- SDWebImage/Core (5.18.8)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SwiftyGif (5.4.4)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- wakelock (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- image_picker (from `.symlinks/plugins/image_picker/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- Firebase
|
||||
- FirebaseCore
|
||||
- FirebaseCoreDiagnostics
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
- FirebaseMessaging
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- Toast
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
firebase_core:
|
||||
:path: ".symlinks/plugins/firebase_core/ios"
|
||||
firebase_messaging:
|
||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
image_picker:
|
||||
:path: ".symlinks/plugins/image_picker/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
||||
wakelock:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c
|
||||
firebase_core: 08f6a85f62060111de5e98d6a214810d11365de9
|
||||
firebase_messaging: 36238f3d0b933af8c919aef608408aae06ba22e8
|
||||
FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0
|
||||
FirebaseCoreDiagnostics: 3b40dfadef5b90433a60ae01f01e90fe87aa76aa
|
||||
FirebaseInstallations: 25764cf322e77f99449395870a65b2bef88e1545
|
||||
FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a
|
||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
||||
GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940
|
||||
GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1
|
||||
image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3
|
||||
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
|
||||
PromisesObjC: 68159ce6952d93e17b2dfe273b8c40907db5ba58
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
|
||||
firebase_core: ce64b0941c6d87c6ef5022ae9116a158236c8c94
|
||||
firebase_messaging: 42912365e62efc1ea3e00724e5eecba6068ddb88
|
||||
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
|
||||
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
|
||||
FirebaseInstallations: 52153982b057d3afcb4e1fbb3eb0b6d00611e681
|
||||
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
|
||||
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
|
||||
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
|
||||
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
||||
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
|
||||
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
|
||||
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
COCOAPODS: 1.14.2
|
||||
|
@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 51;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@ -48,6 +48,7 @@
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
||||
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
@ -116,6 +117,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BE18F7F22B54707500363B2E /* Runner.entitlements */,
|
||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
|
||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
@ -170,7 +172,7 @@
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1300;
|
||||
LastUpgradeCheck = 1430;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
@ -232,10 +234,12 @@
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
@ -246,6 +250,7 @@
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@ -370,6 +375,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 38T664W57F;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
@ -12,6 +14,8 @@
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Treadl</string>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<true/>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@ -46,5 +50,9 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
11
mobile/ios/Runner/Runner.entitlements
Normal file
11
mobile/ios/Runner/Runner.entitlements
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:treadl.com</string>
|
||||
<string>applinks:www.treadl.com</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
@ -4,5 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:treadl.com</string>
|
||||
<string>applinks:www.treadl.com</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -4,5 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:treadl.com</string>
|
||||
<string>applinks:www.treadl.com</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -1,28 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'util.dart';
|
||||
import 'model.dart';
|
||||
|
||||
class Api {
|
||||
|
||||
String _token;
|
||||
String? _token;
|
||||
final String apiBase = 'https://api.treadl.com';
|
||||
//final String apiBase = 'http://localhost:2001';
|
||||
//final String apiBase = 'http://192.168.5.134:2001';
|
||||
|
||||
Future<String> loadToken() async {
|
||||
Api({token: null}) {
|
||||
if (token != null) _token = token;
|
||||
}
|
||||
|
||||
Future<String?> loadToken() async {
|
||||
if (_token != null) {
|
||||
return _token;
|
||||
return _token!;
|
||||
}
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final String token = prefs.getString('apiToken');
|
||||
String? token = prefs.getString('apiToken');
|
||||
return token;
|
||||
}
|
||||
Future<Map<String,String>> getHeaders(method) async {
|
||||
Map<String,String> headers = {};
|
||||
String token = await loadToken();
|
||||
String? token = await loadToken();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['Authorization'] = 'Bearer ' + token!;
|
||||
}
|
||||
if (method == 'POST' || method == 'DELETE') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
@ -34,17 +41,23 @@ class Api {
|
||||
http.Client client = http.Client();
|
||||
return await client.get(url, headers: await getHeaders('GET'));
|
||||
}
|
||||
Future<http.Response> _post(Uri url, Map<String, dynamic> data) async {
|
||||
String json = jsonEncode(data);
|
||||
Future<http.Response> _post(Uri url, Map<String, dynamic>? data) async {
|
||||
String? json = null;
|
||||
if (data != null) {
|
||||
json = jsonEncode(data!);
|
||||
}
|
||||
http.Client client = http.Client();
|
||||
return await client.post(url, headers: await getHeaders('POST'), body: json);
|
||||
}
|
||||
Future<http.Response> _put(Uri url, Map<String, dynamic> data) async {
|
||||
String json = jsonEncode(data);
|
||||
Future<http.Response> _put(Uri url, Map<String, dynamic>? data) async {
|
||||
String? json = null;
|
||||
if (data != null) {
|
||||
json = jsonEncode(data!);
|
||||
}
|
||||
http.Client client = http.Client();
|
||||
return await client.put(url, headers: await getHeaders('POST'), body: json);
|
||||
}
|
||||
Future<http.Response> _delete(Uri url, [Map<String, dynamic> data]) async {
|
||||
Future<http.Response> _delete(Uri url, [Map<String, dynamic>? data]) async {
|
||||
http.Client client = http.Client();
|
||||
if (data != null) {
|
||||
String json = jsonEncode(data);
|
||||
@ -54,10 +67,10 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic> data]) async {
|
||||
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic>? data]) async {
|
||||
String url = apiBase + path;
|
||||
Uri uri = Uri.parse(url);
|
||||
http.Response response;
|
||||
http.Response? response;
|
||||
if (method == 'POST') {
|
||||
response = await _post(uri, data);
|
||||
}
|
||||
@ -70,16 +83,19 @@ class Api {
|
||||
if (method == 'DELETE') {
|
||||
response = await _delete(uri, data);
|
||||
}
|
||||
int status = response.statusCode;
|
||||
if (response == null) {
|
||||
return {'success': false, 'message': 'No response for your request'};
|
||||
}
|
||||
int status = response!.statusCode;
|
||||
if (status == 200) {
|
||||
print('SUCCESS');
|
||||
Map<String, dynamic> respData = jsonDecode(response.body);
|
||||
Map<String, dynamic> respData = jsonDecode(response!.body);
|
||||
return {'success': true, 'payload': respData};
|
||||
}
|
||||
else {
|
||||
print('ERROR');
|
||||
Map<String, dynamic> respData = jsonDecode(response.body);
|
||||
return {'success': false, 'code': response.statusCode, 'message': respData['message']};
|
||||
Map<String, dynamic> respData = jsonDecode(response!.body);
|
||||
return {'success': false, 'code': status, 'message': respData['message']};
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,4 +109,17 @@ class Api {
|
||||
int status = response.statusCode;
|
||||
return status == 200;
|
||||
}
|
||||
|
||||
Future<File?> downloadFile(String url, String fileName) async {
|
||||
Uri uri = Uri.parse(url);
|
||||
http.Client client = http.Client();
|
||||
http.Response response = await client.get(uri);
|
||||
if(response.statusCode == 200) {
|
||||
final String dirPath = await Util.storagePath();
|
||||
final file = File('$dirPath/$fileName');
|
||||
await file.writeAsBytes(response.bodyBytes);
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DataImage extends StatefulWidget {
|
||||
final String _data;
|
||||
DataImage(this._data) {}
|
||||
|
||||
@override
|
||||
DataImageState createState() => new DataImageState(_data);
|
||||
}
|
||||
|
||||
class DataImageState extends State<MyHomePage> {
|
||||
String _base64;
|
||||
DataImageState(this._base64) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_base64 == null)
|
||||
return new Container();
|
||||
Uint8List bytes = BASE64.decode(_base64);
|
||||
return new Scaffold(
|
||||
appBar: new AppBar(title: new Text('Example App')),
|
||||
body: new ListTile(
|
||||
leading: new Image.memory(bytes),
|
||||
title: new Text(_base64),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
115
mobile/lib/explore.dart
Normal file
115
mobile/lib/explore.dart
Normal file
@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'api.dart';
|
||||
import 'util.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _ExploreTabState extends State<ExploreTab> {
|
||||
List<dynamic> objects = [];
|
||||
List<dynamic> projects = [];
|
||||
bool loading = false;
|
||||
int explorePage = 1;
|
||||
final Api api = Api();
|
||||
final Util util = Util();
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getExploreData();
|
||||
getData();
|
||||
}
|
||||
|
||||
void getExploreData() async {
|
||||
if (explorePage == -1) return;
|
||||
var data = await api.request('GET', '/search/explore?page=${explorePage}');
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
loading = false;
|
||||
objects = objects + data['payload']['objects'];
|
||||
explorePage = data['payload']['objects'].length == 0 ? -1 : (explorePage + 1); // Set to -1 to disable 'load more'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void getData() async {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
var data2 = await api.request('GET', '/search/discover');
|
||||
if (data2['success'] == true) {
|
||||
setState(() {
|
||||
projects = data2['payload']['highlightProjects'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<Widget> patternCards = objects.map<Widget>((object) =>
|
||||
PatternCard(object)
|
||||
).toList();
|
||||
if (explorePage > -1) {
|
||||
patternCards.add(Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.pink[50],
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
child:Center(
|
||||
child: CupertinoButton(
|
||||
child: Text('Load more'),
|
||||
onPressed: () => getExploreData(),
|
||||
)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Explore'),
|
||||
),
|
||||
body: loading ?
|
||||
Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator()
|
||||
)
|
||||
: Container(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: 10),
|
||||
CustomText('Discover projects', 'h1', margin: 5),
|
||||
SizedBox(height: 5),
|
||||
Container(
|
||||
height: 130,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: projects.map((p) => ProjectCard(p)).toList()
|
||||
)
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
CustomText('Recent patterns', 'h1', margin: 5),
|
||||
SizedBox(height: 5),
|
||||
Expanded(child: Container(
|
||||
margin: EdgeInsets.only(left: 15, right: 15),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 5,
|
||||
crossAxisSpacing: 5,
|
||||
childAspectRatio: 0.9,
|
||||
children: patternCards,
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExploreTab extends StatefulWidget {
|
||||
@override
|
||||
_ExploreTabState createState() => _ExploreTabState();
|
||||
}
|
||||
|
@ -6,31 +6,41 @@ import 'group_noticeboard.dart';
|
||||
import 'group_members.dart';
|
||||
|
||||
class _GroupScreenState extends State<GroupScreen> {
|
||||
final String id;
|
||||
Map<String, dynamic>? _group;
|
||||
int _selectedIndex = 0;
|
||||
List<Widget> _widgetOptions = <Widget> [];
|
||||
final Map<String, dynamic> _group;
|
||||
|
||||
_GroupScreenState(this._group) {
|
||||
_widgetOptions = <Widget> [
|
||||
GroupNoticeBoardTab(this._group),
|
||||
GroupMembersTab(this._group)
|
||||
];
|
||||
_GroupScreenState(this.id) { }
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
fetchGroup();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
void fetchGroup() async {
|
||||
Api api = Api();
|
||||
var data = await api.request('GET', '/groups/' + id);
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_group = data['payload'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_group['name'])
|
||||
title: Text(_group?['name'] ?? 'Group')
|
||||
),
|
||||
body: Center(
|
||||
child: _widgetOptions.elementAt(_selectedIndex),
|
||||
child: _group != null ?
|
||||
[
|
||||
GroupNoticeBoardTab(_group!),
|
||||
GroupMembersTab(_group!)
|
||||
].elementAt(_selectedIndex)
|
||||
: CircularProgressIndicator(),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: const <BottomNavigationBarItem>[
|
||||
@ -45,15 +55,17 @@ class _GroupScreenState extends State<GroupScreen> {
|
||||
],
|
||||
currentIndex: _selectedIndex,
|
||||
selectedItemColor: Colors.pink[600],
|
||||
onTap: _onItemTapped,
|
||||
onTap: (int index) => setState(() {
|
||||
_selectedIndex = index;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupScreen extends StatefulWidget {
|
||||
final Map<String,dynamic> group;
|
||||
GroupScreen(this.group) { }
|
||||
final String id;
|
||||
GroupScreen(this.id) { }
|
||||
@override
|
||||
_GroupScreenState createState() => _GroupScreenState(group);
|
||||
_GroupScreenState createState() => _GroupScreenState(id);
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'util.dart';
|
||||
import 'group_noticeboard.dart';
|
||||
import 'user.dart';
|
||||
|
||||
class _GroupMembersTabState extends State<GroupMembersTab> {
|
||||
final Util util = new Util();
|
||||
final Map<String,dynamic> _group;
|
||||
final Api api = Api();
|
||||
List<dynamic> _members = [];
|
||||
@ -33,15 +32,8 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
|
||||
|
||||
Widget getMemberCard(member) {
|
||||
return new ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => UserScreen(member),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: util.avatarImage(util.avatarUrl(member), size: 40),
|
||||
onTap: () => context.push('/' + member['username']),
|
||||
leading: Util.avatarImage(Util.avatarUrl(member), size: 40),
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(member['username'])
|
||||
);
|
||||
|
@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'util.dart';
|
||||
import 'api.dart';
|
||||
import 'user.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
||||
final TextEditingController _newEntryController = TextEditingController();
|
||||
final Util utils = new Util();
|
||||
final Api api = Api();
|
||||
Map<String,dynamic> _group;
|
||||
List<dynamic> _entries = [];
|
||||
@ -42,8 +38,10 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
||||
}
|
||||
|
||||
void _sendPost(context) async {
|
||||
String text = _newEntryController.text;
|
||||
if (text.length == 0) return;
|
||||
setState(() => _posting = true);
|
||||
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text});
|
||||
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
|
||||
if (data['success'] == true) {
|
||||
_newEntryController.value = TextEditingValue(text: '');
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
|
@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'group.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'model.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _GroupsTabState extends State<GroupsTab> {
|
||||
List<dynamic> _groups = [];
|
||||
@ -16,6 +16,8 @@ class _GroupsTabState extends State<GroupsTab> {
|
||||
}
|
||||
|
||||
void getGroups() async {
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
if (model.user == null) return;
|
||||
setState(() => _loading = true);
|
||||
Api api = Api();
|
||||
var data = await api.request('GET', '/groups');
|
||||
@ -28,61 +30,40 @@ class _GroupsTabState extends State<GroupsTab> {
|
||||
}
|
||||
|
||||
Widget buildGroupCard(Map<String,dynamic> group) {
|
||||
String description = group['description'];
|
||||
String? description = group['description'];
|
||||
if (description != null && description.length > 80) {
|
||||
description = description.substring(0, 77) + '...';
|
||||
} else {
|
||||
description = '';
|
||||
} else if (description == null) {
|
||||
description = 'This group doesn\'t have a description.';
|
||||
}
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GroupScreen(group),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
new ListTile(
|
||||
leading: Icon(Icons.people),
|
||||
onTap: () => context.push('/groups/' + group['_id']),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(group['name']),
|
||||
subtitle: Text(description.replaceAll("\n", " ")),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
;
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Groups'),
|
||||
),
|
||||
body: _loading ?
|
||||
Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator()
|
||||
)
|
||||
: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
child: (_groups != null && _groups.length > 0) ?
|
||||
ListView.builder(
|
||||
Widget getBody() {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
if (model.user == null)
|
||||
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
|
||||
else if (_loading)
|
||||
return CircularProgressIndicator();
|
||||
else if (_groups != null && _groups.length > 0)
|
||||
return ListView.builder(
|
||||
itemCount: _groups.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildGroupCard(_groups[index]);
|
||||
},
|
||||
)
|
||||
:
|
||||
Column(
|
||||
);
|
||||
else
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@ -90,8 +71,20 @@ class _GroupsTabState extends State<GroupsTab> {
|
||||
Image(image: AssetImage('assets/group.png'), width: 300),
|
||||
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
|
||||
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('My Groups'),
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: getBody()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ 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 'projects.dart';
|
||||
import 'groups.dart';
|
||||
|
||||
@ -13,6 +14,7 @@ class HomeScreen extends StatefulWidget {
|
||||
class _MyStatefulWidgetState extends State<HomeScreen> {
|
||||
int _selectedIndex = 0;
|
||||
List<Widget> _widgetOptions = <Widget> [
|
||||
ExploreTab(),
|
||||
ProjectsTab(),
|
||||
GroupsTab()
|
||||
];
|
||||
@ -32,12 +34,16 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.folder),
|
||||
label: 'Projects',
|
||||
icon: Icon(Icons.explore),
|
||||
label: 'Explore',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person),
|
||||
label: 'Groups',
|
||||
icon: Icon(Icons.folder),
|
||||
label: 'My Projects',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.people),
|
||||
label: 'My Groups',
|
||||
),
|
||||
],
|
||||
currentIndex: _selectedIndex,
|
||||
|
@ -4,17 +4,20 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'util.dart';
|
||||
import 'user.dart';
|
||||
import 'object.dart';
|
||||
import 'project.dart';
|
||||
|
||||
class Alert extends StatelessWidget {
|
||||
final String type;
|
||||
final String title;
|
||||
final String description;
|
||||
final String actionText;
|
||||
final Widget descriptionWidget;
|
||||
final Function action;
|
||||
final Widget? descriptionWidget;
|
||||
final Function? action;
|
||||
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
|
||||
|
||||
@override
|
||||
@ -39,7 +42,7 @@ class Alert extends StatelessWidget {
|
||||
color: accentColor,
|
||||
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.grey[50], spreadRadius: 5),
|
||||
BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
@ -48,12 +51,12 @@ class Alert extends StatelessWidget {
|
||||
Icon(icon, color: color),
|
||||
SizedBox(height: 20),
|
||||
Text(description, textAlign: TextAlign.center),
|
||||
descriptionWidget,
|
||||
descriptionWidget != null ? descriptionWidget! : Text(""),
|
||||
action != null ? CupertinoButton(
|
||||
child: Text(actionText),
|
||||
onPressed: action,
|
||||
) : null
|
||||
].where((o) => o != null).toList()
|
||||
onPressed: () => action!(),
|
||||
) : Text("")
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -61,17 +64,16 @@ class Alert extends StatelessWidget {
|
||||
|
||||
class NoticeboardPost extends StatefulWidget {
|
||||
final Map<String,dynamic> _entry;
|
||||
final Function onDelete;
|
||||
final Function onReply;
|
||||
final Function? onDelete;
|
||||
final Function? onReply;
|
||||
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
|
||||
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
|
||||
}
|
||||
class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
final Map<String,dynamic> _entry;
|
||||
final Util utils = new Util();
|
||||
final Api api = new Api();
|
||||
final Function onDelete;
|
||||
final Function onReply;
|
||||
final Function? onDelete;
|
||||
final Function? onReply;
|
||||
final TextEditingController _replyController = TextEditingController();
|
||||
bool _isReplying = false;
|
||||
bool _replying = false;
|
||||
@ -84,7 +86,9 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
if (data['success'] == true) {
|
||||
_replyController.value = TextEditingValue(text: '');
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
onReply(data['payload']);
|
||||
if (onReply != null) {
|
||||
onReply!(data['payload']);
|
||||
}
|
||||
setState(() {
|
||||
_replying = false;
|
||||
_isReplying = false;
|
||||
@ -95,8 +99,10 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
void _deletePost() async {
|
||||
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
|
||||
if (data['success'] == true) {
|
||||
onDelete(_entry);
|
||||
Navigator.of(context).pop();
|
||||
if (onDelete != null) {
|
||||
onDelete!(_entry);
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,17 +110,17 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
Widget build(BuildContext context) {
|
||||
var createdAt = DateTime.parse(_entry['createdAt']);
|
||||
bool isReply = _entry['inReplyTo'] != null;
|
||||
int replyCount = _entry['replies'] == null ? 0 : _entry['replies'].length;
|
||||
int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length;
|
||||
String replyText = 'Write a reply...';
|
||||
if (replyCount == 1) replyText = '1 Reply';
|
||||
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
|
||||
if (_isReplying) replyText = 'Cancel reply';
|
||||
List<Widget> replyWidgets = [];
|
||||
if (_entry['replies'] != null) {
|
||||
for (int i = 0; i < _entry['replies'].length; i++) {
|
||||
for (int i = 0; i < _entry['replies']!.length; i++) {
|
||||
replyWidgets.add(new Container(
|
||||
key: Key(_entry['replies'][i]['_id']),
|
||||
child: NoticeboardPost(_entry['replies'][i], onDelete: onDelete)
|
||||
key: Key(_entry['replies']![i]['_id']),
|
||||
child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete)
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -127,22 +133,22 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
RaisedButton(
|
||||
color: Colors.orange,
|
||||
ElevatedButton(
|
||||
//color: Colors.orange,
|
||||
onPressed: () {
|
||||
launch('https://www.treadl.com');
|
||||
},
|
||||
child: Text('Report this post'),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
RaisedButton(
|
||||
color: Colors.red,
|
||||
ElevatedButton(
|
||||
//color: Colors.red,
|
||||
onPressed: _deletePost,
|
||||
child: Text('Delete post'),
|
||||
),
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Cancel'),
|
||||
)
|
||||
@ -161,12 +167,8 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => UserScreen(_entry['authorUser']),
|
||||
));
|
||||
},
|
||||
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
||||
onTap: () => context.push('/' + _entry['authorUser']['username']),
|
||||
child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
|
||||
@ -176,18 +178,18 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||
!isReply ? GestureDetector(
|
||||
onTap: () => setState(() => _isReplying = !_isReplying),
|
||||
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
): null,
|
||||
].where((o) => o != null).toList(),
|
||||
): SizedBox(width: 0),
|
||||
],
|
||||
),
|
||||
Row(children: [
|
||||
SizedBox(width: 45),
|
||||
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
|
||||
]),
|
||||
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : null,
|
||||
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
|
||||
Column(
|
||||
children: replyWidgets
|
||||
),
|
||||
].where((o) => o != null).toList(),
|
||||
],
|
||||
))
|
||||
);
|
||||
}
|
||||
@ -215,7 +217,7 @@ class NoticeboardInput extends StatelessWidget {
|
||||
),
|
||||
)),
|
||||
IconButton(
|
||||
onPressed: _onPost,
|
||||
onPressed: () => _onPost!(),
|
||||
color: Colors.pink,
|
||||
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
|
||||
)
|
||||
@ -225,3 +227,174 @@ class NoticeboardInput extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class UserChip extends StatelessWidget {
|
||||
final Map<String,dynamic> user;
|
||||
UserChip(this.user) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ImageProvider? avatar = Util.avatarUrl(user);
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/' + user['username']),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Util.avatarImage(avatar, size: 20),
|
||||
SizedBox(width: 5),
|
||||
Text(user['username'], style: TextStyle(color: Colors.grey))
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PatternCard extends StatelessWidget {
|
||||
final Map<String,dynamic> object;
|
||||
PatternCard(this.object) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
image: NetworkImage(object['previewUrl']),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UserChip(object['projectObject']['owner']),
|
||||
SizedBox(height: 5),
|
||||
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectCard extends StatelessWidget {
|
||||
final Map<String,dynamic> project;
|
||||
ProjectCard(this.project) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.folder, color: Colors.pink[200]),
|
||||
SizedBox(height: 10),
|
||||
UserChip(project['owner']),
|
||||
SizedBox(height: 5),
|
||||
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomText extends StatelessWidget {
|
||||
final String text;
|
||||
final String type;
|
||||
final double margin;
|
||||
TextStyle? style;
|
||||
CustomText(this.text, this.type, {this.margin = 0}) {
|
||||
if (this.type == 'h1') {
|
||||
style = TextStyle(fontSize: 25, fontWeight: FontWeight.bold);
|
||||
}
|
||||
else {
|
||||
style = TextStyle();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.all(this.margin),
|
||||
child: Text(text, style: style)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginNeeded extends StatelessWidget {
|
||||
final String? text;
|
||||
LoginNeeded({this.text}) {}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||
Image(image: AssetImage('assets/login.png'), width: 300),
|
||||
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
|
||||
CupertinoButton(
|
||||
onPressed: () {
|
||||
context.push('/welcome');
|
||||
},
|
||||
child: new Text("Login or register",
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyBox extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
|
||||
EmptyBox(this.title, {this.description}) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||
Image(image: AssetImage('assets/empty.png'), width: 300),
|
||||
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'model.dart';
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
@ -11,15 +13,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final Api api = Api();
|
||||
bool _loggingIn = false;
|
||||
|
||||
void _submit(context) async {
|
||||
void _submit(BuildContext context) async {
|
||||
setState(() => _loggingIn = true);
|
||||
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
|
||||
setState(() => _loggingIn = false);
|
||||
if (data['success'] == true) {
|
||||
String token = data['payload']['token'];
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('apiToken', token);
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
await model.setToken(data['payload']['token']);
|
||||
context.go('/onboarding');
|
||||
}
|
||||
else {
|
||||
showDialog(
|
||||
@ -31,7 +32,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('Try again'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@ -46,20 +47,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
title: Text('Login to Treadl'),
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
Image(image: AssetImage('assets/logo.png'), width: 100),
|
||||
SizedBox(height: 20),
|
||||
Text('Login using your Treadl account.'),
|
||||
SizedBox(height: 20),
|
||||
Text('Login with your Treadl account', style: TextStyle(fontSize: 20)),
|
||||
SizedBox(height: 30),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: _emailController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'sam@example.com', labelText: 'Email address or username'
|
||||
hintText: 'sam@example.com', labelText: 'Email address or username',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
@ -68,7 +66,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type your password', labelText: 'Your password'
|
||||
hintText: 'Type your password', labelText: 'Your password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
@ -80,9 +79,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
)]
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => _submit(context),
|
||||
color: Colors.pink,
|
||||
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white, fontSize: 15)
|
||||
@ -90,7 +88,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -3,19 +3,54 @@ import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
//import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'api.dart';
|
||||
import 'store.dart';
|
||||
import 'model.dart';
|
||||
import 'welcome.dart';
|
||||
import 'login.dart';
|
||||
import 'register.dart';
|
||||
import 'onboarding.dart';
|
||||
import 'home.dart';
|
||||
import 'project.dart';
|
||||
import 'object.dart';
|
||||
import 'settings.dart';
|
||||
import 'group.dart';
|
||||
import 'user.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => Startup()),
|
||||
GoRoute(path: '/welcome', pageBuilder: (context, state) {
|
||||
return CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: WelcomeScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Change the opacity of the screen using a Curve based on the the animation's value
|
||||
return FadeTransition(
|
||||
opacity:
|
||||
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
|
||||
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
|
||||
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
|
||||
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
||||
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
|
||||
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
|
||||
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
|
||||
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
|
||||
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => Store(),
|
||||
create: (context) => AppModel(),
|
||||
child: MyApp()
|
||||
)
|
||||
);
|
||||
@ -37,21 +72,14 @@ class _AppState extends State<MyApp> {
|
||||
// Initialize FlutterFire:
|
||||
future: _initialization,
|
||||
builder: (context, snapshot) {
|
||||
return MaterialApp(
|
||||
return MaterialApp.router(
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Treadl',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.pink,
|
||||
textSelectionColor: Colors.blue,
|
||||
scaffoldBackgroundColor: Color.fromRGBO(255, 251, 248, 1),
|
||||
),
|
||||
home: Startup(),
|
||||
routes: <String, WidgetBuilder>{
|
||||
'/welcome': (BuildContext context) => WelcomeScreen(),
|
||||
'/login': (BuildContext context) => LoginScreen(),
|
||||
'/register': (BuildContext context) => RegisterScreen(),
|
||||
'/onboarding': (BuildContext context) => OnboardingScreen(),
|
||||
'/home': (BuildContext context) => HomeScreen(),
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -64,12 +92,12 @@ class Startup extends StatelessWidget {
|
||||
Startup() {
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
if (message.notification != null) {
|
||||
print(message.notification.body);
|
||||
print(message.notification!);
|
||||
String text = '';
|
||||
if (message.notification != null && message.notification.body != null) {
|
||||
text = message.notification.body;
|
||||
if (message.notification != null && message.notification!.body != null) {
|
||||
text = message.notification!.body!;
|
||||
}
|
||||
Fluttertoast.showToast(
|
||||
/*Fluttertoast.showToast(
|
||||
msg: text,
|
||||
toastLength: Toast.LENGTH_LONG,
|
||||
gravity: ToastGravity.TOP,
|
||||
@ -77,7 +105,7 @@ class Startup extends StatelessWidget {
|
||||
backgroundColor: Colors.grey[100],
|
||||
textColor: Colors.black,
|
||||
fontSize: 16.0
|
||||
);
|
||||
);*/
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -86,10 +114,10 @@ class Startup extends StatelessWidget {
|
||||
if (_handled) return;
|
||||
_handled = true;
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final String token = prefs.getString('apiToken');
|
||||
String? token = prefs.getString('apiToken');
|
||||
if (token != null) {
|
||||
Provider.of<Store>(context, listen: false).setToken(token);
|
||||
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
await model.setToken(token!);
|
||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||
await _firebaseMessaging.requestPermission(
|
||||
alert: true,
|
||||
@ -100,19 +128,14 @@ class Startup extends StatelessWidget {
|
||||
provisional: false,
|
||||
sound: true,
|
||||
);
|
||||
String _pushToken = await _firebaseMessaging.getToken();
|
||||
String? _pushToken = await _firebaseMessaging.getToken();
|
||||
if (_pushToken != null) {
|
||||
print("sending push");
|
||||
Api api = Api();
|
||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
||||
}
|
||||
print('111');
|
||||
// Push without including current route in stack:
|
||||
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
|
||||
print('222');
|
||||
} else {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||
}
|
||||
context.go('/home');
|
||||
}
|
||||
|
||||
@override
|
||||
|
65
mobile/lib/model.dart
Normal file
65
mobile/lib/model.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'api.dart';
|
||||
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
String? avatar;
|
||||
String? avatarUrl;
|
||||
|
||||
User(this.id, this.username, {this.avatar, this.avatarUrl}) {}
|
||||
|
||||
static User loadJSON(Map<String,dynamic> input) {
|
||||
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
|
||||
}
|
||||
}
|
||||
|
||||
class AppModel extends ChangeNotifier {
|
||||
User? user;
|
||||
void setUser(User? u) {
|
||||
user = u;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? apiToken;
|
||||
Future<void> setToken(String? newToken) async {
|
||||
apiToken = newToken;
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
if (apiToken != null) {
|
||||
Api api = Api(token: apiToken!);
|
||||
prefs.setString('apiToken', apiToken!);
|
||||
var data = await api.request('GET', '/users/me');
|
||||
if (data['success'] == true) {
|
||||
setUser(User.loadJSON(data['payload']));
|
||||
print(data);
|
||||
}
|
||||
} else {
|
||||
prefs.remove('apiToken');
|
||||
}
|
||||
}
|
||||
/*
|
||||
/// Internal, private state of the cart.
|
||||
final List<Item> _items = [];
|
||||
|
||||
/// An unmodifiable view of the items in the cart.
|
||||
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
|
||||
|
||||
/// The current total price of all items (assuming all items cost $42).
|
||||
int get totalPrice => _items.length * 42;
|
||||
|
||||
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
|
||||
/// cart from the outside.
|
||||
void add(Item item) {
|
||||
_items.add(item);
|
||||
// This call tells the widgets that are listening to this model to rebuild.
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Removes all items from the cart.
|
||||
void removeAll() {
|
||||
_items.clear();
|
||||
// This call tells the widgets that are listening to this model to rebuild.
|
||||
notifyListeners();
|
||||
}*/
|
||||
}
|
@ -3,22 +3,64 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:io';
|
||||
import 'api.dart';
|
||||
import 'util.dart';
|
||||
import 'model.dart';
|
||||
import 'patterns/pattern.dart';
|
||||
import 'patterns/viewer.dart';
|
||||
|
||||
class _ObjectScreenState extends State<ObjectScreen> {
|
||||
final Map<String,dynamic> _object;
|
||||
final Function _onDelete;
|
||||
final String username;
|
||||
final String projectPath;
|
||||
final String id;
|
||||
Map<String,dynamic>? object;
|
||||
Map<String,dynamic>? pattern;
|
||||
bool _isLoading = false;
|
||||
final Api api = Api();
|
||||
|
||||
_ObjectScreenState(this._object, this._onDelete) { }
|
||||
_ObjectScreenState(this.username, this.projectPath, this.id) { }
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
fetchObject();
|
||||
}
|
||||
|
||||
void fetchObject() async {
|
||||
var data = await api.request('GET', '/objects/' + id);
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
object = data['payload'];
|
||||
pattern = data['payload']['pattern'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _shareObject() async {
|
||||
setState(() => _isLoading = true);
|
||||
File? file;
|
||||
if (object!['type'] == 'pattern') {
|
||||
var data = await api.request('GET', '/objects/' + id + '/wif');
|
||||
if (data['success'] == true) {
|
||||
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
|
||||
}
|
||||
} else {
|
||||
String fileName = Uri.file(object!['url']).pathSegments.last;
|
||||
file = await api.downloadFile(object!['url'], fileName);
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
Util.shareFile(file!, withDelete: true);
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
void _deleteObject(BuildContext context, BuildContext modalContext) async {
|
||||
var data = await api.request('DELETE', '/objects/' + _object['_id']);
|
||||
var data = await api.request('DELETE', '/objects/' + id);
|
||||
if (data['success']) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(modalContext);
|
||||
Navigator.pop(context);
|
||||
_onDelete(_object['_id']);
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,13 +68,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
||||
showDialog(
|
||||
context: modalContext,
|
||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||
title: new Text('Really delete this object?'),
|
||||
title: new Text('Really delete this item?'),
|
||||
content: new Text('This action cannot be undone.'),
|
||||
actions: <Widget>[
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('No'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
@ -44,6 +86,45 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _renameObject(BuildContext context) {
|
||||
TextEditingController renameController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Rename this item'),
|
||||
content: TextField(
|
||||
autofocus: true,
|
||||
controller: renameController,
|
||||
decoration: InputDecoration(hintText: "Enter a new name for the item"),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('CANCEL'),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text('OK'),
|
||||
onPressed: () async {
|
||||
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
|
||||
if (data['success']) {
|
||||
context.pop();
|
||||
object!['name'] = data['payload']['name'];
|
||||
setState(() {
|
||||
object = object;
|
||||
});
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSettingsModal(context) {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
@ -51,13 +132,17 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
||||
return CupertinoActionSheet(
|
||||
title: Text('Manage this object'),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(modalContext).pop(),
|
||||
onPressed: () => modalContext.pop(),
|
||||
child: Text('Cancel')
|
||||
),
|
||||
actions: [
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () => _renameObject(context),
|
||||
child: Text('Rename item'),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () => _confirmDeleteObject(modalContext),
|
||||
child: Text('Delete object'),
|
||||
child: Text('Delete item'),
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
]
|
||||
@ -67,43 +152,79 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
||||
}
|
||||
|
||||
Widget getObjectWidget() {
|
||||
if (_object['isImage'] == true) {
|
||||
return Image.network(_object['url']);
|
||||
if (object == null) {
|
||||
return Center(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [CircularProgressIndicator()]
|
||||
));
|
||||
}
|
||||
else if (_object['type'] == 'pattern') {
|
||||
var dat = Uri.parse(_object['preview']).data;
|
||||
return Image.memory(dat.contentAsBytes());
|
||||
else if (object!['isImage'] == true && object!['url'] != null) {
|
||||
print(object!['url']);
|
||||
return Image.network(object!['url']);
|
||||
}
|
||||
else if (object!['type'] == 'pattern') {
|
||||
if (pattern != null) {
|
||||
return PatternViewer(pattern!, withEditor: true);
|
||||
}
|
||||
else if (object!['previewUrl'] != null) {
|
||||
return Image.network(object!['previewUrl']!);;
|
||||
}
|
||||
else {
|
||||
return RaisedButton(child: Text('View file'), onPressed: () {
|
||||
launch(_object['url']);
|
||||
});
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 50),
|
||||
Icon(Icons.pattern, size: 40),
|
||||
SizedBox(height: 20),
|
||||
Text('A preview of this pattern is not yet available'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return Center(child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Treadl cannot display this type of item.'),
|
||||
SizedBox(height: 20),
|
||||
ElevatedButton(child: Text('View file'), onPressed: () {
|
||||
launch(object!['url']);
|
||||
}),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
User? user = model.user;
|
||||
String description = '';
|
||||
if (_object['description'] != null)
|
||||
description = _object['description'];
|
||||
if (object?['description'] != null)
|
||||
description = object!['description']!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_object['name']),
|
||||
title: Text(object?['name'] ?? 'Object'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.ios_share),
|
||||
onPressed: () {
|
||||
_shareObject();
|
||||
},
|
||||
),
|
||||
Util.canEditProject(user, object?['projectObject']) ? IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
_showSettingsModal(context);
|
||||
},
|
||||
),
|
||||
) : SizedBox(height: 0),
|
||||
]
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
getObjectWidget(),
|
||||
Html(data: description)
|
||||
child: Column(
|
||||
children: [
|
||||
_isLoading ? LinearProgressIndicator() : SizedBox(height: 0),
|
||||
Expanded(child: getObjectWidget()),
|
||||
]
|
||||
)
|
||||
),
|
||||
@ -112,9 +233,11 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
||||
}
|
||||
|
||||
class ObjectScreen extends StatefulWidget {
|
||||
final Map<String,dynamic> _object;
|
||||
final Function _onDelete;
|
||||
ObjectScreen(this._object, this._onDelete) { }
|
||||
final String username;
|
||||
final String projectPath;
|
||||
final String id;
|
||||
ObjectScreen(this.username, this.projectPath, this.id, ) { }
|
||||
@override
|
||||
_ObjectScreenState createState() => _ObjectScreenState(_object, _onDelete);
|
||||
_ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
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:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
@ -9,7 +11,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
);
|
||||
final Api api = Api();
|
||||
bool _loading = false;
|
||||
String _pushToken;
|
||||
String? _pushToken;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -18,6 +20,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
}
|
||||
|
||||
void _requestPushPermissions() async {
|
||||
try {
|
||||
setState(() => _loading = true);
|
||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||
await _firebaseMessaging.requestPermission(
|
||||
@ -30,14 +33,11 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
sound: true,
|
||||
);
|
||||
_pushToken = await _firebaseMessaging.getToken();
|
||||
/*final FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
|
||||
await _firebaseMessaging.requestNotificationPermissions(
|
||||
const IosNotificationSettings(sound: true, badge: true, alert: true, provisional: false),
|
||||
);
|
||||
_pushToken = await _firebaseMessaging.getToken();*/
|
||||
if (_pushToken != null) {
|
||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
||||
}
|
||||
}
|
||||
on Exception { }
|
||||
setState(() => _loading = false);
|
||||
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||||
}
|
||||
@ -57,14 +57,15 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
children: <Widget>[
|
||||
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
|
||||
SizedBox(height: 10),
|
||||
Text('Treadl is a free and safe place for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||
Text('Treadl is a safe space for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||
SizedBox(height: 10),
|
||||
Image(image: AssetImage('assets/folder.png'), width: 300),
|
||||
SizedBox(height: 10),
|
||||
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store and showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||
SizedBox(height: 10),
|
||||
RaisedButton(
|
||||
child: Text('OK, I know what projects are!'),
|
||||
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),
|
||||
CupertinoButton(
|
||||
color: Colors.white,
|
||||
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)),
|
||||
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
|
||||
)
|
||||
]
|
||||
@ -83,13 +84,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||
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),
|
||||
SizedBox(height: 10),
|
||||
RaisedButton(
|
||||
SizedBox(height: 20),
|
||||
CupertinoButton(
|
||||
color: Colors.white,
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
_loading ? CircularProgressIndicator() : null,
|
||||
_loading ? SizedBox(width: 5) : null,
|
||||
Text('What\'s next?'),
|
||||
].where((o) => o != null).toList()),
|
||||
_loading ? CircularProgressIndicator() : SizedBox(width: 0),
|
||||
_loading ? SizedBox(width: 10) : SizedBox(width: 0),
|
||||
Text('Continue', style: TextStyle(color: Colors.pink)),
|
||||
]),
|
||||
onPressed: _requestPushPermissions,
|
||||
)
|
||||
]
|
||||
@ -101,15 +103,16 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 25), textAlign: TextAlign.center),
|
||||
SizedBox(height: 10),
|
||||
Image(image: AssetImage('assets/completed.png'), width: 300),
|
||||
SizedBox(height: 10),
|
||||
Text('You\'re ready to get started. If you have any questions or want to get in touch then just send us a quick tweet.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||
SizedBox(height: 10),
|
||||
RaisedButton(
|
||||
child: Text('Let\'s go'),
|
||||
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false),
|
||||
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),
|
||||
CupertinoButton(
|
||||
color: Colors.white,
|
||||
child: Text('Get started', style: TextStyle(color: Colors.pink)),
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
75
mobile/lib/patterns/drawdown.dart
Normal file
75
mobile/lib/patterns/drawdown.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui' as ui;
|
||||
import '../util.dart';
|
||||
|
||||
class DrawdownPainter extends CustomPainter {
|
||||
final Map<String,dynamic> pattern;
|
||||
final double BASE_SIZE;
|
||||
|
||||
@override
|
||||
DrawdownPainter(this.BASE_SIZE, this.pattern) {}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var weft = pattern['weft'];
|
||||
var warp = pattern['warp'];
|
||||
var tieups = pattern['tieups'];
|
||||
|
||||
var paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Draw grid
|
||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
||||
}
|
||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
||||
}
|
||||
|
||||
for (int tread = 0; tread < weft['treadling']?.length; tread++) {
|
||||
for (int thread = 0; thread < warp['threading']?.length; thread++) {
|
||||
// Ensure we only get a treadle in the allowed bounds
|
||||
int treadle = weft['treadling'][tread]['treadle'] > weft['treadles'] ? 0 : weft['treadling'][tread]['treadle'];
|
||||
int shaft = warp['threading'][thread]['shaft'];
|
||||
Color weftColour = Util.rgb(weft['treadling'][tread]['colour'] ?? weft['defaultColour']);
|
||||
Color warpColour = Util.rgb(warp['threading'][thread]['colour'] ?? warp['defaultColour']);
|
||||
|
||||
// Only capture valid tie-ups (e.g. in case there is data for more shafts, which are then reduced)
|
||||
// Dart throws error if index < 0 so check fiest
|
||||
List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : [];
|
||||
List<dynamic> filteredTieup = tieup.where((t) => t< warp['shafts']).toList();
|
||||
String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft';
|
||||
|
||||
Rect rect = Offset(
|
||||
size.width - BASE_SIZE * (thread + 1),
|
||||
tread * BASE_SIZE
|
||||
) & Size(BASE_SIZE, BASE_SIZE);
|
||||
canvas.drawRect(
|
||||
rect,
|
||||
Paint()
|
||||
..color = threadType == 'warp' ? warpColour : weftColour
|
||||
);
|
||||
|
||||
canvas.drawRect(
|
||||
rect,
|
||||
Paint()
|
||||
..shader = ui.Gradient.linear(
|
||||
threadType == 'warp' ? rect.centerLeft : rect.topCenter,
|
||||
threadType == 'warp' ? rect.centerRight : rect.bottomCenter,
|
||||
[
|
||||
Color.fromRGBO(0,0,0,0.4),
|
||||
Color.fromRGBO(0,0,0,0.0),
|
||||
Color.fromRGBO(0,0,0,0.4),
|
||||
],
|
||||
[0.0,0.5,1.0],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
102
mobile/lib/patterns/pattern.dart
Normal file
102
mobile/lib/patterns/pattern.dart
Normal file
@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'warp.dart';
|
||||
import 'weft.dart';
|
||||
import 'tieup.dart';
|
||||
import 'drawdown.dart';
|
||||
|
||||
class Pattern extends StatelessWidget {
|
||||
final Map<String,dynamic> pattern;
|
||||
final Function? onUpdate;
|
||||
final double BASE_SIZE = 5;
|
||||
|
||||
@override
|
||||
Pattern(this.pattern, {this.onUpdate}) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var warp = pattern['warp'];
|
||||
var weft = pattern['weft'];
|
||||
|
||||
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
||||
double draftHeight = warp['shafts'] * BASE_SIZE + weft['treadling']?.length * BASE_SIZE + BASE_SIZE;
|
||||
|
||||
double tieupTop = BASE_SIZE;
|
||||
double tieupRight = BASE_SIZE;
|
||||
double tieupWidth = weft['treadles'] * BASE_SIZE;
|
||||
double tieupHeight = warp['shafts'] * BASE_SIZE;
|
||||
|
||||
double warpTop = 0;
|
||||
double warpRight = weft['treadles'] * BASE_SIZE + BASE_SIZE * 2;
|
||||
double warpWidth = warp['threading']?.length * BASE_SIZE;
|
||||
double warpHeight = warp['shafts'] * BASE_SIZE + BASE_SIZE;
|
||||
|
||||
double weftRight = 0;
|
||||
double weftTop = warp['shafts'] * BASE_SIZE + BASE_SIZE * 2;
|
||||
double weftWidth = weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
||||
double weftHeight = weft['treadling'].length * BASE_SIZE;
|
||||
|
||||
double drawdownTop = warpHeight + BASE_SIZE;
|
||||
double drawdownRight = weftWidth + BASE_SIZE;
|
||||
double drawdownWidth = warpWidth;
|
||||
double drawdownHeight = weftHeight;
|
||||
|
||||
return Container(
|
||||
width: draftWidth,
|
||||
height: draftHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: tieupRight,
|
||||
top: tieupTop,
|
||||
child: GestureDetector(
|
||||
onTapDown: (details) {
|
||||
var tieups = pattern['tieups'];
|
||||
double dx = details.localPosition.dx;
|
||||
double dy = details.localPosition.dy;
|
||||
int tie = (dx / BASE_SIZE).toInt();
|
||||
int shaft = ((tieupHeight - dy) / BASE_SIZE).toInt() + 1;
|
||||
if (tieups[tie].contains(shaft)) {
|
||||
tieups[tie].remove(shaft);
|
||||
} else {
|
||||
tieups[tie].add(shaft);
|
||||
}
|
||||
print(tieups);
|
||||
if (onUpdate != null) {
|
||||
onUpdate!({'tieups': tieups});
|
||||
}
|
||||
// Toggle tieups[tie][shaft]
|
||||
},
|
||||
child: CustomPaint(
|
||||
size: Size(tieupWidth, tieupHeight),
|
||||
painter: TieupPainter(BASE_SIZE, this.pattern),
|
||||
)),
|
||||
),
|
||||
Positioned(
|
||||
right: warpRight,
|
||||
top: warpTop,
|
||||
child: CustomPaint(
|
||||
size: Size(warpWidth, warpHeight),
|
||||
painter: WarpPainter(BASE_SIZE, this.pattern),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: weftRight,
|
||||
top: weftTop,
|
||||
child: CustomPaint(
|
||||
size: Size(weftWidth, weftHeight),
|
||||
painter: WeftPainter(BASE_SIZE, this.pattern),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: drawdownRight,
|
||||
top: drawdownTop,
|
||||
child: CustomPaint(
|
||||
size: Size(drawdownWidth, drawdownHeight),
|
||||
painter: DrawdownPainter(BASE_SIZE, this.pattern),
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
41
mobile/lib/patterns/tieup.dart
Normal file
41
mobile/lib/patterns/tieup.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TieupPainter extends CustomPainter {
|
||||
final Map<String,dynamic> pattern;
|
||||
final double BASE_SIZE;
|
||||
|
||||
@override
|
||||
TieupPainter(this.BASE_SIZE, this.pattern) {}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var tieup = pattern['tieups'];
|
||||
|
||||
var paint = Paint()
|
||||
..color = Colors.black..strokeWidth = 0.5;
|
||||
|
||||
// Draw grid
|
||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
||||
}
|
||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
||||
}
|
||||
|
||||
for (var i = 0; i < tieup.length; i++) {
|
||||
List<dynamic>? tie = tieup[i];
|
||||
if (tie != null) {
|
||||
for (var j = 0; j < tie!.length; j++) {
|
||||
canvas.drawRect(
|
||||
Offset(i.toDouble()*BASE_SIZE, size.height - (tie[j]*BASE_SIZE)) &
|
||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
||||
paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
68
mobile/lib/patterns/viewer.dart
Normal file
68
mobile/lib/patterns/viewer.dart
Normal file
@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pattern.dart';
|
||||
|
||||
class PatternViewer extends StatefulWidget {
|
||||
final Map<String,dynamic> pattern;
|
||||
final bool withEditor;
|
||||
PatternViewer(this.pattern, {this.withEditor = false}) {}
|
||||
|
||||
@override
|
||||
State<PatternViewer> createState() => _PatternViewerState(this.pattern, this.withEditor);
|
||||
}
|
||||
|
||||
class _PatternViewerState extends State<PatternViewer> {
|
||||
Map<String,dynamic> pattern;
|
||||
final bool withEditor;
|
||||
bool controllerInitialised = false;
|
||||
final controller = TransformationController();
|
||||
final double BASE_SIZE = 5;
|
||||
|
||||
_PatternViewerState(this.pattern, this.withEditor) {}
|
||||
|
||||
void updatePattern(update) {
|
||||
setState(() {
|
||||
pattern!.addAll(update);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!controllerInitialised) {
|
||||
var warp = pattern['warp'];
|
||||
var weft = pattern['weft'];
|
||||
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
||||
final zoomFactor = 1.0;
|
||||
final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0;
|
||||
final yTranslate = 0.0;
|
||||
controller.value.setEntry(0, 0, zoomFactor);
|
||||
controller.value.setEntry(1, 1, zoomFactor);
|
||||
controller.value.setEntry(2, 2, zoomFactor);
|
||||
controller.value.setEntry(0, 3, -xTranslate);
|
||||
controller.value.setEntry(1, 3, -yTranslate);
|
||||
setState(() => controllerInitialised = true);
|
||||
}
|
||||
|
||||
return InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 5,
|
||||
constrained: false,
|
||||
transformationController: controller,
|
||||
child: RepaintBoundary(child: Pattern(pattern))
|
||||
);
|
||||
|
||||
|
||||
/*return Column(
|
||||
children: [
|
||||
Text('Hi'),
|
||||
Expanded(child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 5,
|
||||
constrained: false,
|
||||
transformationController: controller,
|
||||
child: RepaintBoundary(child: Pattern(pattern))))
|
||||
,
|
||||
Text('Another'),
|
||||
]
|
||||
);*/
|
||||
}
|
||||
}
|
65
mobile/lib/patterns/warp.dart
Normal file
65
mobile/lib/patterns/warp.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../util.dart';
|
||||
|
||||
class WarpPainter extends CustomPainter {
|
||||
final Map<String,dynamic> pattern;
|
||||
final double BASE_SIZE;
|
||||
|
||||
@override
|
||||
WarpPainter(this.BASE_SIZE, this.pattern) {}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var warp = pattern['warp'];
|
||||
|
||||
var paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 0.5;
|
||||
var thickPaint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
// Draw grid
|
||||
int columnsPainted = 0;
|
||||
for (double i = size.width; i >= 0; i -= BASE_SIZE) {
|
||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
||||
columnsPainted += 1;
|
||||
}
|
||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
||||
}
|
||||
|
||||
// Draw threads
|
||||
for (var i = 0; i < warp['threading'].length; i++) {
|
||||
var thread = warp['threading'][i];
|
||||
int? shaft = thread?['shaft'];
|
||||
String? colour = warp['defaultColour'];
|
||||
double x = size.width - (i+1)*BASE_SIZE;
|
||||
if (shaft != null) {
|
||||
if (shaft! > 0) {
|
||||
canvas.drawRect(
|
||||
Offset(x, size.height - shaft!*BASE_SIZE) &
|
||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
||||
paint
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
if (thread?['colour'] != null) {
|
||||
colour = thread!['colour'];
|
||||
}
|
||||
if (colour != null) {
|
||||
canvas.drawRect(
|
||||
Offset(x, 0) &
|
||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
||||
Paint()
|
||||
..color = Util.rgb(colour!)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
61
mobile/lib/patterns/weft.dart
Normal file
61
mobile/lib/patterns/weft.dart
Normal file
@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../util.dart';
|
||||
|
||||
class WeftPainter extends CustomPainter {
|
||||
final Map<String,dynamic> pattern;
|
||||
final double BASE_SIZE;
|
||||
|
||||
@override
|
||||
WeftPainter(this.BASE_SIZE, this.pattern) {}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var weft = pattern['weft'];
|
||||
|
||||
var paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 0.5;
|
||||
var thickPaint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
// Draw grid
|
||||
int rowsPainted = 0;
|
||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
||||
}
|
||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
||||
rowsPainted += 1;
|
||||
}
|
||||
|
||||
for (var i = 0; i < weft['treadling'].length; i++) {
|
||||
var thread = weft['treadling'][i];
|
||||
int? treadle = thread?['treadle'];
|
||||
String? colour = weft['defaultColour'];
|
||||
double y = i.toDouble()*BASE_SIZE;
|
||||
if (treadle != null && treadle! > 0) {
|
||||
canvas.drawRect(
|
||||
Offset((treadle!.toDouble()-1)*BASE_SIZE, y) &
|
||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
||||
paint
|
||||
);
|
||||
}
|
||||
if (thread?['colour'] != null) {
|
||||
colour = thread!['colour'];
|
||||
}
|
||||
if (colour != null) {
|
||||
canvas.drawRect(
|
||||
Offset(size.width - BASE_SIZE, y) &
|
||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
||||
Paint()
|
||||
..color = Util.rgb(colour!)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
@ -2,30 +2,432 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:io';
|
||||
import 'api.dart';
|
||||
import 'object.dart';
|
||||
import 'util.dart';
|
||||
import 'model.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _ProjectScreenState extends State<ProjectScreen> {
|
||||
final String username;
|
||||
final String projectPath;
|
||||
final String fullPath;
|
||||
final Function? onUpdate;
|
||||
final Function? onDelete;
|
||||
final picker = ImagePicker();
|
||||
final Api api = Api();
|
||||
Map<String,dynamic>? project;
|
||||
List<dynamic> _objects = [];
|
||||
bool _loading = false;
|
||||
Map<String,dynamic>? _creatingObject;
|
||||
|
||||
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
|
||||
fullPath = username + '/' + projectPath;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getProject(fullPath);
|
||||
getObjects(fullPath);
|
||||
}
|
||||
|
||||
void getProject(String fullName) async {
|
||||
setState(() => _loading = true);
|
||||
var data = await api.request('GET', '/projects/' + fullName);
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
project = data['payload'];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void getObjects(String fullName) async {
|
||||
setState(() => _loading = true);
|
||||
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
_objects = data['payload']['objects'];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _shareProject() {
|
||||
Util.shareUrl('Check out my project on Treadl', Util.appUrl(fullPath));
|
||||
}
|
||||
|
||||
void _onDeleteProject() {
|
||||
context.pop();
|
||||
onDelete!(project!['_id']);
|
||||
}
|
||||
void _onUpdateProject(project) {
|
||||
setState(() {
|
||||
project = project;
|
||||
});
|
||||
onUpdate!(project!['_id'], project!);
|
||||
}
|
||||
|
||||
void _onUpdateObject(String id, Map<String,dynamic> update) {
|
||||
List<dynamic> _newObjects = _objects.map((o) {
|
||||
if (o['_id'] == id) {
|
||||
o.addAll(update);
|
||||
}
|
||||
return o;
|
||||
}).toList();
|
||||
setState(() {
|
||||
_objects = _newObjects;
|
||||
});
|
||||
}
|
||||
void _onDeleteObject(String id) {
|
||||
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
||||
setState(() {
|
||||
_objects = _newObjects;
|
||||
});
|
||||
}
|
||||
|
||||
void _createObject(objectData) async {
|
||||
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
|
||||
setState(() => _creatingObject = null);
|
||||
if (resp['success']) {
|
||||
List<dynamic> newObjects = _objects;
|
||||
newObjects.add(resp['payload']);
|
||||
setState(() {
|
||||
_objects = newObjects;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _createObjectFromWif(String name, String wif) {
|
||||
setState(() => _creatingObject = {
|
||||
'name': name,
|
||||
'type': 'pattern',
|
||||
});
|
||||
_createObject({
|
||||
'name': name,
|
||||
'type': 'pattern',
|
||||
'wif': wif,
|
||||
});
|
||||
}
|
||||
|
||||
void _createObjectFromFile(String name, XFile file) async {
|
||||
final int size = await file.length();
|
||||
final String forId = project!['_id'];
|
||||
final String type = file.mimeType ?? 'text/plain';
|
||||
setState(() => _creatingObject = {
|
||||
'name': name,
|
||||
'type': 'file',
|
||||
});
|
||||
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
||||
if (!data['success']) {
|
||||
setState(() => _creatingObject = null);
|
||||
return;
|
||||
}
|
||||
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], File(file.path), type);
|
||||
if (!uploadSuccess) {
|
||||
setState(() => _creatingObject = null);
|
||||
return;
|
||||
}
|
||||
_createObject({
|
||||
'name': name,
|
||||
'storedName': data['payload']['fileName'],
|
||||
'type': 'file',
|
||||
});
|
||||
}
|
||||
|
||||
void _chooseFile() async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||
if (result != null) {
|
||||
PlatformFile file = result.files.single;
|
||||
XFile xFile = XFile(file.path!);
|
||||
String? ext = file.extension;
|
||||
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
|
||||
final String contents = await xFile.readAsString();
|
||||
_createObjectFromWif(file.name, contents);
|
||||
} else {
|
||||
_createObjectFromFile(file.name, xFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseImage() async {
|
||||
File file;
|
||||
try {
|
||||
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (imageFile == null) return;
|
||||
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
|
||||
String time = f.format(new DateTime.now());
|
||||
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
|
||||
_createObjectFromFile(name, imageFile);
|
||||
}
|
||||
on Exception {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||
title: Text('Treadl needs access'),
|
||||
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
||||
actions: <Widget>[
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('OK'),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showSettingsModal() {
|
||||
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
|
||||
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
||||
}
|
||||
|
||||
Widget getNetworkImageBox(String url) {
|
||||
return new AspectRatio(
|
||||
aspectRatio: 1 / 1,
|
||||
child: new Container(
|
||||
decoration: new BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
image: new DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
alignment: FractionalOffset.topCenter,
|
||||
image: new NetworkImage(url),
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget getIconBox(Icon icon) {
|
||||
return new AspectRatio(
|
||||
aspectRatio: 1 / 1,
|
||||
child: new Container(
|
||||
decoration: new BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
child: icon
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getObjectCard(int index) {
|
||||
Map<String,dynamic>? objectToShow;
|
||||
if (index >= _objects.length) {
|
||||
objectToShow = _creatingObject;
|
||||
objectToShow!['creating'] = true;
|
||||
} else {
|
||||
objectToShow = _objects[index];
|
||||
}
|
||||
Map<String,dynamic> object = objectToShow!;
|
||||
Widget leader;
|
||||
String type;
|
||||
|
||||
if (object['isImage'] == true) {
|
||||
type = 'Image';
|
||||
if (object['url'] != null) {
|
||||
leader = getNetworkImageBox(object['url']!);
|
||||
}
|
||||
else {
|
||||
leader = getIconBox(Icon(Icons.photo));
|
||||
}
|
||||
}
|
||||
else if (object['type'] == 'pattern') {
|
||||
type = 'Weaving pattern';
|
||||
if (object['previewUrl'] != null) {
|
||||
leader = getNetworkImageBox(object['previewUrl']!);
|
||||
}
|
||||
else {
|
||||
leader = getIconBox(Icon(Icons.pattern));
|
||||
}
|
||||
}
|
||||
else if (object['type'] == 'file') {
|
||||
type = 'File';
|
||||
leader = getIconBox(Icon(Icons.insert_drive_file));
|
||||
}
|
||||
else {
|
||||
type = 'Unknown';
|
||||
leader = getIconBox(Icon(Icons.file_present));
|
||||
}
|
||||
if (object['creating'] == true) {
|
||||
leader = CircularProgressIndicator();
|
||||
}
|
||||
|
||||
return new Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/' + username + '/' + projectPath + '/' + object['_id']);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: leader,
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(object['name']),
|
||||
subtitle: Text(type),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBody() {
|
||||
if (_loading || project == null)
|
||||
return CircularProgressIndicator();
|
||||
else if ((_objects != null && _objects.length > 0) || _creatingObject != null)
|
||||
return ListView.builder(
|
||||
itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return getObjectCard(index);
|
||||
},
|
||||
);
|
||||
else
|
||||
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
User? user = model.user;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(project?['name'] ?? 'Project'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.ios_share),
|
||||
onPressed: () {
|
||||
_shareProject();
|
||||
},
|
||||
),
|
||||
onUpdate != null ? IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
showSettingsModal();
|
||||
},
|
||||
) : SizedBox(width: 0),
|
||||
]
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: getBody(),
|
||||
),
|
||||
floatingActionButtonLocation: ExpandableFab.location,
|
||||
floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
|
||||
distance: 70,
|
||||
type: ExpandableFabType.up,
|
||||
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
children: [
|
||||
Row(children:[
|
||||
Container(
|
||||
padding: EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Text('Add an image', style: TextStyle(fontSize: 15, color: Colors.white)),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
onPressed: _chooseImage,
|
||||
child: Icon(Icons.image_outlined),
|
||||
),
|
||||
]),
|
||||
Row(children:[
|
||||
Container(
|
||||
padding: EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Text('Add a WIF or other file', style: TextStyle(fontSize: 15, color: Colors.white)),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
child: const Icon(Icons.insert_drive_file_outlined),
|
||||
onPressed: _chooseFile,
|
||||
),
|
||||
]),
|
||||
],
|
||||
) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectScreen extends StatefulWidget {
|
||||
final String username;
|
||||
final String projectPath;
|
||||
final Map<String,dynamic>? project;
|
||||
final Function? onUpdate;
|
||||
final Function? onDelete;
|
||||
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
|
||||
@override
|
||||
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
|
||||
}
|
||||
|
||||
class _ProjectSettingsDialog extends StatelessWidget {
|
||||
final Map<String,dynamic> _project;
|
||||
final String fullPath;
|
||||
final Map<String,dynamic> project;
|
||||
final Function _onDelete;
|
||||
final Function _onUpdateProject;
|
||||
final Api api = Api();
|
||||
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {}
|
||||
_ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
|
||||
fullPath = project['owner']['username'] + '/' + project['path'];
|
||||
|
||||
void _renameProject(BuildContext context) async {
|
||||
TextEditingController renameController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Rename your project'),
|
||||
content: TextField(
|
||||
autofocus: true,
|
||||
controller: renameController,
|
||||
decoration: InputDecoration(hintText: "Enter a new name for the project"),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('CANCEL'),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text('OK'),
|
||||
onPressed: () async {
|
||||
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
|
||||
if (data['success']) {
|
||||
context.pop();
|
||||
_onUpdateProject(data['payload']);
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleVisibility(BuildContext context, bool checked) async {
|
||||
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'});
|
||||
var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
|
||||
if (data['success']) {
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
_onUpdateProject(data['payload']);
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteProject(BuildContext context, BuildContext modalContext) async {
|
||||
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']);
|
||||
var data = await api.request('DELETE', '/projects/' + fullPath);
|
||||
if (data['success']) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(modalContext);
|
||||
context.pop();
|
||||
context.pop();
|
||||
_onDelete();
|
||||
}
|
||||
}
|
||||
@ -40,7 +442,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('No'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
@ -57,7 +459,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
||||
return CupertinoActionSheet(
|
||||
title: Text('Manage this project'),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => context.pop(),
|
||||
child: Text('Cancel')
|
||||
),
|
||||
actions: [
|
||||
@ -67,7 +469,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CupertinoSwitch(
|
||||
value: _project['visibility'] == 'private',
|
||||
value: project?['visibility'] == 'private',
|
||||
onChanged: (c) => _toggleVisibility(context, c),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
@ -75,6 +477,10 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
||||
]
|
||||
)
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () { _renameProject(context); },
|
||||
child: Text('Rename project'),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () { _confirmDeleteProject(context); },
|
||||
child: Text('Delete project'),
|
||||
@ -84,243 +490,3 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectScreenState extends State<ProjectScreen> {
|
||||
final Function _onDelete;
|
||||
final picker = ImagePicker();
|
||||
final Api api = Api();
|
||||
Map<String,dynamic> _project;
|
||||
List<dynamic> _objects = [];
|
||||
bool _loading = false;
|
||||
bool _creating = false;
|
||||
|
||||
_ProjectScreenState(this._project, this._onDelete) { }
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getObjects(_project['fullName']);
|
||||
}
|
||||
|
||||
void getObjects(String fullName) async {
|
||||
setState(() => _loading = true);
|
||||
print(fullName);
|
||||
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
_objects = data['payload']['objects'];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onDeleteProject() {
|
||||
Navigator.pop(context);
|
||||
_onDelete(_project['_id']);
|
||||
}
|
||||
void _onUpdateProject(project) {
|
||||
setState(() {
|
||||
_project = project;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDeleteObject(String id) {
|
||||
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
||||
setState(() {
|
||||
_objects = _newObjects;
|
||||
});
|
||||
}
|
||||
|
||||
void _chooseImage() async {
|
||||
File file;
|
||||
try {
|
||||
final imageFile = await picker.getImage(source: ImageSource.gallery);
|
||||
if (imageFile == null) return;
|
||||
file = File(imageFile.path);
|
||||
}
|
||||
on Exception {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||
title: Text('Treadl needs access'),
|
||||
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
||||
actions: <Widget>[
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('OK'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
final int size = await file.length();
|
||||
final String forId = _project['_id'];
|
||||
final String fullPath = _project['owner']['username'] + '/' + _project['path'];
|
||||
final String name = file.path.split('/').last;
|
||||
final String ext = name.split('.').last;
|
||||
final String type = 'image/jpeg';//$ext';
|
||||
setState(() => _creating = true);
|
||||
|
||||
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
||||
print(data);
|
||||
if (!data['success']) {
|
||||
setState(() => _creating = false);
|
||||
return;
|
||||
}
|
||||
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], file, type);
|
||||
print(uploadSuccess);
|
||||
if (!uploadSuccess) {
|
||||
setState(() => _creating = false);
|
||||
return;
|
||||
}
|
||||
var newObjectData = {
|
||||
'name': name,
|
||||
'storedName': data['payload']['fileName'],
|
||||
'type': 'file',
|
||||
};
|
||||
var objectData = await api.request('POST', '/projects/$fullPath/objects', newObjectData);
|
||||
setState(() => _creating = false);
|
||||
if (objectData['success']) {
|
||||
List<dynamic> newObjects = _objects;
|
||||
newObjects.add(objectData['payload']);
|
||||
setState(() {
|
||||
_objects = newObjects;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void showSettingsModal() {
|
||||
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject);
|
||||
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
||||
}
|
||||
|
||||
Widget getImageBox(data, [bool isMemory, bool isNetwork]) {
|
||||
return new AspectRatio(
|
||||
aspectRatio: 1 / 1,
|
||||
child: new Container(
|
||||
decoration: new BoxDecoration(
|
||||
image: new DecorationImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: FractionalOffset.topCenter,
|
||||
image: isMemory == true ? new MemoryImage(data) : new NetworkImage(data)
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget getIconBox(Icon icon) {
|
||||
return new AspectRatio(
|
||||
aspectRatio: 1 / 1,
|
||||
child: icon
|
||||
);
|
||||
}
|
||||
|
||||
Widget getObjectCard(int index) {
|
||||
if (index >= _objects.length) {
|
||||
return new Card(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Center(child:CircularProgressIndicator())
|
||||
)
|
||||
);
|
||||
}
|
||||
var object = _objects[index];
|
||||
Widget leader;
|
||||
String type;
|
||||
|
||||
if (object['isImage'] == true) {
|
||||
type = 'Image';
|
||||
leader = getImageBox(object['url']);
|
||||
}
|
||||
else if (object['type'] == 'pattern' && object['preview'] != null) {
|
||||
type = 'Weaving pattern';
|
||||
var dat = Uri.parse(object['preview']).data;
|
||||
leader = getImageBox(dat.contentAsBytes(), true);
|
||||
}
|
||||
else if (object['type'] == 'file') {
|
||||
type = 'File';
|
||||
leader = getIconBox(Icon(Icons.insert_drive_file));
|
||||
}
|
||||
|
||||
return new Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ObjectScreen(object, _onDeleteObject),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
new ListTile(
|
||||
leading: leader,
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(object['name']),
|
||||
subtitle: Text(type),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_project['name']),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
showSettingsModal();
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
body: _loading ?
|
||||
Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator()
|
||||
)
|
||||
: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
child: ((_objects != null && _objects.length > 0) || _creating) ?
|
||||
ListView.builder(
|
||||
itemCount: _objects.length + (_creating ? 1 : 0),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return getObjectCard(index);
|
||||
},
|
||||
)
|
||||
:
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||
Image(image: AssetImage('assets/empty.png'), width: 300),
|
||||
Text('Add something to this project using the button below.', textAlign: TextAlign.center),
|
||||
])
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _chooseImage,
|
||||
child: Icon(Icons.cloud_upload),
|
||||
backgroundColor: Colors.pink[500],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectScreen extends StatefulWidget {
|
||||
final Map<String,dynamic> _project;
|
||||
final Function _onDelete;
|
||||
ProjectScreen(this._project, this._onDelete) { }
|
||||
@override
|
||||
_ProjectScreenState createState() => _ProjectScreenState(_project, _onDelete);
|
||||
}
|
||||
|
@ -1,10 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'routeArguments.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'project.dart';
|
||||
import 'settings.dart';
|
||||
import 'model.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _ProjectsTabState extends State<ProjectsTab> {
|
||||
List<dynamic> _projects = [];
|
||||
bool _loading = false;
|
||||
bool _creatingProject = false;
|
||||
final Api api = Api();
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getProjects();
|
||||
}
|
||||
|
||||
void getProjects() async {
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
if (model.user == null) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
var data = await api.request('GET', '/users/me/projects');
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
_projects = data['payload']['projects'];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreatingProject() {
|
||||
setState(() {
|
||||
_creatingProject = true;
|
||||
});
|
||||
}
|
||||
void _onCreateProject(newProject) {
|
||||
List<dynamic> _newProjects = _projects;
|
||||
_newProjects.insert(0, newProject);
|
||||
setState(() {
|
||||
_projects = _newProjects;
|
||||
_creatingProject = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onUpdateProject(String id, Map<String,dynamic> update) {
|
||||
List<dynamic> _newProjects = _projects.map((p) {
|
||||
if (p['_id'] == id) {
|
||||
p.addAll(update);
|
||||
}
|
||||
return p;
|
||||
}).toList();
|
||||
setState(() {
|
||||
_projects = _newProjects;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDeleteProject(String id) {
|
||||
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
||||
setState(() {
|
||||
_projects = _newProjects;
|
||||
});
|
||||
}
|
||||
|
||||
void showNewProjectDialog() async {
|
||||
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
||||
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
||||
}
|
||||
|
||||
Widget buildProjectCard(Map<String,dynamic> project) {
|
||||
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
|
||||
if (description != null && description.length > 80) {
|
||||
description = description.substring(0, 77) + '...';
|
||||
}
|
||||
if (project['visibility'] == 'public') {
|
||||
description = "PUBLIC PROJECT\n" + description;
|
||||
}
|
||||
else description = "PRIVATE PROJECT\n" + description;
|
||||
return new Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/' + project['owner']['username'] + '/' + project['path']);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(5),
|
||||
child: ListTile(
|
||||
leading: new AspectRatio(
|
||||
aspectRatio: 1 / 1,
|
||||
child: new Container(
|
||||
decoration: new BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
child: Icon(Icons.folder, color: Colors.pink[300])
|
||||
),
|
||||
),
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
||||
subtitle: Text(description),
|
||||
),
|
||||
))
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Widget getBody() {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
if (model.user == null)
|
||||
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
|
||||
if (_loading)
|
||||
return CircularProgressIndicator();
|
||||
else if (_projects != null && _projects.length > 0)
|
||||
return ListView.builder(
|
||||
itemCount: _projects.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildProjectCard(_projects[index]);
|
||||
},
|
||||
);
|
||||
else return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||
Image(image: AssetImage('assets/reading.png'), width: 300),
|
||||
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
User? user = model.user;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('My Projects'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
context.push('/settings');
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: getBody()
|
||||
),
|
||||
floatingActionButton: user != null ? FloatingActionButton(
|
||||
onPressed: showNewProjectDialog,
|
||||
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
||||
backgroundColor: Colors.pink[500],
|
||||
) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectsTab extends StatefulWidget {
|
||||
@override
|
||||
_ProjectsTabState createState() => _ProjectsTabState();
|
||||
}
|
||||
|
||||
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||
final TextEditingController _newProjectNameController = TextEditingController();
|
||||
@ -29,7 +189,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
|
||||
if (data['success'] == true) {
|
||||
_onComplete(data['payload']);
|
||||
Navigator.of(context).pop();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +225,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||
SizedBox(height: 10),
|
||||
CupertinoButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Cancel'),
|
||||
)
|
||||
@ -82,155 +242,3 @@ class _NewProjectDialog extends StatefulWidget {
|
||||
@override
|
||||
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
|
||||
}
|
||||
|
||||
class _ProjectsTabState extends State<ProjectsTab> {
|
||||
List<dynamic> _projects = [];
|
||||
bool _loading = false;
|
||||
bool _creatingProject = false;
|
||||
final Api api = Api();
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getProjects();
|
||||
}
|
||||
|
||||
void getProjects() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
var data = await api.request('GET', '/users/me/projects');
|
||||
if (data['success'] == true) {
|
||||
setState(() {
|
||||
_projects = data['payload']['projects'];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreatingProject() {
|
||||
setState(() {
|
||||
_creatingProject = true;
|
||||
});
|
||||
}
|
||||
void _onCreateProject(newProject) {
|
||||
List<dynamic> _newProjects = _projects;
|
||||
_newProjects.insert(0, newProject);
|
||||
setState(() {
|
||||
_projects = _newProjects;
|
||||
_creatingProject = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDeleteProject(String id) {
|
||||
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
||||
setState(() {
|
||||
_projects = _newProjects;
|
||||
});
|
||||
}
|
||||
|
||||
void showNewProjectDialog() async {
|
||||
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
||||
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
||||
}
|
||||
|
||||
Widget buildProjectCard(Map<String,dynamic> project) {
|
||||
String description = project['description'] != null ? project['description'] : '';
|
||||
if (description != null && description.length > 80) {
|
||||
description = description.substring(0, 77) + '...';
|
||||
}
|
||||
return new Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProjectScreen(project, _onDeleteProject),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
new ListTile(
|
||||
leading: Icon(Icons.folder_open),
|
||||
trailing: Icon(Icons.keyboard_arrow_right),
|
||||
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
||||
subtitle: Text(description.replaceAll("\n", " ")),
|
||||
),
|
||||
/*ButtonBar(
|
||||
children: <Widget>[
|
||||
FlatButton(
|
||||
child: const Text('VIEW'),
|
||||
onPressed: () {
|
||||
|
||||
}
|
||||
),
|
||||
],
|
||||
),*/
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Your Projects'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
body: _loading ?
|
||||
Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator()
|
||||
)
|
||||
: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: (_projects != null && _projects.length > 0) ?
|
||||
ListView.builder(
|
||||
itemCount: _projects.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildProjectCard(_projects[index]);
|
||||
},
|
||||
)
|
||||
:
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||
Image(image: AssetImage('assets/reading.png'), width: 300),
|
||||
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
||||
])
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: showNewProjectDialog,
|
||||
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
||||
backgroundColor: Colors.pink[500],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectsTab extends StatefulWidget {
|
||||
@override
|
||||
_ProjectsTabState createState() => _ProjectsTabState();
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'model.dart';
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
@ -17,10 +19,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
|
||||
setState(() => _registering = false);
|
||||
if (data['success'] == true) {
|
||||
String token = data['payload']['token'];
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('apiToken', token);
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
model.setToken(data['payload']['token']);
|
||||
context.go('/onboarding');
|
||||
}
|
||||
else {
|
||||
showDialog(
|
||||
@ -32,7 +33,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('Try again'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@ -47,15 +48,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
title: Text('Register with Treadl'),
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
child: SingleChildScrollView(
|
||||
child:Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
Image(image: AssetImage('assets/logo.png'), width: 100),
|
||||
SizedBox(height: 20),
|
||||
Text('Register a free account.'),
|
||||
SizedBox(height: 20),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: _usernameController,
|
||||
@ -97,9 +92,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => _submit(context),
|
||||
color: Colors.pink,
|
||||
//color: Colors.pink,
|
||||
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white, fontSize: 15)
|
||||
@ -107,7 +102,6 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
class ProjectScreenArguments {
|
||||
final String projectId;
|
||||
final String projectName;
|
||||
final String projectPath;
|
||||
ProjectScreenArguments(this.projectId, this.projectName, this.projectPath);
|
||||
}
|
@ -1,18 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'api.dart';
|
||||
import 'model.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
void _logout(BuildContext context) async {
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
Api api = Api();
|
||||
api.request('POST', '/accounts/logout');
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.remove('apiToken');
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||
model.setToken(null);
|
||||
model.setUser(null);
|
||||
context.pop();
|
||||
}
|
||||
|
||||
void _deleteAccount(BuildContext context) async {
|
||||
@ -31,19 +34,20 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () { Navigator.of(context).pop(); }
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
child: Text('Delete Account'),
|
||||
onPressed: () async {
|
||||
Api api = Api();
|
||||
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
|
||||
if (data['success'] == true) {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.remove('apiToken');
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||
model.setToken(null);
|
||||
model.setUser(null);
|
||||
context.go('/home');
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
@ -54,7 +58,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: Text('OK'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@ -75,6 +79,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppModel model = Provider.of<AppModel>(context);
|
||||
User? user = model.user;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('About Treadl'),
|
||||
@ -93,6 +99,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
SizedBox(height: 30),
|
||||
|
||||
user != null ? Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.exit_to_app),
|
||||
title: Text('Logout'),
|
||||
@ -103,6 +111,12 @@ class SettingsScreen extends StatelessWidget {
|
||||
title: Text('Delete Account'),
|
||||
onTap: () => _deleteAccount(context),
|
||||
),
|
||||
]
|
||||
) : CupertinoButton(
|
||||
color: Colors.pink,
|
||||
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
|
||||
onPressed: () => context.push('/welcome'),
|
||||
),
|
||||
|
||||
SizedBox(height: 30),
|
||||
|
||||
|
@ -1,9 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Store extends ChangeNotifier {
|
||||
String apiToken;
|
||||
|
||||
void setToken(String newToken) {
|
||||
apiToken = newToken;
|
||||
}
|
||||
}
|
@ -5,18 +5,21 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'util.dart';
|
||||
import 'api.dart';
|
||||
import 'lib.dart';
|
||||
|
||||
class _UserScreenState extends State<UserScreen> {
|
||||
final Util util = new Util();
|
||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
|
||||
final String username;
|
||||
final Api api = Api();
|
||||
Map<String,dynamic> _user;
|
||||
TabController? _tabController;
|
||||
Map<String,dynamic>? _user;
|
||||
bool _loading = false;
|
||||
_UserScreenState(this._user) { }
|
||||
_UserScreenState(this.username) { }
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
getUser(_user['username']);
|
||||
_tabController = new TabController(length: 2, vsync: this);
|
||||
getUser(username);
|
||||
}
|
||||
|
||||
void getUser(String username) async {
|
||||
@ -31,75 +34,137 @@ class _UserScreenState extends State<UserScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String created;
|
||||
if (_user['createdAt'] != null) {
|
||||
DateTime createdAt = DateTime.parse(_user['createdAt']);
|
||||
Widget getBody() {
|
||||
if (_loading)
|
||||
return CircularProgressIndicator();
|
||||
else if (_user != null && _tabController != null) {
|
||||
var u = _user!;
|
||||
String? created;
|
||||
if (u['createdAt'] != null) {
|
||||
DateTime createdAt = DateTime.parse(u['createdAt']!);
|
||||
created = DateFormat('MMMM y').format(createdAt);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_user['username']),
|
||||
),
|
||||
body: _loading ?
|
||||
Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator()
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(children: [
|
||||
util.avatarImage(util.avatarUrl(_user), size: 120),
|
||||
Expanded(child: Container(
|
||||
Util.avatarImage(Util.avatarUrl(u), size: 120),
|
||||
Container(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_user['username'], style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(u['username'], style: Theme.of(context).textTheme.titleLarge),
|
||||
SizedBox(height: 5),
|
||||
_user['location'] != null ?
|
||||
u['location'] != null ?
|
||||
Row(children: [
|
||||
Icon(CupertinoIcons.location),
|
||||
Text(_user['location'])
|
||||
SizedBox(width: 10),
|
||||
Text(u['location'])
|
||||
]) : SizedBox(height: 1),
|
||||
SizedBox(height: 10),
|
||||
Text('Member' + (created != null ? (' since ' + created) : ''),
|
||||
Text('Member' + (created != null ? (' since ' + created!) : ''),
|
||||
style: TextStyle(color: Colors.grey[500])
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
_user['website'] != null ?
|
||||
u['website'] != null ?
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
String url = _user['website'];
|
||||
String url = u['website'];
|
||||
if (!url.startsWith('http')) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
launch(url);
|
||||
},
|
||||
child: Text(_user['website'],
|
||||
child: Text(u['website'],
|
||||
style: TextStyle(color: Colors.pink))
|
||||
) : SizedBox(height: 1),
|
||||
]
|
||||
)
|
||||
))
|
||||
]),
|
||||
SizedBox(height: 30),
|
||||
Text(_user['bio'] != null ? _user['bio'] : '')
|
||||
]
|
||||
)
|
||||
]),
|
||||
SizedBox(height: 10),
|
||||
TabBar(
|
||||
unselectedLabelColor: Colors.black,
|
||||
labelColor: Colors.pink,
|
||||
tabs: [
|
||||
Tab(
|
||||
text: 'Profile',
|
||||
icon: Icon(Icons.person),
|
||||
),
|
||||
Tab(
|
||||
text: 'Projects',
|
||||
icon: Icon(Icons.folder),
|
||||
)
|
||||
],
|
||||
controller: _tabController!,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 30),
|
||||
u['bio'] != null ? Text(u['bio']) :
|
||||
EmptyBox('This user doesn\'t have any more profile information.')
|
||||
]
|
||||
),
|
||||
(u['projects'] != null && u['projects'].length > 0) ?
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 10),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 5,
|
||||
crossAxisSpacing: 5,
|
||||
childAspectRatio: 1.3,
|
||||
children: u['projects'].map<Widget>((p) =>
|
||||
ProjectCard(p)
|
||||
).toList()
|
||||
),
|
||||
) :
|
||||
Container(
|
||||
margin: EdgeInsets.all(10),
|
||||
child: EmptyBox('This user doesn\'t have any public projects'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
]);
|
||||
}
|
||||
else
|
||||
return Text('User not found');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(username),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.person),
|
||||
onPressed: () {
|
||||
launch('https://www.treadl.com/' + username);
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: getBody()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserScreen extends StatefulWidget {
|
||||
final Map<String,dynamic> user;
|
||||
UserScreen(this.user) { }
|
||||
final String username;
|
||||
UserScreen(this.username) { }
|
||||
@override
|
||||
_UserScreenState createState() => _UserScreenState(user);
|
||||
_UserScreenState createState() => _UserScreenState(username);
|
||||
}
|
||||
|
@ -1,21 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'model.dart';
|
||||
|
||||
String APP_URL = 'https://www.treadl.com';
|
||||
|
||||
class Util {
|
||||
|
||||
ImageProvider avatarUrl(Map<String,dynamic> user) {
|
||||
ImageProvider a = AssetImage('assets/avatars/9.png');
|
||||
static ImageProvider? avatarUrl(Map<String,dynamic> user) {
|
||||
if (user != null && user['avatar'] != null) {
|
||||
if (user['avatar'].length < 3) {
|
||||
a = AssetImage('assets/avatars/${user['avatar']}.png');
|
||||
return AssetImage('assets/avatars/${user['avatar']}.png');
|
||||
}
|
||||
else {
|
||||
a =NetworkImage(user['avatarUrl']);
|
||||
return NetworkImage(user['avatarUrl']);
|
||||
}
|
||||
}
|
||||
return a;
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget avatarImage(ImageProvider image, {double size=30}) {
|
||||
static Widget avatarImage(ImageProvider? image, {double size=30}) {
|
||||
if (image != null) {
|
||||
return new Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@ -28,4 +35,64 @@ class Util {
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.pink[400],
|
||||
),
|
||||
child: Icon(Icons.person, size: size/1.5, color: Colors.white)
|
||||
);
|
||||
}
|
||||
|
||||
static Color rgb(String input) {
|
||||
List<String> parts = input.split(',');
|
||||
List<int> iParts = parts.map((p) => int.parse(p)).toList();
|
||||
iParts = iParts.map((p) => p > 255 ? 255 : p).toList();
|
||||
return Color.fromRGBO(iParts[0], iParts[1], iParts[2], 1);
|
||||
}
|
||||
|
||||
static String appUrl(String path) {
|
||||
return APP_URL + '/' + path;
|
||||
}
|
||||
|
||||
static Future<String> storagePath() async {
|
||||
final Directory directory = await getApplicationDocumentsDirectory();
|
||||
return directory.path;
|
||||
}
|
||||
|
||||
static Future<File> writeFile(String fileName, String data) async {
|
||||
final String dirPath = await Util.storagePath();
|
||||
final file = File('$dirPath/$fileName');
|
||||
String contents = data.replaceAll(RegExp(r'\\n'), '\r\n');
|
||||
return await file.writeAsString(contents);
|
||||
}
|
||||
|
||||
static Future<bool> deleteFile(File file) async {
|
||||
await file.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void shareFile(File file, {bool? withDelete}) async {
|
||||
await Share.shareXFiles([XFile(file.path)]);
|
||||
if (withDelete == true) {
|
||||
await Util.deleteFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
static void shareUrl(String text, String url) async {
|
||||
await Share.share('$text: $url');
|
||||
}
|
||||
|
||||
static String ellipsis(String input, int cutoff) {
|
||||
return (input.length <= cutoff)
|
||||
? input
|
||||
: '${input.substring(0, cutoff)}...';
|
||||
}
|
||||
|
||||
static bool canEditProject(User? user, Map<String,dynamic>? project) {
|
||||
if (user == null || project == null) return false;
|
||||
return project['user'] == user.id;
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'store.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'login.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
void _login(BuildContext context) {
|
||||
Navigator.of(context).pushNamed('/login');
|
||||
context.push('/login');
|
||||
}
|
||||
void _register(BuildContext context) {
|
||||
Navigator.of(context).pushNamed('/register');
|
||||
context.push('/register');
|
||||
}
|
||||
|
||||
@override
|
||||
@ -36,11 +36,20 @@ class WelcomeScreen extends StatelessWidget {
|
||||
SizedBox(height: 15),
|
||||
CupertinoButton(
|
||||
onPressed: () => _register(context),
|
||||
color: Colors.pink[400],
|
||||
child: new Text("Register",
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
),
|
||||
SizedBox(height: 35),
|
||||
CupertinoButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: new Text("Cancel",
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
),
|
||||
]),
|
||||
))
|
||||
);
|
||||
|
1
mobile/linux/.gitignore
vendored
Normal file
1
mobile/linux/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
139
mobile/linux/CMakeLists.txt
Normal file
139
mobile/linux/CMakeLists.txt
Normal file
@ -0,0 +1,139 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "mobile")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.mobile")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME above,
|
||||
# not the value here, or `flutter run` will no longer work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
88
mobile/linux/flutter/CMakeLists.txt
Normal file
88
mobile/linux/flutter/CMakeLists.txt
Normal file
@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
19
mobile/linux/flutter/generated_plugin_registrant.cc
Normal file
19
mobile/linux/flutter/generated_plugin_registrant.cc
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
15
mobile/linux/flutter/generated_plugin_registrant.h
Normal file
15
mobile/linux/flutter/generated_plugin_registrant.h
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
25
mobile/linux/flutter/generated_plugins.cmake
Normal file
25
mobile/linux/flutter/generated_plugins.cmake
Normal file
@ -0,0 +1,25 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
6
mobile/linux/main.cc
Normal file
6
mobile/linux/main.cc
Normal file
@ -0,0 +1,6 @@
|
||||
#include "my_application.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
g_autoptr(MyApplication) app = my_application_new();
|
||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||
}
|
104
mobile/linux/my_application.cc
Normal file
104
mobile/linux/my_application.cc
Normal file
@ -0,0 +1,104 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "mobile");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "mobile");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
gtk_widget_show(GTK_WIDGET(window));
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID,
|
||||
"flags", G_APPLICATION_NON_UNIQUE,
|
||||
nullptr));
|
||||
}
|
18
mobile/linux/my_application.h
Normal file
18
mobile/linux/my_application.h
Normal file
@ -0,0 +1,18 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
7
mobile/macos/.gitignore
vendored
Normal file
7
mobile/macos/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Flutter-related
|
||||
**/Flutter/ephemeral/
|
||||
**/Pods/
|
||||
|
||||
# Xcode-related
|
||||
**/dgph
|
||||
**/xcuserdata/
|
2
mobile/macos/Flutter/Flutter-Debug.xcconfig
Normal file
2
mobile/macos/Flutter/Flutter-Debug.xcconfig
Normal file
@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
2
mobile/macos/Flutter/Flutter-Release.xcconfig
Normal file
2
mobile/macos/Flutter/Flutter-Release.xcconfig
Normal file
@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
24
mobile/macos/Flutter/GeneratedPluginRegistrant.swift
Normal file
24
mobile/macos/Flutter/GeneratedPluginRegistrant.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
43
mobile/macos/Podfile
Normal file
43
mobile/macos/Podfile
Normal file
@ -0,0 +1,43 @@
|
||||
platform :osx, '10.14'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_macos_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
end
|
||||
end
|
137
mobile/macos/Podfile.lock
Normal file
137
mobile/macos/Podfile.lock
Normal file
@ -0,0 +1,137 @@
|
||||
PODS:
|
||||
- file_selector_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- Firebase/CoreOnly (10.9.0):
|
||||
- FirebaseCore (= 10.9.0)
|
||||
- Firebase/Messaging (10.9.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 10.9.0)
|
||||
- firebase_core (2.13.1):
|
||||
- Firebase/CoreOnly (~> 10.9.0)
|
||||
- FlutterMacOS
|
||||
- firebase_messaging (14.6.2):
|
||||
- Firebase/CoreOnly (~> 10.9.0)
|
||||
- Firebase/Messaging (~> 10.9.0)
|
||||
- firebase_core
|
||||
- FlutterMacOS
|
||||
- FirebaseCore (10.9.0):
|
||||
- FirebaseCoreInternal (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/Logger (~> 7.8)
|
||||
- FirebaseCoreInternal (10.17.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||
- FirebaseInstallations (10.17.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- PromisesObjC (~> 2.1)
|
||||
- FirebaseMessaging (10.9.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- GoogleDataTransport (~> 9.2)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/Reachability (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- FlutterMacOS (1.0.0)
|
||||
- GoogleDataTransport (9.2.5):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/AppDelegateSwizzler (7.12.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Environment (7.12.0):
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.12.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Network (7.12.0):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (7.12.0)"
|
||||
- GoogleUtilities/Reachability (7.12.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/UserDefaults (7.12.0):
|
||||
- GoogleUtilities/Logger
|
||||
- nanopb (2.30909.1):
|
||||
- nanopb/decode (= 2.30909.1)
|
||||
- nanopb/encode (= 2.30909.1)
|
||||
- nanopb/decode (2.30909.1)
|
||||
- nanopb/encode (2.30909.1)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.3.1)
|
||||
- share_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
|
||||
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Firebase
|
||||
- FirebaseCore
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
- FirebaseMessaging
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
file_selector_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||
firebase_core:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos
|
||||
firebase_messaging:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
share_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
file_selector_macos: 0f85c1108e2fd597b58246bc0b0c1cb483d7593b
|
||||
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
|
||||
firebase_core: bef54c6955ffe824bb73ec34090f4013b6921bc1
|
||||
firebase_messaging: 5fb518ebbce926b8828c283509d68da4cf238eac
|
||||
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
|
||||
FirebaseCoreInternal: 2cf9202e226e3f78d2bf6d56c472686b935bfb7f
|
||||
FirebaseInstallations: 9387bf15abfc69a714f54e54f74a251264fdb79b
|
||||
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
|
||||
GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34
|
||||
nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
|
||||
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
|
||||
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
||||
url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451
|
||||
|
||||
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
|
||||
|
||||
COCOAPODS: 1.14.2
|
791
mobile/macos/Runner.xcodeproj/project.pbxproj
Normal file
791
mobile/macos/Runner.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,791 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXAggregateTarget section */
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
|
||||
isa = PBXAggregateTarget;
|
||||
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
|
||||
buildPhases = (
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */,
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Flutter Assemble";
|
||||
productName = FLX;
|
||||
};
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
9C5D2FCBBECF447966A41993 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */; };
|
||||
D38B0D024BD4B8AC726C2930 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
|
||||
remoteInfo = FLX;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Bundle Framework";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
4F0C6318D69FF8395EECA36F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
622224DB9710132C3A78B0F2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
83DE93373EA349791F3FD1E0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D38B0D024BD4B8AC726C2930 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9C5D2FCBBECF447966A41993 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
|
||||
);
|
||||
path = Configs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10E42044A3C60003C045 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33FAB671232836740065AC1E /* Runner */,
|
||||
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
C58C1204A1B892E15F747458 /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* mobile.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC11242044D66E0003C045 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */,
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */,
|
||||
33CC10F72044A3C60003C045 /* Info.plist */,
|
||||
);
|
||||
name = Resources;
|
||||
path = ..;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
|
||||
);
|
||||
path = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33FAB671232836740065AC1E /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||
33E51914231749380026EE4D /* Release.entitlements */,
|
||||
33CC11242044D66E0003C045 /* Resources */,
|
||||
33BA886A226E78AF003329D5 /* Configs */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C58C1204A1B892E15F747458 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
83DE93373EA349791F3FD1E0 /* Pods-Runner.debug.xcconfig */,
|
||||
622224DB9710132C3A78B0F2 /* Pods-Runner.release.xcconfig */,
|
||||
4F0C6318D69FF8395EECA36F /* Pods-Runner.profile.xcconfig */,
|
||||
9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */,
|
||||
C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2ED35D1ED4D4BDCBC008A8B4 /* Pods_Runner.framework */,
|
||||
1BCB3809294E8264666C3FEC /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
2BC135214874DC0129B89279 /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
F76E7AC3FEF1EB49B333FBDB /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
EABEE0E3EB15CD5AF2892A5B /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* mobile.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 1430;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C80D4294CF70F00263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 33CC10EC2044A3C60003C045;
|
||||
};
|
||||
33CC10EC2044A3C60003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
SystemCapabilities = {
|
||||
com.apple.Sandbox = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
33CC111A2044C6BA0003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 33CC10E42044A3C60003C045;
|
||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
33CC10EC2044A3C60003C045 /* Runner */,
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */,
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C80D3294CF70F00263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EB2044A3C60003C045 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
2BC135214874DC0129B89279 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
|
||||
};
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterInputs.xcfilelist,
|
||||
);
|
||||
inputPaths = (
|
||||
Flutter/ephemeral/tripwire,
|
||||
);
|
||||
outputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterOutputs.xcfilelist,
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
EABEE0E3EB15CD5AF2892A5B /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F76E7AC3FEF1EB49B333FBDB /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C80D1294CF70F00263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10E92044A3C60003C045 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC10EC2044A3C60003C045 /* Runner */;
|
||||
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
|
||||
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
33CC10F52044A3C60003C045 /* Base */,
|
||||
);
|
||||
name = MainMenu.xib;
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9DC6D47D2EF6F01823FAE399 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19871C14AE3FD84E7FFE4D8E /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C79CD41180B769D8F9B17AC6 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mobile.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CE9231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEA231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEB231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
33CC10F92044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FA2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC10FC2044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FD2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC111C2044C6BA0003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC111D2044C6BA0003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C80DB294CF71000263BE5 /* Debug */,
|
||||
331C80DC294CF71000263BE5 /* Release */,
|
||||
331C80DD294CF71000263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10F92044A3C60003C045 /* Debug */,
|
||||
33CC10FA2044A3C60003C045 /* Release */,
|
||||
338D0CE9231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10FC2044A3C60003C045 /* Debug */,
|
||||
33CC10FD2044A3C60003C045 /* Release */,
|
||||
338D0CEA231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC111C2044C6BA0003C045 /* Debug */,
|
||||
33CC111D2044C6BA0003C045 /* Release */,
|
||||
338D0CEB231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user