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:
|
buildweb:
|
||||||
group: build
|
group: build
|
||||||
image: node
|
image: node
|
||||||
when:
|
when:
|
||||||
path: "web/*"
|
path: "web/**/*"
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=https://api.treadl.com
|
- VITE_API_URL=https://api.treadl.com
|
||||||
- VITE_IMAGINARY_URL=https://images.treadl.com
|
- VITE_IMAGINARY_URL=https://images.treadl.com
|
||||||
- VITE_SOURCE_REPO_URL=https://git.wilw.dev/wilw/treadl
|
- VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
|
||||||
- VITE_PATREON_URL=https://www.patreon.com/treadl
|
|
||||||
- VITE_KOFI_URL=https://ko-fi.com/wilw88
|
|
||||||
- VITE_CONTACT_EMAIL=hello@treadl.com
|
|
||||||
- VITE_APP_NAME=Treadl
|
|
||||||
commands:
|
commands:
|
||||||
- cd web
|
- cd web
|
||||||
- yarn install
|
- npm install
|
||||||
- yarn build
|
- npx vite build
|
||||||
|
|
||||||
buildapi:
|
buildapi:
|
||||||
group: build
|
group: build
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
secrets: [docker_username, docker_password]
|
secrets: [docker_username, docker_password]
|
||||||
when:
|
when:
|
||||||
path: "api/*"
|
path: "api/**/*"
|
||||||
settings:
|
settings:
|
||||||
repo: wilw/treadl-api
|
repo: wilw/treadl-api
|
||||||
dockerfile: api/Dockerfile
|
dockerfile: api/Dockerfile
|
||||||
context: api
|
context: api
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
deployweb:
|
deployweb:
|
||||||
image: alpine
|
image: alpine
|
||||||
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
||||||
when:
|
when:
|
||||||
path: "web/*"
|
path: "web/**/*"
|
||||||
commands:
|
commands:
|
||||||
- cd web
|
- cd web
|
||||||
- apk update
|
- apk update
|
||||||
@ -41,4 +38,5 @@ pipeline:
|
|||||||
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
||||||
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||||
|
|
||||||
branches: main
|
when:
|
||||||
|
branch: main
|
||||||
|
104
README.md
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.
|
This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform.
|
||||||
|
|
||||||
## Running and developing Treadl locally
|
|
||||||
|
|
||||||
To run Treadl locally, we recommend taking the following steps:
|
|
||||||
|
|
||||||
1. Check out this repository locally.
|
|
||||||
1. Follow the instructions in the `api/` directory to launch a MongoDB instance and to run the Treadl API.
|
|
||||||
1. Follow the instructions in the `web/` directory to install the local dependencies and run the web UI.
|
|
||||||
|
|
||||||
## Deploying your own version of Treadl
|
## Deploying your own version of Treadl
|
||||||
|
|
||||||
If you'd like to launch your own version of Treadl in a web production environment, follow the steps below. These instructions set-up a basic version of Treadl, and you may want or need to take additional steps for more advanced options.
|
### Run with Docker (recommended)
|
||||||
|
|
||||||
We recommend forking this repository. That way you can make adjustments to the code to suit your needs, and pull in upstream updates as we continue to develop them.
|
We publish and maintain a [Docker image](https://hub.docker.com/r/wilw/treadl) for Treadl, which is the easiest way to get started.
|
||||||
|
|
||||||
### 1. Launch a MongoDB cluster/instance
|
We recommend using Docker Compose and our [template `docker-compose.yml`](https://git.wilw.dev/wilw/treadl/src/branch/main/docker/docker-compose.yml) to configure the app and the MongoDB database. Download this file to your computer and then run `docker compose up` to start Treadl.
|
||||||
|
|
||||||
Treadl uses MongoDB as its data store, and this should be setup first. You can either use a commercial hosted offering, or host the database yourself.
|
In production, it is very important to change the values in the file's `environment` block to suit your own setup. We also strongly recommend the use of a reverse-proxy to handle TLS connections to the app.
|
||||||
|
|
||||||
Hosted options:
|
|
||||||
|
|
||||||
* [MongoDB Atlas](https://www.mongodb.com)
|
### Alternative deployment
|
||||||
* [DigitalOcean managed MongoDB](https://www.digitalocean.com/products/managed-databases-mongodb)
|
|
||||||
|
|
||||||
Self-hosted guides:
|
In scenarios where you want more control over the deployment, or you are more concerned with scalability, you may wish to use a more manual approach.
|
||||||
|
|
||||||
* [Creating a MongoDB Replica Set](https://www.linode.com/docs/guides/create-a-mongodb-replica-set)
|
In this case you'll need to:
|
||||||
* [MongoDB official Docker Image](https://hub.docker.com/_/mongo)
|
- Launch (or re-use) a MongoDB cluster/instance
|
||||||
|
- Provision a server or service for running the Flask app (in the `api/` directory), ensuring all dependencies are installed and that it runs with the needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/api/envfile.template)
|
||||||
|
- Build the web front-end (with `npx vite build` using your needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/web/.env), having installed dependencies with `npm install`) and host the resulting `dist/` directory on a server or object store.
|
||||||
|
|
||||||
Either way, once launched, make a note of the cluster/instance's:
|
|
||||||
|
|
||||||
* URI: The database's URI, probably in a format like `mongodb+srv://USERNAME:PASSWORD@host.com/AUTHDATABASE?retryWrites=true&w=majority`
|
### S3-compatible object storage
|
||||||
* Database: The name of the database, within your cluster/instance, where you want Treadl to store the data.
|
|
||||||
|
|
||||||
### 2. Provision an S3-compatible bucket
|
Treadl uses S3-compatible object storage for storing user uploads. If you want to allow file uploads (apart from WIF files, which are processed directly), you should create and configure a bucket for Treadl to use.
|
||||||
|
|
||||||
Treadl uses S3-compatible object storage for storing assets (e.g. uploaded files). You should create and configure a bucket for Treadl to use.
|
|
||||||
|
|
||||||
Hosted options:
|
Hosted options:
|
||||||
|
|
||||||
* [Amazon S3](https://aws.amazon.com/s3)
|
* [Amazon S3](https://aws.amazon.com/s3)
|
||||||
* [Linode Object Storage](https://www.linode.com/products/object-storage) - Recommended option.
|
* [Linode Object Storage](https://www.linode.com/products/object-storage)
|
||||||
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
|
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
|
||||||
|
|
||||||
Self-hosted options:
|
Self-hosted options:
|
||||||
|
|
||||||
* [MinIO](https://min.io/download)
|
* [MinIO](https://min.io/download)
|
||||||
|
|
||||||
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for later:
|
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for inclusion in your environment file/variables:
|
||||||
|
|
||||||
* Bucket name: The name of the S3-compatible bucket you created
|
* Bucket name: The name of the S3-compatible bucket you created
|
||||||
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
|
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
|
||||||
@ -58,64 +47,49 @@ Once you have a bucket, generate some access keys for the bucket that will enabl
|
|||||||
|
|
||||||
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
|
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
|
||||||
|
|
||||||
### 3. Provision the API
|
|
||||||
|
|
||||||
The best way to run the web API is to do so via Docker. A `Dockerfile` is provided in the `api/` directory.
|
## Running Treadl locally in development mode
|
||||||
|
|
||||||
Simply build the image and transfer it to your server (or just build it directly on the server, if easier).
|
To run Treadl locally, first ensure you have the needed software installed:
|
||||||
|
|
||||||
Make a copy of the `envfile.template` file included in the `api/` directory into a new file named `envfile` and make changes to this file to suit your needs. For example, you will likely need to:
|
- Python ^3.12
|
||||||
|
- Node.js (we recommend v22.x)
|
||||||
|
- Docker (we use this for the Mongo database)
|
||||||
|
- It can be installed via the Docker website or your package manager
|
||||||
|
- Ensure the Docker service is running
|
||||||
|
- [Taskfile](https://taskfile.dev) (convenience tool for running tasks)
|
||||||
|
- This can be installed using `brew install go-task`
|
||||||
|
|
||||||
* Add in the Mongo URI and database into the relevant parts
|
To begin, clone this repository to your computer:
|
||||||
* Add the S3 detais into the relevant parts
|
|
||||||
* Add Mailgun connection details (for sending outbound mail)
|
|
||||||
* Change the app's URL and email addresses
|
|
||||||
|
|
||||||
Once ready, you can launch the API by passing in this envfile (assuming you built the image with a name of `treadl-api`):
|
```bash
|
||||||
|
git clone https://git.wilw.dev/wilw/treadl.git
|
||||||
```shell
|
|
||||||
$ docker run --env-file envfile -d treadl-api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
_Note: a reverse proxy (such as Nginx or Traefik) should be running on your server to proxy traffic through to port 8000 on your running Treadl API container._
|
Next, initialise the project by installing dependencies and creating an environment file for the API:
|
||||||
|
|
||||||
### 4. Host the front-end
|
```bash
|
||||||
|
task init
|
||||||
The front-end is formed from static files that can be simply served from a webserver, from a CDN-fronted object store, or anything else.
|
|
||||||
|
|
||||||
Before building or hosting the front-end, please copy the `.env.development` file into a new file called `.env.production` and make changes to it as required. For example, you will need to:
|
|
||||||
|
|
||||||
* Include the URL of the web API you deployed earlier in the relevant field.
|
|
||||||
* Include a contact email address.
|
|
||||||
|
|
||||||
**Vercel**
|
|
||||||
|
|
||||||
We use [Vercel](https://vercel.com) to host the web UI. Once you have an account to which you are logged-in to locally, the front-end can be deployed by simply running:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ vercel --prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
_Note: You will need to configure Vercel to use your own domain, and set-up a project, etc. first._
|
This generates a 'envfile' in your 'api' directory. You can edit this as needed (though the defaults should allow you to at least launch the app). Note: if you run this command again then any changes you made to your `envfile` will be overwritten.
|
||||||
|
|
||||||
**Manual**
|
Finally, you can start the API and web UI by running:
|
||||||
|
|
||||||
Simply build the app and then deploy the resulting `build/` directory to a server or storage of your choice:
|
```bash
|
||||||
|
task
|
||||||
```shell
|
|
||||||
$ yarn build
|
|
||||||
$ s3cmd cp build/ s3://my-treadl-ui # Example
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Optional extras
|
Note: this command also starts the MongoDB database on port 27017. If the DB is already running, you'll see errors reported, but the API and web will still be launched.
|
||||||
|
|
||||||
**Imaginary server**
|
You can now navigate to [http://localhost:8002](http://localhost:8002) to start using the app.
|
||||||
|
|
||||||
To help improve the performance of the app, you may wish to make use of [Imaginary](https://github.com/h2non/imaginary) to crop/resize large images. The web UI is already equipped to handle Imaginary if a server is configured.
|
If you pull updates from the repository in the future (e.g. with `git pull`) you may need to ensure your dependencies are up-to-date before starting the app again. This can be done with:
|
||||||
|
|
||||||
To use this feature, simply rebuild the app ensuring that an environment entry is made into `.env.production` that includes `"VITE_IMAGINARY_URL=https://your.imaginaryserver.com"`.
|
```bash
|
||||||
|
task install-deps
|
||||||
|
```
|
||||||
|
|
||||||
_Note: If this is not set, Treadl will by default fetch the full size images straight from the S3 source._
|
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
118
Taskfile.yml
Normal file
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
|
config-prod.yml
|
||||||
envfile
|
envfile
|
||||||
firebase.json
|
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
|
# set work directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pip install poetry
|
RUN pip install poetry
|
||||||
|
|
||||||
COPY poetry.lock .
|
COPY poetry.lock .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
|
RUN poetry config virtualenvs.create false --local
|
||||||
RUN poetry export --without-hashes -f requirements.txt | pip install -r /dev/stdin
|
RUN poetry install
|
||||||
|
|
||||||
# Add remaining files
|
# Add remaining files
|
||||||
COPY app.py .
|
COPY . /app/
|
||||||
COPY api/ .
|
|
||||||
COPY util/ .
|
|
||||||
|
|
||||||
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
||||||
|
@ -1,67 +1,3 @@
|
|||||||
# Treadl web API
|
# Treadl web API
|
||||||
|
|
||||||
This directory contains the code for the back-end Treadl API.
|
This directory contains the code for the back-end Treadl API.
|
||||||
|
|
||||||
## Run locally
|
|
||||||
|
|
||||||
To run this web API locally, follow the steps below.
|
|
||||||
|
|
||||||
### 1. Run a local MongoDB instance
|
|
||||||
|
|
||||||
Install MongoDB for your operating system and then launch a local version in the background. For example:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ mongod --fork --dbpath=/path/to/.mongo --logpath /dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
(Remember to restart the database upon system restart or if the instance stops for another reason.)
|
|
||||||
|
|
||||||
### 2. Create and activate a virtual environment
|
|
||||||
|
|
||||||
Install and activate the environment using `virtualenv`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ virtualenv -p python3 .venv # You only need to run this the first time
|
|
||||||
$ source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install dependencies
|
|
||||||
|
|
||||||
We use Poetry to manage dependencies. If you don't have this yet, please refer to [the Poetry documentation](https://python-poetry.org) to install it. Once done, install the dependencies (ensuring you have `source`d your virtualenv first):
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ poetry install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Create an `envfile`
|
|
||||||
|
|
||||||
Copy the template file into a new `envfile`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ cp envfile.template envfile
|
|
||||||
```
|
|
||||||
|
|
||||||
If you need to, make any changes to your new `envfile`. Note that changes are probably not required if you are running this locally. When happy, you can `source` this file too:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ source envfile
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Run the API
|
|
||||||
|
|
||||||
Ensure that both the virtualenv and `envfile` have been loaded into the environment:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ source .venv/bin/activate
|
|
||||||
$ source envfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Now you can run the API:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ flask run
|
|
||||||
```
|
|
||||||
|
|
||||||
The API will now be available on port 2001.
|
|
||||||
|
|
||||||
Remember that you will need a local instance of [MongoDB](https://www.mongodb.com) running for the API to connect to.
|
|
@ -1,37 +1,72 @@
|
|||||||
import datetime, jwt, bcrypt, re, os
|
import datetime
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
import re
|
||||||
|
import os
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, mail, util
|
from util import database, mail, util
|
||||||
from api import uploads
|
|
||||||
|
|
||||||
jwt_secret = os.environ['JWT_SECRET']
|
jwt_secret = os.environ["JWT_SECRET"]
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
def register(username, email, password):
|
|
||||||
|
def register(username, email, password, how_find_us):
|
||||||
if not username or len(username) < 4 or not email or len(email) < 6:
|
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()
|
username = username.lower()
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
if not re.match("^[a-z0-9_]+$", username):
|
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:
|
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()
|
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:
|
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:
|
try:
|
||||||
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
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']}})
|
result = db.users.insert_one(
|
||||||
mail.send({
|
{
|
||||||
'to': os.environ.get('ADMIN_EMAIL'),
|
"username": username,
|
||||||
'subject': '{} signup'.format(os.environ.get('APP_NAME')),
|
"email": email,
|
||||||
'text': 'A new user signed up with username {0} and email {1}'.format(username, email)
|
"password": hashed_password,
|
||||||
})
|
"createdAt": datetime.datetime.now(),
|
||||||
mail.send({
|
"subscriptions": {
|
||||||
'to': email,
|
"email": [
|
||||||
'subject': 'Welcome to {}!'.format(os.environ.get('APP_NAME')),
|
"groups.invited",
|
||||||
'text': '''Dear {0},
|
"groups.joinRequested",
|
||||||
|
"groups.joined",
|
||||||
|
"messages.replied",
|
||||||
|
"projects.commented",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} signup".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "A new user signed up with username {0} and email {1}, discovered from {2}".format(
|
||||||
|
username, email, how_find_us
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": email,
|
||||||
|
"subject": "Welcome to {}!".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": """Dear {0},
|
||||||
|
|
||||||
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
||||||
|
|
||||||
@ -61,155 +96,226 @@ We hope you enjoy using {3} and if you have any comments or feedback please tell
|
|||||||
Best wishes,
|
Best wishes,
|
||||||
|
|
||||||
The {3} Team
|
The {3} Team
|
||||||
'''.format(
|
""".format(
|
||||||
username,
|
username,
|
||||||
os.environ.get('APP_URL'),
|
os.environ.get("APP_URL"),
|
||||||
os.environ.get('CONTACT_EMAIL'),
|
os.environ.get("CONTACT_EMAIL"),
|
||||||
os.environ.get('APP_NAME'),
|
os.environ.get("APP_NAME"),
|
||||||
)})
|
),
|
||||||
return {'token': generate_access_token(result.inserted_id)}
|
}
|
||||||
|
)
|
||||||
|
return {"token": generate_access_token(result.inserted_id)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(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):
|
def login(email, password):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'$or': [{'username': email.lower()}, {'email': email.lower()}]})
|
user = db.users.find_one(
|
||||||
|
{"$or": [{"username": email.lower()}, {"email": email.lower()}]}
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']):
|
if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||||
return {'token': generate_access_token(user['_id'])}
|
return {"token": generate_access_token(user["_id"])}
|
||||||
else:
|
else:
|
||||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise util.errors.BadRequest('Your username or password is incorrect.')
|
raise util.errors.BadRequest("Your username or password is incorrect.")
|
||||||
|
|
||||||
|
|
||||||
def logout(user):
|
def logout(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}})
|
db.users.update_one(
|
||||||
return {'loggedOut': True}
|
{"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
|
||||||
|
)
|
||||||
|
return {"loggedOut": True}
|
||||||
|
|
||||||
|
|
||||||
def update_email(user, data):
|
def update_email(user, data):
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
if not data:
|
||||||
if 'email' not in data: raise util.errors.BadRequest('Invalid request')
|
raise util.errors.BadRequest("Invalid request")
|
||||||
if len(data['email']) < 4: raise util.errors.BadRequest('New email is too short')
|
if "email" not in data:
|
||||||
|
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 = database.get_db()
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'email': data['email']}})
|
db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}})
|
||||||
mail.send({
|
mail.send(
|
||||||
'to': user['email'],
|
{
|
||||||
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')),
|
"to": user["email"],
|
||||||
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(
|
"subject": "Your email address has changed on {}".format(
|
||||||
user['username'],
|
os.environ.get("APP_NAME")
|
||||||
data['email'],
|
),
|
||||||
os.environ.get('APP_NAME'),
|
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
||||||
|
user["username"],
|
||||||
|
data["email"],
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
mail.send(
|
||||||
mail.send({
|
{
|
||||||
'to': data['email'],
|
"to": data["email"],
|
||||||
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')),
|
"subject": "Your email address has changed on {}".format(
|
||||||
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(
|
os.environ.get("APP_NAME")
|
||||||
user['username'],
|
),
|
||||||
data['email'],
|
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
||||||
os.environ.get('APP_NAME'),
|
user["username"],
|
||||||
|
data["email"],
|
||||||
|
os.environ.get("APP_NAME"),
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
return {"email": data["email"]}
|
||||||
return {'email': data['email']}
|
|
||||||
|
|
||||||
def update_password(user, data):
|
def update_password(user, data):
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
if not data:
|
||||||
if 'newPassword' not in data: raise util.errors.BadRequest('Invalid request')
|
raise util.errors.BadRequest("Invalid request")
|
||||||
if len(data['newPassword']) < MIN_PASSWORD_LENGTH: raise util.errors.BadRequest('New password should be at least {0} characters long'.format(MIN_PASSWORD_LENGTH))
|
if "newPassword" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data["newPassword"]) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise util.errors.BadRequest(
|
||||||
|
"New password should be at least {0} characters long".format(
|
||||||
|
MIN_PASSWORD_LENGTH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if 'currentPassword' in data:
|
if "currentPassword" in data:
|
||||||
if not user: raise util.errors.BadRequest('User context is required')
|
if not user:
|
||||||
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']):
|
raise util.errors.BadRequest("User context is required")
|
||||||
raise util.errors.BadRequest('Incorrect password')
|
if not bcrypt.checkpw(
|
||||||
elif 'token' in data:
|
data["currentPassword"].encode("utf-8"), user["password"]
|
||||||
|
):
|
||||||
|
raise util.errors.BadRequest("Incorrect password")
|
||||||
|
elif "token" in data:
|
||||||
try:
|
try:
|
||||||
id = jwt.decode(data['token'], jwt_secret)['sub']
|
id = jwt.decode(data["token"], jwt_secret, algorithms="HS256")["sub"]
|
||||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']})
|
user = db.users.find_one(
|
||||||
if not user: raise Exception
|
{"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
|
||||||
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'),
|
|
||||||
)
|
)
|
||||||
})
|
if not user:
|
||||||
return {'passwordUpdated': True}
|
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):
|
def delete(user, password):
|
||||||
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']):
|
if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
||||||
raise util.errors.BadRequest('Incorrect password')
|
raise util.errors.BadRequest("Incorrect password")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
for project in db.projects.find({'user': user['_id']}):
|
for project in db.projects.find({"user": user["_id"]}):
|
||||||
db.objects.remove({'project': project['_id']})
|
db.objects.delete_many({"project": project["_id"]})
|
||||||
db.projects.remove({'_id': project['_id']})
|
db.projects.delete_one({"_id": project["_id"]})
|
||||||
db.users.remove({'_id': user['_id']})
|
db.comments.delete_many({"user": user["_id"]})
|
||||||
return {'deletedUser': user['_id']}
|
db.users.update_many(
|
||||||
|
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
|
||||||
|
)
|
||||||
|
db.users.delete_one({"_id": user["_id"]})
|
||||||
|
return {"deletedUser": user["_id"]}
|
||||||
|
|
||||||
|
|
||||||
def generate_access_token(user_id):
|
def generate_access_token(user_id):
|
||||||
payload = {
|
payload = {
|
||||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||||
'iat': datetime.datetime.utcnow(),
|
"iat": datetime.datetime.utcnow(),
|
||||||
'sub': str(user_id)
|
"sub": str(user_id),
|
||||||
}
|
}
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode("utf-8")
|
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}})
|
db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def get_user_context(token):
|
def get_user_context(token):
|
||||||
if not token: return None
|
if not token:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, jwt_secret)
|
payload = jwt.decode(token, jwt_secret, algorithms="HS256")
|
||||||
id = payload['sub']
|
id = payload["sub"]
|
||||||
if id:
|
if id:
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token})
|
user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}})
|
db.users.update_one(
|
||||||
user['currentToken'] = token
|
{"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
|
||||||
|
)
|
||||||
|
user["currentToken"] = token
|
||||||
return user
|
return user
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def reset_password(data):
|
def reset_password(data):
|
||||||
if not data or not 'email' in data: raise util.errors.BadRequest('Invalid request')
|
if not data or "email" not in data:
|
||||||
if len(data['email']) < 5: raise util.errors.BadRequest('Your email is too short')
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if len(data["email"]) < 5:
|
||||||
|
raise util.errors.BadRequest("Your email is too short")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'email': data['email'].lower()})
|
user = db.users.find_one({"email": data["email"].lower()})
|
||||||
if user:
|
if user:
|
||||||
payload = {
|
payload = {
|
||||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
||||||
'iat': datetime.datetime.utcnow(),
|
"iat": datetime.datetime.utcnow(),
|
||||||
'sub': str(user['_id'])
|
"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_one(
|
||||||
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}})
|
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
|
||||||
return {'passwordResetEmailSent': True}
|
)
|
||||||
|
return {"passwordResetEmailSent": True}
|
||||||
|
|
||||||
|
|
||||||
def update_push_token(user, data):
|
def update_push_token(user, data):
|
||||||
if not data or 'pushToken' not in data: raise util.errors.BadRequest('Push token is required')
|
if not data or "pushToken" not in data:
|
||||||
|
raise util.errors.BadRequest("Push token is required")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'pushToken': data['pushToken']}})
|
db.users.update_one(
|
||||||
return {'addedPushToken': data['pushToken']}
|
{"_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 util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
DOMAIN = os.environ.get('APP_DOMAIN')
|
DOMAIN = os.environ.get("APP_DOMAIN")
|
||||||
|
|
||||||
|
|
||||||
def webfinger(resource):
|
def webfinger(resource):
|
||||||
if not resource: raise util.errors.BadRequest('Resource required')
|
if not resource:
|
||||||
|
raise util.errors.BadRequest("Resource required")
|
||||||
resource = resource.lower()
|
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)
|
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]
|
username, host = matches[0]
|
||||||
if not username or not host: raise util.errors.BadRequest('Resource invalid')
|
if not username or not host:
|
||||||
if host != DOMAIN: raise util.errors.NotFound('Host unknown')
|
raise util.errors.BadRequest("Resource invalid")
|
||||||
|
if host != DOMAIN:
|
||||||
|
raise util.errors.NotFound("Host unknown")
|
||||||
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'username': username})
|
user = db.users.find_one({"username": username})
|
||||||
if not user: raise util.errors.NotFound('User unknown')
|
if not user:
|
||||||
|
raise util.errors.NotFound("User unknown")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"subject": resource,
|
"subject": resource,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"https://{}/{}".format(DOMAIN, username),
|
"https://{}/{}".format(DOMAIN, username),
|
||||||
"https://{}/u/{}".format(DOMAIN, username)
|
"https://{}/u/{}".format(DOMAIN, username),
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
"type": "text/html",
|
"type": "text/html",
|
||||||
"href": "https://{}/{}".format(DOMAIN, username)
|
"href": "https://{}/{}".format(DOMAIN, username),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "self",
|
"rel": "self",
|
||||||
"type": "application/activity+json",
|
"type": "application/activity+json",
|
||||||
"href": "https://{}/u/{}".format(DOMAIN, username)
|
"href": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
"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):
|
def user(username):
|
||||||
if not username: raise util.errors.BadRequest('Username required')
|
if not username:
|
||||||
|
raise util.errors.BadRequest("Username required")
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'username': username})
|
user = db.users.find_one({"username": username})
|
||||||
if not user: raise util.errors.NotFound('User unknown')
|
if not user:
|
||||||
avatar_url = user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
raise util.errors.NotFound("User unknown")
|
||||||
|
avatar_url = user.get("avatar") and uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
|
)
|
||||||
|
|
||||||
pub_key = None
|
pub_key = None
|
||||||
if user.get('services', {}).get('activityPub', {}).get('publicKey'):
|
if user.get("services", {}).get("activityPub", {}).get("publicKey"):
|
||||||
pub_key = user['services']['activityPub']['publicKey']
|
pub_key = user["services"]["activityPub"]["publicKey"]
|
||||||
else:
|
else:
|
||||||
priv_key, pub_key = util.generate_rsa_keypair()
|
priv_key, pub_key = util.generate_rsa_keypair()
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {
|
db.users.update_one(
|
||||||
'services.activityPub.publicKey': pub_key,
|
{"_id": user["_id"]},
|
||||||
'services.activityPub.privateKey': priv_key,
|
{
|
||||||
}})
|
"$set": {
|
||||||
|
"services.activityPub.publicKey": pub_key,
|
||||||
|
"services.activityPub.privateKey": priv_key,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
resp = {
|
resp = {
|
||||||
"@context": [
|
"@context": [
|
||||||
@ -67,99 +85,106 @@ def user(username):
|
|||||||
],
|
],
|
||||||
"id": "https://{}/u/{}".format(DOMAIN, username),
|
"id": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
#"following": "https://fosstodon.org/users/wilw/following",
|
# "following": "https://fosstodon.org/users/wilw/following",
|
||||||
#"followers": "https://fosstodon.org/users/wilw/followers",
|
# "followers": "https://fosstodon.org/users/wilw/followers",
|
||||||
"inbox": "https://{}/inbox".format(DOMAIN),
|
"inbox": "https://{}/inbox".format(DOMAIN),
|
||||||
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
"preferredUsername": username,
|
"preferredUsername": username,
|
||||||
"name": username,
|
"name": username,
|
||||||
"summary": user.get('bio', ''),
|
"summary": user.get("bio", ""),
|
||||||
"url": "https://{}/{}".format(DOMAIN, username),
|
"url": "https://{}/{}".format(DOMAIN, username),
|
||||||
"discoverable": True,
|
"discoverable": True,
|
||||||
"published": "2021-01-27T00:00:00Z",
|
"published": "2021-01-27T00:00:00Z",
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
||||||
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"publicKeyPem": pub_key.decode('utf-8')
|
"publicKeyPem": pub_key.decode("utf-8"),
|
||||||
},
|
},
|
||||||
"attachment": [],
|
"attachment": [],
|
||||||
"endpoints": {
|
"endpoints": {"sharedInbox": "https://{}/inbox".format(DOMAIN)},
|
||||||
"sharedInbox": "https://{}/inbox".format(DOMAIN)
|
"icon": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||||
},
|
"image": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
||||||
"icon": {
|
|
||||||
"type": "Image",
|
|
||||||
"mediaType": "image/jpeg",
|
|
||||||
"url": avatar_url
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"type": "Image",
|
|
||||||
"mediaType": "image/jpeg",
|
|
||||||
"url": avatar_url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.get('website'):
|
if user.get("website"):
|
||||||
resp['attachment'].append({
|
resp["attachment"].append(
|
||||||
|
{
|
||||||
"type": "PropertyValue",
|
"type": "PropertyValue",
|
||||||
"name": "Website",
|
"name": "Website",
|
||||||
"value": "<a href=\"https://{}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">{}</span><span class=\"invisible\"></span></a>".format(user['website'], user['website'])
|
"value": '<a href="https://{}" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">{}</span><span class="invisible"></span></a>'.format(
|
||||||
})
|
user["website"], user["website"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def outbox(username, page, min_id, max_id):
|
def outbox(username, page, min_id, max_id):
|
||||||
if not username: raise util.errors.BadRequest('Username required')
|
if not username:
|
||||||
|
raise util.errors.BadRequest("Username required")
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({'username': username})
|
user = db.users.find_one({"username": username})
|
||||||
if not user: raise util.errors.NotFound('User unknown')
|
if not user:
|
||||||
|
raise util.errors.NotFound("User unknown")
|
||||||
|
|
||||||
if not page or page != 'true':
|
if not page or page != "true":
|
||||||
return {
|
return {
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username)
|
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username),
|
||||||
}
|
}
|
||||||
if page == 'true':
|
if page == "true":
|
||||||
min_string = '&min_id={}'.format(min_id) if min_id else ''
|
min_string = "&min_id={}".format(min_id) if min_id else ""
|
||||||
max_string = '&max_id={}'.format(max_id) if max_id else ''
|
max_string = "&max_id={}".format(max_id) if max_id else ""
|
||||||
ret = {
|
ret = {
|
||||||
"id": "https://{}/u/{}/outbox?page=true{}{}".format(DOMAIN, username, min_string, max_string),
|
"id": "https://{}/u/{}/outbox?page=true{}{}".format(
|
||||||
|
DOMAIN, username, min_string, max_string
|
||||||
|
),
|
||||||
"type": "OrderedCollectionPage",
|
"type": "OrderedCollectionPage",
|
||||||
#"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
# "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
#"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
# "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"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:
|
for p in project_list:
|
||||||
ret['orderedItems'].append({
|
ret["orderedItems"].append(
|
||||||
"id": "https://{}/{}/{}/activity".format(DOMAIN, username, p['path']),
|
{
|
||||||
|
"id": "https://{}/{}/{}/activity".format(
|
||||||
|
DOMAIN, username, p["path"]
|
||||||
|
),
|
||||||
"type": "Create",
|
"type": "Create",
|
||||||
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2021-10-18T20:06:18Z",
|
"published": p["createdAt"].strftime(
|
||||||
"to": [
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
"https://www.w3.org/ns/activitystreams#Public"
|
), # "2021-10-18T20:06:18Z",
|
||||||
],
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
"object": {
|
"object": {
|
||||||
"id": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
"id": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||||
"type": "Note",
|
"type": "Note",
|
||||||
"summary": None,
|
"summary": None,
|
||||||
#"inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
# "inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
||||||
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2022-08-03T15:43:30Z",
|
"published": p["createdAt"].strftime(
|
||||||
"url": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
), # "2022-08-03T15:43:30Z",
|
||||||
|
"url": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
||||||
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"to": [
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
"https://www.w3.org/ns/activitystreams#Public"
|
|
||||||
],
|
|
||||||
"cc": [
|
"cc": [
|
||||||
"https://{}/u/{}/followers".format(DOMAIN, username),
|
"https://{}/u/{}/followers".format(DOMAIN, username),
|
||||||
],
|
],
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"content": "{} created a project: {}".format(username, p['name']),
|
"content": "{} created a project: {}".format(
|
||||||
|
username, p["name"]
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
return ret
|
return ret
|
@ -1,268 +1,804 @@
|
|||||||
import datetime, re, os
|
import datetime
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import math
|
||||||
import pymongo
|
import pymongo
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util, mail, push
|
from util import database, util, mail, push
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
APP_NAME = os.environ.get('APP_NAME')
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
APP_URL = os.environ.get('APP_URL')
|
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):
|
def create(user, data):
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
if not data:
|
||||||
if len(data.get('name')) < 3: raise util.errors.BadRequest('A longer name is required')
|
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()
|
db = database.get_db()
|
||||||
|
|
||||||
group = {
|
group = {
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'admins': [user['_id']],
|
"admins": [user["_id"]],
|
||||||
'name': data['name'],
|
"name": data["name"],
|
||||||
'description': data.get('description', ''),
|
"description": data.get("description", ""),
|
||||||
'closed': data.get('closed', False),
|
"closed": data.get("closed", False),
|
||||||
|
"advertised": data.get("advertised", False),
|
||||||
|
"memberPermissions": [
|
||||||
|
"viewMembers",
|
||||||
|
"viewNoticeboard",
|
||||||
|
"postNoticeboard",
|
||||||
|
"viewProjects",
|
||||||
|
"postProjects",
|
||||||
|
"viewForumTopics",
|
||||||
|
"postForumTopics",
|
||||||
|
"postForumTopicReplies",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
result = db.groups.insert_one(group)
|
result = db.groups.insert_one(group)
|
||||||
group['_id'] = result.inserted_id
|
group["_id"] = result.inserted_id
|
||||||
create_member(user, group['_id'], user['_id'])
|
create_member(user, group["_id"], user["_id"])
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
def get(user):
|
def get(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
groups = list(db.groups.find({'_id': {'$in': user.get('groups', [])}}))
|
groups = list(db.groups.find({"_id": {"$in": user.get("groups", [])}}))
|
||||||
return {'groups': groups}
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
def get_one(user, id):
|
def get_one(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
group['adminUsers'] = list(db.users.find({'_id': {'$in': group.get('admins', [])}}, {'username': 1, 'avatar': 1}))
|
raise util.errors.NotFound("Group not found")
|
||||||
for u in group['adminUsers']:
|
if group.get("image"):
|
||||||
if 'avatar' in u:
|
group["imageUrl"] = uploads.get_presigned_url(
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
"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
|
return group
|
||||||
|
|
||||||
|
|
||||||
def update(user, id, update):
|
def update(user, id, update):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
raise util.errors.NotFound("Group not found")
|
||||||
allowed_keys = ['name', 'description', 'closed']
|
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)
|
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)
|
return get_one(user, id)
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
raise util.errors.NotFound("Group not found")
|
||||||
db.groups.remove({'_id': id})
|
if user["_id"] not in group.get("admins", []):
|
||||||
db.groupEntries.remove({'group': id})
|
raise util.errors.Forbidden("You're not a group admin")
|
||||||
db.users.update({'groups': id}, {'$pull': {'groups': id}}, multi = True)
|
db.groups.delete_one({"_id": id})
|
||||||
return {'deletedGroup': 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):
|
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()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
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 = {
|
entry = {
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'group': id,
|
"group": id,
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'content': data['content'],
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if 'attachments' in data:
|
if "attachments" in data:
|
||||||
entry['attachments'] = data['attachments']
|
entry["attachments"] = data["attachments"]
|
||||||
for attachment in entry['attachments']:
|
for attachment in entry["attachments"]:
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
if re.search(
|
||||||
attachment['isImage'] = True
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||||
if attachment['type'] == 'file':
|
):
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
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)
|
result = db.groupEntries.insert_one(entry)
|
||||||
entry['_id'] = result.inserted_id
|
entry["_id"] = result.inserted_id
|
||||||
entry['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
entry["authorUser"] = {
|
||||||
if 'avatar' in user:
|
"_id": user["_id"],
|
||||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
"username": user["username"],
|
||||||
|
"avatar": user.get("avatar"),
|
||||||
for u in db.users.find({'_id': {'$ne': user['_id']}, 'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}, {'email': 1, 'username': 1}):
|
}
|
||||||
mail.send({
|
if "avatar" in user:
|
||||||
'to_user': u,
|
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
'subject': 'New message in ' + group['name'],
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
'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,
|
|
||||||
)
|
)
|
||||||
})
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
push.send_multiple(list(db.users.find({'_id': {'$ne': user['_id']}, 'groups': id})), '{} posted in {}'.format(user['username'], group['name']), data['content'][:30] + '...')
|
|
||||||
return 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):
|
def get_entries(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if id not in user.get('groups', []): raise util.errors.BadRequest('You\'re not a member of this group')
|
raise util.errors.NotFound("Group not found")
|
||||||
entries = list(db.groupEntries.find({'group': id}).sort('createdAt', pymongo.DESCENDING))
|
if id not in user.get("groups", []):
|
||||||
authors = list(db.users.find({'_id': {'$in': [e['user'] for e in entries]}}, {'username': 1, 'avatar': 1}))
|
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:
|
for entry in entries:
|
||||||
if 'attachments' in entry:
|
if "attachments" in entry:
|
||||||
for attachment in entry['attachments']:
|
for attachment in entry["attachments"]:
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
attachment["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(id, attachment["storedName"])
|
||||||
|
)
|
||||||
for author in authors:
|
for author in authors:
|
||||||
if entry['user'] == author['_id']:
|
if entry["user"] == author["_id"]:
|
||||||
entry['authorUser'] = author
|
entry["authorUser"] = author
|
||||||
if 'avatar' in author:
|
if "avatar" in author:
|
||||||
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(author['_id'], author['avatar']))
|
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
return {'entries': entries}
|
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||||
|
)
|
||||||
|
return {"entries": entries}
|
||||||
|
|
||||||
|
|
||||||
def delete_entry(user, id, entry_id):
|
def delete_entry(user, id, entry_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
raise util.errors.NotFound("Group not found")
|
||||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
||||||
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')
|
if not entry or entry["group"] != id:
|
||||||
db.groupEntries.remove({'$or': [{'_id': entry_id}, {'inReplyTo': entry_id}]})
|
raise util.errors.NotFound("Entry not found")
|
||||||
return {'deletedEntry': entry_id}
|
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):
|
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()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
entry = db.groupEntries.find_one({'_id': entry_id})
|
raise util.errors.NotFound("Group not found")
|
||||||
if not entry or entry.get('group') != group['_id']: raise util.errors.NotFound('Entry to reply to not found')
|
entry = db.groupEntries.find_one({"_id": entry_id})
|
||||||
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
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 = {
|
reply = {
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'group': id,
|
"group": id,
|
||||||
'inReplyTo': entry_id,
|
"inReplyTo": entry_id,
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'content': data['content'],
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if 'attachments' in data:
|
if "attachments" in data:
|
||||||
reply['attachments'] = data['attachments']
|
reply["attachments"] = data["attachments"]
|
||||||
for attachment in reply['attachments']:
|
for attachment in reply["attachments"]:
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
if re.search(
|
||||||
attachment['isImage'] = True
|
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||||
if attachment['type'] == 'file':
|
):
|
||||||
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
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)
|
result = db.groupEntries.insert_one(reply)
|
||||||
reply['_id'] = result.inserted_id
|
reply["_id"] = result.inserted_id
|
||||||
reply['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
reply["authorUser"] = {
|
||||||
if 'avatar' in user:
|
"_id": user["_id"],
|
||||||
reply['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
"username": user["username"],
|
||||||
op = db.users.find_one({'$and': [{'_id': entry.get('user')}, {'_id': {'$ne': user['_id']}}], 'subscriptions.email': 'messages.replied'})
|
"avatar": user.get("avatar"),
|
||||||
if op:
|
}
|
||||||
mail.send({
|
if "avatar" in user:
|
||||||
'to_user': op,
|
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
'subject': user['username'] + ' replied to your post',
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
'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,
|
|
||||||
)
|
)
|
||||||
})
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
return reply
|
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):
|
def delete_entry_reply(user, id, entry_id, reply_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
reply_id = ObjectId(reply_id)
|
reply_id = ObjectId(reply_id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
raise util.errors.NotFound("Group not found")
|
||||||
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry 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)
|
reply = db.groupEntries.find_one(reply_id)
|
||||||
if not reply or reply.get('inReplyTo') != entry_id: raise util.errors.NotFound('Reply not found')
|
if not reply or reply.get("inReplyTo") != entry_id:
|
||||||
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')
|
raise util.errors.NotFound("Reply not found")
|
||||||
db.groupEntries.remove({'_id': entry_id})
|
if (
|
||||||
return {'deletedEntry': entry_id}
|
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):
|
|
||||||
|
def create_member(user, id, user_id, invited=False):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
user_id = ObjectId(user_id)
|
user_id = ObjectId(user_id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1, 'closed': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1, "closed": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user_id != user['_id']: raise util.errors.Forbidden('Not allowed to add someone else to the group')
|
raise util.errors.NotFound("Group not found")
|
||||||
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')
|
if user_id != user["_id"]:
|
||||||
db.users.update({'_id': user_id}, {'$addToSet': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
raise util.errors.Forbidden("Not allowed to add someone else to the group")
|
||||||
db.invitations.remove({'type': 'group', 'typeId': id, 'recipient': user_id})
|
if (
|
||||||
for admin in db.users.find({'_id': {'$in': group.get('admins', []), '$ne': user_id}, 'subscriptions.email': 'groups.joined'}, {'email': 1, 'username': 1}):
|
group.get("closed")
|
||||||
mail.send({
|
and not invited
|
||||||
'to_user': admin,
|
and user["_id"] not in group.get("admins", [])
|
||||||
'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(
|
raise util.errors.Forbidden("Not allowed to join a closed group")
|
||||||
admin['username'],
|
db.users.update_one(
|
||||||
user['username'],
|
{"_id": user_id},
|
||||||
group['name'],
|
{"$addToSet": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
||||||
'{}/groups/{}'.format(APP_URL, str(id)),
|
)
|
||||||
APP_NAME,
|
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):
|
def get_members(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
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')
|
raise util.errors.NotFound("Group not found")
|
||||||
members = list(db.users.find({'groups': id}, {'username': 1, 'avatar': 1, 'bio': 1, 'groups': 1}))
|
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:
|
for m in members:
|
||||||
if 'avatar' in m:
|
if "avatar" in m:
|
||||||
m['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(m['_id'], m['avatar']))
|
m["avatarUrl"] = uploads.get_presigned_url(
|
||||||
return {'members': members}
|
"users/{0}/{1}".format(m["_id"], m["avatar"])
|
||||||
|
)
|
||||||
|
return {"members": members}
|
||||||
|
|
||||||
|
|
||||||
def delete_member(user, id, user_id):
|
def delete_member(user, id, user_id):
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
user_id = ObjectId(user_id)
|
user_id = ObjectId(user_id)
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user_id != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You can\'t remove this user')
|
raise util.errors.NotFound("Group not found")
|
||||||
if user_id in group.get('admins', []) and len(group['admins']) == 1:
|
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
||||||
raise util.errors.Forbidden('There needs to be at least one admin in this group')
|
raise util.errors.Forbidden("You can't remove this user")
|
||||||
db.users.update({'_id': user_id}, {'$pull': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
if user_id in group.get("admins", []) and len(group["admins"]) == 1:
|
||||||
db.groups.update({'_id': id}, {'$pull': {'admins': user_id}})
|
raise util.errors.Forbidden(
|
||||||
return {'deletedMember': user_id}
|
"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):
|
def get_projects(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
group = db.groups.find_one({"_id": id})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if id not in user.get('groups', []): raise util.errors.Forbidden('You need to be a member to see the project list')
|
raise util.errors.NotFound("Group not found")
|
||||||
projects = list(db.projects.find({'groupVisibility': id}, {'name': 1, 'path': 1, 'user': 1, 'description': 1, 'visibility': 1}))
|
if id not in user.get("groups", []):
|
||||||
authors = list(db.users.find({'groups': id, '_id': {'$in': list(map(lambda p: p['user'], projects))}}, {'username': 1, 'avatar': 1, 'bio': 1}))
|
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:
|
for a in authors:
|
||||||
if 'avatar' in a:
|
if "avatar" in a:
|
||||||
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))
|
a["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
||||||
|
)
|
||||||
for project in projects:
|
for project in projects:
|
||||||
for a in authors:
|
for a in authors:
|
||||||
if project['user'] == a['_id']:
|
if project["user"] == a["_id"]:
|
||||||
project['owner'] = a
|
project["owner"] = a
|
||||||
project['fullName'] = a['username'] + '/' + project['path']
|
project["fullName"] = a["username"] + "/" + project["path"]
|
||||||
break
|
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 datetime
|
||||||
import pymongo
|
import os
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util, mail
|
from util import database, util, mail
|
||||||
from api import uploads, groups
|
from api import uploads, groups
|
||||||
|
|
||||||
APP_NAME = os.environ.get('APP_NAME')
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
APP_URL = os.environ.get('APP_URL')
|
APP_URL = os.environ.get("APP_URL")
|
||||||
|
|
||||||
|
|
||||||
def get(user):
|
def get(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
admin_groups = list(db.groups.find({'admins': user['_id']}))
|
admin_groups = list(db.groups.find({"admins": user["_id"]}))
|
||||||
invites = list(db.invitations.find({'$or': [{'recipient': user['_id']}, {'recipientGroup': {'$in': list(map(lambda g: g['_id'], admin_groups))}}]}))
|
invites = list(
|
||||||
inviters = list(db.users.find({'_id': {'$in': [i['user'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
db.invitations.find(
|
||||||
|
{
|
||||||
|
"$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:
|
for invite in invites:
|
||||||
invite['recipient'] = user['_id']
|
invite["recipient"] = user["_id"]
|
||||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||||
for u in inviters:
|
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
if u['_id'] == invite['user']:
|
inviter = next((u for u in inviters if u["_id"] == invite["user"]), None)
|
||||||
if 'avatar' in u:
|
if inviter:
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
if "avatar" in inviter:
|
||||||
invite['invitedBy'] = u
|
inviter["avatarUrl"] = uploads.get_presigned_url(
|
||||||
break
|
"users/{0}/{1}".format(inviter["_id"], inviter["avatar"])
|
||||||
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["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:
|
for invite in sent_invites:
|
||||||
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
if invite["type"] in ["group", "groupJoinRequest"]:
|
||||||
for u in recipients:
|
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
if u['_id'] == invite.get('recipient'):
|
recipient = next(
|
||||||
if 'avatar' in u:
|
(u for u in recipients if u["_id"] == invite.get("recipient")), None
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
)
|
||||||
invite['invitedBy'] = u
|
if recipient:
|
||||||
break
|
if "avatar" in recipient:
|
||||||
return {'invitations': invites, 'sentInvitations': sent_invites}
|
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||||
|
)
|
||||||
|
invite["invitedBy"] = recipient
|
||||||
|
return {"invitations": invites, "sentInvitations": sent_invites}
|
||||||
|
|
||||||
|
|
||||||
def accept(user, id):
|
def accept(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
invite = db.invitations.find_one({'_id': id})
|
invite = db.invitations.find_one({"_id": id})
|
||||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
if not invite:
|
||||||
if invite['type'] == 'group':
|
raise util.errors.NotFound("Invitation not found")
|
||||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to accept')
|
if invite["type"] == "group":
|
||||||
group = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
if invite["recipient"] != user["_id"]:
|
||||||
|
raise util.errors.Forbidden("This invitation is not yours to accept")
|
||||||
|
group = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
||||||
if not group:
|
if not group:
|
||||||
db.invitations.remove({'_id': id})
|
db.invitations.delete_one({"_id": id})
|
||||||
return {'acceptedInvitation': id}
|
return {"acceptedInvitation": id}
|
||||||
groups.create_member(user, group['_id'], user['_id'], invited = True)
|
groups.create_member(user, group["_id"], user["_id"], invited=True)
|
||||||
db.invitations.remove({'_id': id})
|
db.invitations.delete_one({"_id": id})
|
||||||
return {'acceptedInvitation': id, 'group': group}
|
return {"acceptedInvitation": id, "group": group}
|
||||||
if invite['type'] == 'groupJoinRequest':
|
if invite["type"] == "groupJoinRequest":
|
||||||
group = db.groups.find_one({'_id': invite['typeId']})
|
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')
|
if user["_id"] not in group.get("admins", []):
|
||||||
requester = db.users.find_one({'_id': invite['user']})
|
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:
|
if not group or not requester:
|
||||||
db.invitations.remove({'_id': id})
|
db.invitations.delete_one({"_id": id})
|
||||||
return {'acceptedInvitation': id}
|
return {"acceptedInvitation": id}
|
||||||
groups.create_member(requester, group['_id'], requester['_id'], invited = True)
|
groups.create_member(requester, group["_id"], requester["_id"], invited=True)
|
||||||
db.invitations.remove({'_id': id})
|
db.invitations.delete_one({"_id": id})
|
||||||
return {'acceptedInvitation': id, 'group': group}
|
return {"acceptedInvitation": id, "group": group}
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
invite = db.invitations.find_one({'_id': id})
|
invite = db.invitations.find_one({"_id": id})
|
||||||
if not invite: raise util.errors.NotFound('Invitation not found')
|
if not invite:
|
||||||
if invite['type'] == 'group':
|
raise util.errors.NotFound("Invitation not found")
|
||||||
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to decline')
|
if invite["type"] == "group":
|
||||||
if invite['type'] == 'groupJoinRequest':
|
if invite["recipient"] != user["_id"]:
|
||||||
group = db.groups.find_one({'_id': invite['typeId']})
|
raise util.errors.Forbidden("This invitation is not yours to decline")
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to manage this request')
|
if invite["type"] == "groupJoinRequest":
|
||||||
db.invitations.remove({'_id': id})
|
group = db.groups.find_one({"_id": invite["typeId"]})
|
||||||
return {'deletedInvitation': id}
|
if user["_id"] not in group.get("admins", []):
|
||||||
|
raise util.errors.Forbidden(
|
||||||
|
"You need to be an admin of this group to manage this request"
|
||||||
|
)
|
||||||
|
db.invitations.delete_one({"_id": id})
|
||||||
|
return {"deletedInvitation": id}
|
||||||
|
|
||||||
|
|
||||||
def create_group_invitation(user, group_id, data):
|
def create_group_invitation(user, group_id, data):
|
||||||
if not data or 'user' not in data: raise util.errors.BadRequest('Invalid request')
|
if not data or "user" not in data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
recipient_id = ObjectId(data['user'])
|
recipient_id = ObjectId(data["user"])
|
||||||
group_id = ObjectId(group_id)
|
group_id = ObjectId(group_id)
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to invite users')
|
raise util.errors.NotFound("Group not found")
|
||||||
recipient = db.users.find_one({'_id': recipient_id}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
if user["_id"] not in group.get("admins", []):
|
||||||
if not recipient: raise util.errors.NotFound('User not found')
|
raise util.errors.Forbidden("You need to be a group admin to invite users")
|
||||||
if group_id in recipient.get('groups', []): raise util.errors.BadRequest('This user is already in this group')
|
recipient = db.users.find_one(
|
||||||
if db.invitations.find_one({'recipient': recipient_id, 'typeId': group_id, 'type': 'group'}):
|
{"_id": recipient_id},
|
||||||
raise util.errors.BadRequest('This user has already been invited to this group')
|
{"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 = {
|
invite = {
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'recipient': recipient_id,
|
"recipient": recipient_id,
|
||||||
'type': 'group',
|
"type": "group",
|
||||||
'typeId': group_id
|
"typeId": group_id,
|
||||||
}
|
}
|
||||||
result = db.invitations.insert_one(invite)
|
result = db.invitations.insert_one(invite)
|
||||||
if 'groups.invited' in recipient.get('subscriptions', {}).get('email', []):
|
if "groups.invited" in recipient.get("subscriptions", {}).get("email", []):
|
||||||
mail.send({
|
mail.send(
|
||||||
'to_user': recipient,
|
{
|
||||||
'subject': 'You\'ve been invited to a group on {}!'.format(APP_NAME),
|
"to_user": recipient,
|
||||||
'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(
|
"subject": "You've been invited to a group on {}!".format(APP_NAME),
|
||||||
recipient['username'],
|
"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(
|
||||||
group['name'],
|
recipient["username"],
|
||||||
|
group["name"],
|
||||||
APP_URL,
|
APP_URL,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
invite["_id"] = result.inserted_id
|
||||||
invite['_id'] = result.inserted_id
|
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|
||||||
def create_group_request(user, group_id):
|
def create_group_request(user, group_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(group_id)
|
group_id = ObjectId(group_id)
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if group_id in user.get('groups'): raise util.errors.BadRequest('You are already a member of this group')
|
raise util.errors.NotFound("Group not found")
|
||||||
admin = db.users.find_one({'_id': {'$in': group.get('admins', [])}}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
if group_id in user.get("groups", []):
|
||||||
if not admin: raise util.errors.NotFound('No users can approve you to join this group')
|
raise util.errors.BadRequest("You are already a member of this group")
|
||||||
if db.invitations.find_one({'recipient': user['_id'], 'typeId': group_id, 'type': 'group'}):
|
admin = db.users.find_one(
|
||||||
raise util.errors.BadRequest('You have already been invited to this group')
|
{"_id": {"$in": group.get("admins", [])}},
|
||||||
if db.invitations.find_one({'user': user['_id'], 'typeId': group_id, 'type': 'groupJoinRequest'}):
|
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
||||||
raise util.errors.BadRequest('You have already requested access to this group')
|
)
|
||||||
|
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 = {
|
invite = {
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'recipientGroup': group['_id'],
|
"recipientGroup": group["_id"],
|
||||||
'type': 'groupJoinRequest',
|
"type": "groupJoinRequest",
|
||||||
'typeId': group_id
|
"typeId": group_id,
|
||||||
}
|
}
|
||||||
result = db.invitations.insert_one(invite)
|
result = db.invitations.insert_one(invite)
|
||||||
if 'groups.joinRequested' in admin.get('subscriptions', {}).get('email', []):
|
if "groups.joinRequested" in admin.get("subscriptions", {}).get("email", []):
|
||||||
mail.send({
|
mail.send(
|
||||||
'to_user': admin,
|
{
|
||||||
'subject': 'Someone wants to join your group',
|
"to_user": admin,
|
||||||
'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(
|
"subject": "Someone wants to join your group",
|
||||||
admin['username'],
|
"text": "Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.".format(
|
||||||
user['username'],
|
admin["username"],
|
||||||
group['name'],
|
user["username"],
|
||||||
|
group["name"],
|
||||||
APP_URL,
|
APP_URL,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
invite["_id"] = result.inserted_id
|
||||||
invite['_id'] = result.inserted_id
|
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|
||||||
def get_group_invitations(user, id):
|
def get_group_invitations(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(id)
|
group_id = ObjectId(id)
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
raise util.errors.NotFound("Group not found")
|
||||||
invites = list(db.invitations.find({'type': 'group', 'typeId': group_id}))
|
if user["_id"] not in group.get("admins", []):
|
||||||
recipients = list(db.users.find({'_id': {'$in': [i['recipient'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||||
|
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 invite in invites:
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
if invite['recipient'] == recipient['_id']:
|
if invite["recipient"] == recipient["_id"]:
|
||||||
if 'avatar' in recipient:
|
if "avatar" in recipient:
|
||||||
recipient['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(recipient['_id'], recipient['avatar']))
|
recipient["avatarUrl"] = uploads.get_presigned_url(
|
||||||
invite['recipientUser'] = recipient
|
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
||||||
|
)
|
||||||
|
invite["recipientUser"] = recipient
|
||||||
break
|
break
|
||||||
return {'invitations': invites}
|
return {"invitations": invites}
|
||||||
|
|
||||||
|
|
||||||
def delete_group_invitation(user, id, invite_id):
|
def delete_group_invitation(user, id, invite_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(id)
|
group_id = ObjectId(id)
|
||||||
invite_id = ObjectId(invite_id)
|
invite_id = ObjectId(invite_id)
|
||||||
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
||||||
if not group: raise util.errors.NotFound('Group not found')
|
if not group:
|
||||||
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
raise util.errors.NotFound("Group not found")
|
||||||
invite = db.invitations.find_one({'_id': invite_id})
|
if user["_id"] not in group.get("admins", []):
|
||||||
if not invite or invite['typeId'] != group_id: raise util.errors.NotFound('This invite could not be found')
|
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
||||||
db.invitations.remove({'_id': invite_id})
|
invite = db.invitations.find_one({"_id": invite_id})
|
||||||
return {'deletedInvite': invite_id}
|
if not invite or invite["typeId"] != group_id:
|
||||||
|
raise util.errors.NotFound("This invite could not be found")
|
||||||
|
db.invitations.delete_one({"_id": invite_id})
|
||||||
|
return {"deletedInvite": invite_id}
|
||||||
|
@ -1,154 +1,272 @@
|
|||||||
import datetime, base64, os
|
import datetime
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import requests
|
import requests
|
||||||
from util import database, wif, util, mail
|
from util import database, wif, util, mail
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
APP_NAME = os.environ.get('APP_NAME')
|
APP_NAME = os.environ.get("APP_NAME")
|
||||||
|
APP_URL = os.environ.get("APP_URL")
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||||
if not obj:
|
if not obj:
|
||||||
raise util.errors.NotFound('Object not found')
|
raise util.errors.NotFound("Object not found")
|
||||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||||
if not project:
|
if not project:
|
||||||
raise util.errors.NotFound('Project not found')
|
raise util.errors.NotFound("Project not found")
|
||||||
if project['user'] != user['_id']:
|
if not util.can_edit_project(user, project):
|
||||||
raise util.errors.Forbidden('Forbidden', 403)
|
raise util.errors.Forbidden("Forbidden", 403)
|
||||||
db.objects.remove(ObjectId(id))
|
db.objects.delete_one({"_id": ObjectId(id)})
|
||||||
return {'deletedObject': id}
|
return {"deletedObject": id}
|
||||||
|
|
||||||
|
|
||||||
def get(user, id):
|
def get(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
if not obj:
|
if not obj:
|
||||||
raise util.errors.NotFound('Object not found')
|
raise util.errors.NotFound("Object not found")
|
||||||
|
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
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def copy_to_project(user, id, project_id):
|
def copy_to_project(user, id, project_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj: raise util.errors.NotFound('This object could not be found')
|
if not obj:
|
||||||
original_project = db.projects.find_one(obj['project'])
|
raise util.errors.NotFound("This object could not be found")
|
||||||
|
original_project = db.projects.find_one(obj["project"])
|
||||||
if not original_project:
|
if not original_project:
|
||||||
raise util.errors.NotFound('Project not found')
|
raise util.errors.NotFound("Project not found")
|
||||||
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']):
|
if not original_project.get("openSource") and not util.can_edit_project(
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
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))
|
target_project = db.projects.find_one(ObjectId(project_id))
|
||||||
if not target_project or target_project['user'] != user['_id']:
|
if not target_project or not util.can_edit_project(user, target_project):
|
||||||
raise util.errors.Forbidden('You don\'t own the target project')
|
raise util.errors.Forbidden("You don't own the target project")
|
||||||
|
|
||||||
obj['_id'] = ObjectId()
|
obj["_id"] = ObjectId()
|
||||||
obj['project'] = target_project['_id']
|
obj["project"] = target_project["_id"]
|
||||||
obj['createdAt'] = datetime.datetime.now()
|
obj["createdAt"] = datetime.datetime.now()
|
||||||
obj['commentCount'] = 0
|
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)
|
db.objects.insert_one(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def get_wif(user, id):
|
def get_wif(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
if not obj:
|
||||||
project = db.projects.find_one(obj['project'])
|
raise util.errors.NotFound("Object not found")
|
||||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
project = db.projects.find_one(obj["project"])
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||||
|
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:
|
try:
|
||||||
output = wif.dumps(obj).replace('\n', '\\n')
|
output = wif.dumps(obj).replace("\n", "\\n")
|
||||||
return {'wif': output}
|
return {"wif": output}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise util.errors.BadRequest('Unable to create WIF file')
|
raise util.errors.BadRequest("Unable to create WIF file")
|
||||||
|
|
||||||
|
|
||||||
def get_pdf(user, id):
|
def get_pdf(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
if not obj:
|
||||||
project = db.projects.find_one(obj['project'])
|
raise util.errors.NotFound("Object not found")
|
||||||
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
project = db.projects.find_one(obj["project"])
|
||||||
raise util.errors.Forbidden('This project is not open-source')
|
if not project.get("openSource") and not util.can_edit_project(user, project):
|
||||||
|
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:
|
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()
|
response.raise_for_status()
|
||||||
pdf = uploads.get_file('objects/' + id + '/export.pdf')
|
pdf = uploads.get_file("objects/" + id + "/export.pdf")
|
||||||
body64 = base64.b64encode(pdf['Body'].read())
|
body64 = base64.b64encode(pdf["Body"].read())
|
||||||
bytes_str = str(body64).replace("b'", '')[:-1]
|
return {"pdf": body64.decode("ascii")}
|
||||||
return {'pdf': body64.decode('ascii')}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
raise util.errors.BadRequest('Unable to export PDF')
|
raise util.errors.BadRequest("Unable to export PDF")
|
||||||
|
|
||||||
|
|
||||||
def update(user, id, data):
|
def update(user, id, data):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
if not obj:
|
||||||
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
raise util.errors.NotFound("Object not found")
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
if not project:
|
||||||
allowed_keys = ['name', 'description', 'pattern', 'preview']
|
raise util.errors.NotFound("Project not found")
|
||||||
|
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)
|
updater = util.build_updater(data, allowed_keys)
|
||||||
if updater:
|
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)
|
return get(user, id)
|
||||||
|
|
||||||
|
|
||||||
def create_comment(user, id, data):
|
def create_comment(user, id, data):
|
||||||
if not data or not data.get('content'): raise util.errors.BadRequest('Comment data is required')
|
if not data or not data.get("content"):
|
||||||
|
raise util.errors.BadRequest("Comment data is required")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
if not obj: raise util.errors.NotFound('We could not find the specified object')
|
if not obj:
|
||||||
project = db.projects.find_one({'_id': obj['project']})
|
raise util.errors.NotFound("We could not find the specified object")
|
||||||
comment = {
|
comment = {
|
||||||
'content': data.get('content', ''),
|
"content": data.get("content", ""),
|
||||||
'object': ObjectId(id),
|
"object": ObjectId(id),
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'createdAt': datetime.datetime.now()
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
result = db.comments.insert_one(comment)
|
result = db.comments.insert_one(comment)
|
||||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': 1}})
|
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
||||||
comment['_id'] = result.inserted_id
|
comment["_id"] = result.inserted_id
|
||||||
comment['authorUser'] = {
|
comment["authorUser"] = {
|
||||||
'username': user['username'],
|
"username": user["username"],
|
||||||
'avatar': user.get('avatar'),
|
"avatar": user.get("avatar"),
|
||||||
'avatarUrl': uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], 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)
|
|
||||||
),
|
),
|
||||||
APP_NAME,
|
}
|
||||||
)
|
util.send_moderation_request(user, "comments", comment)
|
||||||
})
|
|
||||||
return comment
|
return comment
|
||||||
|
|
||||||
def get_comments(user, id):
|
|
||||||
|
def send_comment_notification(id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
comments = list(db.comments.find({'object': ObjectId(id)}))
|
comment = db.comments.find_one({"_id": ObjectId(id)})
|
||||||
user_ids = list(map(lambda c:c['user'], comments))
|
user = db.users.find_one({"_id": comment["user"]})
|
||||||
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
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 comment in comments:
|
||||||
for u in users:
|
for u in users:
|
||||||
if comment['user'] == u['_id']:
|
if comment["user"] == u["_id"]:
|
||||||
comment['authorUser'] = u
|
comment["authorUser"] = u
|
||||||
if 'avatar' in u:
|
if "avatar" in u:
|
||||||
comment['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
comment["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
return {'comments': comments}
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
return {"comments": comments}
|
||||||
|
|
||||||
|
|
||||||
def delete_comment(user, id, comment_id):
|
def delete_comment(user, id, comment_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
comment = db.comments.find_one({'_id': ObjectId(comment_id)})
|
comment = db.comments.find_one({"_id": ObjectId(comment_id)})
|
||||||
obj = db.objects.find_one({'_id': ObjectId(id)})
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
if not comment or not obj or obj['_id'] != comment['object']: raise util.errors.NotFound('Comment not found')
|
if not comment or not obj or obj["_id"] != comment["object"]:
|
||||||
project = db.projects.find_one({'_id': obj['project']})
|
raise util.errors.NotFound("Comment not found")
|
||||||
if comment['user'] != user['_id'] and comment['user'] != project['user']: raise util.errors.Forbidden('You can\'t delete this comment')
|
project = db.projects.find_one({"_id": obj["project"]})
|
||||||
db.comments.remove({'_id': comment['_id']})
|
if comment["user"] != user["_id"] and not util.can_edit_project(user, project):
|
||||||
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': -1}})
|
raise util.errors.Forbidden("You can't delete this comment")
|
||||||
return {'deletedComment': comment['_id']}
|
db.comments.delete_one({"_id": comment["_id"]})
|
||||||
|
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": -1}})
|
||||||
|
return {"deletedComment": comment["_id"]}
|
||||||
|
@ -1,190 +1,362 @@
|
|||||||
import datetime, re
|
import datetime
|
||||||
|
import re
|
||||||
|
import os
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, wif, util
|
from util import database, wif, util, mail
|
||||||
from api import uploads
|
from api import uploads, objects
|
||||||
|
|
||||||
default_pattern = {
|
default_pattern = {
|
||||||
'warp': {
|
"warp": {
|
||||||
'shafts': 8,
|
"shafts": 8,
|
||||||
'threads': 100,
|
"threading": [{"shaft": 0}] * 100,
|
||||||
'threading': [{'shaft': 0}] * 100,
|
"defaultColour": "178,53,111",
|
||||||
'defaultColour': '178,53,111',
|
"defaultSpacing": 1,
|
||||||
'defaultSpacing': 1,
|
"defaultThickness": 1,
|
||||||
'defaultThickness': 1,
|
"guideFrequency": 8,
|
||||||
},
|
},
|
||||||
'weft': {
|
"weft": {
|
||||||
'treadles': 8,
|
"treadles": 8,
|
||||||
'threads': 50,
|
"treadling": [{"treadle": 0}] * 50,
|
||||||
'treadling': [{'treadle': 0}] * 50,
|
"defaultColour": "53,69,178",
|
||||||
'defaultColour': '53,69,178',
|
"defaultSpacing": 1,
|
||||||
'defaultSpacing': 1,
|
"defaultThickness": 1,
|
||||||
'defaultThickness': 1
|
"guideFrequency": 8,
|
||||||
},
|
},
|
||||||
'tieups': [[]] * 8,
|
"tieups": [[]] * 8,
|
||||||
'colours': ['256,256,256', '0,0,0', '50,0,256', '0,68,256', '0,256,256', '0,256,0', '119,256,0', '256,256,0', '256,136,0', '256,0,0', '256,0,153', '204,0,256', '132,102,256', '102,155,256', '102,256,256', '102,256,102', '201,256,102', '256,256,102', '256,173,102', '256,102,102', '256,102,194', '224,102,256', '31,0,153', '0,41,153', '0,153,153', '0,153,0', '71,153,0', '153,153,0', '153,82,0', '153,0,0', '153,0,92', '122,0,153', '94,68,204', '68,102,204', '68,204,204', '68,204,68', '153,204,68', '204,204,68', '204,136,68', '204,68,68', '204,68,153', '170,68,204', '37,0,204', '0,50,204', '0,204,204', '0,204,0', '89,204,0', '204,204,0', '204,102,0', '204,0,0', '204,0,115', '153,0,204', '168,136,256', '136,170,256', '136,256,256', '136,256,136', '230,256,136', '256,256,136', '256,178,136', '256,136,136', '256,136,204', '240,136,256', '49,34,238', '34,68,238', '34,238,238', '34,238,34', '71,238,34', '238,238,34', '238,82,34', '238,34,34', '238,34,92', '122,34,238', '128,102,238', '102,136,238', '102,238,238', '102,238,102', '187,238,102', '238,238,102', '238,170,102', '238,102,102', '238,102,187', '204,102,238', '178,53,111', '53,69,178'],
|
"colours": [
|
||||||
|
"256,256,256",
|
||||||
|
"0,0,0",
|
||||||
|
"50,0,256",
|
||||||
|
"0,68,256",
|
||||||
|
"0,256,256",
|
||||||
|
"0,256,0",
|
||||||
|
"119,256,0",
|
||||||
|
"256,256,0",
|
||||||
|
"256,136,0",
|
||||||
|
"256,0,0",
|
||||||
|
"256,0,153",
|
||||||
|
"204,0,256",
|
||||||
|
"132,102,256",
|
||||||
|
"102,155,256",
|
||||||
|
"102,256,256",
|
||||||
|
"102,256,102",
|
||||||
|
"201,256,102",
|
||||||
|
"256,256,102",
|
||||||
|
"256,173,102",
|
||||||
|
"256,102,102",
|
||||||
|
"256,102,194",
|
||||||
|
"224,102,256",
|
||||||
|
"31,0,153",
|
||||||
|
"0,41,153",
|
||||||
|
"0,153,153",
|
||||||
|
"0,153,0",
|
||||||
|
"71,153,0",
|
||||||
|
"153,153,0",
|
||||||
|
"153,82,0",
|
||||||
|
"153,0,0",
|
||||||
|
"153,0,92",
|
||||||
|
"122,0,153",
|
||||||
|
"94,68,204",
|
||||||
|
"68,102,204",
|
||||||
|
"68,204,204",
|
||||||
|
"68,204,68",
|
||||||
|
"153,204,68",
|
||||||
|
"204,204,68",
|
||||||
|
"204,136,68",
|
||||||
|
"204,68,68",
|
||||||
|
"204,68,153",
|
||||||
|
"170,68,204",
|
||||||
|
"37,0,204",
|
||||||
|
"0,50,204",
|
||||||
|
"0,204,204",
|
||||||
|
"0,204,0",
|
||||||
|
"89,204,0",
|
||||||
|
"204,204,0",
|
||||||
|
"204,102,0",
|
||||||
|
"204,0,0",
|
||||||
|
"204,0,115",
|
||||||
|
"153,0,204",
|
||||||
|
"168,136,256",
|
||||||
|
"136,170,256",
|
||||||
|
"136,256,256",
|
||||||
|
"136,256,136",
|
||||||
|
"230,256,136",
|
||||||
|
"256,256,136",
|
||||||
|
"256,178,136",
|
||||||
|
"256,136,136",
|
||||||
|
"256,136,204",
|
||||||
|
"240,136,256",
|
||||||
|
"49,34,238",
|
||||||
|
"34,68,238",
|
||||||
|
"34,238,238",
|
||||||
|
"34,238,34",
|
||||||
|
"71,238,34",
|
||||||
|
"238,238,34",
|
||||||
|
"238,82,34",
|
||||||
|
"238,34,34",
|
||||||
|
"238,34,92",
|
||||||
|
"122,34,238",
|
||||||
|
"128,102,238",
|
||||||
|
"102,136,238",
|
||||||
|
"102,238,238",
|
||||||
|
"102,238,102",
|
||||||
|
"187,238,102",
|
||||||
|
"238,238,102",
|
||||||
|
"238,170,102",
|
||||||
|
"238,102,102",
|
||||||
|
"238,102,187",
|
||||||
|
"204,102,238",
|
||||||
|
"178,53,111",
|
||||||
|
"53,69,178",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def derive_path(name):
|
def derive_path(name):
|
||||||
path = name.replace(' ', '-').lower()
|
path = name.replace(" ", "-").lower()
|
||||||
return re.sub('[^0-9a-z\-]+', '', path)
|
return re.sub("[^0-9a-z\-]+", "", path)
|
||||||
|
|
||||||
|
|
||||||
def get_by_username(username, project_path):
|
def get_by_username(username, project_path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1})
|
owner = db.users.find_one({"username": username}, {"_id": 1, "username": 1})
|
||||||
if not owner:
|
if not owner:
|
||||||
raise util.errors.BadRequest('User not found')
|
raise util.errors.BadRequest("User not found")
|
||||||
project = db.projects.find_one({'user': owner['_id'], 'path': project_path})
|
project = db.projects.find_one({"user": owner["_id"], "path": project_path})
|
||||||
if not project:
|
if not project:
|
||||||
raise util.errors.NotFound('Project not found')
|
raise util.errors.NotFound("Project not found")
|
||||||
project['owner'] = owner
|
project["owner"] = owner
|
||||||
project['fullName'] = owner['username'] + '/' + project['path']
|
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
def create(user, data):
|
def create(user, data):
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
if not data:
|
||||||
name = data.get('name', '')
|
raise util.errors.BadRequest("Invalid request")
|
||||||
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
|
name = data.get("name", "")
|
||||||
|
if len(name) < 3:
|
||||||
|
raise util.errors.BadRequest("A longer name is required")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
|
|
||||||
path = derive_path(name)
|
path = derive_path(name)
|
||||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||||
raise util.errors.BadRequest('Bad Name')
|
raise util.errors.BadRequest("Bad Name")
|
||||||
groups = data.get('groupVisibility', [])
|
groups = data.get("groupVisibility", [])
|
||||||
group_visibility = []
|
group_visibility = []
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_visibility.append(ObjectId(group))
|
group_visibility.append(ObjectId(group))
|
||||||
proj = {
|
proj = {
|
||||||
'name': name,
|
"name": name,
|
||||||
'description': data.get('description', ''),
|
"description": data.get("description", ""),
|
||||||
'visibility': data.get('visibility', 'public'),
|
"visibility": data.get("visibility", "public"),
|
||||||
'openSource': data.get('openSource', True),
|
"openSource": data.get("openSource", True),
|
||||||
'groupVisibility': group_visibility,
|
"groupVisibility": group_visibility,
|
||||||
'path': path,
|
"path": path,
|
||||||
'user': user['_id'],
|
"user": user["_id"],
|
||||||
'createdAt': datetime.datetime.now()
|
"createdAt": datetime.datetime.now(),
|
||||||
}
|
}
|
||||||
result = db.projects.insert_one(proj)
|
result = db.projects.insert_one(proj)
|
||||||
proj['_id'] = result.inserted_id
|
proj["_id"] = result.inserted_id
|
||||||
proj['owner'] = {'_id': user['_id'], 'username': user['username']}
|
proj["owner"] = {"_id": user["_id"], "username": user["username"]}
|
||||||
proj['fullName'] = user['username'] + '/' + proj['path']
|
proj["fullName"] = user["username"] + "/" + proj["path"]
|
||||||
return proj
|
return proj
|
||||||
|
|
||||||
|
|
||||||
def get(user, username, path):
|
def get(user, username, path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
owner = db.users.find_one(
|
||||||
if not owner: raise util.errors.NotFound('User not found')
|
{"username": username},
|
||||||
project = db.projects.find_one({'user': owner['_id'], 'path': path})
|
{
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
"_id": 1,
|
||||||
|
"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):
|
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:
|
if "avatar" in owner:
|
||||||
owner['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(owner['_id'], owner['avatar']))
|
owner["avatarUrl"] = uploads.get_presigned_url(
|
||||||
project['owner'] = owner
|
"users/{0}/{1}".format(owner["_id"], owner["avatar"])
|
||||||
project['fullName'] = owner['username'] + '/' + project['path']
|
)
|
||||||
|
project["owner"] = owner
|
||||||
|
project["fullName"] = owner["username"] + "/" + project["path"]
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
def update(user, username, project_path, update):
|
def update(user, username, project_path, update):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, project_path)
|
project = get_by_username(username, project_path)
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
if not util.can_edit_project(user, project):
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
|
||||||
current_path = project_path
|
current_path = project_path
|
||||||
if 'name' in update:
|
if "name" in update:
|
||||||
if len(update['name']) < 3: raise util.errors.BadRequest('The name is too short.')
|
if len(update["name"]) < 3:
|
||||||
path = derive_path(update['name'])
|
raise util.errors.BadRequest("The name is too short.")
|
||||||
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
path = derive_path(update["name"])
|
||||||
raise util.errors.BadRequest('You already have a project with a similar name')
|
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
||||||
update['path'] = path
|
raise util.errors.BadRequest(
|
||||||
|
"You already have a project with a similar name"
|
||||||
|
)
|
||||||
|
update["path"] = path
|
||||||
current_path = path
|
current_path = path
|
||||||
update['groupVisibility'] = list(map(lambda g: ObjectId(g), update.get('groupVisibility', [])))
|
update["groupVisibility"] = list(
|
||||||
allowed_keys = ['name', 'description', 'path', 'visibility', 'openSource', 'groupVisibility']
|
map(lambda g: ObjectId(g), update.get("groupVisibility", []))
|
||||||
|
)
|
||||||
|
allowed_keys = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"path",
|
||||||
|
"visibility",
|
||||||
|
"openSource",
|
||||||
|
"groupVisibility",
|
||||||
|
]
|
||||||
updater = util.build_updater(update, allowed_keys)
|
updater = util.build_updater(update, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
db.projects.update({'_id': project['_id']}, updater)
|
db.projects.update_one({"_id": project["_id"]}, updater)
|
||||||
return get(user, username, current_path)
|
return get(user, username, current_path)
|
||||||
|
|
||||||
|
|
||||||
def delete(user, username, project_path):
|
def delete(user, username, project_path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, project_path)
|
project = get_by_username(username, project_path)
|
||||||
if project['user'] != user['_id']:
|
if not util.can_edit_project(user, project):
|
||||||
raise util.errors.Forbidden('Forbidden')
|
raise util.errors.Forbidden("Forbidden")
|
||||||
db.projects.remove({'_id': project['_id']})
|
db.projects.delete_one({"_id": project["_id"]})
|
||||||
db.objects.remove({'project': project['_id']})
|
db.objects.delete_many({"project": project["_id"]})
|
||||||
return {'deletedProject': project['_id'] }
|
return {"deletedProject": project["_id"]}
|
||||||
|
|
||||||
|
|
||||||
def get_objects(user, username, path):
|
def get_objects(user, username, path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, path)
|
project = get_by_username(username, path)
|
||||||
if not project: raise util.errors.NotFound('Project not found')
|
if not project:
|
||||||
|
raise util.errors.NotFound("Project not found")
|
||||||
if not util.can_view_project(user, project):
|
if not util.can_view_project(user, project):
|
||||||
raise util.errors.Forbidden('This project is private')
|
raise util.errors.Forbidden("This project is private")
|
||||||
|
|
||||||
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:
|
for obj in objs:
|
||||||
if obj['type'] == 'file' and 'storedName' in obj:
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
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
|
return objs
|
||||||
|
|
||||||
|
|
||||||
def create_object(user, username, path, data):
|
def create_object(user, username, path, data):
|
||||||
if not data and not data.get('type'): raise util.errors.BadRequest('Invalid request')
|
if not data and not data.get("type"):
|
||||||
if not data.get('type'): raise util.errors.BadRequest('Object type is required.')
|
raise util.errors.BadRequest("Invalid request")
|
||||||
|
if not data.get("type"):
|
||||||
|
raise util.errors.BadRequest("Object type is required.")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, path)
|
project = get_by_username(username, path)
|
||||||
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
if not util.can_edit_project(user, project):
|
||||||
file_count = db.objects.find({'project': project['_id']}).count()
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
|
||||||
if data['type'] == 'file':
|
if data["type"] == "file":
|
||||||
if not 'storedName' in data:
|
if "storedName" not in data:
|
||||||
raise util.errors.BadRequest('File stored name must be included')
|
raise util.errors.BadRequest("File stored name must be included")
|
||||||
obj = {
|
obj = {
|
||||||
'project': project['_id'],
|
"project": project["_id"],
|
||||||
'name': data.get('name', 'Untitled file'),
|
"name": data.get("name", "Untitled file"),
|
||||||
'storedName': data['storedName'],
|
"storedName": data["storedName"],
|
||||||
'createdAt': datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
'type': 'file',
|
"type": "file",
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', data['storedName'].lower()):
|
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
||||||
obj['isImage'] = True
|
obj["isImage"] = True
|
||||||
result = db.objects.insert_one(obj)
|
result = db.objects.insert_one(obj)
|
||||||
obj['_id'] = result.inserted_id
|
obj["_id"] = result.inserted_id
|
||||||
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
obj["url"] = uploads.get_presigned_url(
|
||||||
if obj.get('isImage'):
|
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
||||||
|
)
|
||||||
|
if obj.get("isImage"):
|
||||||
|
|
||||||
def handle_cb(h):
|
def handle_cb(h):
|
||||||
db.objects.update_one({'_id': obj['_id']}, {'$set': {'imageBlurHash': h}})
|
db.objects.update_one(
|
||||||
uploads.blur_image('projects/' + str(project['_id']) + '/' + data['storedName'], handle_cb)
|
{"_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
|
return obj
|
||||||
if data['type'] == 'pattern':
|
if data["type"] == "pattern":
|
||||||
if data.get('wif'):
|
obj = {
|
||||||
|
"project": project["_id"],
|
||||||
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"type": "pattern",
|
||||||
|
}
|
||||||
|
if data.get("wif"):
|
||||||
try:
|
try:
|
||||||
pattern = wif.loads(data['wif'])
|
pattern = wif.loads(data["wif"])
|
||||||
if pattern:
|
if pattern:
|
||||||
obj = {
|
obj["name"] = pattern["name"]
|
||||||
'project': project['_id'],
|
obj["pattern"] = pattern
|
||||||
'name': pattern['name'],
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'type': 'pattern',
|
|
||||||
'pattern': pattern
|
|
||||||
}
|
|
||||||
result = db.objects.insert_one(obj)
|
|
||||||
obj['_id'] = result.inserted_id
|
|
||||||
return obj
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise util.errors.BadRequest('Unable to load WIF file. It is either invalid or in a format we cannot understand.')
|
mail.send(
|
||||||
elif data.get('name'):
|
{
|
||||||
pattern = default_pattern.copy()
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
pattern['warp'].update({'shafts': data.get('shafts', 8)})
|
"subject": "Error loading WIF file",
|
||||||
pattern['weft'].update({'treadles': data.get('treadles', 8)})
|
"text": "A WIF file failed to parse with error: {}. The contents are below:\n\n{}".format(
|
||||||
obj = {
|
e, data["wif"]
|
||||||
'project': project['_id'],
|
),
|
||||||
'name': data['name'],
|
|
||||||
'createdAt': datetime.datetime.now(),
|
|
||||||
'type': 'pattern',
|
|
||||||
'pattern': pattern
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
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)
|
result = db.objects.insert_one(obj)
|
||||||
obj['_id'] = result.inserted_id
|
obj["_id"] = result.inserted_id
|
||||||
return obj
|
images = wif.generate_images(obj)
|
||||||
raise util.errors.BadRequest('Unable to create object')
|
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 datetime
|
||||||
import pymongo
|
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util, mail
|
from util import database, util
|
||||||
from api import uploads, groups
|
from api import uploads, objects, groups
|
||||||
|
|
||||||
|
|
||||||
def get_users(user):
|
def get_users(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
if not util.is_root(user):
|
||||||
users = list(db.users.find({}, {'username': 1, 'avatar': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1, 'roles': 1, 'groups': 1}).sort('lastSeenAt', -1))
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
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 = []
|
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:
|
for u in users:
|
||||||
if 'avatar' in u:
|
group_ids += u.get("groups", [])
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
groups = list(db.groups.find({"_id": {"$in": group_ids}}, {"name": 1}))
|
||||||
u['projects'] = []
|
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:
|
for p in projects:
|
||||||
if p['user'] == u['_id']:
|
if p["user"] == u["_id"]:
|
||||||
u['projects'].append(p)
|
u["projects"].append(p)
|
||||||
u['groupMemberships'] = []
|
u["groupMemberships"] = []
|
||||||
if u.get('groups'):
|
if u.get("groups"):
|
||||||
for g in groups:
|
for g in groups:
|
||||||
if g['_id'] in u.get('groups', []):
|
if g["_id"] in u.get("groups", []):
|
||||||
u['groupMemberships'].append(g)
|
u["groupMemberships"].append(g)
|
||||||
return {'users': users}
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
def get_groups(user):
|
def get_groups(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
groups = list(db.groups.find({}))
|
groups = list(db.groups.find({}))
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group['memberCount'] = db.users.find({'groups': group['_id']}).count()
|
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
||||||
return {'groups': groups}
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
|
def get_moderation(user):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
object_list = list(db.objects.find({"moderationRequired": True}))
|
||||||
|
for obj in object_list:
|
||||||
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
|
||||||
|
)
|
||||||
|
comment_list = list(db.comments.find({"moderationRequired": True}))
|
||||||
|
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
|
||||||
|
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
|
||||||
|
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
|
||||||
|
for entry in group_entry_list:
|
||||||
|
for a in entry.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(entry["group"], a["storedName"])
|
||||||
|
)
|
||||||
|
group_topic_reply_list = list(
|
||||||
|
db.groupForumTopicReplies.find({"moderationRequired": True})
|
||||||
|
)
|
||||||
|
for reply in group_topic_reply_list:
|
||||||
|
for a in reply.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/topics/{1}/{2}".format(
|
||||||
|
reply["group"], reply["topic"], a["storedName"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"objects": object_list,
|
||||||
|
"comments": comment_list,
|
||||||
|
"users": user_list,
|
||||||
|
"groups": group_list,
|
||||||
|
"groupEntries": group_entry_list,
|
||||||
|
"groupForumTopicReplies": group_topic_reply_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def moderate(user, item_type, item_id, allowed):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
if item_type not in [
|
||||||
|
"objects",
|
||||||
|
"comments",
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"groupEntries",
|
||||||
|
"groupForumTopicReplies",
|
||||||
|
]:
|
||||||
|
raise util.errors.BadRequest("Invalid item type")
|
||||||
|
item_id = ObjectId(item_id)
|
||||||
|
item = db[item_type].find_one({"_id": item_id})
|
||||||
|
# For now, handle only allowed moderations.
|
||||||
|
# Disallowed will be manually managed.
|
||||||
|
if item and allowed:
|
||||||
|
db[item_type].update_one(
|
||||||
|
{"_id": item_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"moderationRequired": False,
|
||||||
|
"moderated": True,
|
||||||
|
"moderatedAt": datetime.datetime.now(),
|
||||||
|
"moderatedBy": user["_id"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if item_type == "comments":
|
||||||
|
objects.send_comment_notification(item_id)
|
||||||
|
if item_type == "groupEntries":
|
||||||
|
groups.send_entry_notification(item_id)
|
||||||
|
if item_type == "groupForumTopicReplies":
|
||||||
|
groups.send_forum_topic_reply_notification(item_id)
|
||||||
|
return {"success": True}
|
||||||
|
@ -1,74 +1,253 @@
|
|||||||
import re, random
|
import re
|
||||||
|
import random
|
||||||
import pymongo
|
import pymongo
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
def all(user, params):
|
def all(user, params):
|
||||||
if not params or 'query' not in params: raise util.errors.BadRequest('Username parameter needed')
|
if not params or "query" not in params:
|
||||||
expression = re.compile(params['query'], re.IGNORECASE)
|
raise util.errors.BadRequest("Query parameter needed")
|
||||||
|
expression = re.compile(params["query"], re.IGNORECASE)
|
||||||
db = database.get_db()
|
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:
|
for u in users:
|
||||||
if 'avatar' in u:
|
if "avatar" in u:
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
||||||
|
)
|
||||||
|
|
||||||
projects = list(db.projects.find({'name': expression, '$or': [
|
my_projects = list(db.projects.find({"user": user["_id"]}, {"name": 1, "path": 1}))
|
||||||
{'user': user['_id']},
|
objects = list(
|
||||||
{'groupVisibility': {'$in': user.get('groups', [])}},
|
db.objects.find(
|
||||||
{'visibility': 'public'}
|
{
|
||||||
]}, {'name': 1, 'path': 1, 'user': 1}).limit(5))
|
"project": {"$in": list(map(lambda p: p["_id"], my_projects))},
|
||||||
proj_users = list(db.users.find({'_id': {'$in': list(map(lambda p:p['user'], projects))}}, {'username': 1, 'avatar': 1}))
|
"name": expression,
|
||||||
|
},
|
||||||
|
{"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 in projects:
|
||||||
for proj_user in proj_users:
|
for proj_user in proj_users:
|
||||||
if proj['user'] == proj_user['_id']:
|
if proj["user"] == proj_user["_id"]:
|
||||||
proj['owner'] = proj_user
|
proj["owner"] = proj_user
|
||||||
proj['fullName'] = proj_user['username'] + '/' + proj['path']
|
proj["fullName"] = proj_user["username"] + "/" + proj["path"]
|
||||||
if 'avatar' in proj_user:
|
if "avatar" in proj_user:
|
||||||
proj['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(proj_user['_id'], proj_user['avatar']))
|
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):
|
def users(user, params):
|
||||||
if not user: raise util.errors.Forbidden('You need to be logged in')
|
if not user:
|
||||||
if not params or 'username' not in params: raise util.errors.BadRequest('Username parameter needed')
|
raise util.errors.Forbidden("You need to be logged in")
|
||||||
expression = re.compile(params['username'], re.IGNORECASE)
|
if not params or "username" not in params:
|
||||||
|
raise util.errors.BadRequest("Username parameter needed")
|
||||||
|
expression = re.compile(params["username"], re.IGNORECASE)
|
||||||
db = database.get_db()
|
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:
|
for u in users:
|
||||||
if 'avatar' in u:
|
if "avatar" in u:
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
return {'users': users}
|
"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()
|
db = database.get_db()
|
||||||
projects = []
|
projects = []
|
||||||
users = []
|
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)
|
random.shuffle(all_projects)
|
||||||
for p in all_projects:
|
for p in all_projects:
|
||||||
if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}):
|
if db.objects.find_one(
|
||||||
owner = db.users.find_one({'_id': p['user']}, {'username': 1})
|
{"project": p["_id"], "name": {"$ne": "Untitled pattern"}}
|
||||||
p['fullName'] = owner['username'] + '/' + p['path']
|
):
|
||||||
|
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)
|
projects.append(p)
|
||||||
if len(projects) >= count: break
|
if len(projects) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
interest_fields = ['bio', 'avatar', 'website', 'facebook', 'twitter', 'instagram', 'location']
|
interest_fields = [
|
||||||
all_users = list(db.users.find({'_id': {'$ne': user['_id']}, '$or': list(map(lambda f: {f: {'$exists': True}}, interest_fields))}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}))
|
"bio",
|
||||||
|
"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)
|
random.shuffle(all_users)
|
||||||
for u in all_users:
|
for u in all_users:
|
||||||
if 'avatar' in u:
|
if "avatar" in u:
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
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)
|
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 {
|
return {
|
||||||
'highlightProjects': projects,
|
"highlightProjects": projects,
|
||||||
'highlightUsers': users,
|
"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 threading import Thread
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.client import Config
|
|
||||||
import blurhash
|
import blurhash
|
||||||
from util import database
|
from util import database, util
|
||||||
|
from api.groups import has_group_permission
|
||||||
|
|
||||||
|
|
||||||
def sanitise_filename(s):
|
def sanitise_filename(s):
|
||||||
bad_chars = re.compile('[^a-zA-Z0-9_.]')
|
bad_chars = re.compile("[^a-zA-Z0-9_.]")
|
||||||
s = bad_chars.sub('_', s)
|
s = bad_chars.sub("_", s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def get_s3():
|
def get_s3():
|
||||||
session = boto3.session.Session()
|
session = boto3.session.Session()
|
||||||
|
|
||||||
s3_client = session.client(
|
s3_client = session.client(
|
||||||
service_name='s3',
|
service_name="s3",
|
||||||
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||||
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
|
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||||
endpoint_url=os.environ['AWS_S3_ENDPOINT'],
|
endpoint_url=os.environ["AWS_S3_ENDPOINT"],
|
||||||
)
|
)
|
||||||
return s3_client
|
return s3_client
|
||||||
|
|
||||||
|
|
||||||
def get_presigned_url(path):
|
def get_presigned_url(path):
|
||||||
return os.environ['AWS_S3_ENDPOINT'] + os.environ['AWS_S3_BUCKET'] + '/' + path
|
return os.environ["AWS_S3_ENDPOINT"] + os.environ["AWS_S3_BUCKET"] + "/" + path
|
||||||
s3 = get_s3()
|
s3 = get_s3()
|
||||||
return s3.generate_presigned_url('get_object',
|
return s3.generate_presigned_url(
|
||||||
Params = {
|
"get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
|
||||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
|
||||||
'Key': path
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(path, data):
|
||||||
|
s3 = get_s3()
|
||||||
|
s3.upload_fileobj(
|
||||||
|
data,
|
||||||
|
os.environ["AWS_S3_BUCKET"],
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_file(key):
|
def get_file(key):
|
||||||
s3 = get_s3()
|
s3 = get_s3()
|
||||||
return s3.get_object(
|
return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
|
||||||
Bucket = os.environ['AWS_S3_BUCKET'],
|
|
||||||
Key = key
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_file_upload_request(user, file_name, file_size, file_type, for_type, for_id):
|
|
||||||
|
def generate_file_upload_request(
|
||||||
|
user, file_name, file_size, file_type, for_type, for_id
|
||||||
|
):
|
||||||
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
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()
|
db = database.get_db()
|
||||||
allowed = False
|
allowed = False
|
||||||
path = ''
|
path = ""
|
||||||
if for_type == 'project':
|
if for_type == "project":
|
||||||
project = db.projects.find_one(ObjectId(for_id))
|
project = db.projects.find_one(ObjectId(for_id))
|
||||||
allowed = project and project.get('user') == user['_id']
|
allowed = project and util.can_edit_project(user, project)
|
||||||
path = 'projects/' + for_id + '/'
|
path = "projects/" + for_id + "/"
|
||||||
if for_type == 'user':
|
if for_type == "user":
|
||||||
allowed = for_id == str(user['_id'])
|
allowed = for_id == str(user["_id"])
|
||||||
path = 'users/' + for_id + '/'
|
path = "users/" + for_id + "/"
|
||||||
if for_type == 'group':
|
if for_type == "group":
|
||||||
allowed = ObjectId(for_id) in user.get('groups', [])
|
allowed = ObjectId(for_id) in user.get("groups", [])
|
||||||
path = 'groups/' + for_id + '/'
|
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:
|
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)
|
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 ''))
|
new_name = sanitise_filename(
|
||||||
s3 = get_s3()
|
"{0}_{1}{2}".format(
|
||||||
signed_url = s3.generate_presigned_url('put_object',
|
file_body or file_name, int(time.time()), file_extension or ""
|
||||||
Params = {
|
|
||||||
'Bucket': os.environ['AWS_S3_BUCKET'],
|
|
||||||
'Key': path + new_name,
|
|
||||||
'ContentType': file_type
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return {
|
)
|
||||||
'signedRequest': signed_url,
|
s3 = get_s3()
|
||||||
'fileName': new_name
|
signed_url = s3.generate_presigned_url(
|
||||||
}
|
"put_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": os.environ["AWS_S3_BUCKET"],
|
||||||
|
"Key": path + new_name,
|
||||||
|
"ContentType": file_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"signedRequest": signed_url, "fileName": new_name}
|
||||||
|
|
||||||
|
|
||||||
def handle_blur_image(key, func):
|
def handle_blur_image(key, func):
|
||||||
f = get_file(key)['Body']
|
f = get_file(key)["Body"]
|
||||||
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
||||||
func(bhash)
|
func(bhash)
|
||||||
|
|
||||||
|
|
||||||
def blur_image(key, func):
|
def blur_image(key, func):
|
||||||
thr = Thread(target=handle_blur_image, args=[key, func])
|
thr = Thread(target=handle_blur_image, args=[key, func])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
378
api/api/users.py
378
api/api/users.py
@ -1,93 +1,355 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
def me(user):
|
def me(user):
|
||||||
|
db = database.get_db()
|
||||||
return {
|
return {
|
||||||
'_id': user['_id'],
|
"_id": user["_id"],
|
||||||
'username': user['username'],
|
"username": user["username"],
|
||||||
'bio': user.get('bio'),
|
"bio": user.get("bio"),
|
||||||
'email': user.get('email'),
|
"email": user.get("email"),
|
||||||
'avatar': user.get('avatar'),
|
"avatar": user.get("avatar"),
|
||||||
'avatarUrl': user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])),
|
"avatarUrl": user.get("avatar")
|
||||||
'roles': user.get('roles', []),
|
and uploads.get_presigned_url(
|
||||||
'groups': user.get('groups', []),
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
'subscriptions': user.get('subscriptions'),
|
),
|
||||||
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
|
"roles": user.get("roles", []),
|
||||||
'isSilverSupporter': user.get('isSilverSupporter'),
|
"groups": user.get("groups", []),
|
||||||
'isGoldSupporter': user.get('isGoldSupporter'),
|
"subscriptions": user.get("subscriptions"),
|
||||||
|
"finishedTours": user.get("completedTours", []) + user.get("skippedTours", []),
|
||||||
|
"isSilverSupporter": user.get("isSilverSupporter"),
|
||||||
|
"isGoldSupporter": user.get("isGoldSupporter"),
|
||||||
|
"followerCount": db.users.count_documents({"following.user": user["_id"]}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get(user, username):
|
def get(user, username):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
fetch_user = db.users.find_one(
|
||||||
|
{"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:
|
if not fetch_user:
|
||||||
raise util.errors.NotFound('User not found')
|
raise util.errors.NotFound("User not found")
|
||||||
project_query = {'user': fetch_user['_id']}
|
project_query = {"user": fetch_user["_id"]}
|
||||||
if not user or not user['_id'] == fetch_user['_id']:
|
if not user or not user["_id"] == fetch_user["_id"]:
|
||||||
project_query['visibility'] = 'public'
|
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
|
return fetch_user
|
||||||
|
|
||||||
|
|
||||||
def update(user, username, data):
|
def update(user, username, data):
|
||||||
if not data: raise util.errors.BadRequest('Invalid request')
|
if not data:
|
||||||
|
raise util.errors.BadRequest("Invalid request")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user['username'] != username:
|
if user["username"] != username:
|
||||||
raise util.errors.Forbidden('Not allowed')
|
raise util.errors.Forbidden("Not allowed")
|
||||||
allowed_keys = ['username', 'avatar', 'bio', 'location', 'website', 'twitter', 'facebook', 'linkedIn', 'instagram']
|
allowed_keys = [
|
||||||
if 'username' in data:
|
"username",
|
||||||
if not data.get('username') or len(data['username']) < 3:
|
"avatar",
|
||||||
raise util.errors.BadRequest('New username is not valid')
|
"bio",
|
||||||
if db.users.find({'username': data['username'].lower()}).count():
|
"location",
|
||||||
raise util.errors.BadRequest('A user with this username already exists')
|
"website",
|
||||||
data['username'] = data['username'].lower()
|
"twitter",
|
||||||
if 'avatar' in data and len(data['avatar']) > 3: # Not a default avatar
|
"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):
|
def handle_cb(h):
|
||||||
db.users.update_one({'_id': user['_id']}, {'$set': {'avatarBlurHash': h}})
|
db.users.update_one({"_id": user["_id"]}, {"$set": {"avatarBlurHash": h}})
|
||||||
uploads.blur_image('users/' + str(user['_id']) + '/' + data['avatar'], handle_cb)
|
|
||||||
|
uploads.blur_image(
|
||||||
|
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
|
||||||
|
)
|
||||||
updater = util.build_updater(data, allowed_keys)
|
updater = util.build_updater(data, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
db.users.update({'username': username}, updater)
|
if "avatar" in updater.get(
|
||||||
return get(user, data.get('username', username))
|
"$unset", {}
|
||||||
|
): # Also unset blurhash if removing avatar
|
||||||
|
updater["$unset"]["avatarBlurHash"] = ""
|
||||||
|
if "$set" in updater and (
|
||||||
|
"avatar" in data or "bio" in data or "website" in data or "username" in data
|
||||||
|
):
|
||||||
|
updater["$set"]["moderationRequired"] = True
|
||||||
|
util.send_moderation_request(user, "users", user)
|
||||||
|
db.users.update_one({"username": username}, updater)
|
||||||
|
return get(user, data.get("username", username))
|
||||||
|
|
||||||
|
|
||||||
def finish_tour(user, username, tour, status):
|
def finish_tour(user, username, tour, status):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user['username'] != username:
|
if user["username"] != username:
|
||||||
raise util.errors.Forbidden('Not allowed')
|
raise util.errors.Forbidden("Not allowed")
|
||||||
key = 'completedTours' if status == 'completed' else 'skippedTours'
|
key = "completedTours" if status == "completed" else "skippedTours"
|
||||||
db.users.update_one({'_id': user['_id']}, {'$addToSet': {key: tour}})
|
db.users.update_one({"_id": user["_id"]}, {"$addToSet": {key: tour}})
|
||||||
return {'finishedTour': tour}
|
return {"finishedTour": tour}
|
||||||
|
|
||||||
|
|
||||||
def get_projects(user, id):
|
def get_projects(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
u = db.users.find_one(id, {'username': 1, 'avatar': 1})
|
u = db.users.find_one(id, {"username": 1, "avatar": 1})
|
||||||
if not u: raise util.errors.NotFound('User not found')
|
if not u:
|
||||||
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
raise util.errors.NotFound("User not found")
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||||
|
)
|
||||||
projects = []
|
projects = []
|
||||||
for project in db.projects.find({'user': ObjectId(id)}):
|
project_query = {"user": ObjectId(id)}
|
||||||
project['owner'] = u
|
if not user or not user["_id"] == ObjectId(id):
|
||||||
project['fullName'] = u['username'] + '/' + project['path']
|
project_query["visibility"] = "public"
|
||||||
|
for project in db.projects.find(project_query):
|
||||||
|
project["owner"] = u
|
||||||
|
project["fullName"] = u["username"] + "/" + project["path"]
|
||||||
projects.append(project)
|
projects.append(project)
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
|
|
||||||
def create_email_subscription(user, username, subscription):
|
def create_email_subscription(user, username, subscription):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
if user["username"] != username:
|
||||||
u = db.users.find_one({'username': username})
|
raise util.errors.Forbidden("Forbidden")
|
||||||
db.users.update({'_id': u['_id']}, {'$addToSet': {'subscriptions.email': subscription}})
|
u = db.users.find_one({"username": username})
|
||||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
db.users.update_one(
|
||||||
return {'subscriptions': subs.get('subscriptions', {})}
|
{"_id": u["_id"]}, {"$addToSet": {"subscriptions.email": subscription}}
|
||||||
|
)
|
||||||
|
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||||
|
return {"subscriptions": subs.get("subscriptions", {})}
|
||||||
|
|
||||||
|
|
||||||
def delete_email_subscription(user, username, subscription):
|
def delete_email_subscription(user, username, subscription):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
if user["username"] != username:
|
||||||
u = db.users.find_one({'username': username})
|
raise util.errors.Forbidden("Forbidden")
|
||||||
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
u = db.users.find_one({"username": username})
|
||||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
db.users.update_one(
|
||||||
return {'subscriptions': subs.get('subscriptions', {})}
|
{"_id": u["_id"]}, {"$pull": {"subscriptions.email": subscription}}
|
||||||
|
)
|
||||||
|
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
||||||
|
return {"subscriptions": subs.get("subscriptions", {})}
|
||||||
|
|
||||||
|
|
||||||
|
def create_follower(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
target_user = db.users.find_one({"username": username.lower()})
|
||||||
|
if not target_user:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
if target_user["_id"] == user["_id"]:
|
||||||
|
raise util.errors.BadRequest("Cannot follow yourself")
|
||||||
|
follow_object = {
|
||||||
|
"user": target_user["_id"],
|
||||||
|
"followedAt": datetime.datetime.utcnow(),
|
||||||
|
}
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$addToSet": {"following": follow_object}}
|
||||||
|
)
|
||||||
|
return follow_object
|
||||||
|
|
||||||
|
|
||||||
|
def delete_follower(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
target_user = db.users.find_one({"username": username.lower()})
|
||||||
|
if not target_user:
|
||||||
|
raise util.errors.NotFound("User not found")
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]}, {"$pull": {"following": {"user": target_user["_id"]}}}
|
||||||
|
)
|
||||||
|
return {"unfollowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed(user, username):
|
||||||
|
db = database.get_db()
|
||||||
|
if user["username"] != username:
|
||||||
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
following_user_ids = list(map(lambda f: f["user"], user.get("following", [])))
|
||||||
|
following_project_ids = list(
|
||||||
|
map(
|
||||||
|
lambda p: p["_id"],
|
||||||
|
db.projects.find(
|
||||||
|
{"user": {"$in": following_user_ids}, "visibility": "public"},
|
||||||
|
{"_id": 1},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days=365)
|
||||||
|
|
||||||
|
# Fetch the items for the feed
|
||||||
|
recent_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{
|
||||||
|
"_id": {"$in": following_project_ids},
|
||||||
|
"createdAt": {"$gt": one_year_ago},
|
||||||
|
"visibility": "public",
|
||||||
|
},
|
||||||
|
{"user": 1, "createdAt": 1, "name": 1, "path": 1, "visibility": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
recent_objects = list(
|
||||||
|
db.objects.find(
|
||||||
|
{
|
||||||
|
"project": {"$in": following_project_ids},
|
||||||
|
"createdAt": {"$gt": one_year_ago},
|
||||||
|
},
|
||||||
|
{"project": 1, "createdAt": 1, "name": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
recent_comments = list(
|
||||||
|
db.comments.find(
|
||||||
|
{"user": {"$in": following_user_ids}, "createdAt": {"$gt": one_year_ago}},
|
||||||
|
{"user": 1, "createdAt": 1, "object": 1, "content": 1},
|
||||||
|
)
|
||||||
|
.sort("createdAt", -1)
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process objects (as don't know the user)
|
||||||
|
object_project_ids = list(map(lambda o: o["project"], recent_objects))
|
||||||
|
object_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{"_id": {"$in": object_project_ids}, "visibility": "public"}, {"user": 1}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for obj in recent_objects:
|
||||||
|
for proj in object_projects:
|
||||||
|
if obj["project"] == proj["_id"]:
|
||||||
|
obj["user"] = proj.get("user")
|
||||||
|
|
||||||
|
# Process comments as don't know the project
|
||||||
|
comment_object_ids = list(map(lambda c: c["object"], recent_comments))
|
||||||
|
comment_objects = list(
|
||||||
|
db.objects.find({"_id": {"$in": comment_object_ids}}, {"project": 1})
|
||||||
|
)
|
||||||
|
for com in recent_comments:
|
||||||
|
for obj in comment_objects:
|
||||||
|
if com["object"] == obj["_id"]:
|
||||||
|
com["project"] = obj.get("project")
|
||||||
|
|
||||||
|
# Prepare the feed items, and sort it
|
||||||
|
feed_items = []
|
||||||
|
for p in recent_projects:
|
||||||
|
p["feedType"] = "project"
|
||||||
|
feed_items.append(p)
|
||||||
|
for o in recent_objects:
|
||||||
|
o["feedType"] = "object"
|
||||||
|
feed_items.append(o)
|
||||||
|
for c in recent_comments:
|
||||||
|
c["feedType"] = "comment"
|
||||||
|
feed_items.append(c)
|
||||||
|
feed_items.sort(key=lambda d: d["createdAt"], reverse=True)
|
||||||
|
feed_items = feed_items[:20]
|
||||||
|
|
||||||
|
# Post-process the feed, adding user/project objects
|
||||||
|
feed_user_ids = set()
|
||||||
|
feed_project_ids = set()
|
||||||
|
for f in feed_items:
|
||||||
|
feed_user_ids.add(f.get("user"))
|
||||||
|
feed_project_ids.add(f.get("project"))
|
||||||
|
feed_projects = list(
|
||||||
|
db.projects.find(
|
||||||
|
{"_id": {"$in": list(feed_project_ids)}, "visibility": "public"},
|
||||||
|
{"name": 1, "path": 1, "user": 1, "visibility": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feed_users = list(
|
||||||
|
db.users.find(
|
||||||
|
{
|
||||||
|
"$or": [
|
||||||
|
{"_id": {"$in": list(feed_user_ids)}},
|
||||||
|
{"_id": {"$in": list(map(lambda p: p["user"], feed_projects))}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for u in feed_users:
|
||||||
|
if "avatar" in u:
|
||||||
|
u["avatarUrl"] = uploads.get_presigned_url(
|
||||||
|
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
||||||
|
)
|
||||||
|
feed_user_map = {}
|
||||||
|
feed_project_map = {}
|
||||||
|
for u in feed_users:
|
||||||
|
feed_user_map[str(u["_id"])] = u
|
||||||
|
for p in feed_projects:
|
||||||
|
feed_project_map[str(p["_id"])] = p
|
||||||
|
for f in feed_items:
|
||||||
|
if f.get("user") and feed_user_map.get(str(f["user"])):
|
||||||
|
f["userObject"] = feed_user_map.get(str(f["user"]))
|
||||||
|
if f.get("project") and feed_project_map.get(str(f["project"])):
|
||||||
|
f["projectObject"] = feed_project_map.get(str(f["project"]))
|
||||||
|
if f.get("projectObject", {}).get("user") and feed_user_map.get(
|
||||||
|
str(f["projectObject"]["user"])
|
||||||
|
):
|
||||||
|
f["projectObject"]["userObject"] = feed_user_map.get(
|
||||||
|
str(f["projectObject"]["user"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter out orphaned or non-public comments/objects
|
||||||
|
def filter_func(f):
|
||||||
|
if f["feedType"] == "comment" and not f.get("projectObject"):
|
||||||
|
return False
|
||||||
|
if f["feedType"] == "object" and not f.get("projectObject"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
feed_items = list(filter(filter_func, feed_items))
|
||||||
|
|
||||||
|
return {"feed": feed_items}
|
||||||
|
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"
|
"s3:GetObject"
|
||||||
],
|
],
|
||||||
"Resource": [
|
"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]
|
[tool.poetry]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
package-mode = false
|
||||||
description = "Treadl API"
|
description = "Treadl API"
|
||||||
authors = ["Will <will@treadl.com>"]
|
authors = ["Will <will@treadl.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.12"
|
||||||
flask = "^1.1.1"
|
flask = "^3.0.3"
|
||||||
bcrypt = "^3.1.7"
|
bcrypt = "^4.2.0"
|
||||||
pyjwt = "^1.7.1"
|
pyjwt = "^2.9.0"
|
||||||
boto3 = "^1.10.50"
|
boto3 = "^1.35.34"
|
||||||
flask-cors = "^3.0.8"
|
flask-cors = "^5.0.0"
|
||||||
dnspython = "^1.16.0"
|
dnspython = "^2.6.1"
|
||||||
requests = "^2.22.0"
|
requests = "^2.32.3"
|
||||||
botocore = "^1.13.50"
|
pymongo = "^4.10.1"
|
||||||
pymongo = "^3.10.1"
|
flask_limiter = "^3.8.0"
|
||||||
flask_limiter = "^1.3.1"
|
firebase-admin = "^6.5.0"
|
||||||
werkzeug = "^1.0.1"
|
blurhash-python = "^1.2.2"
|
||||||
firebase-admin = "^4.3.0"
|
gunicorn = "^23.0.0"
|
||||||
blurhash-python = "^1.0.2"
|
sentry-sdk = {extras = ["flask"], version = "^2.15.0"}
|
||||||
gunicorn = "^20.0.4"
|
pyOpenSSL = "^24.2.1"
|
||||||
sentry-sdk = {extras = ["flask"], version = "^1.5.10"}
|
webargs = "^8.6.0"
|
||||||
pyOpenSSL = "^22.0.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
ruff = "^0.6.9"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry>=0.12"]
|
requires = ["poetry>=0.12"]
|
||||||
build-backend = "poetry.masonry.api"
|
build-backend = "poetry.masonry.api"
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
from flask import g
|
|
||||||
|
|
||||||
db = None
|
db = None
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
global db
|
global db
|
||||||
|
|
||||||
if db is None:
|
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
|
return db
|
||||||
|
@ -2,33 +2,39 @@ import os
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
def handle_send(data):
|
|
||||||
if 'from' not in data:
|
|
||||||
data['from'] = '{} <{}>'.format(os.environ.get('APP_NAME'), os.environ.get('FROM_EMAIL'))
|
|
||||||
if 'to_user' in data:
|
|
||||||
user = data['to_user']
|
|
||||||
data['to'] = user['username'] + ' <' + user['email'] + '>'
|
|
||||||
del data['to_user']
|
|
||||||
data['text'] += '\n\nFrom the team at {0}\n\n\n\n--\n\nDon\'t like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}'.format(
|
|
||||||
os.environ.get('APP_NAME'),
|
|
||||||
os.environ.get('APP_URL'),
|
|
||||||
os.environ.get('CONTACT_EMAIL')
|
|
||||||
)
|
|
||||||
data['reply-to'] = os.environ.get('CONTACT_EMAIL')
|
|
||||||
|
|
||||||
base_url = os.environ.get('MAILGUN_URL')
|
def handle_send(data):
|
||||||
api_key = os.environ.get('MAILGUN_KEY')
|
if "from" not in data:
|
||||||
|
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:
|
if base_url and api_key:
|
||||||
auth = ('api', api_key)
|
auth = ("api", api_key)
|
||||||
try:
|
try:
|
||||||
response = requests.post(base_url, auth=auth, data=data)
|
response = requests.post(base_url, auth=auth, data=data)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except:
|
except Exception:
|
||||||
print('Unable to send email')
|
print("Unable to send email")
|
||||||
else:
|
else:
|
||||||
print('Not sending email. Message pasted below.')
|
print("Not sending email. Message pasted below.")
|
||||||
print(data)
|
print(data)
|
||||||
|
|
||||||
|
|
||||||
def send(data):
|
def send(data):
|
||||||
thr = Thread(target=handle_send, args=[data])
|
thr = Thread(target=handle_send, args=[data])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
@ -4,52 +4,63 @@ from firebase_admin import messaging
|
|||||||
|
|
||||||
default_app = firebase_admin.initialize_app()
|
default_app = firebase_admin.initialize_app()
|
||||||
|
|
||||||
def handle_send_multiple(users, title, body, extra = {}):
|
|
||||||
|
def handle_send_multiple(users, title, body, extra={}):
|
||||||
tokens = []
|
tokens = []
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.get('pushToken'): tokens.append(user['pushToken'])
|
if user.get("pushToken"):
|
||||||
if not tokens: return
|
tokens.append(user["pushToken"])
|
||||||
|
if not tokens:
|
||||||
|
return
|
||||||
|
|
||||||
# Create a list containing up to 500 messages.
|
# 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),
|
notification=messaging.Notification(title, body),
|
||||||
apns=messaging.APNSConfig(
|
apns=messaging.APNSConfig(
|
||||||
payload=messaging.APNSPayload(
|
payload=messaging.APNSPayload(
|
||||||
aps=messaging.Aps(badge=1, sound='default'),
|
aps=messaging.Aps(badge=1, sound="default"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
token=t,
|
token=t,
|
||||||
data=extra,
|
data=extra,
|
||||||
), tokens))
|
),
|
||||||
|
tokens,
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = messaging.send_all(messages)
|
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:
|
except Exception as e:
|
||||||
print('Error sending notification', str(e))
|
print("Error sending notification", str(e))
|
||||||
|
|
||||||
def send_multiple(users, title, body, extra = {}):
|
|
||||||
|
def send_multiple(users, title, body, extra={}):
|
||||||
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
|
||||||
def send_single(user, title, body, extra = {}):
|
|
||||||
token = user.get('pushToken')
|
def send_single(user, title, body, extra={}):
|
||||||
if not token: return
|
token = user.get("pushToken")
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
message = messaging.Message(
|
message = messaging.Message(
|
||||||
notification=messaging.Notification(
|
notification=messaging.Notification(
|
||||||
title = title,
|
title=title,
|
||||||
body = body,
|
body=body,
|
||||||
),
|
),
|
||||||
apns=messaging.APNSConfig(
|
apns=messaging.APNSConfig(
|
||||||
payload=messaging.APNSPayload(
|
payload=messaging.APNSPayload(
|
||||||
aps=messaging.Aps(badge=1, sound='default'),
|
aps=messaging.Aps(badge=1, sound="default"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data = extra,
|
data=extra,
|
||||||
token = token,
|
token=token,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = messaging.send(message)
|
response = messaging.send(message)
|
||||||
# Response is a message ID string.
|
# Response is a message ID string.
|
||||||
print('Successfully sent message:', response)
|
print("Successfully sent message:", response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print('Error sending notification', str(e))
|
print("Error sending notification", str(e))
|
||||||
|
123
api/util/util.py
123
api/util/util.py
@ -1,4 +1,6 @@
|
|||||||
import json, datetime
|
import os
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
from flask import request, Response
|
from flask import request, Response
|
||||||
import werkzeug
|
import werkzeug
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
@ -6,43 +8,65 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from api import accounts
|
from api import accounts
|
||||||
from util import util
|
from util import util, mail
|
||||||
|
|
||||||
errors = werkzeug.exceptions
|
errors = werkzeug.exceptions
|
||||||
|
|
||||||
def get_user(required = True):
|
|
||||||
|
def get_user(required=True):
|
||||||
headers = request.headers
|
headers = request.headers
|
||||||
if not headers.get('Authorization') and required:
|
if not headers.get("Authorization") and required:
|
||||||
raise util.errors.Unauthorized('This resource requires authentication')
|
raise util.errors.Unauthorized("This resource requires authentication")
|
||||||
if headers.get('Authorization'):
|
if headers.get("Authorization"):
|
||||||
user = accounts.get_user_context(headers.get('Authorization').replace('Bearer ', ''))
|
user = accounts.get_user_context(
|
||||||
|
headers.get("Authorization").replace("Bearer ", "")
|
||||||
|
)
|
||||||
if user is None and required:
|
if user is None and required:
|
||||||
raise util.errors.Unauthorized('Invalid token')
|
raise util.errors.Unauthorized("Invalid token")
|
||||||
return user
|
return user
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def limit_by_client():
|
def limit_by_client():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if data:
|
if data:
|
||||||
if data.get('email'): return data.get('email')
|
if data.get("email"):
|
||||||
if data.get('token'): return data.get('token')
|
return data.get("email")
|
||||||
|
if data.get("token"):
|
||||||
|
return data.get("token")
|
||||||
return get_remote_address()
|
return get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
def limit_by_user():
|
def limit_by_user():
|
||||||
user = util.get_user(required = False)
|
user = util.get_user(required=False)
|
||||||
return user['_id'] if user else get_remote_address()
|
return user["_id"] if user else get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
|
def is_root(user):
|
||||||
|
return user and "root" in user.get("roles", [])
|
||||||
|
|
||||||
|
|
||||||
def can_view_project(user, project):
|
def can_view_project(user, project):
|
||||||
if not project: return False
|
if not project:
|
||||||
if project.get('visibility') == 'public':
|
|
||||||
return 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
|
|
||||||
return False
|
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):
|
def filter_keys(obj, allowed_keys):
|
||||||
filtered = {}
|
filtered = {}
|
||||||
@ -51,35 +75,67 @@ def filter_keys(obj, allowed_keys):
|
|||||||
filtered[key] = obj[key]
|
filtered[key] = obj[key]
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def build_updater(obj, allowed_keys):
|
def build_updater(obj, allowed_keys):
|
||||||
if not obj: return {}
|
if not obj:
|
||||||
|
return {}
|
||||||
allowed = filter_keys(obj, allowed_keys)
|
allowed = filter_keys(obj, allowed_keys)
|
||||||
updater = {}
|
updater = {}
|
||||||
for key in allowed:
|
for key in allowed:
|
||||||
if not allowed[key]:
|
if not allowed[key]:
|
||||||
if '$unset' not in updater: updater['$unset'] = {}
|
if "$unset" not in updater:
|
||||||
updater['$unset'][key] = ''
|
updater["$unset"] = {}
|
||||||
|
updater["$unset"][key] = ""
|
||||||
else:
|
else:
|
||||||
if '$set' not in updater: updater['$set'] = {}
|
if "$set" not in updater:
|
||||||
updater['$set'][key] = allowed[key]
|
updater["$set"] = {}
|
||||||
|
updater["$set"][key] = allowed[key]
|
||||||
return updater
|
return updater
|
||||||
|
|
||||||
def generate_rsa_keypair():
|
|
||||||
private_key = rsa.generate_private_key(
|
def send_report_email(report):
|
||||||
public_exponent=65537,
|
if not report:
|
||||||
key_size=4096
|
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(
|
private_pem = private_key.private_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
encryption_algorithm=serialization.NoEncryption()
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
)
|
)
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
public_pem = public_key.public_bytes(
|
public_pem = public_key.public_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
)
|
)
|
||||||
return private_pem, public_pem
|
return private_pem, public_pem
|
||||||
|
|
||||||
|
|
||||||
class MongoJsonEncoder(json.JSONEncoder):
|
class MongoJsonEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, (datetime.datetime, datetime.date)):
|
if isinstance(obj, (datetime.datetime, datetime.date)):
|
||||||
@ -88,8 +144,9 @@ class MongoJsonEncoder(json.JSONEncoder):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
def jsonify(*args, **kwargs):
|
def jsonify(*args, **kwargs):
|
||||||
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
||||||
resp = Response(resp_data)
|
resp = Response(resp_data)
|
||||||
resp.headers['Content-Type'] = 'application/json'
|
resp.headers["Content-Type"] = "application/json"
|
||||||
return resp
|
return resp
|
||||||
|
660
api/util/wif.py
660
api/util/wif.py
@ -1,197 +1,585 @@
|
|||||||
|
import io
|
||||||
import configparser
|
import configparser
|
||||||
|
import time
|
||||||
|
from threading import Thread
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
from api import uploads
|
||||||
|
from util import database
|
||||||
|
|
||||||
|
|
||||||
def normalise_colour(max_color, triplet):
|
def normalise_colour(max_color, triplet):
|
||||||
color_factor = 256/max_color
|
color_factor = 256 / max_color
|
||||||
components = triplet.split(',')
|
components = triplet.split(",")
|
||||||
new_components = []
|
new_components = []
|
||||||
for component in components:
|
for component in components:
|
||||||
new_components.append(str(int(float(color_factor) * int(component))))
|
new_components.append(str(int(float(color_factor) * int(float(component)))))
|
||||||
return ','.join(new_components)
|
return ",".join(new_components)
|
||||||
|
|
||||||
|
|
||||||
def denormalise_colour(max_color, triplet):
|
def denormalise_colour(max_color, triplet):
|
||||||
color_factor = max_color/256
|
color_factor = max_color / 256
|
||||||
components = triplet.split(',')
|
components = triplet.split(",")
|
||||||
new_components = []
|
new_components = []
|
||||||
for component in components:
|
for component in components:
|
||||||
new_components.append(str(int(float(color_factor) * int(component))))
|
new_components.append(str(int(float(color_factor) * int(component))))
|
||||||
return ','.join(new_components)
|
return ",".join(new_components)
|
||||||
|
|
||||||
|
|
||||||
|
def colour_tuple(triplet):
|
||||||
|
if not triplet:
|
||||||
|
return None
|
||||||
|
components = triplet.split(",")
|
||||||
|
return tuple(map(lambda c: int(c), components))
|
||||||
|
|
||||||
|
|
||||||
|
def darken_colour(c_tuple, val):
|
||||||
|
def darken(c):
|
||||||
|
c = c * val
|
||||||
|
if c < 0:
|
||||||
|
c = 0
|
||||||
|
if c > 255:
|
||||||
|
c = 255
|
||||||
|
return int(c)
|
||||||
|
|
||||||
|
return tuple(map(darken, c_tuple))
|
||||||
|
|
||||||
|
|
||||||
def get_colour_index(colours, colour):
|
def get_colour_index(colours, colour):
|
||||||
for (index, c) in enumerate(colours):
|
for index, c in enumerate(colours):
|
||||||
if c == colour: return index + 1
|
if c == colour:
|
||||||
|
return index + 1
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def dumps(obj):
|
def dumps(obj):
|
||||||
if not obj or not obj['pattern']: raise Exception('Invalid pattern')
|
if not obj or not obj["pattern"]:
|
||||||
|
raise Exception("Invalid pattern")
|
||||||
wif = []
|
wif = []
|
||||||
|
|
||||||
wif.append('[WIF]')
|
wif.append("[WIF]")
|
||||||
wif.append('Version=1.1')
|
wif.append("Version=1.1")
|
||||||
wif.append('Source Program=Treadl')
|
wif.append("Source Program=Treadl")
|
||||||
wif.append('Source Version=1')
|
wif.append("Source Version=1")
|
||||||
|
|
||||||
wif.append('\n[CONTENTS]')
|
wif.append("\n[CONTENTS]")
|
||||||
wif.append('COLOR PALETTE=true')
|
wif.append("COLOR PALETTE=true")
|
||||||
wif.append('TEXT=true')
|
wif.append("TEXT=true")
|
||||||
wif.append('WEAVING=true')
|
wif.append("WEAVING=true")
|
||||||
wif.append('WARP=true')
|
wif.append("WARP=true")
|
||||||
wif.append('WARP COLORS=true')
|
wif.append("WARP COLORS=true")
|
||||||
wif.append('WEFT COLORS=true')
|
wif.append("WEFT COLORS=true")
|
||||||
wif.append('WEFT=true')
|
wif.append("WEFT=true")
|
||||||
wif.append('COLOR TABLE=true')
|
wif.append("COLOR TABLE=true")
|
||||||
wif.append('THREADING=true')
|
wif.append("THREADING=true")
|
||||||
wif.append('TIEUP=true')
|
wif.append("TIEUP=true")
|
||||||
wif.append('TREADLING=true')
|
wif.append("TREADLING=true")
|
||||||
|
|
||||||
wif.append('\n[TEXT]')
|
wif.append("\n[TEXT]")
|
||||||
wif.append('Title={0}'.format(obj['name']))
|
wif.append("Title={0}".format(obj["name"]))
|
||||||
|
|
||||||
wif.append('\n[COLOR TABLE]')
|
wif.append("\n[COLOR TABLE]")
|
||||||
for (index, colour) in enumerate(obj['pattern']['colours']):
|
for index, colour in enumerate(obj["pattern"]["colours"]):
|
||||||
wif.append('{0}={1}'.format(index + 1, denormalise_colour(999, colour)))
|
wif.append("{0}={1}".format(index + 1, denormalise_colour(999, colour)))
|
||||||
|
|
||||||
wif.append('\n[COLOR PALETTE]')
|
wif.append("\n[COLOR PALETTE]")
|
||||||
wif.append('Range=0,999')
|
wif.append("Range=0,999")
|
||||||
wif.append('Entries={0}'.format(len(obj['pattern']['colours'])))
|
wif.append("Entries={0}".format(len(obj["pattern"]["colours"])))
|
||||||
|
|
||||||
wif.append('\n[WEAVING]')
|
wif.append("\n[WEAVING]")
|
||||||
wif.append('Rising Shed=true')
|
wif.append("Rising Shed=true")
|
||||||
wif.append('Treadles={0}'.format(obj['pattern']['weft']['treadles']))
|
wif.append("Treadles={0}".format(obj["pattern"]["weft"]["treadles"]))
|
||||||
wif.append('Shafts={0}'.format(obj['pattern']['warp']['shafts']))
|
wif.append("Shafts={0}".format(obj["pattern"]["warp"]["shafts"]))
|
||||||
|
|
||||||
wif.append('\n[WARP]')
|
wif.append("\n[WARP]")
|
||||||
wif.append('Units=centimeters')
|
wif.append("Units=centimeters")
|
||||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['warp']['defaultColour'])))
|
wif.append(
|
||||||
wif.append('Threads={0}'.format(obj['pattern']['warp']['threads']))
|
"Color={0}".format(
|
||||||
wif.append('Spacing=0.212')
|
get_colour_index(
|
||||||
wif.append('Thickness=0.212')
|
obj["pattern"]["colours"], obj["pattern"]["warp"]["defaultColour"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wif.append("Threads={0}".format(len(obj["pattern"]["warp"]["threading"])))
|
||||||
|
wif.append("Spacing=0.212")
|
||||||
|
wif.append("Thickness=0.212")
|
||||||
|
|
||||||
wif.append('\n[WARP COLORS]')
|
wif.append("\n[WARP COLORS]")
|
||||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||||
if 'colour' in thread:
|
if "colour" in thread:
|
||||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
wif.append(
|
||||||
|
"{0}={1}".format(
|
||||||
|
index + 1,
|
||||||
|
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
wif.append('\n[THREADING]')
|
wif.append("\n[THREADING]")
|
||||||
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
||||||
wif.append('{0}={1}'.format(index + 1, thread['shaft']))
|
wif.append("{0}={1}".format(index + 1, thread["shaft"]))
|
||||||
|
|
||||||
wif.append('\n[WEFT]')
|
wif.append("\n[WEFT]")
|
||||||
wif.append('Units=centimeters')
|
wif.append("Units=centimeters")
|
||||||
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['weft']['defaultColour'])))
|
wif.append(
|
||||||
wif.append('Threads={0}'.format(obj['pattern']['weft']['threads']))
|
"Color={0}".format(
|
||||||
wif.append('Spacing=0.212')
|
get_colour_index(
|
||||||
wif.append('Thickness=0.212')
|
obj["pattern"]["colours"], obj["pattern"]["weft"]["defaultColour"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wif.append("Threads={0}".format(len(obj["pattern"]["weft"]["treadling"])))
|
||||||
|
wif.append("Spacing=0.212")
|
||||||
|
wif.append("Thickness=0.212")
|
||||||
|
|
||||||
wif.append('\n[WEFT COLORS]')
|
wif.append("\n[WEFT COLORS]")
|
||||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||||
if 'colour' in thread:
|
if "colour" in thread:
|
||||||
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
wif.append(
|
||||||
|
"{0}={1}".format(
|
||||||
|
index + 1,
|
||||||
|
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
wif.append('\n[TREADLING]')
|
wif.append("\n[TREADLING]")
|
||||||
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
||||||
wif.append('{0}={1}'.format(index + 1, thread['treadle']))
|
wif.append("{0}={1}".format(index + 1, thread["treadle"]))
|
||||||
|
|
||||||
wif.append('\n[TIEUP]')
|
wif.append("\n[TIEUP]")
|
||||||
for (index, tieup) in enumerate(obj['pattern']['tieups']):
|
for index, tieup in enumerate(obj["pattern"]["tieups"]):
|
||||||
wif.append('{0}={1}'.format(str(index + 1), ','.join(str(x) for x in tieup)))
|
wif.append("{0}={1}".format(str(index + 1), ",".join(str(x) for x in tieup)))
|
||||||
|
|
||||||
|
return "\n".join(wif)
|
||||||
|
|
||||||
return '\n'.join(wif)
|
|
||||||
|
|
||||||
def loads(wif_file):
|
def loads(wif_file):
|
||||||
config = configparser.ConfigParser(allow_no_value=True, strict=False)
|
# Ensure file exists:
|
||||||
config.read_string(wif_file.lower())
|
if not wif_file or type(wif_file) is not str:
|
||||||
|
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 = {}
|
draft = {}
|
||||||
|
|
||||||
text = config['text']
|
if "wif" in config:
|
||||||
draft['name'] = text.get('title')
|
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
|
max_color = 255
|
||||||
if 'color palette' in config:
|
if "color palette" in config:
|
||||||
color_palette = config['color palette']
|
color_palette = config["color palette"]
|
||||||
color_range = color_palette.get('range').split(',')
|
color_range = color_palette.get("range").split(",")
|
||||||
min_color = int(color_range[0])
|
|
||||||
max_color = int(color_range[1])
|
max_color = int(color_range[1])
|
||||||
|
|
||||||
if 'color table' in config:
|
if "color table" in config:
|
||||||
color_table = config['color table']
|
color_table = config["color table"]
|
||||||
draft['colours'] = [None]*len(color_table)
|
draft["colours"] = [None] * len(color_table)
|
||||||
for x in color_table:
|
for x in color_table:
|
||||||
draft['colours'][int(x)-1] = normalise_colour(max_color, color_table[x])
|
draft["colours"][int(x) - 1] = normalise_colour(max_color, color_table[x])
|
||||||
if not draft.get('colours'): draft['colours'] = []
|
if not draft.get("colours"):
|
||||||
if len(draft['colours']) < 2:
|
draft["colours"] = []
|
||||||
draft['colours'] += [normalise_colour(255, '255,255,255'), normalise_colour(255, '0,0,255')]
|
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']
|
threading = config["threading"] if "threading" in config else []
|
||||||
warp = config['warp']
|
warp = config["warp"] if "warp" in config else None
|
||||||
draft['warp'] = {}
|
draft["warp"] = {}
|
||||||
draft['warp']['shafts'] = weaving.getint('shafts')
|
draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
|
||||||
draft['warp']['threading'] = []
|
draft["warp"]["threading"] = []
|
||||||
|
|
||||||
|
# Work out default warp colour
|
||||||
if warp.get('color'):
|
if warp and warp.get("color"):
|
||||||
warp_colour_index = warp.getint('color') - 1
|
warp_colour_index = warp.getint("color") - 1
|
||||||
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index]
|
if warp_colour_index < len(draft["colours"]):
|
||||||
|
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
|
||||||
else:
|
if not draft.get("warp").get("defaultColour"):
|
||||||
# In case of no color table or colour index out of bounds
|
# In case of no color table or colour index out of bounds
|
||||||
draft['warp']['defaultColour'] = draft['colours'][0]
|
draft["warp"]["defaultColour"] = draft["colours"][0]
|
||||||
|
|
||||||
for x in threading:
|
for x in threading:
|
||||||
shaft = threading[x]
|
shaft = threading[x].strip()
|
||||||
if ',' in shaft:
|
if "," in shaft:
|
||||||
shaft = shaft.split(",")[0]
|
shaft = shaft.split(",")[0]
|
||||||
shaft = int(shaft)
|
shaft = int(shaft) if shaft else 0
|
||||||
while int(x) >= len(draft['warp']['threading']) - 1:
|
while int(x) >= len(draft["warp"]["threading"]) - 1:
|
||||||
draft['warp']['threading'].append({'shaft': 0})
|
draft["warp"]["threading"].append({"shaft": 0})
|
||||||
draft['warp']['threading'][int(x) - 1] = {'shaft': shaft}
|
draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
|
||||||
draft['warp']['threads'] = len(draft['warp']['threading'])
|
if shaft > draft["warp"]["shafts"]:
|
||||||
|
draft["warp"]["shafts"] = shaft
|
||||||
|
draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
|
||||||
try:
|
try:
|
||||||
warp_colours = config['warp colors']
|
warp_colours = config["warp colors"]
|
||||||
for x in warp_colours:
|
for x in warp_colours:
|
||||||
draft['warp']['threading'][int(x) - 1]['colour'] = draft['colours'][warp_colours.getint(x)-1]
|
draft["warp"]["threading"][int(x) - 1]["colour"] = draft["colours"][
|
||||||
except Exception as e:
|
warp_colours.getint(x) - 1
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if not draft["warp"]["threading"]: # Make a bunch of empty threads
|
||||||
|
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
|
||||||
|
|
||||||
treadling = config['treadling']
|
treadling = config["treadling"] if "treadling" in config else []
|
||||||
weft = config['weft']
|
weft = config["weft"] if "weft" in config else None
|
||||||
draft['weft'] = {}
|
draft["weft"] = {}
|
||||||
draft['weft']['treadles'] = weaving.getint('treadles')
|
draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
|
||||||
draft['weft']['treadling'] = []
|
draft["weft"]["treadling"] = []
|
||||||
|
|
||||||
if weft.get('color'):
|
# Work out default weft colour
|
||||||
weft_colour_index = weft.getint('color') - 1
|
if weft and weft.get("color"):
|
||||||
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index]
|
weft_colour_index = weft.getint("color") - 1
|
||||||
else:
|
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
|
# In case of no color table or colour index out of bounds
|
||||||
draft['weft']['defaultColour'] = draft['colours'][1]
|
draft["weft"]["defaultColour"] = draft["colours"][1]
|
||||||
|
|
||||||
for x in treadling:
|
for x in treadling:
|
||||||
shaft = treadling[x]
|
treadle = treadling[x].strip()
|
||||||
if ',' in shaft:
|
if "," in treadle:
|
||||||
shaft = shaft.split(",")[0]
|
treadle = treadle.split(",")[0]
|
||||||
shaft = int(shaft)
|
treadle = int(treadle) if treadle else 0
|
||||||
while int(x) >= len(draft['weft']['treadling']) - 1:
|
while int(x) >= len(draft["weft"]["treadling"]) - 1:
|
||||||
draft['weft']['treadling'].append({'treadle': 0})
|
draft["weft"]["treadling"].append({"treadle": 0})
|
||||||
draft['weft']['treadling'][int(x) - 1] = {'treadle': shaft}
|
draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
|
||||||
draft['weft']['threads'] = len(draft['weft']['treadling'])
|
if treadle > draft["weft"]["treadles"]:
|
||||||
|
draft["weft"]["treadles"] = treadle
|
||||||
|
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
|
||||||
try:
|
try:
|
||||||
weft_colours = config['weft colors']
|
weft_colours = config["weft colors"]
|
||||||
for x in weft_colours:
|
for x in weft_colours:
|
||||||
draft['weft']['treadling'][int(x) - 1]['colour'] = draft['colours'][weft_colours.getint(x)-1]
|
draft["weft"]["treadling"][int(x) - 1]["colour"] = draft["colours"][
|
||||||
except: pass
|
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']
|
tieup = config["tieup"] if "tieup" in config else None
|
||||||
draft['tieups'] = []#[0]*len(tieup)
|
draft["tieups"] = []
|
||||||
|
if tieup:
|
||||||
for x in tieup:
|
for x in tieup:
|
||||||
while int(x) >= len(draft['tieups']) - 1:
|
while int(x) >= len(draft["tieups"]) - 1:
|
||||||
draft['tieups'].append([])
|
draft["tieups"].append([])
|
||||||
split = tieup[x].split(',')
|
|
||||||
try:
|
try:
|
||||||
draft['tieups'][int(x)-1] = [int(i) for i in split]
|
split = tieup[x].split(",")
|
||||||
except:
|
draft["tieups"][int(x) - 1] = [int(i) for i in split]
|
||||||
draft['tieups'][int(x)-1] = []
|
except Exception:
|
||||||
|
draft["tieups"][int(x) - 1] = []
|
||||||
|
|
||||||
return draft
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f
|
revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
|
||||||
channel: stable
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: linux
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: macos
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
- platform: windows
|
||||||
|
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
|
@ -6,7 +6,7 @@ The source code for Treadl's iOS and Android application.
|
|||||||
|
|
||||||
The application is written in Dart using the Flutter framework.
|
The application is written in Dart using the Flutter framework.
|
||||||
|
|
||||||
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality.
|
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality and for adding images to projects when out and about.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
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 {
|
android {
|
||||||
compileSdkVersion 31
|
compileSdkVersion 33
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
@ -44,8 +44,8 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.treadl"
|
applicationId "com.treadl"
|
||||||
minSdkVersion 19
|
minSdkVersion 29
|
||||||
targetSdkVersion 31
|
targetSdkVersion 34
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,14 @@
|
|||||||
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="http" android:host="treadl.com" />
|
||||||
|
<data android:scheme="https" android:host="treadl.com" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
// Generated file.
|
// Generated file.
|
||||||
|
//
|
||||||
// If you wish to remove Flutter's multidex support, delete this entire file.
|
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||||
|
//
|
||||||
|
// Modifications to this file should be done in a copy under a different name
|
||||||
|
// as this file may be regenerated.
|
||||||
|
|
||||||
package io.flutter.app;
|
package io.flutter.app;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import androidx.annotation.CallSuper;
|
import androidx.annotation.CallSuper;
|
||||||
import androidx.multidex.MultiDex;
|
import androidx.multidex.MultiDex;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support.
|
* Extension of {@link android.app.Application}, adding multidex support.
|
||||||
*/
|
*/
|
||||||
public class FlutterMultiDexApplication extends FlutterApplication {
|
public class FlutterMultiDexApplication extends Application {
|
||||||
@Override
|
@Override
|
||||||
@CallSuper
|
@CallSuper
|
||||||
protected void attachBaseContext(Context base) {
|
protected void attachBaseContext(Context base) {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.6.10'
|
ext.kotlin_version = '1.8.20'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:3.5.0'
|
classpath 'com.android.tools.build:gradle:7.4.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.3'
|
classpath 'com.google.gms:google-services:4.3.3'
|
||||||
}
|
}
|
||||||
@ -27,6 +27,6 @@ subprojects {
|
|||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
#Fri Jun 23 08:50:38 CEST 2017
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
|
||||||
|
BIN
mobile/assets/login.png
Normal file
BIN
mobile/assets/login.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>9.0</string>
|
<string>11.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '9.0'
|
# platform :ios, '11.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
@ -1,153 +1,186 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Firebase/CoreOnly (8.11.0):
|
- DKImagePickerController/Core (4.3.4):
|
||||||
- FirebaseCore (= 8.11.0)
|
- DKImagePickerController/ImageDataManager
|
||||||
- Firebase/Messaging (8.11.0):
|
- DKImagePickerController/Resource
|
||||||
- Firebase/CoreOnly
|
- DKImagePickerController/ImageDataManager (4.3.4)
|
||||||
- FirebaseMessaging (~> 8.11.0)
|
- DKImagePickerController/PhotoGallery (4.3.4):
|
||||||
- firebase_core (1.13.1):
|
- DKImagePickerController/Core
|
||||||
- Firebase/CoreOnly (= 8.11.0)
|
- DKPhotoGallery
|
||||||
|
- DKImagePickerController/Resource (4.3.4)
|
||||||
|
- DKPhotoGallery (0.0.17):
|
||||||
|
- DKPhotoGallery/Core (= 0.0.17)
|
||||||
|
- DKPhotoGallery/Model (= 0.0.17)
|
||||||
|
- DKPhotoGallery/Preview (= 0.0.17)
|
||||||
|
- DKPhotoGallery/Resource (= 0.0.17)
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
- DKPhotoGallery/Core (0.0.17):
|
||||||
|
- DKPhotoGallery/Model
|
||||||
|
- DKPhotoGallery/Preview
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
- DKPhotoGallery/Model (0.0.17):
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
- DKPhotoGallery/Preview (0.0.17):
|
||||||
|
- DKPhotoGallery/Model
|
||||||
|
- DKPhotoGallery/Resource
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
- DKPhotoGallery/Resource (0.0.17):
|
||||||
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
- file_picker (0.0.1):
|
||||||
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_messaging (11.2.8):
|
- Firebase/CoreOnly (10.9.0):
|
||||||
- Firebase/Messaging (= 8.11.0)
|
- FirebaseCore (= 10.9.0)
|
||||||
|
- Firebase/Messaging (10.9.0):
|
||||||
|
- Firebase/CoreOnly
|
||||||
|
- FirebaseMessaging (~> 10.9.0)
|
||||||
|
- firebase_core (2.13.1):
|
||||||
|
- Firebase/CoreOnly (= 10.9.0)
|
||||||
|
- Flutter
|
||||||
|
- firebase_messaging (14.6.2):
|
||||||
|
- Firebase/Messaging (= 10.9.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
- FirebaseCore (8.11.0):
|
- FirebaseCore (10.9.0):
|
||||||
- FirebaseCoreDiagnostics (~> 8.0)
|
- FirebaseCoreInternal (~> 10.0)
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- GoogleUtilities/Environment (~> 7.8)
|
||||||
- GoogleUtilities/Logger (~> 7.7)
|
- GoogleUtilities/Logger (~> 7.8)
|
||||||
- FirebaseCoreDiagnostics (8.12.0):
|
- FirebaseCoreInternal (10.10.0):
|
||||||
- GoogleDataTransport (~> 9.1)
|
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- FirebaseInstallations (10.10.0):
|
||||||
- GoogleUtilities/Logger (~> 7.7)
|
- FirebaseCore (~> 10.0)
|
||||||
- nanopb (~> 2.30908.0)
|
- GoogleUtilities/Environment (~> 7.8)
|
||||||
- FirebaseInstallations (8.12.0):
|
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||||
- FirebaseCore (~> 8.0)
|
- PromisesObjC (~> 2.1)
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- FirebaseMessaging (10.9.0):
|
||||||
- GoogleUtilities/UserDefaults (~> 7.7)
|
- FirebaseCore (~> 10.0)
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- FirebaseInstallations (~> 10.0)
|
||||||
- FirebaseMessaging (8.11.0):
|
- GoogleDataTransport (~> 9.2)
|
||||||
- FirebaseCore (~> 8.0)
|
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
|
||||||
- FirebaseInstallations (~> 8.0)
|
- GoogleUtilities/Environment (~> 7.8)
|
||||||
- GoogleDataTransport (~> 9.1)
|
- GoogleUtilities/Reachability (~> 7.8)
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
|
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||||
- GoogleUtilities/Reachability (~> 7.7)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 7.7)
|
|
||||||
- nanopb (~> 2.30908.0)
|
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- fluttertoast (0.0.2):
|
- GoogleDataTransport (9.2.3):
|
||||||
- Flutter
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- Toast
|
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||||
- GoogleDataTransport (9.1.2):
|
|
||||||
- GoogleUtilities/Environment (~> 7.2)
|
|
||||||
- nanopb (~> 2.30908.0)
|
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
- GoogleUtilities/AppDelegateSwizzler (7.7.0):
|
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Network
|
- GoogleUtilities/Network
|
||||||
- GoogleUtilities/Environment (7.7.0):
|
- GoogleUtilities/Environment (7.11.1):
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
- GoogleUtilities/Logger (7.7.0):
|
- GoogleUtilities/Logger (7.11.1):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Network (7.7.0):
|
- GoogleUtilities/Network (7.11.1):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- "GoogleUtilities/NSData+zlib"
|
- "GoogleUtilities/NSData+zlib"
|
||||||
- GoogleUtilities/Reachability
|
- GoogleUtilities/Reachability
|
||||||
- "GoogleUtilities/NSData+zlib (7.7.0)"
|
- "GoogleUtilities/NSData+zlib (7.11.1)"
|
||||||
- GoogleUtilities/Reachability (7.7.0):
|
- GoogleUtilities/Reachability (7.11.1):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/UserDefaults (7.7.0):
|
- GoogleUtilities/UserDefaults (7.11.1):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- image_picker (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- nanopb (2.30908.0):
|
- nanopb (2.30909.0):
|
||||||
- nanopb/decode (= 2.30908.0)
|
- nanopb/decode (= 2.30909.0)
|
||||||
- nanopb/encode (= 2.30908.0)
|
- nanopb/encode (= 2.30909.0)
|
||||||
- nanopb/decode (2.30908.0)
|
- nanopb/decode (2.30909.0)
|
||||||
- nanopb/encode (2.30908.0)
|
- nanopb/encode (2.30909.0)
|
||||||
- PromisesObjC (2.0.0)
|
- path_provider_foundation (0.0.1):
|
||||||
- shared_preferences_ios (0.0.1):
|
|
||||||
- Flutter
|
- Flutter
|
||||||
- Toast (4.0.0)
|
- FlutterMacOS
|
||||||
|
- PromisesObjC (2.2.0)
|
||||||
|
- SDWebImage (5.18.8):
|
||||||
|
- SDWebImage/Core (= 5.18.8)
|
||||||
|
- SDWebImage/Core (5.18.8)
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- SwiftyGif (5.4.4)
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- video_player_avfoundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- wakelock (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- image_picker (from `.symlinks/plugins/image_picker/ios`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
|
||||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
|
- DKImagePickerController
|
||||||
|
- DKPhotoGallery
|
||||||
- Firebase
|
- Firebase
|
||||||
- FirebaseCore
|
- FirebaseCore
|
||||||
- FirebaseCoreDiagnostics
|
- FirebaseCoreInternal
|
||||||
- FirebaseInstallations
|
- FirebaseInstallations
|
||||||
- FirebaseMessaging
|
- FirebaseMessaging
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- nanopb
|
- nanopb
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- Toast
|
- SDWebImage
|
||||||
|
- SwiftyGif
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
file_picker:
|
||||||
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
:path: ".symlinks/plugins/firebase_core/ios"
|
:path: ".symlinks/plugins/firebase_core/ios"
|
||||||
firebase_messaging:
|
firebase_messaging:
|
||||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
fluttertoast:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
image_picker:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/image_picker/ios"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
shared_preferences_ios:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
video_player_avfoundation:
|
|
||||||
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
|
||||||
wakelock:
|
|
||||||
:path: ".symlinks/plugins/wakelock/ios"
|
|
||||||
webview_flutter_wkwebview:
|
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c
|
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||||
firebase_core: 08f6a85f62060111de5e98d6a214810d11365de9
|
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||||
firebase_messaging: 36238f3d0b933af8c919aef608408aae06ba22e8
|
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||||
FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0
|
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
|
||||||
FirebaseCoreDiagnostics: 3b40dfadef5b90433a60ae01f01e90fe87aa76aa
|
firebase_core: ce64b0941c6d87c6ef5022ae9116a158236c8c94
|
||||||
FirebaseInstallations: 25764cf322e77f99449395870a65b2bef88e1545
|
firebase_messaging: 42912365e62efc1ea3e00724e5eecba6068ddb88
|
||||||
FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a
|
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
|
||||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
|
||||||
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
FirebaseInstallations: 52153982b057d3afcb4e1fbb3eb0b6d00611e681
|
||||||
GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940
|
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
|
||||||
GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1
|
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||||
image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3
|
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
|
||||||
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
|
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
|
||||||
PromisesObjC: 68159ce6952d93e17b2dfe273b8c40907db5ba58
|
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
||||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
|
||||||
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
|
SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a
|
||||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||||
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
|
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
||||||
|
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||||
|
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||||
|
|
||||||
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
|
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
|
||||||
|
|
||||||
COCOAPODS: 1.10.1
|
COCOAPODS: 1.14.2
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 51;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@ -48,6 +48,7 @@
|
|||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
||||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
||||||
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
@ -116,6 +117,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
BE18F7F22B54707500363B2E /* Runner.entitlements */,
|
||||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
|
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
|
||||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
|
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@ -170,7 +172,7 @@
|
|||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1300;
|
LastUpgradeCheck = 1430;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
@ -232,10 +234,12 @@
|
|||||||
};
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
);
|
);
|
||||||
name = "Thin Binary";
|
name = "Thin Binary";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@ -246,6 +250,7 @@
|
|||||||
};
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
@ -370,6 +375,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 38T664W57F;
|
DEVELOPMENT_TEAM = 38T664W57F;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1300"
|
LastUpgradeVersion = "1430"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
@ -12,6 +14,8 @@
|
|||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Treadl</string>
|
<string>Treadl</string>
|
||||||
|
<key>FlutterDeepLinkingEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@ -46,5 +50,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
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>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:treadl.com</string>
|
||||||
|
<string>applinks:www.treadl.com</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -4,5 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:treadl.com</string>
|
||||||
|
<string>applinks:www.treadl.com</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -1,28 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'util.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
|
|
||||||
String _token;
|
String? _token;
|
||||||
final String apiBase = 'https://api.treadl.com';
|
final String apiBase = 'https://api.treadl.com';
|
||||||
//final String apiBase = 'http://localhost:2001';
|
//final String apiBase = 'http://192.168.5.134:2001';
|
||||||
|
|
||||||
Future<String> loadToken() async {
|
Api({token: null}) {
|
||||||
|
if (token != null) _token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> loadToken() async {
|
||||||
if (_token != null) {
|
if (_token != null) {
|
||||||
return _token;
|
return _token!;
|
||||||
}
|
}
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
final String token = prefs.getString('apiToken');
|
String? token = prefs.getString('apiToken');
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
Future<Map<String,String>> getHeaders(method) async {
|
Future<Map<String,String>> getHeaders(method) async {
|
||||||
Map<String,String> headers = {};
|
Map<String,String> headers = {};
|
||||||
String token = await loadToken();
|
String? token = await loadToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
headers['Authorization'] = 'Bearer ' + token!;
|
||||||
}
|
}
|
||||||
if (method == 'POST' || method == 'DELETE') {
|
if (method == 'POST' || method == 'DELETE') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
@ -34,17 +41,23 @@ class Api {
|
|||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.get(url, headers: await getHeaders('GET'));
|
return await client.get(url, headers: await getHeaders('GET'));
|
||||||
}
|
}
|
||||||
Future<http.Response> _post(Uri url, Map<String, dynamic> data) async {
|
Future<http.Response> _post(Uri url, Map<String, dynamic>? data) async {
|
||||||
String json = jsonEncode(data);
|
String? json = null;
|
||||||
|
if (data != null) {
|
||||||
|
json = jsonEncode(data!);
|
||||||
|
}
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.post(url, headers: await getHeaders('POST'), body: json);
|
return await client.post(url, headers: await getHeaders('POST'), body: json);
|
||||||
}
|
}
|
||||||
Future<http.Response> _put(Uri url, Map<String, dynamic> data) async {
|
Future<http.Response> _put(Uri url, Map<String, dynamic>? data) async {
|
||||||
String json = jsonEncode(data);
|
String? json = null;
|
||||||
|
if (data != null) {
|
||||||
|
json = jsonEncode(data!);
|
||||||
|
}
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.put(url, headers: await getHeaders('POST'), body: json);
|
return await client.put(url, headers: await getHeaders('POST'), body: json);
|
||||||
}
|
}
|
||||||
Future<http.Response> _delete(Uri url, [Map<String, dynamic> data]) async {
|
Future<http.Response> _delete(Uri url, [Map<String, dynamic>? data]) async {
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
String json = jsonEncode(data);
|
String json = jsonEncode(data);
|
||||||
@ -54,10 +67,10 @@ class Api {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic> data]) async {
|
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic>? data]) async {
|
||||||
String url = apiBase + path;
|
String url = apiBase + path;
|
||||||
Uri uri = Uri.parse(url);
|
Uri uri = Uri.parse(url);
|
||||||
http.Response response;
|
http.Response? response;
|
||||||
if (method == 'POST') {
|
if (method == 'POST') {
|
||||||
response = await _post(uri, data);
|
response = await _post(uri, data);
|
||||||
}
|
}
|
||||||
@ -70,16 +83,19 @@ class Api {
|
|||||||
if (method == 'DELETE') {
|
if (method == 'DELETE') {
|
||||||
response = await _delete(uri, data);
|
response = await _delete(uri, data);
|
||||||
}
|
}
|
||||||
int status = response.statusCode;
|
if (response == null) {
|
||||||
|
return {'success': false, 'message': 'No response for your request'};
|
||||||
|
}
|
||||||
|
int status = response!.statusCode;
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
print('SUCCESS');
|
print('SUCCESS');
|
||||||
Map<String, dynamic> respData = jsonDecode(response.body);
|
Map<String, dynamic> respData = jsonDecode(response!.body);
|
||||||
return {'success': true, 'payload': respData};
|
return {'success': true, 'payload': respData};
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
print('ERROR');
|
print('ERROR');
|
||||||
Map<String, dynamic> respData = jsonDecode(response.body);
|
Map<String, dynamic> respData = jsonDecode(response!.body);
|
||||||
return {'success': false, 'code': response.statusCode, 'message': respData['message']};
|
return {'success': false, 'code': status, 'message': respData['message']};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,4 +109,17 @@ class Api {
|
|||||||
int status = response.statusCode;
|
int status = response.statusCode;
|
||||||
return status == 200;
|
return status == 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<File?> downloadFile(String url, String fileName) async {
|
||||||
|
Uri uri = Uri.parse(url);
|
||||||
|
http.Client client = http.Client();
|
||||||
|
http.Response response = await client.get(uri);
|
||||||
|
if(response.statusCode == 200) {
|
||||||
|
final String dirPath = await Util.storagePath();
|
||||||
|
final file = File('$dirPath/$fileName');
|
||||||
|
await file.writeAsBytes(response.bodyBytes);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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';
|
import 'group_members.dart';
|
||||||
|
|
||||||
class _GroupScreenState extends State<GroupScreen> {
|
class _GroupScreenState extends State<GroupScreen> {
|
||||||
|
final String id;
|
||||||
|
Map<String, dynamic>? _group;
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
List<Widget> _widgetOptions = <Widget> [];
|
|
||||||
final Map<String, dynamic> _group;
|
|
||||||
|
|
||||||
_GroupScreenState(this._group) {
|
_GroupScreenState(this.id) { }
|
||||||
_widgetOptions = <Widget> [
|
|
||||||
GroupNoticeBoardTab(this._group),
|
@override
|
||||||
GroupMembersTab(this._group)
|
void initState() {
|
||||||
];
|
fetchGroup();
|
||||||
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onItemTapped(int index) {
|
void fetchGroup() async {
|
||||||
|
Api api = Api();
|
||||||
|
var data = await api.request('GET', '/groups/' + id);
|
||||||
|
if (data['success'] == true) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedIndex = index;
|
_group = data['payload'];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_group['name'])
|
title: Text(_group?['name'] ?? 'Group')
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: _widgetOptions.elementAt(_selectedIndex),
|
child: _group != null ?
|
||||||
|
[
|
||||||
|
GroupNoticeBoardTab(_group!),
|
||||||
|
GroupMembersTab(_group!)
|
||||||
|
].elementAt(_selectedIndex)
|
||||||
|
: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
items: const <BottomNavigationBarItem>[
|
items: const <BottomNavigationBarItem>[
|
||||||
@ -45,15 +55,17 @@ class _GroupScreenState extends State<GroupScreen> {
|
|||||||
],
|
],
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
selectedItemColor: Colors.pink[600],
|
selectedItemColor: Colors.pink[600],
|
||||||
onTap: _onItemTapped,
|
onTap: (int index) => setState(() {
|
||||||
|
_selectedIndex = index;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GroupScreen extends StatefulWidget {
|
class GroupScreen extends StatefulWidget {
|
||||||
final Map<String,dynamic> group;
|
final String id;
|
||||||
GroupScreen(this.group) { }
|
GroupScreen(this.id) { }
|
||||||
@override
|
@override
|
||||||
_GroupScreenState createState() => _GroupScreenState(group);
|
_GroupScreenState createState() => _GroupScreenState(id);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
import 'group_noticeboard.dart';
|
|
||||||
import 'user.dart';
|
import 'user.dart';
|
||||||
|
|
||||||
class _GroupMembersTabState extends State<GroupMembersTab> {
|
class _GroupMembersTabState extends State<GroupMembersTab> {
|
||||||
final Util util = new Util();
|
|
||||||
final Map<String,dynamic> _group;
|
final Map<String,dynamic> _group;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
List<dynamic> _members = [];
|
List<dynamic> _members = [];
|
||||||
@ -33,15 +32,8 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
|
|||||||
|
|
||||||
Widget getMemberCard(member) {
|
Widget getMemberCard(member) {
|
||||||
return new ListTile(
|
return new ListTile(
|
||||||
onTap: () {
|
onTap: () => context.push('/' + member['username']),
|
||||||
Navigator.push(
|
leading: Util.avatarImage(Util.avatarUrl(member), size: 40),
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => UserScreen(member),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
leading: util.avatarImage(util.avatarUrl(member), size: 40),
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
title: Text(member['username'])
|
title: Text(member['username'])
|
||||||
);
|
);
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'util.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'user.dart';
|
|
||||||
import 'lib.dart';
|
import 'lib.dart';
|
||||||
|
|
||||||
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
||||||
final TextEditingController _newEntryController = TextEditingController();
|
final TextEditingController _newEntryController = TextEditingController();
|
||||||
final Util utils = new Util();
|
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
Map<String,dynamic> _group;
|
Map<String,dynamic> _group;
|
||||||
List<dynamic> _entries = [];
|
List<dynamic> _entries = [];
|
||||||
@ -42,8 +38,10 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _sendPost(context) async {
|
void _sendPost(context) async {
|
||||||
|
String text = _newEntryController.text;
|
||||||
|
if (text.length == 0) return;
|
||||||
setState(() => _posting = true);
|
setState(() => _posting = true);
|
||||||
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text});
|
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_newEntryController.value = TextEditingValue(text: '');
|
_newEntryController.value = TextEditingValue(text: '');
|
||||||
FocusScope.of(context).requestFocus(FocusNode());
|
FocusScope.of(context).requestFocus(FocusNode());
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:convert';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'group.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
import 'lib.dart';
|
||||||
|
|
||||||
class _GroupsTabState extends State<GroupsTab> {
|
class _GroupsTabState extends State<GroupsTab> {
|
||||||
List<dynamic> _groups = [];
|
List<dynamic> _groups = [];
|
||||||
@ -16,6 +16,8 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void getGroups() async {
|
void getGroups() async {
|
||||||
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
|
if (model.user == null) return;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
var data = await api.request('GET', '/groups');
|
var data = await api.request('GET', '/groups');
|
||||||
@ -28,61 +30,40 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget buildGroupCard(Map<String,dynamic> group) {
|
Widget buildGroupCard(Map<String,dynamic> group) {
|
||||||
String description = group['description'];
|
String? description = group['description'];
|
||||||
if (description != null && description.length > 80) {
|
if (description != null && description.length > 80) {
|
||||||
description = description.substring(0, 77) + '...';
|
description = description.substring(0, 77) + '...';
|
||||||
} else {
|
} else if (description == null) {
|
||||||
description = '';
|
description = 'This group doesn\'t have a description.';
|
||||||
}
|
}
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () => context.push('/groups/' + group['_id']),
|
||||||
Navigator.push(
|
child: ListTile(
|
||||||
context,
|
leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => GroupScreen(group),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: <Widget>[
|
|
||||||
new ListTile(
|
|
||||||
leading: Icon(Icons.people),
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
title: Text(group['name']),
|
title: Text(group['name']),
|
||||||
subtitle: Text(description.replaceAll("\n", " ")),
|
subtitle: Text(description.replaceAll("\n", " ")),
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget getBody() {
|
||||||
Widget build(BuildContext context) {
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
return Scaffold(
|
if (model.user == null)
|
||||||
appBar: AppBar(
|
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
|
||||||
title: Text('Groups'),
|
else if (_loading)
|
||||||
),
|
return CircularProgressIndicator();
|
||||||
body: _loading ?
|
else if (_groups != null && _groups.length > 0)
|
||||||
Container(
|
return ListView.builder(
|
||||||
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(
|
|
||||||
itemCount: _groups.length,
|
itemCount: _groups.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
return buildGroupCard(_groups[index]);
|
return buildGroupCard(_groups[index]);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
:
|
else
|
||||||
Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@ -90,8 +71,20 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
Image(image: AssetImage('assets/group.png'), width: 300),
|
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('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
|
||||||
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
|
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
|
||||||
])
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'explore.dart';
|
||||||
import 'projects.dart';
|
import 'projects.dart';
|
||||||
import 'groups.dart';
|
import 'groups.dart';
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ class HomeScreen extends StatefulWidget {
|
|||||||
class _MyStatefulWidgetState extends State<HomeScreen> {
|
class _MyStatefulWidgetState extends State<HomeScreen> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
List<Widget> _widgetOptions = <Widget> [
|
List<Widget> _widgetOptions = <Widget> [
|
||||||
|
ExploreTab(),
|
||||||
ProjectsTab(),
|
ProjectsTab(),
|
||||||
GroupsTab()
|
GroupsTab()
|
||||||
];
|
];
|
||||||
@ -32,12 +34,16 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
|
|||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
items: const <BottomNavigationBarItem>[
|
items: const <BottomNavigationBarItem>[
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.folder),
|
icon: Icon(Icons.explore),
|
||||||
label: 'Projects',
|
label: 'Explore',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.person),
|
icon: Icon(Icons.folder),
|
||||||
label: 'Groups',
|
label: 'My Projects',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.people),
|
||||||
|
label: 'My Groups',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
|
@ -4,17 +4,20 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
import 'user.dart';
|
import 'user.dart';
|
||||||
|
import 'object.dart';
|
||||||
|
import 'project.dart';
|
||||||
|
|
||||||
class Alert extends StatelessWidget {
|
class Alert extends StatelessWidget {
|
||||||
final String type;
|
final String type;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final String actionText;
|
final String actionText;
|
||||||
final Widget descriptionWidget;
|
final Widget? descriptionWidget;
|
||||||
final Function action;
|
final Function? action;
|
||||||
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
|
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -39,7 +42,7 @@ class Alert extends StatelessWidget {
|
|||||||
color: accentColor,
|
color: accentColor,
|
||||||
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
|
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(color: Colors.grey[50], spreadRadius: 5),
|
BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -48,12 +51,12 @@ class Alert extends StatelessWidget {
|
|||||||
Icon(icon, color: color),
|
Icon(icon, color: color),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
Text(description, textAlign: TextAlign.center),
|
Text(description, textAlign: TextAlign.center),
|
||||||
descriptionWidget,
|
descriptionWidget != null ? descriptionWidget! : Text(""),
|
||||||
action != null ? CupertinoButton(
|
action != null ? CupertinoButton(
|
||||||
child: Text(actionText),
|
child: Text(actionText),
|
||||||
onPressed: action,
|
onPressed: () => action!(),
|
||||||
) : null
|
) : Text("")
|
||||||
].where((o) => o != null).toList()
|
]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -61,17 +64,16 @@ class Alert extends StatelessWidget {
|
|||||||
|
|
||||||
class NoticeboardPost extends StatefulWidget {
|
class NoticeboardPost extends StatefulWidget {
|
||||||
final Map<String,dynamic> _entry;
|
final Map<String,dynamic> _entry;
|
||||||
final Function onDelete;
|
final Function? onDelete;
|
||||||
final Function onReply;
|
final Function? onReply;
|
||||||
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
|
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
|
||||||
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
|
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
|
||||||
}
|
}
|
||||||
class _NoticeboardPostState extends State<NoticeboardPost> {
|
class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||||
final Map<String,dynamic> _entry;
|
final Map<String,dynamic> _entry;
|
||||||
final Util utils = new Util();
|
|
||||||
final Api api = new Api();
|
final Api api = new Api();
|
||||||
final Function onDelete;
|
final Function? onDelete;
|
||||||
final Function onReply;
|
final Function? onReply;
|
||||||
final TextEditingController _replyController = TextEditingController();
|
final TextEditingController _replyController = TextEditingController();
|
||||||
bool _isReplying = false;
|
bool _isReplying = false;
|
||||||
bool _replying = false;
|
bool _replying = false;
|
||||||
@ -84,7 +86,9 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_replyController.value = TextEditingValue(text: '');
|
_replyController.value = TextEditingValue(text: '');
|
||||||
FocusScope.of(context).requestFocus(FocusNode());
|
FocusScope.of(context).requestFocus(FocusNode());
|
||||||
onReply(data['payload']);
|
if (onReply != null) {
|
||||||
|
onReply!(data['payload']);
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_replying = false;
|
_replying = false;
|
||||||
_isReplying = false;
|
_isReplying = false;
|
||||||
@ -95,8 +99,10 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
void _deletePost() async {
|
void _deletePost() async {
|
||||||
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
|
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
onDelete(_entry);
|
if (onDelete != null) {
|
||||||
Navigator.of(context).pop();
|
onDelete!(_entry);
|
||||||
|
}
|
||||||
|
context.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,17 +110,17 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var createdAt = DateTime.parse(_entry['createdAt']);
|
var createdAt = DateTime.parse(_entry['createdAt']);
|
||||||
bool isReply = _entry['inReplyTo'] != null;
|
bool isReply = _entry['inReplyTo'] != null;
|
||||||
int replyCount = _entry['replies'] == null ? 0 : _entry['replies'].length;
|
int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length;
|
||||||
String replyText = 'Write a reply...';
|
String replyText = 'Write a reply...';
|
||||||
if (replyCount == 1) replyText = '1 Reply';
|
if (replyCount == 1) replyText = '1 Reply';
|
||||||
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
|
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
|
||||||
if (_isReplying) replyText = 'Cancel reply';
|
if (_isReplying) replyText = 'Cancel reply';
|
||||||
List<Widget> replyWidgets = [];
|
List<Widget> replyWidgets = [];
|
||||||
if (_entry['replies'] != null) {
|
if (_entry['replies'] != null) {
|
||||||
for (int i = 0; i < _entry['replies'].length; i++) {
|
for (int i = 0; i < _entry['replies']!.length; i++) {
|
||||||
replyWidgets.add(new Container(
|
replyWidgets.add(new Container(
|
||||||
key: Key(_entry['replies'][i]['_id']),
|
key: Key(_entry['replies']![i]['_id']),
|
||||||
child: NoticeboardPost(_entry['replies'][i], onDelete: onDelete)
|
child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,22 +133,22 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
color: Colors.orange,
|
//color: Colors.orange,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
launch('https://www.treadl.com');
|
launch('https://www.treadl.com');
|
||||||
},
|
},
|
||||||
child: Text('Report this post'),
|
child: Text('Report this post'),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
color: Colors.red,
|
//color: Colors.red,
|
||||||
onPressed: _deletePost,
|
onPressed: _deletePost,
|
||||||
child: Text('Delete post'),
|
child: Text('Delete post'),
|
||||||
),
|
),
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
context.pop();
|
||||||
},
|
},
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
)
|
)
|
||||||
@ -161,12 +167,8 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () => context.push('/' + _entry['authorUser']['username']),
|
||||||
Navigator.push(context, MaterialPageRoute(
|
child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
||||||
builder: (context) => UserScreen(_entry['authorUser']),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 5),
|
SizedBox(width: 5),
|
||||||
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
|
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
|
||||||
@ -176,18 +178,18 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
!isReply ? GestureDetector(
|
!isReply ? GestureDetector(
|
||||||
onTap: () => setState(() => _isReplying = !_isReplying),
|
onTap: () => setState(() => _isReplying = !_isReplying),
|
||||||
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
|
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
): null,
|
): SizedBox(width: 0),
|
||||||
].where((o) => o != null).toList(),
|
],
|
||||||
),
|
),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
SizedBox(width: 45),
|
SizedBox(width: 45),
|
||||||
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
|
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
|
||||||
]),
|
]),
|
||||||
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : null,
|
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
|
||||||
Column(
|
Column(
|
||||||
children: replyWidgets
|
children: replyWidgets
|
||||||
),
|
),
|
||||||
].where((o) => o != null).toList(),
|
],
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -215,7 +217,7 @@ class NoticeboardInput extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _onPost,
|
onPressed: () => _onPost!(),
|
||||||
color: Colors.pink,
|
color: Colors.pink,
|
||||||
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
|
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
|
||||||
)
|
)
|
||||||
@ -225,3 +227,174 @@ class NoticeboardInput extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UserChip extends StatelessWidget {
|
||||||
|
final Map<String,dynamic> user;
|
||||||
|
UserChip(this.user) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
ImageProvider? avatar = Util.avatarUrl(user);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push('/' + user['username']),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Util.avatarImage(avatar, size: 20),
|
||||||
|
SizedBox(width: 5),
|
||||||
|
Text(user['username'], style: TextStyle(color: Colors.grey))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PatternCard extends StatelessWidget {
|
||||||
|
final Map<String,dynamic> object;
|
||||||
|
PatternCard(this.object) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6.0),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: NetworkImage(object['previewUrl']),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
UserChip(object['projectObject']['owner']),
|
||||||
|
SizedBox(height: 5),
|
||||||
|
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectCard extends StatelessWidget {
|
||||||
|
final Map<String,dynamic> project;
|
||||||
|
ProjectCard(this.project) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6.0),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 200,
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.folder, color: Colors.pink[200]),
|
||||||
|
SizedBox(height: 10),
|
||||||
|
UserChip(project['owner']),
|
||||||
|
SizedBox(height: 5),
|
||||||
|
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomText extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final String type;
|
||||||
|
final double margin;
|
||||||
|
TextStyle? style;
|
||||||
|
CustomText(this.text, this.type, {this.margin = 0}) {
|
||||||
|
if (this.type == 'h1') {
|
||||||
|
style = TextStyle(fontSize: 25, fontWeight: FontWeight.bold);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
style = TextStyle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.all(this.margin),
|
||||||
|
child: Text(text, style: style)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginNeeded extends StatelessWidget {
|
||||||
|
final String? text;
|
||||||
|
LoginNeeded({this.text}) {}
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||||
|
Image(image: AssetImage('assets/login.png'), width: 300),
|
||||||
|
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
|
||||||
|
CupertinoButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/welcome');
|
||||||
|
},
|
||||||
|
child: new Text("Login or register",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyBox extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
EmptyBox(this.title, {this.description}) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||||
|
Image(image: AssetImage('assets/empty.png'), width: 300),
|
||||||
|
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final TextEditingController _emailController = TextEditingController();
|
final TextEditingController _emailController = TextEditingController();
|
||||||
@ -11,15 +13,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
bool _loggingIn = false;
|
bool _loggingIn = false;
|
||||||
|
|
||||||
void _submit(context) async {
|
void _submit(BuildContext context) async {
|
||||||
setState(() => _loggingIn = true);
|
setState(() => _loggingIn = true);
|
||||||
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
|
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
|
||||||
setState(() => _loggingIn = false);
|
setState(() => _loggingIn = false);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
String token = data['payload']['token'];
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
await model.setToken(data['payload']['token']);
|
||||||
prefs.setString('apiToken', token);
|
context.go('/onboarding');
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -31,7 +32,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('Try again'),
|
child: Text('Try again'),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -46,20 +47,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
title: Text('Login to Treadl'),
|
title: Text('Login to Treadl'),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.all(10.0),
|
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
||||||
child: SingleChildScrollView(
|
child: ListView(
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Image(image: AssetImage('assets/logo.png'), width: 100),
|
Text('Login with your Treadl account', style: TextStyle(fontSize: 20)),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 30),
|
||||||
Text('Login using your Treadl account.'),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
TextField(
|
TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
decoration: InputDecoration(
|
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),
|
SizedBox(height: 10),
|
||||||
@ -68,7 +66,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Type your password', labelText: 'Your password'
|
hintText: 'Type your password', labelText: 'Your password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
@ -80,9 +79,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
)]
|
)]
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => _submit(context),
|
onPressed: () => _submit(context),
|
||||||
color: Colors.pink,
|
|
||||||
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
|
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white, fontSize: 15)
|
style: TextStyle(color: Colors.white, fontSize: 15)
|
||||||
@ -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:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
//import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'store.dart';
|
import 'model.dart';
|
||||||
import 'welcome.dart';
|
import 'welcome.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
import 'register.dart';
|
import 'register.dart';
|
||||||
import 'onboarding.dart';
|
import 'onboarding.dart';
|
||||||
import 'home.dart';
|
import 'home.dart';
|
||||||
|
import 'project.dart';
|
||||||
|
import 'object.dart';
|
||||||
|
import 'settings.dart';
|
||||||
|
import 'group.dart';
|
||||||
|
import 'user.dart';
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (context, state) => Startup()),
|
||||||
|
GoRoute(path: '/welcome', pageBuilder: (context, state) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: WelcomeScreen(),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
// Change the opacity of the screen using a Curve based on the the animation's value
|
||||||
|
return FadeTransition(
|
||||||
|
opacity:
|
||||||
|
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
|
||||||
|
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
|
||||||
|
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
|
||||||
|
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
||||||
|
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
|
||||||
|
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
|
||||||
|
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
|
||||||
|
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
|
||||||
|
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => Store(),
|
create: (context) => AppModel(),
|
||||||
child: MyApp()
|
child: MyApp()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -37,21 +72,14 @@ class _AppState extends State<MyApp> {
|
|||||||
// Initialize FlutterFire:
|
// Initialize FlutterFire:
|
||||||
future: _initialization,
|
future: _initialization,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
return MaterialApp(
|
return MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: 'Treadl',
|
title: 'Treadl',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.pink,
|
primarySwatch: Colors.pink,
|
||||||
textSelectionColor: Colors.blue,
|
scaffoldBackgroundColor: Color.fromRGBO(255, 251, 248, 1),
|
||||||
),
|
),
|
||||||
home: Startup(),
|
|
||||||
routes: <String, WidgetBuilder>{
|
|
||||||
'/welcome': (BuildContext context) => WelcomeScreen(),
|
|
||||||
'/login': (BuildContext context) => LoginScreen(),
|
|
||||||
'/register': (BuildContext context) => RegisterScreen(),
|
|
||||||
'/onboarding': (BuildContext context) => OnboardingScreen(),
|
|
||||||
'/home': (BuildContext context) => HomeScreen(),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -64,12 +92,12 @@ class Startup extends StatelessWidget {
|
|||||||
Startup() {
|
Startup() {
|
||||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||||
if (message.notification != null) {
|
if (message.notification != null) {
|
||||||
print(message.notification.body);
|
print(message.notification!);
|
||||||
String text = '';
|
String text = '';
|
||||||
if (message.notification != null && message.notification.body != null) {
|
if (message.notification != null && message.notification!.body != null) {
|
||||||
text = message.notification.body;
|
text = message.notification!.body!;
|
||||||
}
|
}
|
||||||
Fluttertoast.showToast(
|
/*Fluttertoast.showToast(
|
||||||
msg: text,
|
msg: text,
|
||||||
toastLength: Toast.LENGTH_LONG,
|
toastLength: Toast.LENGTH_LONG,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
@ -77,7 +105,7 @@ class Startup extends StatelessWidget {
|
|||||||
backgroundColor: Colors.grey[100],
|
backgroundColor: Colors.grey[100],
|
||||||
textColor: Colors.black,
|
textColor: Colors.black,
|
||||||
fontSize: 16.0
|
fontSize: 16.0
|
||||||
);
|
);*/
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -86,10 +114,10 @@ class Startup extends StatelessWidget {
|
|||||||
if (_handled) return;
|
if (_handled) return;
|
||||||
_handled = true;
|
_handled = true;
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
final String token = prefs.getString('apiToken');
|
String? token = prefs.getString('apiToken');
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
Provider.of<Store>(context, listen: false).setToken(token);
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
|
await model.setToken(token!);
|
||||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
await _firebaseMessaging.requestPermission(
|
await _firebaseMessaging.requestPermission(
|
||||||
alert: true,
|
alert: true,
|
||||||
@ -100,19 +128,14 @@ class Startup extends StatelessWidget {
|
|||||||
provisional: false,
|
provisional: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
);
|
);
|
||||||
String _pushToken = await _firebaseMessaging.getToken();
|
String? _pushToken = await _firebaseMessaging.getToken();
|
||||||
if (_pushToken != null) {
|
if (_pushToken != null) {
|
||||||
print("sending push");
|
print("sending push");
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
||||||
}
|
}
|
||||||
print('111');
|
|
||||||
// Push without including current route in stack:
|
|
||||||
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
|
|
||||||
print('222');
|
|
||||||
} else {
|
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
|
||||||
}
|
}
|
||||||
|
context.go('/home');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
65
mobile/lib/model.dart
Normal file
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:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'dart:io';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'util.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
import 'patterns/pattern.dart';
|
||||||
|
import 'patterns/viewer.dart';
|
||||||
|
|
||||||
class _ObjectScreenState extends State<ObjectScreen> {
|
class _ObjectScreenState extends State<ObjectScreen> {
|
||||||
final Map<String,dynamic> _object;
|
final String username;
|
||||||
final Function _onDelete;
|
final String projectPath;
|
||||||
|
final String id;
|
||||||
|
Map<String,dynamic>? object;
|
||||||
|
Map<String,dynamic>? pattern;
|
||||||
|
bool _isLoading = false;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
|
|
||||||
_ObjectScreenState(this._object, this._onDelete) { }
|
_ObjectScreenState(this.username, this.projectPath, this.id) { }
|
||||||
|
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
fetchObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchObject() async {
|
||||||
|
var data = await api.request('GET', '/objects/' + id);
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
object = data['payload'];
|
||||||
|
pattern = data['payload']['pattern'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareObject() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
File? file;
|
||||||
|
if (object!['type'] == 'pattern') {
|
||||||
|
var data = await api.request('GET', '/objects/' + id + '/wif');
|
||||||
|
if (data['success'] == true) {
|
||||||
|
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String fileName = Uri.file(object!['url']).pathSegments.last;
|
||||||
|
file = await api.downloadFile(object!['url'], fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file != null) {
|
||||||
|
Util.shareFile(file!, withDelete: true);
|
||||||
|
}
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
void _deleteObject(BuildContext context, BuildContext modalContext) async {
|
void _deleteObject(BuildContext context, BuildContext modalContext) async {
|
||||||
var data = await api.request('DELETE', '/objects/' + _object['_id']);
|
var data = await api.request('DELETE', '/objects/' + id);
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
Navigator.pop(context);
|
context.go('/home');
|
||||||
Navigator.pop(modalContext);
|
|
||||||
Navigator.pop(context);
|
|
||||||
_onDelete(_object['_id']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,13 +68,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: modalContext,
|
context: modalContext,
|
||||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||||
title: new Text('Really delete this object?'),
|
title: new Text('Really delete this item?'),
|
||||||
content: new Text('This action cannot be undone.'),
|
content: new Text('This action cannot be undone.'),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('No'),
|
child: Text('No'),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
@ -44,6 +86,45 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _renameObject(BuildContext context) {
|
||||||
|
TextEditingController renameController = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('Rename this item'),
|
||||||
|
content: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: renameController,
|
||||||
|
decoration: InputDecoration(hintText: "Enter a new name for the item"),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: Text('CANCEL'),
|
||||||
|
onPressed: () {
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: Text('OK'),
|
||||||
|
onPressed: () async {
|
||||||
|
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
|
||||||
|
if (data['success']) {
|
||||||
|
context.pop();
|
||||||
|
object!['name'] = data['payload']['name'];
|
||||||
|
setState(() {
|
||||||
|
object = object;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showSettingsModal(context) {
|
void _showSettingsModal(context) {
|
||||||
showCupertinoModalPopup(
|
showCupertinoModalPopup(
|
||||||
context: context,
|
context: context,
|
||||||
@ -51,13 +132,17 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
return CupertinoActionSheet(
|
return CupertinoActionSheet(
|
||||||
title: Text('Manage this object'),
|
title: Text('Manage this object'),
|
||||||
cancelButton: CupertinoActionSheetAction(
|
cancelButton: CupertinoActionSheetAction(
|
||||||
onPressed: () => Navigator.of(modalContext).pop(),
|
onPressed: () => modalContext.pop(),
|
||||||
child: Text('Cancel')
|
child: Text('Cancel')
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
CupertinoActionSheetAction(
|
||||||
|
onPressed: () => _renameObject(context),
|
||||||
|
child: Text('Rename item'),
|
||||||
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () => _confirmDeleteObject(modalContext),
|
onPressed: () => _confirmDeleteObject(modalContext),
|
||||||
child: Text('Delete object'),
|
child: Text('Delete item'),
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -67,43 +152,79 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget getObjectWidget() {
|
Widget getObjectWidget() {
|
||||||
if (_object['isImage'] == true) {
|
if (object == null) {
|
||||||
return Image.network(_object['url']);
|
return Center(child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [CircularProgressIndicator()]
|
||||||
|
));
|
||||||
}
|
}
|
||||||
else if (_object['type'] == 'pattern') {
|
else if (object!['isImage'] == true && object!['url'] != null) {
|
||||||
var dat = Uri.parse(_object['preview']).data;
|
print(object!['url']);
|
||||||
return Image.memory(dat.contentAsBytes());
|
return Image.network(object!['url']);
|
||||||
|
}
|
||||||
|
else if (object!['type'] == 'pattern') {
|
||||||
|
if (pattern != null) {
|
||||||
|
return PatternViewer(pattern!, withEditor: true);
|
||||||
|
}
|
||||||
|
else if (object!['previewUrl'] != null) {
|
||||||
|
return Image.network(object!['previewUrl']!);;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return RaisedButton(child: Text('View file'), onPressed: () {
|
return Column(
|
||||||
launch(_object['url']);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
|
User? user = model.user;
|
||||||
String description = '';
|
String description = '';
|
||||||
if (_object['description'] != null)
|
if (object?['description'] != null)
|
||||||
description = _object['description'];
|
description = object!['description']!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_object['name']),
|
title: Text(object?['name'] ?? 'Object'),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
icon: Icon(Icons.ios_share),
|
||||||
|
onPressed: () {
|
||||||
|
_shareObject();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Util.canEditProject(user, object?['projectObject']) ? IconButton(
|
||||||
icon: Icon(Icons.settings),
|
icon: Icon(Icons.settings),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_showSettingsModal(context);
|
_showSettingsModal(context);
|
||||||
},
|
},
|
||||||
),
|
) : SizedBox(height: 0),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.all(10.0),
|
margin: const EdgeInsets.all(10.0),
|
||||||
child: ListView(
|
child: Column(
|
||||||
children: <Widget>[
|
children: [
|
||||||
getObjectWidget(),
|
_isLoading ? LinearProgressIndicator() : SizedBox(height: 0),
|
||||||
Html(data: description)
|
Expanded(child: getObjectWidget()),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -112,9 +233,11 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ObjectScreen extends StatefulWidget {
|
class ObjectScreen extends StatefulWidget {
|
||||||
final Map<String,dynamic> _object;
|
final String username;
|
||||||
final Function _onDelete;
|
final String projectPath;
|
||||||
ObjectScreen(this._object, this._onDelete) { }
|
final String id;
|
||||||
|
ObjectScreen(this.username, this.projectPath, this.id, ) { }
|
||||||
@override
|
@override
|
||||||
_ObjectScreenState createState() => _ObjectScreenState(_object, _onDelete);
|
_ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
|
||||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||||
@ -9,7 +11,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
);
|
);
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String _pushToken;
|
String? _pushToken;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -18,6 +20,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _requestPushPermissions() async {
|
void _requestPushPermissions() async {
|
||||||
|
try {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
await _firebaseMessaging.requestPermission(
|
await _firebaseMessaging.requestPermission(
|
||||||
@ -30,14 +33,11 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
sound: true,
|
sound: true,
|
||||||
);
|
);
|
||||||
_pushToken = await _firebaseMessaging.getToken();
|
_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) {
|
if (_pushToken != null) {
|
||||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
on Exception { }
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
|
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
@ -57,14 +57,15 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
|
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('Treadl is a free and safe place for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
Text('Treadl is a safe space for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Image(image: AssetImage('assets/folder.png'), width: 300),
|
Image(image: AssetImage('assets/folder.png'), width: 300),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store and showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store or showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 20),
|
||||||
RaisedButton(
|
CupertinoButton(
|
||||||
child: Text('OK, I know what projects are!'),
|
color: Colors.white,
|
||||||
|
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)),
|
||||||
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
|
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -83,13 +84,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 20),
|
||||||
RaisedButton(
|
CupertinoButton(
|
||||||
|
color: Colors.white,
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
_loading ? CircularProgressIndicator() : null,
|
_loading ? CircularProgressIndicator() : SizedBox(width: 0),
|
||||||
_loading ? SizedBox(width: 5) : null,
|
_loading ? SizedBox(width: 10) : SizedBox(width: 0),
|
||||||
Text('What\'s next?'),
|
Text('Continue', style: TextStyle(color: Colors.pink)),
|
||||||
].where((o) => o != null).toList()),
|
]),
|
||||||
onPressed: _requestPushPermissions,
|
onPressed: _requestPushPermissions,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -101,15 +103,16 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 25), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Image(image: AssetImage('assets/completed.png'), width: 300),
|
Image(image: AssetImage('assets/completed.png'), width: 300),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('You\'re ready to get started. If you have any questions or want to get in touch then just send us a quick tweet.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('You\'re ready to get started. We hope you enjoy using Treadl.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 20),
|
||||||
RaisedButton(
|
CupertinoButton(
|
||||||
child: Text('Let\'s go'),
|
color: Colors.white,
|
||||||
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false),
|
child: Text('Get started', style: TextStyle(color: Colors.pink)),
|
||||||
|
onPressed: () => context.go('/home'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
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:flutter/cupertino.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'object.dart';
|
import 'util.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
import 'lib.dart';
|
||||||
|
|
||||||
|
class _ProjectScreenState extends State<ProjectScreen> {
|
||||||
|
final String username;
|
||||||
|
final String projectPath;
|
||||||
|
final String fullPath;
|
||||||
|
final Function? onUpdate;
|
||||||
|
final Function? onDelete;
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final Api api = Api();
|
||||||
|
Map<String,dynamic>? project;
|
||||||
|
List<dynamic> _objects = [];
|
||||||
|
bool _loading = false;
|
||||||
|
Map<String,dynamic>? _creatingObject;
|
||||||
|
|
||||||
|
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
|
||||||
|
fullPath = username + '/' + projectPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
getProject(fullPath);
|
||||||
|
getObjects(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void getProject(String fullName) async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
var data = await api.request('GET', '/projects/' + fullName);
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
project = data['payload'];
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void getObjects(String fullName) async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_objects = data['payload']['objects'];
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareProject() {
|
||||||
|
Util.shareUrl('Check out my project on Treadl', Util.appUrl(fullPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeleteProject() {
|
||||||
|
context.pop();
|
||||||
|
onDelete!(project!['_id']);
|
||||||
|
}
|
||||||
|
void _onUpdateProject(project) {
|
||||||
|
setState(() {
|
||||||
|
project = project;
|
||||||
|
});
|
||||||
|
onUpdate!(project!['_id'], project!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUpdateObject(String id, Map<String,dynamic> update) {
|
||||||
|
List<dynamic> _newObjects = _objects.map((o) {
|
||||||
|
if (o['_id'] == id) {
|
||||||
|
o.addAll(update);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}).toList();
|
||||||
|
setState(() {
|
||||||
|
_objects = _newObjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void _onDeleteObject(String id) {
|
||||||
|
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
||||||
|
setState(() {
|
||||||
|
_objects = _newObjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createObject(objectData) async {
|
||||||
|
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
|
||||||
|
setState(() => _creatingObject = null);
|
||||||
|
if (resp['success']) {
|
||||||
|
List<dynamic> newObjects = _objects;
|
||||||
|
newObjects.add(resp['payload']);
|
||||||
|
setState(() {
|
||||||
|
_objects = newObjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createObjectFromWif(String name, String wif) {
|
||||||
|
setState(() => _creatingObject = {
|
||||||
|
'name': name,
|
||||||
|
'type': 'pattern',
|
||||||
|
});
|
||||||
|
_createObject({
|
||||||
|
'name': name,
|
||||||
|
'type': 'pattern',
|
||||||
|
'wif': wif,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createObjectFromFile(String name, XFile file) async {
|
||||||
|
final int size = await file.length();
|
||||||
|
final String forId = project!['_id'];
|
||||||
|
final String type = file.mimeType ?? 'text/plain';
|
||||||
|
setState(() => _creatingObject = {
|
||||||
|
'name': name,
|
||||||
|
'type': 'file',
|
||||||
|
});
|
||||||
|
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
||||||
|
if (!data['success']) {
|
||||||
|
setState(() => _creatingObject = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], File(file.path), type);
|
||||||
|
if (!uploadSuccess) {
|
||||||
|
setState(() => _creatingObject = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_createObject({
|
||||||
|
'name': name,
|
||||||
|
'storedName': data['payload']['fileName'],
|
||||||
|
'type': 'file',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _chooseFile() async {
|
||||||
|
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||||
|
if (result != null) {
|
||||||
|
PlatformFile file = result.files.single;
|
||||||
|
XFile xFile = XFile(file.path!);
|
||||||
|
String? ext = file.extension;
|
||||||
|
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
|
||||||
|
final String contents = await xFile.readAsString();
|
||||||
|
_createObjectFromWif(file.name, contents);
|
||||||
|
} else {
|
||||||
|
_createObjectFromFile(file.name, xFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _chooseImage() async {
|
||||||
|
File file;
|
||||||
|
try {
|
||||||
|
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (imageFile == null) return;
|
||||||
|
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
|
||||||
|
String time = f.format(new DateTime.now());
|
||||||
|
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
|
||||||
|
_createObjectFromFile(name, imageFile);
|
||||||
|
}
|
||||||
|
on Exception {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||||
|
title: Text('Treadl needs access'),
|
||||||
|
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
||||||
|
actions: <Widget>[
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDefaultAction: true,
|
||||||
|
child: Text('OK'),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showSettingsModal() {
|
||||||
|
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
|
||||||
|
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getNetworkImageBox(String url) {
|
||||||
|
return new AspectRatio(
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
child: new Container(
|
||||||
|
decoration: new BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10.0),
|
||||||
|
image: new DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: FractionalOffset.topCenter,
|
||||||
|
image: new NetworkImage(url),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Widget getIconBox(Icon icon) {
|
||||||
|
return new AspectRatio(
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
child: new Container(
|
||||||
|
decoration: new BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(10.0),
|
||||||
|
),
|
||||||
|
child: icon
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getObjectCard(int index) {
|
||||||
|
Map<String,dynamic>? objectToShow;
|
||||||
|
if (index >= _objects.length) {
|
||||||
|
objectToShow = _creatingObject;
|
||||||
|
objectToShow!['creating'] = true;
|
||||||
|
} else {
|
||||||
|
objectToShow = _objects[index];
|
||||||
|
}
|
||||||
|
Map<String,dynamic> object = objectToShow!;
|
||||||
|
Widget leader;
|
||||||
|
String type;
|
||||||
|
|
||||||
|
if (object['isImage'] == true) {
|
||||||
|
type = 'Image';
|
||||||
|
if (object['url'] != null) {
|
||||||
|
leader = getNetworkImageBox(object['url']!);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
leader = getIconBox(Icon(Icons.photo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (object['type'] == 'pattern') {
|
||||||
|
type = 'Weaving pattern';
|
||||||
|
if (object['previewUrl'] != null) {
|
||||||
|
leader = getNetworkImageBox(object['previewUrl']!);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
leader = getIconBox(Icon(Icons.pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (object['type'] == 'file') {
|
||||||
|
type = 'File';
|
||||||
|
leader = getIconBox(Icon(Icons.insert_drive_file));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
type = 'Unknown';
|
||||||
|
leader = getIconBox(Icon(Icons.file_present));
|
||||||
|
}
|
||||||
|
if (object['creating'] == true) {
|
||||||
|
leader = CircularProgressIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
context.push('/' + username + '/' + projectPath + '/' + object['_id']);
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
leading: leader,
|
||||||
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
|
title: Text(object['name']),
|
||||||
|
subtitle: Text(type),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getBody() {
|
||||||
|
if (_loading || project == null)
|
||||||
|
return CircularProgressIndicator();
|
||||||
|
else if ((_objects != null && _objects.length > 0) || _creatingObject != null)
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
return getObjectCard(index);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
|
User? user = model.user;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(project?['name'] ?? 'Project'),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.ios_share),
|
||||||
|
onPressed: () {
|
||||||
|
_shareProject();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onUpdate != null ? IconButton(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
onPressed: () {
|
||||||
|
showSettingsModal();
|
||||||
|
},
|
||||||
|
) : SizedBox(width: 0),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: getBody(),
|
||||||
|
),
|
||||||
|
floatingActionButtonLocation: ExpandableFab.location,
|
||||||
|
floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
|
||||||
|
distance: 70,
|
||||||
|
type: ExpandableFabType.up,
|
||||||
|
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Row(children:[
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[800],
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text('Add an image', style: TextStyle(fontSize: 15, color: Colors.white)),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: null,
|
||||||
|
onPressed: _chooseImage,
|
||||||
|
child: Icon(Icons.image_outlined),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Row(children:[
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[800],
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text('Add a WIF or other file', style: TextStyle(fontSize: 15, color: Colors.white)),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: null,
|
||||||
|
child: const Icon(Icons.insert_drive_file_outlined),
|
||||||
|
onPressed: _chooseFile,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectScreen extends StatefulWidget {
|
||||||
|
final String username;
|
||||||
|
final String projectPath;
|
||||||
|
final Map<String,dynamic>? project;
|
||||||
|
final Function? onUpdate;
|
||||||
|
final Function? onDelete;
|
||||||
|
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
|
||||||
|
@override
|
||||||
|
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
|
||||||
|
}
|
||||||
|
|
||||||
class _ProjectSettingsDialog extends StatelessWidget {
|
class _ProjectSettingsDialog extends StatelessWidget {
|
||||||
final Map<String,dynamic> _project;
|
final String fullPath;
|
||||||
|
final Map<String,dynamic> project;
|
||||||
final Function _onDelete;
|
final Function _onDelete;
|
||||||
final Function _onUpdateProject;
|
final Function _onUpdateProject;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {}
|
_ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
|
||||||
|
fullPath = project['owner']['username'] + '/' + project['path'];
|
||||||
|
|
||||||
|
void _renameProject(BuildContext context) async {
|
||||||
|
TextEditingController renameController = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('Rename your project'),
|
||||||
|
content: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: renameController,
|
||||||
|
decoration: InputDecoration(hintText: "Enter a new name for the project"),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: Text('CANCEL'),
|
||||||
|
onPressed: () {
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: Text('OK'),
|
||||||
|
onPressed: () async {
|
||||||
|
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
|
||||||
|
if (data['success']) {
|
||||||
|
context.pop();
|
||||||
|
_onUpdateProject(data['payload']);
|
||||||
|
}
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _toggleVisibility(BuildContext context, bool checked) async {
|
void _toggleVisibility(BuildContext context, bool checked) async {
|
||||||
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'});
|
var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
Navigator.pop(context);
|
context.pop();
|
||||||
_onUpdateProject(data['payload']);
|
_onUpdateProject(data['payload']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteProject(BuildContext context, BuildContext modalContext) async {
|
void _deleteProject(BuildContext context, BuildContext modalContext) async {
|
||||||
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']);
|
var data = await api.request('DELETE', '/projects/' + fullPath);
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
Navigator.pop(context);
|
context.pop();
|
||||||
Navigator.pop(modalContext);
|
context.pop();
|
||||||
_onDelete();
|
_onDelete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,7 +442,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('No'),
|
child: Text('No'),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
@ -57,7 +459,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
return CupertinoActionSheet(
|
return CupertinoActionSheet(
|
||||||
title: Text('Manage this project'),
|
title: Text('Manage this project'),
|
||||||
cancelButton: CupertinoActionSheetAction(
|
cancelButton: CupertinoActionSheetAction(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => context.pop(),
|
||||||
child: Text('Cancel')
|
child: Text('Cancel')
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@ -67,7 +469,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CupertinoSwitch(
|
CupertinoSwitch(
|
||||||
value: _project['visibility'] == 'private',
|
value: project?['visibility'] == 'private',
|
||||||
onChanged: (c) => _toggleVisibility(context, c),
|
onChanged: (c) => _toggleVisibility(context, c),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
@ -75,6 +477,10 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
CupertinoActionSheetAction(
|
||||||
|
onPressed: () { _renameProject(context); },
|
||||||
|
child: Text('Rename project'),
|
||||||
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () { _confirmDeleteProject(context); },
|
onPressed: () { _confirmDeleteProject(context); },
|
||||||
child: Text('Delete project'),
|
child: Text('Delete project'),
|
||||||
@ -84,243 +490,3 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProjectScreenState extends State<ProjectScreen> {
|
|
||||||
final Function _onDelete;
|
|
||||||
final picker = ImagePicker();
|
|
||||||
final Api api = Api();
|
|
||||||
Map<String,dynamic> _project;
|
|
||||||
List<dynamic> _objects = [];
|
|
||||||
bool _loading = false;
|
|
||||||
bool _creating = false;
|
|
||||||
|
|
||||||
_ProjectScreenState(this._project, this._onDelete) { }
|
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
getObjects(_project['fullName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
void getObjects(String fullName) async {
|
|
||||||
setState(() => _loading = true);
|
|
||||||
print(fullName);
|
|
||||||
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
_objects = data['payload']['objects'];
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeleteProject() {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_onDelete(_project['_id']);
|
|
||||||
}
|
|
||||||
void _onUpdateProject(project) {
|
|
||||||
setState(() {
|
|
||||||
_project = project;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeleteObject(String id) {
|
|
||||||
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
|
||||||
setState(() {
|
|
||||||
_objects = _newObjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _chooseImage() async {
|
|
||||||
File file;
|
|
||||||
try {
|
|
||||||
final imageFile = await picker.getImage(source: ImageSource.gallery);
|
|
||||||
if (imageFile == null) return;
|
|
||||||
file = File(imageFile.path);
|
|
||||||
}
|
|
||||||
on Exception {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
|
||||||
title: Text('Treadl needs access'),
|
|
||||||
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
|
||||||
actions: <Widget>[
|
|
||||||
CupertinoDialogAction(
|
|
||||||
isDefaultAction: true,
|
|
||||||
child: Text('OK'),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final int size = await file.length();
|
|
||||||
final String forId = _project['_id'];
|
|
||||||
final String fullPath = _project['owner']['username'] + '/' + _project['path'];
|
|
||||||
final String name = file.path.split('/').last;
|
|
||||||
final String ext = name.split('.').last;
|
|
||||||
final String type = 'image/jpeg';//$ext';
|
|
||||||
setState(() => _creating = true);
|
|
||||||
|
|
||||||
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
|
||||||
print(data);
|
|
||||||
if (!data['success']) {
|
|
||||||
setState(() => _creating = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], file, type);
|
|
||||||
print(uploadSuccess);
|
|
||||||
if (!uploadSuccess) {
|
|
||||||
setState(() => _creating = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var newObjectData = {
|
|
||||||
'name': name,
|
|
||||||
'storedName': data['payload']['fileName'],
|
|
||||||
'type': 'file',
|
|
||||||
};
|
|
||||||
var objectData = await api.request('POST', '/projects/$fullPath/objects', newObjectData);
|
|
||||||
setState(() => _creating = false);
|
|
||||||
if (objectData['success']) {
|
|
||||||
List<dynamic> newObjects = _objects;
|
|
||||||
newObjects.add(objectData['payload']);
|
|
||||||
setState(() {
|
|
||||||
_objects = newObjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void showSettingsModal() {
|
|
||||||
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject);
|
|
||||||
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getImageBox(data, [bool isMemory, bool isNetwork]) {
|
|
||||||
return new AspectRatio(
|
|
||||||
aspectRatio: 1 / 1,
|
|
||||||
child: new Container(
|
|
||||||
decoration: new BoxDecoration(
|
|
||||||
image: new DecorationImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
alignment: FractionalOffset.topCenter,
|
|
||||||
image: isMemory == true ? new MemoryImage(data) : new NetworkImage(data)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Widget getIconBox(Icon icon) {
|
|
||||||
return new AspectRatio(
|
|
||||||
aspectRatio: 1 / 1,
|
|
||||||
child: icon
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getObjectCard(int index) {
|
|
||||||
if (index >= _objects.length) {
|
|
||||||
return new Card(
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.all(10),
|
|
||||||
child: Center(child:CircularProgressIndicator())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
var object = _objects[index];
|
|
||||||
Widget leader;
|
|
||||||
String type;
|
|
||||||
|
|
||||||
if (object['isImage'] == true) {
|
|
||||||
type = 'Image';
|
|
||||||
leader = getImageBox(object['url']);
|
|
||||||
}
|
|
||||||
else if (object['type'] == 'pattern' && object['preview'] != null) {
|
|
||||||
type = 'Weaving pattern';
|
|
||||||
var dat = Uri.parse(object['preview']).data;
|
|
||||||
leader = getImageBox(dat.contentAsBytes(), true);
|
|
||||||
}
|
|
||||||
else if (object['type'] == 'file') {
|
|
||||||
type = 'File';
|
|
||||||
leader = getIconBox(Icon(Icons.insert_drive_file));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Card(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => ObjectScreen(object, _onDeleteObject),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: <Widget>[
|
|
||||||
new ListTile(
|
|
||||||
leading: leader,
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
|
||||||
title: Text(object['name']),
|
|
||||||
subtitle: Text(type),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(_project['name']),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
onPressed: () {
|
|
||||||
showSettingsModal();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
body: _loading ?
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: CircularProgressIndicator()
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
child: ((_objects != null && _objects.length > 0) || _creating) ?
|
|
||||||
ListView.builder(
|
|
||||||
itemCount: _objects.length + (_creating ? 1 : 0),
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
return getObjectCard(index);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
:
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
|
||||||
Image(image: AssetImage('assets/empty.png'), width: 300),
|
|
||||||
Text('Add something to this project using the button below.', textAlign: TextAlign.center),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: _chooseImage,
|
|
||||||
child: Icon(Icons.cloud_upload),
|
|
||||||
backgroundColor: Colors.pink[500],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProjectScreen extends StatefulWidget {
|
|
||||||
final Map<String,dynamic> _project;
|
|
||||||
final Function _onDelete;
|
|
||||||
ProjectScreen(this._project, this._onDelete) { }
|
|
||||||
@override
|
|
||||||
_ProjectScreenState createState() => _ProjectScreenState(_project, _onDelete);
|
|
||||||
}
|
|
||||||
|
@ -1,10 +1,170 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'routeArguments.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'project.dart';
|
import 'model.dart';
|
||||||
import 'settings.dart';
|
import 'lib.dart';
|
||||||
|
|
||||||
|
class _ProjectsTabState extends State<ProjectsTab> {
|
||||||
|
List<dynamic> _projects = [];
|
||||||
|
bool _loading = false;
|
||||||
|
bool _creatingProject = false;
|
||||||
|
final Api api = Api();
|
||||||
|
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
getProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
void getProjects() async {
|
||||||
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
|
if (model.user == null) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
var data = await api.request('GET', '/users/me/projects');
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_projects = data['payload']['projects'];
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCreatingProject() {
|
||||||
|
setState(() {
|
||||||
|
_creatingProject = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void _onCreateProject(newProject) {
|
||||||
|
List<dynamic> _newProjects = _projects;
|
||||||
|
_newProjects.insert(0, newProject);
|
||||||
|
setState(() {
|
||||||
|
_projects = _newProjects;
|
||||||
|
_creatingProject = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUpdateProject(String id, Map<String,dynamic> update) {
|
||||||
|
List<dynamic> _newProjects = _projects.map((p) {
|
||||||
|
if (p['_id'] == id) {
|
||||||
|
p.addAll(update);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}).toList();
|
||||||
|
setState(() {
|
||||||
|
_projects = _newProjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeleteProject(String id) {
|
||||||
|
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
||||||
|
setState(() {
|
||||||
|
_projects = _newProjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void showNewProjectDialog() async {
|
||||||
|
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
||||||
|
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildProjectCard(Map<String,dynamic> project) {
|
||||||
|
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
|
||||||
|
if (description != null && description.length > 80) {
|
||||||
|
description = description.substring(0, 77) + '...';
|
||||||
|
}
|
||||||
|
if (project['visibility'] == 'public') {
|
||||||
|
description = "PUBLIC PROJECT\n" + description;
|
||||||
|
}
|
||||||
|
else description = "PRIVATE PROJECT\n" + description;
|
||||||
|
return new Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
context.push('/' + project['owner']['username'] + '/' + project['path']);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.all(5),
|
||||||
|
child: ListTile(
|
||||||
|
leading: new AspectRatio(
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
child: new Container(
|
||||||
|
decoration: new BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(10.0),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.folder, color: Colors.pink[300])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
|
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
||||||
|
subtitle: Text(description),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getBody() {
|
||||||
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
|
if (model.user == null)
|
||||||
|
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
|
||||||
|
if (_loading)
|
||||||
|
return CircularProgressIndicator();
|
||||||
|
else if (_projects != null && _projects.length > 0)
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: _projects.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
return buildProjectCard(_projects[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
else return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||||
|
Image(image: AssetImage('assets/reading.png'), width: 300),
|
||||||
|
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
|
User? user = model.user;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('My Projects'),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/settings');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: getBody()
|
||||||
|
),
|
||||||
|
floatingActionButton: user != null ? FloatingActionButton(
|
||||||
|
onPressed: showNewProjectDialog,
|
||||||
|
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
||||||
|
backgroundColor: Colors.pink[500],
|
||||||
|
) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectsTab extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_ProjectsTabState createState() => _ProjectsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||||
final TextEditingController _newProjectNameController = TextEditingController();
|
final TextEditingController _newProjectNameController = TextEditingController();
|
||||||
@ -29,7 +189,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
|||||||
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
|
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_onComplete(data['payload']);
|
_onComplete(data['payload']);
|
||||||
Navigator.of(context).pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +225,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
|||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
context.pop();
|
||||||
},
|
},
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
)
|
)
|
||||||
@ -82,155 +242,3 @@ class _NewProjectDialog extends StatefulWidget {
|
|||||||
@override
|
@override
|
||||||
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
|
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProjectsTabState extends State<ProjectsTab> {
|
|
||||||
List<dynamic> _projects = [];
|
|
||||||
bool _loading = false;
|
|
||||||
bool _creatingProject = false;
|
|
||||||
final Api api = Api();
|
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
getProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
void getProjects() async {
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
});
|
|
||||||
var data = await api.request('GET', '/users/me/projects');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
_projects = data['payload']['projects'];
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onCreatingProject() {
|
|
||||||
setState(() {
|
|
||||||
_creatingProject = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
void _onCreateProject(newProject) {
|
|
||||||
List<dynamic> _newProjects = _projects;
|
|
||||||
_newProjects.insert(0, newProject);
|
|
||||||
setState(() {
|
|
||||||
_projects = _newProjects;
|
|
||||||
_creatingProject = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeleteProject(String id) {
|
|
||||||
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
|
||||||
setState(() {
|
|
||||||
_projects = _newProjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void showNewProjectDialog() async {
|
|
||||||
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
|
||||||
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildProjectCard(Map<String,dynamic> project) {
|
|
||||||
String description = project['description'] != null ? project['description'] : '';
|
|
||||||
if (description != null && description.length > 80) {
|
|
||||||
description = description.substring(0, 77) + '...';
|
|
||||||
}
|
|
||||||
return new Card(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => ProjectScreen(project, _onDeleteProject),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
new ListTile(
|
|
||||||
leading: Icon(Icons.folder_open),
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
|
||||||
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
|
||||||
subtitle: Text(description.replaceAll("\n", " ")),
|
|
||||||
),
|
|
||||||
/*ButtonBar(
|
|
||||||
children: <Widget>[
|
|
||||||
FlatButton(
|
|
||||||
child: const Text('VIEW'),
|
|
||||||
onPressed: () {
|
|
||||||
|
|
||||||
}
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),*/
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text('Your Projects'),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.info_outline),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
body: _loading ?
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: CircularProgressIndicator()
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: (_projects != null && _projects.length > 0) ?
|
|
||||||
ListView.builder(
|
|
||||||
itemCount: _projects.length,
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
return buildProjectCard(_projects[index]);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
:
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
|
||||||
Image(image: AssetImage('assets/reading.png'), width: 300),
|
|
||||||
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: showNewProjectDialog,
|
|
||||||
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
|
||||||
backgroundColor: Colors.pink[500],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProjectsTab extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
_ProjectsTabState createState() => _ProjectsTabState();
|
|
||||||
}
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
final TextEditingController _usernameController = TextEditingController();
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
@ -17,10 +19,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
|
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
|
||||||
setState(() => _registering = false);
|
setState(() => _registering = false);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
String token = data['payload']['token'];
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
model.setToken(data['payload']['token']);
|
||||||
prefs.setString('apiToken', token);
|
context.go('/onboarding');
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -32,7 +33,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('Try again'),
|
child: Text('Try again'),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -47,15 +48,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
title: Text('Register with Treadl'),
|
title: Text('Register with Treadl'),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.all(10.0),
|
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
||||||
child: SingleChildScrollView(
|
child: ListView(
|
||||||
child:Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Image(image: AssetImage('assets/logo.png'), width: 100),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
Text('Register a free account.'),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
TextField(
|
TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
@ -97,9 +92,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => _submit(context),
|
onPressed: () => _submit(context),
|
||||||
color: Colors.pink,
|
//color: Colors.pink,
|
||||||
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
|
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white, fontSize: 15)
|
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/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'model.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
|
||||||
void _logout(BuildContext context) async {
|
void _logout(BuildContext context) async {
|
||||||
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
api.request('POST', '/accounts/logout');
|
api.request('POST', '/accounts/logout');
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
model.setToken(null);
|
||||||
prefs.remove('apiToken');
|
model.setUser(null);
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteAccount(BuildContext context) async {
|
void _deleteAccount(BuildContext context) async {
|
||||||
@ -31,19 +34,20 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
onPressed: () { Navigator.of(context).pop(); }
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
child: Text('Delete Account'),
|
child: Text('Delete Account'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
|
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
AppModel model = Provider.of<AppModel>(context, listen: false);
|
||||||
prefs.remove('apiToken');
|
model.setToken(null);
|
||||||
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
model.setUser(null);
|
||||||
|
context.go('/home');
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -54,7 +58,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('OK'),
|
child: Text('OK'),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -75,6 +79,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
AppModel model = Provider.of<AppModel>(context);
|
||||||
|
User? user = model.user;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('About Treadl'),
|
title: Text('About Treadl'),
|
||||||
@ -93,6 +99,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
|
|
||||||
|
user != null ? Column(
|
||||||
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.exit_to_app),
|
leading: Icon(Icons.exit_to_app),
|
||||||
title: Text('Logout'),
|
title: Text('Logout'),
|
||||||
@ -103,6 +111,12 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
title: Text('Delete Account'),
|
title: Text('Delete Account'),
|
||||||
onTap: () => _deleteAccount(context),
|
onTap: () => _deleteAccount(context),
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
) : CupertinoButton(
|
||||||
|
color: Colors.pink,
|
||||||
|
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
|
||||||
|
onPressed: () => context.push('/welcome'),
|
||||||
|
),
|
||||||
|
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
|
|
||||||
|
@ -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 'package:intl/intl.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'lib.dart';
|
||||||
|
|
||||||
class _UserScreenState extends State<UserScreen> {
|
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
|
||||||
final Util util = new Util();
|
final String username;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
Map<String,dynamic> _user;
|
TabController? _tabController;
|
||||||
|
Map<String,dynamic>? _user;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
_UserScreenState(this._user) { }
|
_UserScreenState(this.username) { }
|
||||||
|
|
||||||
@override
|
@override
|
||||||
initState() {
|
initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
getUser(_user['username']);
|
_tabController = new TabController(length: 2, vsync: this);
|
||||||
|
getUser(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
void getUser(String username) async {
|
void getUser(String username) async {
|
||||||
@ -31,75 +34,137 @@ class _UserScreenState extends State<UserScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget getBody() {
|
||||||
Widget build(BuildContext context) {
|
if (_loading)
|
||||||
String created;
|
return CircularProgressIndicator();
|
||||||
if (_user['createdAt'] != null) {
|
else if (_user != null && _tabController != null) {
|
||||||
DateTime createdAt = DateTime.parse(_user['createdAt']);
|
var u = _user!;
|
||||||
|
String? created;
|
||||||
|
if (u['createdAt'] != null) {
|
||||||
|
DateTime createdAt = DateTime.parse(u['createdAt']!);
|
||||||
created = DateFormat('MMMM y').format(createdAt);
|
created = DateFormat('MMMM y').format(createdAt);
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Column(
|
||||||
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(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
util.avatarImage(util.avatarUrl(_user), size: 120),
|
Util.avatarImage(Util.avatarUrl(u), size: 120),
|
||||||
Expanded(child: Container(
|
Container(
|
||||||
padding: EdgeInsets.only(left: 10),
|
padding: EdgeInsets.only(left: 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(_user['username'], style: Theme.of(context).textTheme.titleMedium),
|
Text(u['username'], style: Theme.of(context).textTheme.titleLarge),
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
_user['location'] != null ?
|
u['location'] != null ?
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Icon(CupertinoIcons.location),
|
Icon(CupertinoIcons.location),
|
||||||
Text(_user['location'])
|
SizedBox(width: 10),
|
||||||
|
Text(u['location'])
|
||||||
]) : SizedBox(height: 1),
|
]) : SizedBox(height: 1),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('Member' + (created != null ? (' since ' + created) : ''),
|
Text('Member' + (created != null ? (' since ' + created!) : ''),
|
||||||
style: TextStyle(color: Colors.grey[500])
|
style: TextStyle(color: Colors.grey[500])
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
_user['website'] != null ?
|
u['website'] != null ?
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
String url = _user['website'];
|
String url = u['website'];
|
||||||
if (!url.startsWith('http')) {
|
if (!url.startsWith('http')) {
|
||||||
url = 'http://' + url;
|
url = 'http://' + url;
|
||||||
}
|
}
|
||||||
launch(url);
|
launch(url);
|
||||||
},
|
},
|
||||||
child: Text(_user['website'],
|
child: Text(u['website'],
|
||||||
style: TextStyle(color: Colors.pink))
|
style: TextStyle(color: Colors.pink))
|
||||||
) : SizedBox(height: 1),
|
) : 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 {
|
class UserScreen extends StatefulWidget {
|
||||||
final Map<String,dynamic> user;
|
final String username;
|
||||||
UserScreen(this.user) { }
|
UserScreen(this.username) { }
|
||||||
@override
|
@override
|
||||||
_UserScreenState createState() => _UserScreenState(user);
|
_UserScreenState createState() => _UserScreenState(username);
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'model.dart';
|
||||||
|
|
||||||
|
String APP_URL = 'https://www.treadl.com';
|
||||||
|
|
||||||
class Util {
|
class Util {
|
||||||
|
|
||||||
ImageProvider avatarUrl(Map<String,dynamic> user) {
|
static ImageProvider? avatarUrl(Map<String,dynamic> user) {
|
||||||
ImageProvider a = AssetImage('assets/avatars/9.png');
|
|
||||||
if (user != null && user['avatar'] != null) {
|
if (user != null && user['avatar'] != null) {
|
||||||
if (user['avatar'].length < 3) {
|
if (user['avatar'].length < 3) {
|
||||||
a = AssetImage('assets/avatars/${user['avatar']}.png');
|
return AssetImage('assets/avatars/${user['avatar']}.png');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
a =NetworkImage(user['avatarUrl']);
|
return NetworkImage(user['avatarUrl']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return a;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget avatarImage(ImageProvider image, {double size=30}) {
|
static Widget avatarImage(ImageProvider? image, {double size=30}) {
|
||||||
|
if (image != null) {
|
||||||
return new Container(
|
return new Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: 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/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'store.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
|
|
||||||
class WelcomeScreen extends StatelessWidget {
|
class WelcomeScreen extends StatelessWidget {
|
||||||
void _login(BuildContext context) {
|
void _login(BuildContext context) {
|
||||||
Navigator.of(context).pushNamed('/login');
|
context.push('/login');
|
||||||
}
|
}
|
||||||
void _register(BuildContext context) {
|
void _register(BuildContext context) {
|
||||||
Navigator.of(context).pushNamed('/register');
|
context.push('/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -36,11 +36,20 @@ class WelcomeScreen extends StatelessWidget {
|
|||||||
SizedBox(height: 15),
|
SizedBox(height: 15),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
onPressed: () => _register(context),
|
onPressed: () => _register(context),
|
||||||
|
color: Colors.pink[400],
|
||||||
child: new Text("Register",
|
child: new Text("Register",
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 35),
|
||||||
|
CupertinoButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: new Text("Cancel",
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
1
mobile/linux/.gitignore
vendored
Normal file
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