26 Commits
Author SHA1 Message Date
drake 1879150e72 kotlin upgrade 5 2024-06-28 13:43:03 -05:00
drake c233d71119 kotlin upgrade 4 2024-06-28 13:28:40 -05:00
drake 1e215ee86d kotlin upgrade 3 2024-06-28 13:18:08 -05:00
drake b8d2fc208b kotlin upgrade 2 2024-06-28 13:01:16 -05:00
drake a91364a5d7 kotlin upgrade 2024-06-28 12:46:02 -05:00
drake db8f44234a more fixes 2024-06-27 21:50:36 -05:00
drake 921ce17736 Api change 2024-06-27 12:07:59 -05:00
drake 4ce022d23d clearbit logo proxy 2 + sign in page 2024-06-27 11:56:27 -05:00
drake a875a1430d clearbit logo proxy 2024-06-27 11:35:26 -05:00
drake 0360736af0 api change for weird cors v3 2024-06-27 11:12:00 -05:00
drake 1802461f77 api change for weird cors v2 2024-06-27 10:49:52 -05:00
drake b92626b677 api change for weird cors 2024-06-27 10:41:34 -05:00
drake 1a6bf08bde more fixes 2024-06-26 21:35:47 -05:00
drake 1f7b850d66 formatting changes for presentation 2024-06-26 20:59:30 -05:00
drake 577801423d more fixes 2024-06-26 20:33:05 -05:00
drake a97ec0411d sign in page changes 2024-06-26 20:22:32 -05:00
drake 02bce8318a more fixes 2 2024-06-26 16:01:37 -05:00
drake 5614888454 more fixes 2024-06-26 13:30:02 -05:00
drake fd94dbf20d fixes 2024-06-26 13:06:03 -05:00
drake c4758eac73 api Change and catch change 2024-06-25 18:37:54 -05:00
drake 1e45bd173b api Change 2024-06-25 18:37:14 -05:00
drake e1f8c15e9a More fixes 2024-06-25 18:27:53 -05:00
drake 3cdf3b54ed API change and README updates 2024-06-24 20:24:52 -05:00
drake c65e225291 fixes 2024-06-23 18:56:22 -05:00
drake b860ae52f6 more fixes and features 2024-06-23 17:02:19 -05:00
drake 03abc1191d 0.2 fixes 2024-06-23 14:36:18 -05:00
23 changed files with 1998 additions and 1870 deletions
+30 -11
View File
@@ -1,10 +1,10 @@
# Job Link # Job Link
This is my app `Job Link` for the 2023-2024 FBLA Coding and Programming event.\ This is my app `Job Link` for the 2023-2024 FBLA Coding and Programming event.\
WI SLC Winner. WI SLC Winner.
It uses the [Flutter Framework](https://flutter.dev/) for the front end and the [Dart Language](https://dart.dev/) for both the front end and API. It uses the [Flutter Framework](https://flutter.dev/) for the front end and
the [Dart Language](https://dart.dev/) for both the front end and API.
## 3rd Party Libraries ## 3rd Party Libraries
@@ -16,24 +16,32 @@ It uses the [Flutter Framework](https://flutter.dev/) for the front end and the
- [open_filex](https://pub.dev/packages/open_filex) - [open_filex](https://pub.dev/packages/open_filex)
- [postgres](https://pub.dev/packages/postgres) - [postgres](https://pub.dev/packages/postgres)
- [argon2](https://pub.dev/packages/argon2) - [argon2](https://pub.dev/packages/argon2)
- [rive](https://pub.dev/packages/rive)
[Clearbit's logo API](https://clearbit.com/logo) was also used to get logos for businesses.
## Requirements ## Requirements
### Job Link ### Job Link
- **OS**: Windows, Linux, MacOS, Android, IOS. Note that some releases may not contain a MacOS or IOS build.
- **OS**: Windows, Linux, MacOS, Android, IOS. Note that some releases may not contain a MacOS or
IOS build.
- Stable internet connection. - Stable internet connection.
### API ### API
- **OS**: Windows, Linux, MacOS. - **OS**: Windows, Linux, MacOS.
- Stable internet connection. - Stable internet connection.
## Installation/Usage ## Installation/Usage
Please view the README in [fbla_ui](fbla_ui/README.md) and [fbla-api](fbla-api/README.md) for specific instructions for installation and usage for each part of the app. Please view the README in [fbla_ui](fbla_ui/README.md) and [fbla-api](fbla-api/README.md) for
specific instructions for installation and usage for each part of the app.
## Competitions ## Competitions
[Here](https://docs.google.com/presentation/d/1ZbSE9RqobU2T-NDIm3CUtT_9nEhOm3_B47NZl1-c_QA) is the presentation used for competitions. [Here](https://docs.google.com/presentation/d/1ZbSE9RqobU2T-NDIm3CUtT_9nEhOm3_B47NZl1-c_QA) is the
presentation used for competitions.
### WI State Leadership Conference ### WI State Leadership Conference
@@ -44,13 +52,24 @@ Used release 0.1.1\
Questions asked (with my answers): Questions asked (with my answers):
**Q**: Why did you decide to use the apps (framework and tools I assume) you used?\ **Q**: Why did you decide to use the apps (framework and tools I assume) you used?\
**A**: I decided to use Flutter primarily because of its cross-platform capabilities and its integration with the [Material design specification](https://m3.material.io/). I also decided to use it because I had some previous experience with Flutter and Dart, and Dart is somewhat similar to Java which I also had experience with. **A**: I decided to use Flutter primarily because of its cross-platform capabilities and its
integration with the [Material design specification](https://m3.material.io/). I also decided to use
it because I had some previous experience with Flutter and Dart, and Dart is somewhat similar to
Java which I also had experience with.
This one seems to just be a clarification for the `User input is validated` category of the [scoring sheet](https://connect.fbla.org/headquarters/files/High%20School%20Competitive%20Events%20Resources/Individual%20Guidelines/Presentation%20Events/Coding--Programming.pdf) (page 5-6)\ This one seems to just be a clarification for the `User input is validated` category of
**Q**: You mentioned that you validated that the description [of the business] is required, do you validate all the inputs or just that?\ the [scoring sheet](https://connect.fbla.org/headquarters/files/High%20School%20Competitive%20Events%20Resources/Individual%20Guidelines/Presentation%20Events/Coding--Programming.pdf) (
**A**: I validate that all of the required fields are inputted, and I also check for a valid email address and website format. page 5-6)\
**Q**: You mentioned that you validated that the description [of the business] is required, do you
validate all the inputs or just that?\
**A**: I validate that all of the required fields are inputted, and I also check for a valid email
address and website format.
This seems to be for the [scoring sheet](https://connect.fbla.org/headquarters/files/High%20School%20Competitive%20Events%20Resources/Individual%20Guidelines/Presentation%20Events/Coding--Programming.pdf) (page 5-6) `Code Quality` section.\ This seems to be for
the [scoring sheet](https://connect.fbla.org/headquarters/files/High%20School%20Competitive%20Events%20Resources/Individual%20Guidelines/Presentation%20Events/Coding--Programming.pdf) (
page 5-6) `Code Quality` section.\
While looking through a binder of the presentation and source code:\ While looking through a binder of the presentation and source code:\
**Q**: Is your source code all properly commented?\ **Q**: Is your source code all properly commented?\
**A**: While it is hard to comment UI code because of the nature of it, I did my best to use comments where applicable, and I also provide extensive documentation of the installation and usage of the app in the documentation in the README files. **A**: While it is hard to comment UI code because of the nature of it, I did my best to use
comments where applicable, and I also provide extensive documentation of the installation and usage
of the app in the documentation in the README files.
+55 -7
View File
@@ -4,13 +4,16 @@ This is the API for my 2023-2024 FBLA Coding & Programming App
1. Install [Dart SDK](https://dart.dev/get-dart). 1. Install [Dart SDK](https://dart.dev/get-dart).
2. Clone the repo . 2. Clone the repo .
```bash ```bash
git clone https://git.marinodev.com/MarinoDev/FBLA24.git git clone https://git.marinodev.com/MarinoDev/FBLA24.git
cd FBLA24/fbla-api/ cd FBLA24/fbla-api/
``` ```
3. Run `dart pub install` to install dart packages. 3. Run `dart pub install` to install dart packages.
4. Install [PostgreSQL](https://www.postgresql.org/) and set it up. 4. Install [PostgreSQL](https://www.postgresql.org/) and set it up.
5. Create `fbla` database in postgres. 5. Create `fbla` database in postgres.
```SQL ```SQL
CREATE DATABASE fbla CREATE DATABASE fbla
WITH WITH
@@ -19,9 +22,11 @@ CREATE DATABASE fbla
CONNECTION LIMIT = -1 CONNECTION LIMIT = -1
IS_TEMPLATE = False; IS_TEMPLATE = False;
``` ```
Make sure to change [username] to the actual username of your postgres instance. Make sure to change [username] to the actual username of your postgres instance.
6. Create `businesses` table. 6. Create `businesses` table.
```SQL ```SQL
-- Table: public.businesses -- Table: public.businesses
@@ -48,9 +53,39 @@ TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.businesses ALTER TABLE IF EXISTS public.businesses
OWNER to [username]; OWNER to [username];
``` ```
Make sure to change [username] to the actual username of your postgres instance. Make sure to change [username] to the actual username of your postgres instance.
7. Create `users` table. 7. Create `listings` table.
```SQL
-- Table: public.listings
-- DROP TABLE IF EXISTS public.listings;
CREATE TABLE IF NOT EXISTS public.listings
(
id integer NOT NULL GENERATED ALWAYS AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
"businessId" integer NOT NULL,
name text COLLATE pg_catalog."default" NOT NULL,
description text COLLATE pg_catalog."default" NOT NULL,
type text COLLATE pg_catalog."default" NOT NULL,
wage text COLLATE pg_catalog."default",
link text COLLATE pg_catalog."default",
"offerType" text COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT listing_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.listings
OWNER to [username];
```
Make sure to change [username] to the actual username of your postgres instance.
8. Create `users` table.
```SQL ```SQL
-- Table: public.users -- Table: public.users
@@ -68,11 +103,19 @@ CREATE TABLE IF NOT EXISTS public.users
TABLESPACE pg_default; TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.users ALTER TABLE IF EXISTS public.users
OWNER to postgres; OWNER to [username];
``` ```
8. Set environment variables `JOBLINK_POSTGRES_ADDRESS` (IP address), `JOBLINK_POSTGRES_PORT` (default 5432), `JOBLINK_POSTGRES_USERNAME`, and `JOBLINK_POSTGRES_PASSWORD` to appropriate information for your postgres database. Also set `JOBLINK_SECRET_KEY` to anything you want to generate JSON Web Tokens.
9. Set lib/create_first_user.dart username and password variables at top of file and run with `dart run lib/create_first_user.dart`.\ Make sure to change [username] to the actual username of your postgres instance.
Note: the username and password you use here will be what you use to log into the app as an admin.
8. Set environment variables `JOBLINK_POSTGRES_ADDRESS` (IP address), `JOBLINK_POSTGRES_PORT` (
default 5432), `JOBLINK_POSTGRES_USERNAME`, and `JOBLINK_POSTGRES_PASSWORD` to appropriate
information for your postgres database. Also set `JOBLINK_SECRET_KEY` to anything you want to
generate JSON Web Tokens.
9. Set lib/create_first_user.dart username and password variables at top of file and run
with `dart run lib/create_first_user.dart`.\
Note: the username and password you use here will be what you use to log into the app as an
admin.
## Usage ## Usage
@@ -83,9 +126,14 @@ Note the address it is serving at, you will need this to connect it to the UI, a
### Docker ### Docker
Run it in a docker container with [Docker Build](https://docs.docker.com/reference/cli/docker/image/build/) and [Docker Compose](https://docs.docker.com/compose/) using the included [Dockerfile](Dockerfile) and [docker-compose.yml](docker-compose.yml) files. If using `Docker Compose`, change the first portion of the volumes path on line 9 to any desired storage location.\ Run it in a docker container
with [Docker Build](https://docs.docker.com/reference/cli/docker/image/build/)
and [Docker Compose](https://docs.docker.com/compose/) using the included [Dockerfile](Dockerfile)
and [docker-compose.yml](docker-compose.yml) files. If using `Docker Compose`, change the first
portion of the volumes path on line 9 to any desired storage location.\
Note the address it is serving at, you will need this to connect it to the UI, and to run tests. Note the address it is serving at, you will need this to connect it to the UI, and to run tests.
### Testing ### Testing
You can test all functionality of your api with `dart run test/fbla_api_test.dart`, setting `apiAddress` on line 8 to your api address. You can test all functionality of your api with `dart run test/fbla_api_test.dart`,
setting `apiAddress` on line 8 to your api address.
+8 -7
View File
@@ -1,14 +1,17 @@
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:argon2/argon2.dart'; import 'package:argon2/argon2.dart';
import 'dart:io';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
// Set these to the desired username and password of your user
String username = 'admin'; String username = 'admin';
String password = 'password'; String password = 'adminPassword';
var r = Random.secure(); var r = Random.secure();
String randomSalt = String.fromCharCodes(List.generate(32, (index) => r.nextInt(33) + 89)); String randomSalt =
String.fromCharCodes(List.generate(32, (index) => r.nextInt(33) + 89));
final salt = randomSalt.toBytesLatin1(); final salt = randomSalt.toBytesLatin1();
var parameters = Argon2Parameters( var parameters = Argon2Parameters(
@@ -37,10 +40,8 @@ Future<void> main() async {
argon2.generateBytes(passwordBytes, result); argon2.generateBytes(passwordBytes, result);
var resultHex = result.toHexString(); var resultHex = result.toHexString();
postgres.query( postgres.query('''
'''
INSERT INTO public.users (username, password_hash, salt) INSERT INTO public.users (username, password_hash, salt)
VALUES ('$username', '$resultHex', '$randomSalt') VALUES ('$username', '$resultHex', '$randomSalt')
''' ''');
);
} }
+107 -54
View File
@@ -22,7 +22,19 @@ enum BusinessType {
other, other,
} }
enum JobType { cashier, server, mechanic, other } enum JobType {
retail,
customerService,
foodService,
finance,
healthcare,
education,
maintenance,
manufacturing,
other,
}
enum OfferType { job, internship, apprenticeship }
class Business { class Business {
int id; int id;
@@ -84,6 +96,7 @@ class JobListing {
JobType type; JobType type;
String? wage; String? wage;
String? link; String? link;
OfferType offerType;
JobListing( JobListing(
{this.id, {this.id,
@@ -92,7 +105,8 @@ class JobListing {
required this.description, required this.description,
required this.type, required this.type,
this.wage, this.wage,
this.link}); this.link,
required this.offerType});
factory JobListing.fromJson(Map<String, dynamic> json) { factory JobListing.fromJson(Map<String, dynamic> json) {
bool typeValid = true; bool typeValid = true;
@@ -110,7 +124,7 @@ class JobListing {
type: typeValid ? JobType.values.byName(json['type']) : JobType.other, type: typeValid ? JobType.values.byName(json['type']) : JobType.other,
wage: json['wage'], wage: json['wage'],
link: json['link'], link: json['link'],
); offerType: OfferType.values.byName(json['offerType']));
} }
} }
@@ -154,6 +168,15 @@ void main() async {
final app = Router(); final app = Router();
// CORS preflight acceptor
app.options('/<ignored|.*>', (Request request) {
return Response.ok(null, headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
});
});
// routes // routes
app.get('/fbla-api/hello', (Request request) async { app.get('/fbla-api/hello', (Request request) async {
print('Hello received'); print('Hello received');
@@ -166,49 +189,57 @@ void main() async {
app.get('/fbla-api/businessdata/overview/jobs', (Request request) async { app.get('/fbla-api/businessdata/overview/jobs', (Request request) async {
print('business overview request received'); print('business overview request received');
var filters = request.url.queryParameters['filters']?.split(',') ?? var typeFilters = request.url.queryParameters['typeFilters']?.split(',') ??
JobType.values.asNameMap().keys; JobType.values.asNameMap().keys;
var offerFilters =
request.url.queryParameters['offerFilters']?.split(',') ??
OfferType.values.asNameMap().keys;
Map<String, dynamic> output = {};
for (int i = 0; i < filters.length; i++) {
var postgresResult = (await postgres.query(''' var postgresResult = (await postgres.query('''
SELECT json_agg( SELECT jsonb_agg(
json_build_object( jsonb_build_object(
'id', b.id, 'id', b.id,
'name', b.name, 'name', b.name,
'contactName', b."contactName", 'contactName', b."contactName",
'contactEmail', b."contactEmail", 'contactEmail', b."contactEmail",
'contactPhone', b."contactPhone", 'contactPhone', b."contactPhone",
'locationName', b."locationName", 'locationName', b."locationName",
'listings', ( 'locationAddress', b."locationAddress",
SELECT json_agg( 'listings', b.listings
json_build_object(
'id', l.id,
'name', l.name,
'description', l.description,
'type', l.type,
'wage', l.wage,
'link', l.link
) )
) AS result
FROM (
SELECT
businesses.id,
businesses.name,
businesses."contactName",
businesses."contactEmail",
businesses."contactPhone",
businesses."locationName",
businesses."locationAddress",
jsonb_agg(
jsonb_build_object(
'id', listings.id,
'name', listings.name,
'description', listings.description,
'type', listings.type,
'wage', listings.wage,
'link', listings.link,
'offerType', listings."offerType"
) )
FROM listings l ) AS listings
WHERE l."businessId" = b.id AND l.type = '${filters.elementAt(i)}' FROM businesses
) JOIN listings ON businesses.id = listings."businessId"
) AND listings.type IN (${typeFilters.map((element) => "'$element'").join(',')})
) AND listings."offerType" IN (${offerFilters.map((element) => "'$element'").join(',')})
FROM businesses b GROUP BY businesses.id, businesses.name, businesses."contactName", businesses."contactEmail",
WHERE b.id IN (SELECT "businessId" FROM public.listings WHERE type='${filters.elementAt(i)}') businesses."contactPhone", businesses."locationName", businesses."locationAddress"
GROUP BY b.id; ) b
WHERE b.listings IS NOT NULL;
''')); '''));
if (postgresResult.isNotEmpty) {
output.addAll({filters.elementAt(i): postgresResult[0][0]});
}
}
return Response.ok( return Response.ok(
json.encode(output), json.encode(postgresResult[0][0]),
headers: { headers: {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/plain' 'Content-Type': 'text/plain'
@@ -234,7 +265,8 @@ void main() async {
'website', website, 'website', website,
'contactEmail', "contactEmail", 'contactEmail', "contactEmail",
'contactPhone', "contactPhone", 'contactPhone', "contactPhone",
'locationName', "locationName" 'locationName', "locationName",
'locationAddress', "locationAddress"
) )
) FROM public.businesses WHERE type='${filters.elementAt(i)}' ) FROM public.businesses WHERE type='${filters.elementAt(i)}'
'''))[0][0]; '''))[0][0];
@@ -244,7 +276,6 @@ void main() async {
} }
} }
// await Future.delayed(Duration(seconds: 5));
return Response.ok( return Response.ok(
json.encode(output), json.encode(output),
headers: { headers: {
@@ -301,6 +332,7 @@ void main() async {
'name', l.name, 'name', l.name,
'description', l.description, 'description', l.description,
'type', l.type, 'type', l.type,
'offerType', l."offerType",
'wage', l.wage, 'wage', l.wage,
'link', l.link 'link', l.link
) )
@@ -348,25 +380,11 @@ void main() async {
'contactPhone', b."contactPhone", 'contactPhone', b."contactPhone",
'notes', b.notes, 'notes', b.notes,
'locationName', b."locationName", 'locationName', b."locationName",
'locationAddress', b."locationAddress", 'locationAddress', b."locationAddress"
'listings', CASE
WHEN COUNT(l.id) = 0 THEN 'null'
ELSE json_agg(
json_build_object(
'id', l.id,
'businessId', l."businessId",
'name', l.name,
'description', l.description,
'type', l.type,
'wage', l.wage,
'link', l.link
)
)
END
) )
FROM businesses b FROM businesses b
LEFT JOIN listings l ON b.id = l."businessId" LEFT JOIN listings l ON b.id = l."businessId"
WHERE b.id IN ${'$filters'.replaceAll('[', '(').replaceAll(']', ')')} WHERE b.id IN (${filters.join(',')})
GROUP BY b.id; GROUP BY b.id;
''')); '''));
@@ -413,8 +431,8 @@ void main() async {
print('business logo request received'); print('business logo request received');
var logo = File('logos/$logoId.png'); var logo = File('logos/$logoId.png');
try {
List<int> content = logo.readAsBytesSync(); List<int> content = logo.readAsBytesSync();
return Response.ok( return Response.ok(
content, content,
headers: { headers: {
@@ -422,6 +440,16 @@ void main() async {
'Content-Type': 'image/png' 'Content-Type': 'image/png'
}, },
); );
} catch (e) {
print('Error reading logo!');
return Response.notFound(
'logo not found',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'image/png'
},
);
}
}); });
app.post('/fbla-api/createbusiness', (Request request) async { app.post('/fbla-api/createbusiness', (Request request) async {
print('create business request received'); print('create business request received');
@@ -475,8 +503,8 @@ void main() async {
JobListing listing = JobListing.fromJson(json); JobListing listing = JobListing.fromJson(json);
await postgres.query(''' await postgres.query('''
INSERT INTO listings ("businessId", name, description, type, wage, link) INSERT INTO listings ("businessId", name, description, type, wage, link, "offerType")
VALUES ('${listing.businessId}', '${listing.name.replaceAll("'", "''")}', '${listing.description.replaceAll("'", "''")}', '${listing.type.name}', '${listing.wage ?? 'NULL'}', '${listing.link?.replaceAll("'", "''") ?? 'NULL'}') VALUES ('${listing.businessId}', '${listing.name.replaceAll("'", "''")}', '${listing.description.replaceAll("'", "''")}', '${listing.type.name}', '${listing.wage ?? 'NULL'}', '${listing.link?.replaceAll("'", "''") ?? 'NULL'}', '${listing.offerType.name}')
''' '''
.replaceAll("'null'", 'NULL')); .replaceAll("'null'", 'NULL'));
@@ -619,7 +647,7 @@ void main() async {
await postgres.query(''' await postgres.query('''
UPDATE listings SET UPDATE listings SET
"businessId" = ${listing.businessId}, name = '${listing.name.replaceAll("'", "''")}'::text, description = '${listing.description.replaceAll("'", "''")}'::text, type = '${listing.type.name}'::text, wage = '${listing.wage ?? 'NULL'}'::text, link = '${listing.link?.replaceAll("'", "''") ?? 'NULL'}'::text WHERE "businessId" = ${listing.businessId}, name = '${listing.name.replaceAll("'", "''")}'::text, description = '${listing.description.replaceAll("'", "''")}'::text, type = '${listing.type.name}'::text, wage = '${listing.wage ?? 'NULL'}'::text, link = '${listing.link?.replaceAll("'", "''") ?? 'NULL'}'::text, "offerType"='${listing.offerType.name}'::text WHERE
id = ${listing.id}; id = ${listing.id};
''' '''
.replaceAll("'null'", 'NULL')); .replaceAll("'null'", 'NULL'));
@@ -801,6 +829,31 @@ void main() async {
}, },
); );
}); });
app.get('/fbla-api/clearbit/<website>',
(Request request, String website) async {
print('clearbit logo request received');
website = Uri.decodeComponent(website);
var response =
await http.get(Uri.parse('https://logo.clearbit.com/$website'));
if (response.statusCode == 200) {
return Response.ok(
response.bodyBytes,
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'image/png'
},
);
} else {
return Response.notFound(
'logo not found',
headers: {
'Access-Control-Allow-Origin': '*',
},
);
}
});
// get ip address for hosting // get ip address for hosting
for (var interface in await NetworkInterface.list()) { for (var interface in await NetworkInterface.list()) {
+16 -16
View File
@@ -3,7 +3,7 @@ pipeline {
stages { stages {
stage('Flutter Cleanup') { stage('Flutter Cleanup') {
steps { steps {
sh '''flutter upgrade --force sh '''flutter upgrade
flutter pub upgrade flutter pub upgrade
flutter --version flutter --version
flutter doctor flutter doctor
@@ -40,10 +40,10 @@ flutter clean'''
steps { steps {
script { script {
def remote = [ def remote = [
name: 'HostServer', name : 'HostServer',
host: '192.168.0.216', host : '192.168.0.216',
user: 'fbla', user : 'fbla',
password: 'fbla', password : 'fbla',
allowAnyHosts: true, allowAnyHosts: true,
] ]
sshRemove(path: '/home/fbla/fbla-webserver/webfiles/fbla', remote: remote) sshRemove(path: '/home/fbla/fbla-webserver/webfiles/fbla', remote: remote)
@@ -58,13 +58,13 @@ flutter clean'''
steps { steps {
script { script {
def remote = [ def remote = [
name: 'HostServer', name : 'HostServer',
host: '192.168.0.216', host : '192.168.0.216',
user: 'fbla', user : 'fbla',
password: 'fbla', password : 'fbla',
allowAnyHosts: true, allowAnyHosts: true,
] ]
if(env.BRANCH_NAME == 'main') { if (env.BRANCH_NAME == 'main') {
sshRemove(path: '/home/fbla/builds/main/linux', remote: remote) sshRemove(path: '/home/fbla/builds/main/linux', remote: remote)
sshCommand remote: remote, command: "mkdir /home/fbla/builds/main/linux" sshCommand remote: remote, command: "mkdir /home/fbla/builds/main/linux"
sshPut(from: 'build/linux/x64/release', into: '/home/fbla/builds/main/linux', remote: remote) sshPut(from: 'build/linux/x64/release', into: '/home/fbla/builds/main/linux', remote: remote)
@@ -105,12 +105,12 @@ flutter clean'''
steps { steps {
script { script {
def remote = [ def remote = [
name: 'MarinoDev', name : 'MarinoDev',
host: 'marinodev.com', host : 'marinodev.com',
port: 21098, port : 21098,
user: 'mariehdi', user : 'mariehdi',
identityFile: '/var/jenkins_home/marinoDevPrivateKey', identityFile : '/var/jenkins_home/marinoDevPrivateKey',
passphrase: 'marinodev', passphrase : 'marinodev',
allowAnyHosts: true, allowAnyHosts: true,
] ]
sshRemove(path: '/home/mariehdi/public_html/fbla', remote: remote) sshRemove(path: '/home/mariehdi/public_html/fbla', remote: remote)
+3
View File
@@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
use_build_context_synchronously: ignore
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
+6 -10
View File
@@ -1,3 +1,9 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@@ -21,10 +22,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
namespace "com.marinodev.fbla_ui" namespace "com.marinodev.fbla_ui"
compileSdkVersion flutter.compileSdkVersion compileSdkVersion flutter.compileSdkVersion
@@ -69,5 +66,4 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
} }
+1 -13
View File
@@ -1,16 +1,3 @@
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -22,6 +9,7 @@ rootProject.buildDir = '../build'
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
subprojects { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
+22 -8
View File
@@ -1,11 +1,25 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.24" apply false
}
include ":app"
+13 -86
View File
@@ -20,6 +20,7 @@ class Home extends StatefulWidget {
class _HomeState extends State<Home> { class _HomeState extends State<Home> {
Set<JobType> jobTypeFilters = <JobType>{}; Set<JobType> jobTypeFilters = <JobType>{};
Set<OfferType> offerTypeFilters = <OfferType>{};
Set<BusinessType> businessTypeFilters = <BusinessType>{}; Set<BusinessType> businessTypeFilters = <BusinessType>{};
String searchQuery = ''; String searchQuery = '';
late Future refreshBusinessDataOverviewJobFuture; late Future refreshBusinessDataOverviewJobFuture;
@@ -65,12 +66,16 @@ class _HomeState extends State<Home> {
} }
Future<void> _updateOverviewBusinessesJobsCallback( Future<void> _updateOverviewBusinessesJobsCallback(
Set<JobType>? newFilters) async { Set<JobType>? newJobTypeFilters,
if (newFilters != null) { Set<OfferType>? newOfferTypeFilters) async {
jobTypeFilters = Set.from(newFilters); if (newJobTypeFilters != null) {
jobTypeFilters = Set.from(newJobTypeFilters);
} }
var refreshedData = if (newOfferTypeFilters != null) {
fetchBusinessDataOverviewJobs(typeFilters: jobTypeFilters.toList()); offerTypeFilters = Set.from(newOfferTypeFilters);
}
var refreshedData = fetchBusinessDataOverviewJobs(
typeFilters: jobTypeFilters, offerFilters: offerTypeFilters);
await refreshedData; await refreshedData;
setState(() { setState(() {
refreshBusinessDataOverviewJobFuture = refreshedData; refreshBusinessDataOverviewJobFuture = refreshedData;
@@ -94,13 +99,11 @@ class _HomeState extends State<Home> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth; bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth;
return Scaffold( return Scaffold(
// floatingActionButton: _getFAB(widescreen, scrollControllerBusinesses,
// scrollControllerJobs, currentPageIndex),
bottomNavigationBar: _getNavigationBar(widescreen), bottomNavigationBar: _getNavigationBar(widescreen),
body: RefreshIndicator( body: RefreshIndicator(
edgeOffset: 145, edgeOffset: 145,
onRefresh: () async { onRefresh: () async {
_updateOverviewBusinessesJobsCallback(null); _updateOverviewBusinessesJobsCallback(null, null);
_updateOverviewBusinessesBusinessCallback(null); _updateOverviewBusinessesBusinessCallback(null);
}, },
child: widescreen child: widescreen
@@ -168,13 +171,6 @@ class _HomeState extends State<Home> {
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
label: 'Job Listings'), label: 'Job Listings'),
// NavigationDestination(
// icon: const Icon(Icons.description_outlined),
// selectedIcon: Icon(
// Icons.description,
// color: Theme.of(context).colorScheme.onSurface,
// ),
// label: 'Export Data')
], ],
); );
} }
@@ -186,7 +182,6 @@ class _HomeState extends State<Home> {
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: currentPageIndex, selectedIndex: currentPageIndex,
groupAlignment: -1,
indicatorColor: indicatorColor:
Theme.of(context).colorScheme.primary.withOpacity(0.5), Theme.of(context).colorScheme.primary.withOpacity(0.5),
trailing: Expanded( trailing: Expanded(
@@ -219,7 +214,6 @@ class _HomeState extends State<Home> {
), ),
if (loggedIn) if (loggedIn)
FloatingActionButton( FloatingActionButton(
child: Icon(Icons.add),
heroTag: 'Homepage', heroTag: 'Homepage',
onPressed: () { onPressed: () {
if (currentPageIndex == 0) { if (currentPageIndex == 0) {
@@ -236,6 +230,7 @@ class _HomeState extends State<Home> {
const CreateEditJobListing())); const CreateEditJobListing()));
} }
}, },
child: const Icon(Icons.add),
) )
], ],
), ),
@@ -260,51 +255,11 @@ class _HomeState extends State<Home> {
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
label: const Text('Job Listings')), label: const Text('Job Listings')),
// NavigationRailDestination(
// icon: const Icon(Icons.description_outlined),
// selectedIcon: Icon(
// Icons.description,
// color: Theme.of(context).colorScheme.onSurface,
// ),
// label: const Text('Export Data'))
], ],
), ),
// children.first
], ],
); );
// }
// return children.first;
} }
// Widget _contentPane() {
// return IndexedStack(
// index: currentPageIndex,
// children: [
// BusinessesOverview(
// searchQuery: searchQuery,
// refreshBusinessDataOverviewFuture:
// refreshBusinessDataOverviewBusinessFuture,
// updateBusinessesCallback: _updateOverviewBusinessesBusinessCallback,
// themeCallback: widget.themeCallback,
// updateLoggedIn: _updateLoggedIn,
// ),
// JobsOverview(
// searchQuery: searchQuery,
// refreshJobDataOverviewFuture: refreshBusinessDataOverviewJobFuture,
// updateBusinessesCallback: _updateOverviewBusinessesJobsCallback,
// themeCallback: widget.themeCallback, updateLoggedIn: _updateLoggedIn),
// ExportData(
// searchQuery: searchQuery,
// refreshBusinessDataOverviewFuture:
// refreshBusinessDataOverviewBusinessFuture,
// updateBusinessesWithJobCallback:
// _updateOverviewBusinessesJobsCallback,
// themeCallback: widget.themeCallback,
// refreshJobDataOverviewFuture: refreshBusinessDataOverviewJobFuture,
// updateBusinessesCallback: _updateOverviewBusinessesBusinessCallback)
// ],
// );
// }
} }
class _ContentPane extends StatelessWidget { class _ContentPane extends StatelessWidget {
@@ -314,7 +269,7 @@ class _ContentPane extends StatelessWidget {
updateOverviewBusinessesBusinessCallback; updateOverviewBusinessesBusinessCallback;
final void Function() themeCallback; final void Function() themeCallback;
final Future refreshBusinessDataOverviewJobFuture; final Future refreshBusinessDataOverviewJobFuture;
final Future<void> Function(Set<JobType>) final Future<void> Function(Set<JobType>?, Set<OfferType>?)
updateOverviewBusinessesJobsCallback; updateOverviewBusinessesJobsCallback;
final int currentPageIndex; final int currentPageIndex;
final void Function(bool) updateLoggedIn; final void Function(bool) updateLoggedIn;
@@ -350,35 +305,7 @@ class _ContentPane extends StatelessWidget {
themeCallback: themeCallback, themeCallback: themeCallback,
updateLoggedIn: updateLoggedIn, updateLoggedIn: updateLoggedIn,
), ),
// ExportData(
// searchQuery: searchQuery,
// refreshBusinessDataOverviewFuture:
// refreshBusinessDataOverviewBusinessFuture,
// updateBusinessesWithJobCallback:
// updateOverviewBusinessesJobsCallback,
// themeCallback: themeCallback,
// refreshJobDataOverviewFuture: refreshBusinessDataOverviewJobFuture,
// updateBusinessesCallback: updateOverviewBusinessesBusinessCallback)
], ],
); );
} }
} }
// class FABAnimator extends FloatingActionButtonAnimator {
// @override
// Offset getOffset({Offset begin, Offset end, double progress}) {
// return end;
// }
//
// @override
// Animation<double> getRotationAnimation({required Animation<double> parent}) {
// return Tween<double>(begin: 0.0, end: 1.0).animate(parent);
// throw UnimplementedError();
// }
//
// @override
// Animation<double> getScaleAnimation({required Animation<double> parent}) {
// return Tween<double>(begin: 0.0, end: 1.0).animate(parent);
// throw UnimplementedError();
// }
// }
+43 -7
View File
@@ -69,32 +69,68 @@ class _MainAppState extends State<MainApp> {
title: 'Job Link', title: 'Job Link',
themeMode: themeMode, themeMode: themeMode,
darkTheme: ThemeData( darkTheme: ThemeData(
scaffoldBackgroundColor: const Color(0xFF121212),
colorScheme: ColorScheme.dark( colorScheme: ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
primary: Colors.blue.shade700, primary: Colors.blue.shade700,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: Colors.blue.shade900, secondary: Colors.blue.shade900,
onSecondary: Colors.white,
surface: const Color.fromARGB(255, 31, 31, 31), surface: const Color.fromARGB(255, 31, 31, 31),
surfaceContainer: const Color.fromARGB(255, 40, 40, 40), surfaceContainer: const Color.fromARGB(255, 46, 46, 46),
tertiary: Colors.green.shade900, tertiary: Colors.green.shade900,
), ),
iconTheme: const IconThemeData(color: Colors.white), iconTheme: const IconThemeData(color: Colors.white),
inputDecorationTheme: const InputDecorationTheme(),
useMaterial3: true, useMaterial3: true,
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.grey.withOpacity(0.1),
labelStyle: const TextStyle(color: Colors.grey),
floatingLabelStyle: WidgetStateTextStyle.resolveWith((states) {
if (states.contains(WidgetState.focused) &&
!states.contains(WidgetState.hovered)) {
return TextStyle(color: Colors.blue.shade700);
}
return const TextStyle(color: Colors.grey);
}),
),
dropdownMenuTheme: const DropdownMenuThemeData(
inputDecorationTheme: InputDecorationTheme(
filled: true,
),
),
), ),
theme: ThemeData( theme: ThemeData(
scaffoldBackgroundColor: Colors.grey.shade300,
colorScheme: ColorScheme.light( colorScheme: ColorScheme.light(
brightness: Brightness.light, brightness: Brightness.light,
primary: Colors.blue.shade700, primary: Colors.blue.shade700,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: Colors.blue.shade200, secondary: Colors.blue.shade300,
surface: Colors.grey.shade200, onSecondary: Colors.black,
surfaceContainer: Colors.grey.shade300, surface: Colors.grey.shade100,
surfaceContainer: Colors.grey.shade200,
tertiary: Colors.green, tertiary: Colors.green,
), ),
iconTheme: const IconThemeData(color: Colors.black), iconTheme: const IconThemeData(color: Colors.black),
inputDecorationTheme: inputDecorationTheme: InputDecorationTheme(
const InputDecorationTheme(border: UnderlineInputBorder()), // border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey.withOpacity(0.25),
labelStyle: TextStyle(color: Colors.grey.shade700),
floatingLabelStyle: WidgetStateTextStyle.resolveWith((states) {
if (states.contains(WidgetState.focused) &&
!states.contains(WidgetState.hovered)) {
return TextStyle(color: Colors.blue.shade700);
}
return TextStyle(color: Colors.grey.shade700);
}),
),
dropdownMenuTheme: const DropdownMenuThemeData(
inputDecorationTheme: InputDecorationTheme(
filled: true,
),
),
useMaterial3: true, useMaterial3: true,
), ),
home: Home(themeCallback: _switchTheme, initialPage: widget.initialPage), home: Home(themeCallback: _switchTheme, initialPage: widget.initialPage),
+74 -81
View File
@@ -4,12 +4,12 @@ import 'package:fbla_ui/pages/create_edit_listing.dart';
import 'package:fbla_ui/pages/listing_detail.dart'; import 'package:fbla_ui/pages/listing_detail.dart';
import 'package:fbla_ui/shared/api_logic.dart'; import 'package:fbla_ui/shared/api_logic.dart';
import 'package:fbla_ui/shared/global_vars.dart'; import 'package:fbla_ui/shared/global_vars.dart';
import 'package:fbla_ui/shared/utils.dart';
import 'package:fbla_ui/shared/widgets.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rive/rive.dart'; import 'package:rive/rive.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../shared/utils.dart';
class BusinessDetail extends StatefulWidget { class BusinessDetail extends StatefulWidget {
final int id; final int id;
final String name; final String name;
@@ -22,6 +22,7 @@ class BusinessDetail extends StatefulWidget {
class _CreateBusinessDetailState extends State<BusinessDetail> { class _CreateBusinessDetailState extends State<BusinessDetail> {
late Future loadBusiness; late Future loadBusiness;
bool _isRetrying = false;
@override @override
void initState() { void initState() {
@@ -60,11 +61,17 @@ class _CreateBusinessDetailState extends State<BusinessDetail> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: FilledButton( child: FilledButton(
child: const Text('Retry'), child: const Text('Retry'),
onPressed: () { onPressed: () async {
var refreshedData = fetchBusiness(widget.id); if (!_isRetrying) {
setState(() {
_isRetrying = true;
});
var refreshedData =
await fetchBusiness(widget.id);
setState(() { setState(() {
loadBusiness = refreshedData; loadBusiness = refreshedData;
}); });
}
}, },
), ),
), ),
@@ -105,36 +112,50 @@ class _CreateBusinessDetailState extends State<BusinessDetail> {
}); });
} }
ListView _detailBody(Business business) { Widget _detailBody(Business business) {
return ListView( return ListView(
children: [ children: [
// Title, logo, desc, website // Title, logo, desc, website
Center(
child: SizedBox(
width: 800,
child: Column(
children: [
Padding( Padding(
padding: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.only(top: 4.0),
child: Card( child: Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
ListTile( Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ListTile(
titleAlignment: ListTileTitleAlignment.titleHeight,
title: Text(business.name!, title: Text(business.name!,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold)), fontSize: 24, fontWeight: FontWeight.bold)),
subtitle: Text( subtitle: Text(
business.description!, business.description!,
textAlign: TextAlign.left,
), ),
contentPadding:
const EdgeInsets.only(bottom: 8, left: 16),
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
child: Image.network('$apiAddress/logos/${business.id}', child: Image.network(
'$apiAddress/logos/${business.id}',
width: 48, width: 48,
height: 48, errorBuilder: (BuildContext context, height: 48, errorBuilder:
Object exception, StackTrace? stackTrace) { (BuildContext context, Object exception,
return Icon(getIconFromBusinessType(business.type!), StackTrace? stackTrace) {
return Icon(
getIconFromBusinessType(
business.type ?? BusinessType.other),
size: 48); size: 48);
}), }),
), ),
), ),
),
if (business.website != null)
ListTile( ListTile(
leading: const Icon(Icons.link), leading: const Icon(Icons.link),
title: const Text('Website'), title: const Text('Website'),
@@ -153,81 +174,45 @@ class _CreateBusinessDetailState extends State<BusinessDetail> {
), ),
), ),
// Available positions // Available positions
if (business.listings != null) if (business.listings != null || loggedIn)
Card(
clipBehavior: Clip.antiAlias,
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: _GetListingsTitle(business)),
_JobList(business: business)
]),
),
// Contact info
Card( Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0), padding: const EdgeInsets.only(left: 16, top: 8),
child: Text( child: _GetListingsTitle(business)),
business.contactName!, if (business.listings != null)
textAlign: TextAlign.left, _JobList(business: business)
style: const TextStyle( else
fontSize: 20, fontWeight: FontWeight.bold), Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(left: 16.0),
child: Text('No job listings exist.'),
), ),
),
],
),
if (business.contactPhone != null)
ListTile(
leading: const Icon(Icons.phone),
title: Text(business.contactPhone!),
// maybe replace ! with ?? ''. same is true for below
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor:
Theme.of(context).colorScheme.surface,
title: Text('Contact ${business.contactName}'),
content: Text(
'Would you like to call or text ${business.contactName}?'),
actions: [
TextButton( TextButton(
child: const Text('Text'), child: const Text('add one?'),
onPressed: () { onPressed: () {
launchUrl(Uri.parse( Navigator.of(context).push(
'sms:${business.contactPhone}')); MaterialPageRoute(
Navigator.of(context).pop(); builder: (context) =>
}), CreateEditJobListing(
TextButton( inputBusiness: business,
child: const Text('Call'), )));
onPressed: () async {
launchUrl(Uri.parse(
'tel:${business.contactPhone}'));
Navigator.of(context).pop();
}),
],
);
});
}, },
), )
if (business.contactEmail != null)
ListTile(
leading: const Icon(Icons.email),
title: Text(business.contactEmail!),
onTap: () {
launchUrl(Uri.parse('mailto:${business.contactEmail}'));
},
),
], ],
), ),
)
]),
), ),
// Contact info
ContactInformationCard(business: business),
// Location // Location
Card( Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -237,7 +222,7 @@ class _CreateBusinessDetailState extends State<BusinessDetail> {
subtitle: Text(business.locationAddress!), subtitle: Text(business.locationAddress!),
onTap: () { onTap: () {
launchUrl(Uri.parse(Uri.encodeFull( launchUrl(Uri.parse(Uri.encodeFull(
'https://www.google.com/maps/search/?api=1&query=${business.locationName}'))); 'https://www.google.com/maps/search/?api=1&query=${business.locationName} ${business.locationAddress}')));
}, },
), ),
), ),
@@ -248,12 +233,17 @@ class _CreateBusinessDetailState extends State<BusinessDetail> {
leading: const Icon(Icons.notes), leading: const Icon(Icons.notes),
title: const Text( title: const Text(
'Additional Notes', 'Additional Notes',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
), ),
subtitle: Text(business.notes!), subtitle: Text(business.notes!),
), ),
), ),
], ],
),
),
),
],
); );
} }
@@ -350,6 +340,7 @@ class _JobListItem extends StatelessWidget {
subtitle: Text( subtitle: Text(
jobListing.description, jobListing.description,
style: const TextStyle(overflow: TextOverflow.ellipsis), style: const TextStyle(overflow: TextOverflow.ellipsis),
maxLines: 2,
), ),
trailing: _getEditIcon(context, fromBusiness, jobListing), trailing: _getEditIcon(context, fromBusiness, jobListing),
onTap: () { onTap: () {
@@ -390,14 +381,16 @@ class _GetListingsTitle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!loggedIn) { if (!loggedIn) {
return const Text('Available Postitions', return Text(
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)); 'Available Postitions (${fromBusiness.listings?.length ?? '0'})',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold));
} else { } else {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Available Postitions', Text('Available Postitions (${fromBusiness.listings?.length ?? '0'})',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), style:
const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Padding( Padding(
padding: const EdgeInsets.only(right: 24.0), padding: const EdgeInsets.only(right: 24.0),
child: IconButton( child: IconButton(
+40 -20
View File
@@ -40,6 +40,7 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
ScrollController controller = ScrollController(); ScrollController controller = ScrollController();
bool _extended = true; bool _extended = true;
double prevPixelPosition = 0; double prevPixelPosition = 0;
bool _isRetrying = false;
Map<BusinessType, List<Business>> _filterBySearch( Map<BusinessType, List<Business>> _filterBySearch(
Map<BusinessType, List<Business>> businesses, String query) { Map<BusinessType, List<Business>> businesses, String query) {
@@ -138,9 +139,14 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: FilledButton( child: FilledButton(
child: const Text('Retry'), child: const Text('Retry'),
onPressed: () { onPressed: () async {
widget.updateBusinessesCallback( if (!_isRetrying) {
setState(() {
_isRetrying = true;
});
await widget.updateBusinessesCallback(
businessTypeFilters); businessTypeFilters);
}
}, },
), ),
), ),
@@ -213,6 +219,7 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
: Theme.of(context).colorScheme.onSurface, : Theme.of(context).colorScheme.onSurface,
), ),
onPressed: () { onPressed: () {
selectedChips = Set.from(businessTypeFilters);
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
@@ -224,15 +231,24 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
}); });
} }
List<Padding> chips = []; List<Widget> chips = [];
for (var type in BusinessType.values) { for (var type in BusinessType.values) {
chips.add(Padding( chips.add(FilterChip(
padding: const EdgeInsets.all(4), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
child: FilterChip(
showCheckmark: false, showCheckmark: false,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)), borderRadius: BorderRadius.circular(20),
label: Text(getNameFromBusinessType(type)), side: BorderSide(
color:
Theme.of(context).colorScheme.secondary)),
selectedColor: Theme.of(context).colorScheme.secondary,
label: Text(
getNameFromBusinessType(type),
style: TextStyle(
color: selectedChips.contains(type)
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onSurface),
),
selected: selectedChips.contains(type), selected: selectedChips.contains(type),
onSelected: (bool selected) { onSelected: (bool selected) {
if (selected) { if (selected) {
@@ -241,8 +257,7 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
selectedChips.remove(type); selectedChips.remove(type);
} }
setDialogState(filters); setDialogState(filters);
}), }));
));
} }
return AlertDialog( return AlertDialog(
@@ -250,6 +265,8 @@ class _BusinessesOverviewState extends State<BusinessesOverview> {
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: Wrap( child: Wrap(
spacing: 8,
runSpacing: 8,
children: chips, children: chips,
), ),
), ),
@@ -338,9 +355,9 @@ class _BusinessDisplayPanelState extends State<BusinessDisplayPanel> {
); );
} }
List<BusinessHeader> headers = []; List<_BusinessHeader> headers = [];
for (BusinessType businessType in widget.groupedBusinesses.keys) { for (BusinessType businessType in widget.groupedBusinesses.keys) {
headers.add(BusinessHeader( headers.add(_BusinessHeader(
businessType: businessType, businessType: businessType,
widescreen: widget.widescreen, widescreen: widget.widescreen,
businesses: widget.groupedBusinesses[businessType]!)); businesses: widget.groupedBusinesses[businessType]!));
@@ -351,23 +368,22 @@ class _BusinessDisplayPanelState extends State<BusinessDisplayPanel> {
} }
} }
class BusinessHeader extends StatefulWidget { class _BusinessHeader extends StatefulWidget {
final BusinessType businessType; final BusinessType businessType;
final List<Business> businesses; final List<Business> businesses;
final bool widescreen; final bool widescreen;
const BusinessHeader({ const _BusinessHeader({
super.key,
required this.businessType, required this.businessType,
required this.businesses, required this.businesses,
required this.widescreen, required this.widescreen,
}); });
@override @override
State<BusinessHeader> createState() => _BusinessHeaderState(); State<_BusinessHeader> createState() => _BusinessHeaderState();
} }
class _BusinessHeaderState extends State<BusinessHeader> { class _BusinessHeaderState extends State<_BusinessHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverStickyHeader( return SliverStickyHeader(
@@ -391,7 +407,8 @@ class _BusinessHeaderState extends State<BusinessHeader> {
getIconFromBusinessType(widget.businessType), getIconFromBusinessType(widget.businessType),
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
)), )),
Text(getNameFromBusinessType(widget.businessType)), Text(getNameFromBusinessType(widget.businessType),
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary)),
], ],
); );
} }
@@ -433,6 +450,7 @@ class _BusinessHeaderState extends State<BusinessHeader> {
} }
} }
/// A desktop widget that displays basic info about a business
Widget _businessTile(Business business, BusinessType jobType) { Widget _businessTile(Business business, BusinessType jobType) {
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -491,17 +509,18 @@ class _BusinessHeaderState extends State<BusinessHeader> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (business.website != null)
IconButton( IconButton(
icon: const Icon(Icons.link), icon: const Icon(Icons.link),
onPressed: () { onPressed: () {
launchUrl(Uri.parse('https://${business.website}')); launchUrl(Uri.parse(business.website!));
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.location_on), icon: const Icon(Icons.location_on),
onPressed: () { onPressed: () {
launchUrl(Uri.parse(Uri.encodeFull( launchUrl(Uri.parse(Uri.encodeFull(
'https://www.google.com/maps/search/?api=1&query=${business.locationName}'))); 'https://www.google.com/maps/search/?api=1&query=${business.locationName} ${business.locationAddress}')));
}, },
), ),
if (business.contactPhone != null) if (business.contactPhone != null)
@@ -555,6 +574,7 @@ class _BusinessHeaderState extends State<BusinessHeader> {
); );
} }
/// A mobile widget that displays basic info about a business
Widget _businessListItem(Business business, BusinessType? jobType) { Widget _businessListItem(Business business, BusinessType? jobType) {
return Card( return Card(
child: ListTile( child: ListTile(
+176 -177
View File
@@ -1,5 +1,6 @@
import 'package:fbla_ui/main.dart'; import 'package:fbla_ui/main.dart';
import 'package:fbla_ui/shared/api_logic.dart'; import 'package:fbla_ui/shared/api_logic.dart';
import 'package:fbla_ui/shared/global_vars.dart';
import 'package:fbla_ui/shared/utils.dart'; import 'package:fbla_ui/shared/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -23,15 +24,14 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
late TextEditingController _notesController; late TextEditingController _notesController;
late TextEditingController _locationNameController; late TextEditingController _locationNameController;
late TextEditingController _locationAddressController; late TextEditingController _locationAddressController;
late bool widescreen;
// late TextEditingController _businessTypeController;
Business business = Business( Business business = Business(
id: 0, id: 0,
name: 'Business', name: 'Business',
description: 'Add details about the business below.', description: 'Add details about the business below.',
type: null, type: null,
website: '', website: null,
contactName: null, contactName: null,
contactEmail: null, contactEmail: null,
contactPhone: null, contactPhone: null,
@@ -56,8 +56,8 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
_descriptionController = TextEditingController(); _descriptionController = TextEditingController();
} }
_websiteController = TextEditingController( _websiteController = TextEditingController(
text: business.website! text: business.website
.replaceAll('https://', '') ?.replaceAll('https://', '')
.replaceAll('http://', '') .replaceAll('http://', '')
.replaceAll('www.', '')); .replaceAll('www.', ''));
_contactNameController = TextEditingController(text: business.contactName); _contactNameController = TextEditingController(text: business.contactName);
@@ -76,6 +76,7 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth;
return PopScope( return PopScope(
canPop: !_isLoading, canPop: !_isLoading,
onPopInvoked: _handlePop, onPopInvoked: _handlePop,
@@ -87,10 +88,14 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
? Text('Edit ${widget.inputBusiness?.name}', maxLines: 1) ? Text('Edit ${widget.inputBusiness?.name}', maxLines: 1)
: const Text('Add New Business'), : const Text('Add New Business'),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: !widescreen
child: _isLoading ? FloatingActionButton.extended(
? const Padding( heroTag: 'saveBusiness',
padding: EdgeInsets.all(16.0), label: const Text('Save'),
icon: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 3.0, strokeWidth: 3.0,
@@ -98,54 +103,20 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
) )
: const Icon(Icons.save), : const Icon(Icons.save),
onPressed: () async { onPressed: () async {
if (business.type == null) { if (!_isLoading) {
setState(() { await _saveBusiness(context);
dropDownErrorText = 'Business type is required';
});
formKey.currentState!.validate();
} else {
setState(() {
dropDownErrorText = null;
});
if (formKey.currentState!.validate()) {
formKey.currentState?.save();
setState(() {
_isLoading = true;
});
String? result;
if (widget.inputBusiness != null) {
result = await editBusiness(business);
} else {
result = await createBusiness(business);
}
setState(() {
_isLoading = false;
});
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content: Text(result)));
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const MainApp()));
}
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: const Text('Check field inputs!'), width: 400,
width: 200,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( content: Text('Please wait for it to save.'),
borderRadius: BorderRadius.circular(10)),
), ),
); );
} }
}
}, },
), )
: null,
body: ListView( body: ListView(
children: [ children: [
Center( Center(
@@ -153,41 +124,53 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
width: 800, width: 800,
child: Column( child: Column(
children: [ children: [
ListTile( Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Card(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ListTile(
titleAlignment:
ListTileTitleAlignment.titleHeight,
title: Text(business.name!, title: Text(business.name!,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold)), fontSize: 24,
fontWeight: FontWeight.bold)),
subtitle: Text( subtitle: Text(
business.description!, business.description!,
textAlign: TextAlign.left,
), ),
contentPadding:
const EdgeInsets.only(bottom: 8, left: 16),
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
child: Image.network( child: Image.network(
'$apiAddress/clearbit/${Uri.encodeComponent(business.website ?? '')}',
width: 48, width: 48,
height: 48, height: 48, errorBuilder:
'https://logo.clearbit.com/${business.website}', (BuildContext context,
errorBuilder: (BuildContext context, Object exception,
Object exception, StackTrace? stackTrace) { StackTrace? stackTrace) {
return Icon( return Icon(
getIconFromBusinessType(business.type != null getIconFromBusinessType(business.type ??
? business.type! BusinessType.other),
: BusinessType.other),
size: 48); size: 48);
}), }),
), ),
), ),
),
),
),
Card( Card(
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0), top: 8.0,
bottom: 8.0,
left: 8.0,
right: 8.0),
child: TextFormField( child: TextFormField(
controller: _nameController, controller: _nameController,
autovalidateMode:
AutovalidateMode.onUserInteraction,
maxLength: 30, maxLength: 30,
onChanged: (inputName) { onChanged: (inputName) {
setState(() { setState(() {
@@ -210,11 +193,9 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
), ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0), bottom: 8.0, left: 8.0, right: 8.0),
child: TextFormField( child: TextFormField(
controller: _descriptionController, controller: _descriptionController,
autovalidateMode:
AutovalidateMode.onUserInteraction,
maxLength: 500, maxLength: 500,
maxLines: null, maxLines: null,
onChanged: (inputDesc) { onChanged: (inputDesc) {
@@ -242,49 +223,56 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
left: 8.0, right: 8.0, bottom: 16.0), left: 8.0, right: 8.0, bottom: 16.0),
child: TextFormField( child: TextFormField(
controller: _websiteController, controller: _websiteController,
autovalidateMode:
AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
onChanged: (inputUrl) { onChanged: (inputUrl) {
business.website = Uri.encodeFull(inputUrl); setState(() {
business.website =
Uri.encodeFull(inputUrl);
});
if (inputUrl.trim().isEmpty) {
business.website = null;
} else {
if (!business.website! if (!business.website!
.contains('http://') && .contains('http://') &&
!business.website! !business.website!
.contains('https://')) { .contains('https://')) {
setState(() {
business.website = business.website =
'https://${business.website}'; 'https://${business.website!.trim()}';
});
}
} }
}, },
onTapOutside: (PointerDownEvent event) { onTapOutside: (PointerDownEvent event) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Website (required)', labelText: 'Website',
), ),
validator: (value) { validator: (value) {
if (value != null && if (value != null &&
!RegExp(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\/\s]*)*') value.isNotEmpty &&
!RegExp(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/[^/\s]*)*')
.hasMatch(value)) { .hasMatch(value)) {
return 'Enter a valid Website'; return 'Enter a valid Website';
} }
if (value != null && value.trim().isEmpty) {
return 'Website is required';
}
return null; return null;
}, },
), ),
), ),
Padding( Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 8.0), left: 8.0, right: 8.0, bottom: 16.0),
child: Row( child: DropdownMenu<BusinessType>(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text('Type of Business',
style: TextStyle(fontSize: 16)),
DropdownMenu<BusinessType>(
initialSelection: business.type, initialSelection: business.type,
width: (MediaQuery.sizeOf(context).width -
24) <
776
? MediaQuery.sizeOf(context).width - 24
: 776,
menuHeight: 300,
label: const Text('Business Type'), label: const Text('Business Type'),
errorText: dropDownErrorText, errorText: dropDownErrorText,
dropdownMenuEntries: [ dropdownMenuEntries: [
@@ -292,8 +280,8 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
in BusinessType.values) in BusinessType.values)
DropdownMenuEntry( DropdownMenuEntry(
value: type, value: type,
label: getNameFromBusinessType( label:
type)), getNameFromBusinessType(type)),
], ],
onSelected: (inputType) { onSelected: (inputType) {
setState(() { setState(() {
@@ -302,85 +290,11 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
}); });
}, },
), ),
],
), ),
), ),
// Padding(
// padding: const EdgeInsets.only(
// left: 8.0, right: 8.0, bottom: 16.0),
// child: Row(
// children: [
// ElevatedButton(
// style: ButtonStyle(
// backgroundColor:
// MaterialStateProperty.all(
// Theme.of(context)
// .colorScheme
// .background)),
// child: const Row(
// children: [
// Icon(Icons.search),
// Text('Search For Location'),
// ],
// ),
// onPressed: () {},
// ),
// const Padding(
// padding: EdgeInsets.only(
// left: 32.0, right: 32.0),
// child: Text(
// 'OR',
// style: TextStyle(fontSize: 24),
// ),
// ),
// Expanded(
// child: Column(
// children: [
// TextFormField(
// controller: _locationNameController,
// onChanged: (inputName) {
// setState(() {
// business.locationName =
// inputName;
// });
// },
// onTapOutside:
// (PointerDownEvent event) {
// FocusScope.of(context).unfocus();
// },
// decoration: const InputDecoration(
// labelText:
// 'Location Name (optional)',
// ),
// ),
// TextFormField(
// controller:
// _locationAddressController,
// onChanged: (inputAddr) {
// setState(() {
// business.locationAddress =
// inputAddr;
// });
// },
// onTapOutside:
// (PointerDownEvent event) {
// FocusScope.of(context).unfocus();
// },
// decoration: const InputDecoration(
// labelText:
// 'Location Address (optional)',
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 8.0), left: 8.0, right: 8.0, bottom: 16.0),
child: TextFormField( child: TextFormField(
controller: _contactNameController, controller: _contactNameController,
onSaved: (inputText) { onSaved: (inputText) {
@@ -393,8 +307,6 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
labelText: labelText:
'Contact Information Name (required)', 'Contact Information Name (required)',
), ),
autovalidateMode:
AutovalidateMode.onUserInteraction,
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return 'Contact name is required'; return 'Contact name is required';
@@ -405,13 +317,11 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
), ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 8.0), left: 8.0, right: 8.0, bottom: 16.0),
child: TextFormField( child: TextFormField(
controller: _contactPhoneController, controller: _contactPhoneController,
inputFormatters: [PhoneFormatter()], inputFormatters: [PhoneFormatter()],
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
autovalidateMode:
AutovalidateMode.onUserInteraction,
onChanged: (inputText) { onChanged: (inputText) {
if (inputText.trim().isEmpty) { if (inputText.trim().isEmpty) {
business.contactPhone = null; business.contactPhone = null;
@@ -430,7 +340,9 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
(value == null || value.isEmpty)) { (value == null || value.isEmpty)) {
return 'At least one contact method is required'; return 'At least one contact method is required';
} }
if (value != null && value.length != 14) { if (value != null &&
value.isNotEmpty &&
value.length != 14) {
return 'Enter a valid phone number'; return 'Enter a valid phone number';
} }
return null; return null;
@@ -439,7 +351,7 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
), ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 8.0), left: 8.0, right: 8.0, bottom: 16.0),
child: TextFormField( child: TextFormField(
controller: _contactEmailController, controller: _contactEmailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
@@ -450,8 +362,6 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
business.contactEmail = inputText.trim(); business.contactEmail = inputText.trim();
} }
}, },
autovalidateMode:
AutovalidateMode.onUserInteraction,
onTapOutside: (PointerDownEvent event) { onTapOutside: (PointerDownEvent event) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
@@ -482,7 +392,7 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
), ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 8.0), left: 8.0, right: 8.0, bottom: 16.0),
child: TextFormField( child: TextFormField(
controller: _locationNameController, controller: _locationNameController,
onChanged: (inputName) { onChanged: (inputName) {
@@ -490,8 +400,6 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
business.locationName = inputName.trim(); business.locationName = inputName.trim();
}); });
}, },
autovalidateMode:
AutovalidateMode.onUserInteraction,
onTapOutside: (PointerDownEvent event) { onTapOutside: (PointerDownEvent event) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
@@ -516,8 +424,6 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
business.locationAddress = inputAddr; business.locationAddress = inputAddr;
}); });
}, },
autovalidateMode:
AutovalidateMode.onUserInteraction,
onTapOutside: (PointerDownEvent event) { onTapOutside: (PointerDownEvent event) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
@@ -558,9 +464,55 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
], ],
), ),
), ),
SizedBox( if (!widescreen)
const SizedBox(
height: 75, height: 75,
) )
else
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: FilledButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 8.0, right: 8.0, bottom: 8.0),
child: _isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Theme.of(context)
.colorScheme
.onPrimary,
strokeWidth: 3.0,
),
)
: const Icon(Icons.save),
),
const Text('Save'),
],
),
onPressed: () async {
if (!_isLoading) {
await _saveBusiness(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content:
Text('Please wait for it to save.'),
),
);
}
},
),
),
)
], ],
), ),
), ),
@@ -582,6 +534,53 @@ class _CreateEditBusinessState extends State<CreateEditBusiness> {
); );
} }
} }
Future<void> _saveBusiness(BuildContext context) async {
if (business.type == null) {
setState(() {
dropDownErrorText = 'Business type is required';
});
formKey.currentState!.validate();
} else {
setState(() {
dropDownErrorText = null;
});
if (formKey.currentState!.validate()) {
formKey.currentState?.save();
setState(() {
_isLoading = true;
});
String? result;
if (widget.inputBusiness != null) {
result = await editBusiness(business);
} else {
result = await createBusiness(business);
}
setState(() {
_isLoading = false;
});
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content: Text(result)));
} else {
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (context) => const MainApp()));
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Check field inputs!'),
width: 200,
behavior: SnackBarBehavior.floating,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
}
}
} }
class PhoneFormatter extends TextInputFormatter { class PhoneFormatter extends TextInputFormatter {
+502 -338
View File
@@ -1,9 +1,11 @@
import 'package:fbla_ui/main.dart';
import 'package:fbla_ui/shared/api_logic.dart'; import 'package:fbla_ui/shared/api_logic.dart';
import 'package:fbla_ui/shared/global_vars.dart';
import 'package:fbla_ui/shared/utils.dart'; import 'package:fbla_ui/shared/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rive/rive.dart'; import 'package:rive/rive.dart';
import '../main.dart';
class CreateEditJobListing extends StatefulWidget { class CreateEditJobListing extends StatefulWidget {
final JobListing? inputJobListing; final JobListing? inputJobListing;
final Business? inputBusiness; final Business? inputBusiness;
@@ -21,19 +23,23 @@ class _CreateEditJobListingState extends State<CreateEditJobListing> {
late TextEditingController _descriptionController; late TextEditingController _descriptionController;
late TextEditingController _wageController; late TextEditingController _wageController;
late TextEditingController _linkController; late TextEditingController _linkController;
List nameMapping = []; List<Map<String, dynamic>> nameMapping = [];
String? typeDropdownErrorText; String? typeDropdownErrorText;
String? businessDropdownErrorText; String? businessDropdownErrorText;
late bool widescreen;
JobListing listing = JobListing( JobListing listing = JobListing(
id: null, id: null,
businessId: null, businessId: null,
name: 'Job Listing', name: 'Job Listing',
description: 'Add details about the business below.', description: 'Add details about the job below.',
type: null, type: null,
wage: null, wage: null,
link: null); link: null,
offerType: null);
bool _isLoading = false; bool _isLoading = false;
late String businessName;
bool _isRetrying = false;
@override @override
void initState() { void initState() {
@@ -53,15 +59,17 @@ class _CreateEditJobListingState extends State<CreateEditJobListing> {
.replaceAll('http://', '') .replaceAll('http://', '')
.replaceAll('www.', '')); .replaceAll('www.', ''));
getBusinessNameMapping = fetchBusinessNames(); getBusinessNameMapping = fetchBusinessNames();
businessName = widget.inputBusiness?.name ?? 'Offering business';
if (widget.inputBusiness != null) {
listing.businessId = widget.inputBusiness!.id;
}
} }
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.inputBusiness != null) { widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth;
listing.businessId = widget.inputBusiness!.id;
}
return PopScope( return PopScope(
canPop: !_isLoading, canPop: !_isLoading,
onPopInvoked: _handlePop, onPopInvoked: _handlePop,
@@ -73,10 +81,14 @@ class _CreateEditJobListingState extends State<CreateEditJobListing> {
? Text('Edit ${widget.inputJobListing?.name}', maxLines: 1) ? Text('Edit ${widget.inputJobListing?.name}', maxLines: 1)
: const Text('Add New Job Listing'), : const Text('Add New Job Listing'),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: !widescreen
child: _isLoading ? FloatingActionButton.extended(
? const Padding( heroTag: 'saveListing',
padding: EdgeInsets.all(16.0), label: const Text('Save'),
icon: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 3.0, strokeWidth: 3.0,
@@ -84,6 +96,483 @@ class _CreateEditJobListingState extends State<CreateEditJobListing> {
) )
: const Icon(Icons.save), : const Icon(Icons.save),
onPressed: () async { onPressed: () async {
if (!_isLoading) {
await _saveListing(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content: Text('Please wait for it to save.'),
),
);
}
})
: null,
body: FutureBuilder(
future: getBusinessNameMapping,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
if (snapshot.data.runtimeType == String) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(children: [
Center(
child: Text(snapshot.data,
textAlign: TextAlign.center)),
Padding(
padding: const EdgeInsets.all(8.0),
child: FilledButton(
child: const Text('Retry'),
onPressed: () async {
if (!_isRetrying) {
setState(() {
_isRetrying = true;
});
var refreshedData = fetchBusinessNames();
await refreshedData;
setState(() {
getBusinessNameMapping = refreshedData;
_isRetrying = false;
});
}
},
),
),
]),
);
}
nameMapping = snapshot.data;
nameMapping.sort((a, b) =>
a['name'].toString().compareTo(b['name'].toString()));
return ListView(
children: [
Center(
child: SizedBox(
width: 800,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Card(
child: Padding(
padding:
const EdgeInsets.only(right: 8.0),
child: ListTile(
titleAlignment: ListTileTitleAlignment
.titleHeight,
title: Text(listing.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold)),
subtitle: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
businessName,
style: const TextStyle(
fontSize: 18),
),
Text(
listing.description,
),
],
),
contentPadding:
const EdgeInsets.only(left: 16),
leading: ClipRRect(
borderRadius:
BorderRadius.circular(6.0),
child: Image.network(
'$apiAddress/logos/${listing.businessId}',
width: 48,
height: 48, errorBuilder:
(BuildContext context,
Object exception,
StackTrace?
stackTrace) {
return Icon(
getIconFromJobType(
listing.type ??
JobType.other),
size: 48);
}),
),
),
),
),
),
// Business Type Dropdown
Card(
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0,
top: 8.0),
child: DropdownMenu<int>(
menuHeight: 300,
width: (MediaQuery.sizeOf(context)
.width -
24) <
776
? MediaQuery.sizeOf(context)
.width -
24
: 776,
errorText:
businessDropdownErrorText,
initialSelection:
widget.inputBusiness?.id,
label: const Text(
'Offering Business'),
dropdownMenuEntries: [
for (Map<String, dynamic> map
in nameMapping)
DropdownMenuEntry(
value: map['id']!,
label: map['name'])
],
onSelected: (inputType) {
setState(() {
listing.businessId =
inputType!;
businessName = nameMapping
.where((element) =>
element['id'] ==
listing.businessId)
.first['name'];
businessDropdownErrorText =
null;
});
},
),
),
),
Align(
alignment: Alignment.centerLeft,
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 16.0,
top: 8.0),
child: DropdownMenu<JobType>(
initialSelection:
listing.type,
label: const Text('Job Type'),
errorText:
typeDropdownErrorText,
width: calculateDropdownWidth(
context),
menuHeight: 300,
dropdownMenuEntries: [
for (JobType type
in JobType.values)
DropdownMenuEntry(
value: type,
label:
getNameFromJobType(
type))
],
onSelected: (inputType) {
setState(() {
listing.type = inputType!;
typeDropdownErrorText =
null;
});
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0,
top: 8.0),
child: DropdownMenu<OfferType>(
initialSelection:
listing.offerType,
label:
const Text('Offer Type'),
errorText:
typeDropdownErrorText,
width: calculateDropdownWidth(
context),
dropdownMenuEntries: [
for (OfferType type
in OfferType.values)
DropdownMenuEntry(
value: type,
label:
getNameFromOfferType(
type))
],
onSelected: (inputType) {
setState(() {
listing.offerType =
inputType!;
typeDropdownErrorText =
null;
});
},
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0),
child: TextFormField(
controller: _nameController,
maxLength: 40,
onChanged: (inputName) {
setState(() {
listing.name = inputName;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Job Listing Name (required)',
),
validator: (value) {
if (value != null &&
value.isEmpty) {
return 'Name is required';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0),
child: TextFormField(
controller: _descriptionController,
maxLength: 500,
maxLines: null,
onChanged: (inputDesc) {
setState(() {
listing.description = inputDesc;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Job Listing Description (required)',
),
validator: (value) {
if (value != null &&
value.isEmpty) {
return 'Description is required';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 16.0),
child: TextFormField(
controller: _wageController,
onChanged: (input) {
setState(() {
listing.wage = input;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText: 'Wage Information',
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 16.0),
child: TextFormField(
controller: _linkController,
keyboardType: TextInputType.url,
onChanged: (inputUrl) {
if (inputUrl != '') {
listing.link =
Uri.encodeFull(inputUrl);
if (!listing.link!
.contains('http://') &&
!listing.link!
.contains('https://')) {
listing.link =
'https://${listing.link}';
}
} else {
listing.link = null;
}
},
validator: (value) {
if (value != null &&
value.isNotEmpty &&
!RegExp(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/[^/\s]*)*')
.hasMatch(value)) {
return 'Enter a valid Website';
}
return null;
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Additional Information Link',
),
),
),
],
),
),
if (!widescreen)
const SizedBox(
height: 75,
)
else
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: FilledButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 8.0,
right: 8.0,
bottom: 8.0),
child: _isLoading
? SizedBox(
width: 24,
height: 24,
child:
CircularProgressIndicator(
color:
Theme.of(context)
.colorScheme
.onPrimary,
strokeWidth: 3.0,
),
)
: const Icon(Icons.save),
),
const Text('Save'),
],
),
onPressed: () async {
if (!_isLoading) {
await _saveListing(context);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
width: 400,
behavior:
SnackBarBehavior.floating,
content: Text(
'Please wait for it to save.'),
),
);
}
},
),
),
)
],
),
),
),
],
);
} else if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: Text(
'Error when loading data! Error: ${snapshot.error}'),
);
}
} else if (snapshot.connectionState ==
ConnectionState.waiting) {
return Container(
padding: const EdgeInsets.all(8.0),
alignment: Alignment.center,
child: const SizedBox(
width: 75,
height: 75,
child: RiveAnimation.asset(
'assets/mdev_triangle_loading.riv'),
),
);
}
return const Padding(
padding: EdgeInsets.only(left: 16.0, right: 16.0),
child: Text('Error when loading data!'),
);
}),
)),
);
}
double calculateDropdownWidth(BuildContext context) {
double screenWidth = MediaQuery.sizeOf(context).width;
if ((screenWidth - 40) / 2 < 200) {
return screenWidth - 24;
} else if ((screenWidth - 40) / 2 < 380) {
return (screenWidth - 40) / 2;
} else {
return 380;
}
}
void _handlePop(bool didPop) {
if (!didPop) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content: Text('Please wait for it to save.'),
),
);
}
}
Future<void> _saveListing(BuildContext context) async {
if (listing.type == null || listing.businessId == null) { if (listing.type == null || listing.businessId == null) {
if (listing.type == null) { if (listing.type == null) {
setState(() { setState(() {
@@ -135,336 +624,11 @@ class _CreateEditJobListingState extends State<CreateEditJobListing> {
content: const Text('Check field inputs!'), content: const Text('Check field inputs!'),
width: 200, width: 200,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape:
borderRadius: BorderRadius.circular(10)), RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
); );
} }
} }
}),
body: FutureBuilder(
future: getBusinessNameMapping,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
if (snapshot.data.runtimeType == String) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(children: [
Center(
child: Text(snapshot.data,
textAlign: TextAlign.center)),
Padding(
padding: const EdgeInsets.all(8.0),
child: FilledButton(
child: const Text('Retry'),
onPressed: () async {
var refreshedData = fetchBusinessNames();
await refreshedData;
setState(() {
getBusinessNameMapping = refreshedData;
});
},
),
),
]),
);
}
nameMapping = snapshot.data;
return ListView(
children: [
Center(
child: SizedBox(
width: 800,
child: Column(
children: [
ListTile(
title: Text(listing.name,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold)),
subtitle: Text(
listing.description,
textAlign: TextAlign.left,
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(6.0),
child: Image.network(
width: 48,
height: 48,
listing.businessId != null
? '$apiAddress/logos/${listing.businessId}'
: '',
errorBuilder: (BuildContext context,
Object exception,
StackTrace? stackTrace) {
return Icon(
getIconFromJobType(
listing.type ?? JobType.other,
),
size: 48);
}),
),
),
// Business Type Dropdown
Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0,
top: 8),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text('Type of Job',
style:
TextStyle(fontSize: 16)),
DropdownMenu<JobType>(
initialSelection: listing.type,
label: const Text('Job Type'),
errorText:
typeDropdownErrorText,
dropdownMenuEntries: [
for (JobType type
in JobType.values)
DropdownMenuEntry(
value: type,
label:
getNameFromJobType(
type))
],
onSelected: (inputType) {
setState(() {
listing.type = inputType!;
typeDropdownErrorText =
null;
});
},
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0,
top: 8),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text(
'Business that has the job',
style:
TextStyle(fontSize: 16)),
DropdownMenu<int>(
errorText:
businessDropdownErrorText,
initialSelection:
widget.inputBusiness?.id,
label: const Text('Business'),
dropdownMenuEntries: [
for (Map<String, dynamic> map
in nameMapping)
DropdownMenuEntry(
value: map['id']!,
label: map['name'])
],
onSelected: (inputType) {
setState(() {
listing.businessId =
inputType!;
businessDropdownErrorText =
null;
});
},
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0, right: 8.0),
child: TextFormField(
controller: _nameController,
autovalidateMode: AutovalidateMode
.onUserInteraction,
maxLength: 30,
onChanged: (inputName) {
setState(() {
listing.name = inputName;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Job Listing Name (required)',
),
validator: (value) {
if (value != null &&
value.isEmpty) {
return 'Name is required';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0, right: 8.0),
child: TextFormField(
controller: _descriptionController,
autovalidateMode: AutovalidateMode
.onUserInteraction,
maxLength: 500,
maxLines: null,
onChanged: (inputDesc) {
setState(() {
listing.description = inputDesc;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Job Listing Description (required)',
),
validator: (value) {
if (value != null &&
value.isEmpty) {
return 'Description is required';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0),
child: TextFormField(
controller: _wageController,
onChanged: (input) {
setState(() {
listing.wage = input;
});
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText: 'Wage Information',
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: 8.0),
child: TextFormField(
controller: _linkController,
autovalidateMode: AutovalidateMode
.onUserInteraction,
keyboardType: TextInputType.url,
onChanged: (inputUrl) {
if (inputUrl != '') {
listing.link =
Uri.encodeFull(inputUrl);
if (!listing.link!
.contains('http://') &&
!listing.link!
.contains('https://')) {
listing.link =
'https://${listing.link}';
}
}
listing.link = null;
},
validator: (value) {
if (value != null &&
value.isNotEmpty &&
!RegExp(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\/\s]*)*')
.hasMatch(value)) {
return 'Enter a valid Website';
}
return null;
},
onTapOutside:
(PointerDownEvent event) {
FocusScope.of(context).unfocus();
},
decoration: const InputDecoration(
labelText:
'Additional Information Link',
),
),
),
],
),
),
const SizedBox(
height: 75,
)
],
),
),
),
],
);
} else if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: Text(
'Error when loading data! Error: ${snapshot.error}'),
);
}
} else if (snapshot.connectionState ==
ConnectionState.waiting) {
return Container(
padding: const EdgeInsets.all(8.0),
alignment: Alignment.center,
child: const SizedBox(
width: 75,
height: 75,
child: RiveAnimation.asset(
'assets/mdev_triangle_loading.riv'),
),
);
}
return const Padding(
padding: EdgeInsets.only(left: 16.0, right: 16.0),
child: Text('Error when loading data!'),
);
}),
)),
);
}
void _handlePop(bool didPop) {
if (!didPop) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
width: 400,
behavior: SnackBarBehavior.floating,
content: Text('Please wait for it to save.'),
),
);
}
} }
} }
+83 -103
View File
@@ -1,11 +1,14 @@
import 'package:fbla_ui/main.dart'; import 'package:fbla_ui/main.dart';
import 'package:fbla_ui/pages/business_detail.dart';
import 'package:fbla_ui/pages/create_edit_listing.dart'; import 'package:fbla_ui/pages/create_edit_listing.dart';
import 'package:fbla_ui/shared/api_logic.dart'; import 'package:fbla_ui/shared/api_logic.dart';
import 'package:fbla_ui/shared/global_vars.dart'; import 'package:fbla_ui/shared/global_vars.dart';
import 'package:fbla_ui/shared/utils.dart'; import 'package:fbla_ui/shared/utils.dart';
import 'package:fbla_ui/shared/widgets.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
/// A page to view all specific details about a single job listing (with its businesses contact info)
class JobListingDetail extends StatefulWidget { class JobListingDetail extends StatefulWidget {
final JobListing listing; final JobListing listing;
final Business fromBusiness; final Business fromBusiness;
@@ -29,49 +32,102 @@ class _CreateBusinessDetailState extends State<JobListingDetail> {
); );
} }
ListView _detailBody(JobListing listing) { /// body of the JobListingDetail
Widget _detailBody(JobListing listing) {
return ListView( return ListView(
children: [ children: [
// Title, logo, desc, website Center(
child: SizedBox(
width: 800,
child: Column(
children: [
// Top summary card
Padding( Padding(
padding: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.only(top: 4.0),
child: Card( child: _summaryCard(listing)),
// Wage card
if (listing.wage != null && listing.wage != '')
Card(
child: ListTile(
leading: const Icon(Icons.attach_money),
subtitle: Text(listing.wage!),
title: const Text('Wage Information'),
),
),
// Contact information for the business contact
ContactInformationCard(business: widget.fromBusiness)
],
),
),
),
],
);
}
/// Top card including title, logo, description, and business name
Widget _summaryCard(JobListing listing) {
return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.only(right: 8.0),
child: Row( child: ListTile(
minVerticalPadding: 0,
titleAlignment: ListTileTitleAlignment.titleHeight,
title: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${listing.name} (${getNameFromOfferType(listing.offerType!)})',
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold)),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( MouseRegion(
padding: const EdgeInsets.only(right: 16.0), cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return BusinessDetail(
id: widget.fromBusiness.id,
name: widget.fromBusiness.name!);
}));
},
child: Text(
widget.fromBusiness.name!,
style: const TextStyle(fontSize: 18),
),
),
),
Text(listing.description),
],
),
contentPadding: const EdgeInsets.only(bottom: 8, left: 16),
leading: Badge(
label: Text(
getLetterFromOfferType(listing.offerType!),
style: const TextStyle(fontSize: 16),
),
largeSize: 24,
offset: const Offset(12, -3),
textColor: Colors.white,
backgroundColor: getColorFromOfferType(listing.offerType!),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
child: Image.network( child: Image.network(
'$apiAddress/logos/${listing.businessId}', '$apiAddress/logos/${widget.fromBusiness.id}',
width: 48, width: 48,
height: 48, errorBuilder: (BuildContext context, height: 48, errorBuilder: (BuildContext context,
Object exception, StackTrace? stackTrace) { Object exception, StackTrace? stackTrace) {
return Icon(getIconFromJobType(listing.type!), return Icon(
getIconFromJobType(listing.type ?? JobType.other),
size: 48); size: 48);
}), }),
), ),
), ),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(listing.name,
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold)),
Text(widget.fromBusiness.name!,
style: const TextStyle(fontSize: 16)),
Text(
listing.description,
),
],
),
],
), ),
), ),
if (listing.link != null && listing.link != '') if (listing.link != null && listing.link != '')
@@ -90,88 +146,10 @@ class _CreateBusinessDetailState extends State<JobListingDetail> {
), ),
], ],
), ),
),
),
// Wage
Visibility(
visible: listing.wage != null && listing.wage != '',
child: Card(
child: ListTile(
leading: const Icon(Icons.attach_money),
subtitle: Text(listing.wage!),
title: const Text('Wage Information'),
),
),
),
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
child: Text(
widget.fromBusiness.contactName!,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
if (widget.fromBusiness.contactPhone != null)
ListTile(
leading: const Icon(Icons.phone),
title: Text(widget.fromBusiness.contactPhone!),
// maybe replace ! with ?? ''. same is true for below
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor:
Theme.of(context).colorScheme.surface,
title: Text(
'Contact ${widget.fromBusiness.contactName}'),
content: Text(
'Would you like to call or text ${widget.fromBusiness.contactName}?'),
actions: [
TextButton(
child: const Text('Text'),
onPressed: () {
launchUrl(Uri.parse(
'sms:${widget.fromBusiness.contactPhone}'));
Navigator.of(context).pop();
}),
TextButton(
child: const Text('Call'),
onPressed: () async {
launchUrl(Uri.parse(
'tel:${widget.fromBusiness.contactPhone}'));
Navigator.of(context).pop();
}),
],
);
});
},
),
if (widget.fromBusiness.contactEmail != null)
ListTile(
leading: const Icon(Icons.email),
title: Text(widget.fromBusiness.contactEmail!),
onTap: () {
launchUrl(Uri.parse(
'mailto:${widget.fromBusiness.contactEmail}'));
},
),
],
),
),
],
); );
} }
/// Edit / delete actions if the user is logged in
List<Widget>? _getActions(JobListing listing, Business fromBusiness) { List<Widget>? _getActions(JobListing listing, Business fromBusiness) {
if (loggedIn) { if (loggedIn) {
return [ return [
@@ -217,7 +195,9 @@ class _CreateBusinessDetailState extends State<JobListingDetail> {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const MainApp())); builder: (context) => const MainApp(
initialPage: 1,
)));
} }
}), }),
], ],
+166 -52
View File
@@ -15,7 +15,8 @@ import 'package:url_launcher/url_launcher.dart';
class JobsOverview extends StatefulWidget { class JobsOverview extends StatefulWidget {
final String searchQuery; final String searchQuery;
final Future refreshJobDataOverviewFuture; final Future refreshJobDataOverviewFuture;
final Future<void> Function(Set<JobType>) updateBusinessesCallback; final Future<void> Function(Set<JobType>?, Set<OfferType>?)
updateBusinessesCallback;
final void Function() themeCallback; final void Function() themeCallback;
final void Function(bool) updateLoggedIn; final void Function(bool) updateLoggedIn;
@@ -36,10 +37,12 @@ class _JobsOverviewState extends State<JobsOverview> {
bool _isPreviousData = false; bool _isPreviousData = false;
late Map<JobType, List<Business>> overviewBusinesses; late Map<JobType, List<Business>> overviewBusinesses;
Set<JobType> jobTypeFilters = <JobType>{}; Set<JobType> jobTypeFilters = <JobType>{};
Set<OfferType> offerTypeFilters = <OfferType>{};
String searchQuery = ''; String searchQuery = '';
ScrollController controller = ScrollController(); ScrollController controller = ScrollController();
bool _extended = true; bool _extended = true;
double prevPixelPosition = 0; double prevPixelPosition = 0;
bool _isRetrying = false;
Map<JobType, List<Business>> _filterBySearch( Map<JobType, List<Business>> _filterBySearch(
Map<JobType, List<Business>> businesses, String query) { Map<JobType, List<Business>> businesses, String query) {
@@ -66,9 +69,15 @@ class _JobsOverviewState extends State<JobsOverview> {
}); });
} }
void _setFilters(Set<JobType> filters) async { void _setFilters(Set<JobType>? newJobTypeFilters,
jobTypeFilters = Set.from(filters); Set<OfferType>? newOfferTypeFilters) async {
widget.updateBusinessesCallback(jobTypeFilters); if (newJobTypeFilters != null) {
jobTypeFilters = Set.from(newJobTypeFilters);
}
if (newOfferTypeFilters != null) {
offerTypeFilters = Set.from(newOfferTypeFilters);
}
widget.updateBusinessesCallback(jobTypeFilters, offerTypeFilters);
} }
void _scrollListener() { void _scrollListener() {
@@ -114,9 +123,8 @@ class _JobsOverviewState extends State<JobsOverview> {
setSearch: _setSearch, setSearch: _setSearch,
searchHintText: 'Search Job Listings', searchHintText: 'Search Job Listings',
themeCallback: widget.themeCallback, themeCallback: widget.themeCallback,
filterIconButton: _filterIconButton( filterIconButton:
jobTypeFilters, _filterIconButton(jobTypeFilters, offerTypeFilters),
),
updateLoggedIn: widget.updateLoggedIn, updateLoggedIn: widget.updateLoggedIn,
generatePDF: _generatePDF, generatePDF: _generatePDF,
), ),
@@ -138,8 +146,14 @@ class _JobsOverviewState extends State<JobsOverview> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: FilledButton( child: FilledButton(
child: const Text('Retry'), child: const Text('Retry'),
onPressed: () { onPressed: () async {
widget.updateBusinessesCallback(jobTypeFilters); if (!_isRetrying) {
setState(() {
_isRetrying = true;
});
await widget.updateBusinessesCallback(
null, null);
}
}, },
), ),
), ),
@@ -201,77 +215,147 @@ class _JobsOverviewState extends State<JobsOverview> {
); );
} }
Widget _filterIconButton(Set<JobType> filters) { Widget _filterIconButton(
Set<JobType> selectedChips = Set.from(filters); Set<JobType> jobTypeFilters, Set<OfferType> offerTypeFilters) {
Set<JobType> selectedJobTypeChips = Set.from(jobTypeFilters);
Set<OfferType> selectedOfferTypeChips = Set.from(offerTypeFilters);
return IconButton( return IconButton(
icon: Icon( icon: Icon(
Icons.filter_list, Icons.filter_list,
color: filters.isNotEmpty color: jobTypeFilters.isNotEmpty
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface, : Theme.of(context).colorScheme.onSurface,
), ),
onPressed: () { onPressed: () {
selectedJobTypeChips = Set.from(jobTypeFilters);
selectedOfferTypeChips = Set.from(offerTypeFilters);
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return StatefulBuilder( return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) { builder: (BuildContext context, StateSetter setState) {
void setDialogState(Set<JobType> newFilters) { void setDialogState(Set<JobType>? newJobTypeFilters,
Set<OfferType>? newOfferTypeFilters) {
if (newJobTypeFilters != null) {
setState(() { setState(() {
filters = newFilters; selectedJobTypeChips = newJobTypeFilters;
}); });
} }
if (newOfferTypeFilters != null) {
setState(() {
selectedOfferTypeChips = newOfferTypeFilters;
});
}
}
List<Padding> chips = []; List<Widget> jobTypeChips = [];
for (var type in JobType.values) { for (JobType type in JobType.values) {
chips.add(Padding( jobTypeChips.add(FilterChip(
padding: const EdgeInsets.all(4), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
child: FilterChip(
showCheckmark: false, showCheckmark: false,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)), borderRadius: BorderRadius.circular(20),
label: Text(getNameFromJobType(type)), side: BorderSide(
selected: selectedChips.contains(type), color:
Theme.of(context).colorScheme.secondary)),
selectedColor: Theme.of(context).colorScheme.secondary,
label: Text(
getNameFromJobType(type),
style: TextStyle(
color: selectedJobTypeChips.contains(type)
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onSurface),
),
selected: selectedJobTypeChips.contains(type),
onSelected: (bool selected) { onSelected: (bool selected) {
if (selected) { if (selected) {
selectedChips.add(type); selectedJobTypeChips.add(type);
} else { } else {
selectedChips.remove(type); selectedJobTypeChips.remove(type);
} }
setDialogState(filters); setDialogState(selectedJobTypeChips, null);
}), }));
)); }
List<Widget> offerTypeChips = [];
for (OfferType type in OfferType.values) {
offerTypeChips.add(FilterChip(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
showCheckmark: false,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color:
Theme.of(context).colorScheme.secondary)),
selectedColor: Theme.of(context).colorScheme.secondary,
label: Text(
getNameFromOfferType(type),
style: TextStyle(
color: selectedOfferTypeChips.contains(type)
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onSurface),
),
selected: selectedOfferTypeChips.contains(type),
onSelected: (bool selected) {
if (selected) {
selectedOfferTypeChips.add(type);
} else {
selectedOfferTypeChips.remove(type);
}
setDialogState(null, selectedOfferTypeChips);
}));
} }
return AlertDialog( return AlertDialog(
title: const Text('Filter Options'), title: const Text('Filter Options'),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Job Type Filters:'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap( child: Wrap(
children: chips, spacing: 8,
runSpacing: 8,
children: jobTypeChips,
),
),
const Text('Offer Type Filters:'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: offerTypeChips,
),
),
],
), ),
), ),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Reset'), child: const Text('Reset'),
onPressed: () { onPressed: () {
_setFilters(<JobType>{}); _setFilters(<JobType>{}, <OfferType>{});
// selectedChips = <BusinessType>{};
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
TextButton( TextButton(
child: const Text('Cancel'), child: const Text('Cancel'),
onPressed: () { onPressed: () {
// selectedChips = Set.from(filters); // setDialogState(jobTypeFilters, offerTypeFilters);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
TextButton( TextButton(
child: const Text('Apply'), child: const Text('Apply'),
onPressed: () { onPressed: () {
_setFilters(selectedChips); _setFilters(
selectedJobTypeChips, selectedOfferTypeChips);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
) )
@@ -337,9 +421,9 @@ class _JobDisplayPanelState extends State<JobDisplayPanel> {
); );
} }
List<BusinessHeader> headers = []; List<_JobHeader> headers = [];
for (JobType jobType in widget.jobGroupedBusinesses.keys) { for (JobType jobType in widget.jobGroupedBusinesses.keys) {
headers.add(BusinessHeader( headers.add(_JobHeader(
jobType: jobType, jobType: jobType,
widescreen: widget.widescreen, widescreen: widget.widescreen,
businesses: widget.jobGroupedBusinesses[jobType]!)); businesses: widget.jobGroupedBusinesses[jobType]!));
@@ -349,23 +433,22 @@ class _JobDisplayPanelState extends State<JobDisplayPanel> {
} }
} }
class BusinessHeader extends StatefulWidget { class _JobHeader extends StatefulWidget {
final JobType jobType; final JobType jobType;
final List<Business> businesses; final List<Business> businesses;
final bool widescreen; final bool widescreen;
const BusinessHeader({ const _JobHeader({
super.key,
required this.jobType, required this.jobType,
required this.businesses, required this.businesses,
required this.widescreen, required this.widescreen,
}); });
@override @override
State<BusinessHeader> createState() => _BusinessHeaderState(); State<_JobHeader> createState() => _JobHeaderState();
} }
class _BusinessHeaderState extends State<BusinessHeader> { class _JobHeaderState extends State<_JobHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverStickyHeader( return SliverStickyHeader(
@@ -389,7 +472,8 @@ class _BusinessHeaderState extends State<BusinessHeader> {
getIconFromJobType(widget.jobType), getIconFromJobType(widget.jobType),
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
)), )),
Text(getNameFromJobType(widget.jobType)), Text(getNameFromJobType(widget.jobType),
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary)),
], ],
); );
} }
@@ -408,7 +492,7 @@ class _BusinessHeaderState extends State<BusinessHeader> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
childCount: businesses.length, childCount: businesses.length,
(BuildContext context, int index) { (BuildContext context, int index) {
return _businessTile( return _jobBusinessTile(
businesses[index], businesses[index],
widget.jobType, widget.jobType,
); );
@@ -421,7 +505,7 @@ class _BusinessHeaderState extends State<BusinessHeader> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
childCount: businesses.length, childCount: businesses.length,
(BuildContext context, int index) { (BuildContext context, int index) {
return _businessListItem( return _jobBusinessListItem(
businesses[index], businesses[index],
widget.jobType, widget.jobType,
); );
@@ -431,7 +515,8 @@ class _BusinessHeaderState extends State<BusinessHeader> {
} }
} }
Widget _businessTile(Business business, JobType jobType) { /// A desktop widget that displays basic info about a job
Widget _jobBusinessTile(Business business, JobType jobType) {
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
@@ -451,21 +536,35 @@ class _BusinessHeaderState extends State<BusinessHeader> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Badge(
label: Text(
getLetterFromOfferType(
business.listings![0].offerType!),
style: const TextStyle(fontSize: 16),
),
largeSize: 24,
offset: const Offset(12, -3),
textColor: Colors.white,
backgroundColor: getColorFromOfferType(
business.listings![0].offerType!),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
child: Image.network('$apiAddress/logos/${business.id}', child: Image.network(
'$apiAddress/logos/${business.id}',
height: 48, height: 48,
width: 48, errorBuilder: (BuildContext context, width: 48, errorBuilder: (BuildContext context,
Object exception, StackTrace? stackTrace) { Object exception, StackTrace? stackTrace) {
return Icon(getIconFromBusinessType(business.type!), return Icon(
getIconFromJobType(business.listings![0].type!),
size: 48); size: 48);
}), }),
),
)), )),
Flexible( Flexible(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
business.listings![0].name, '${business.listings![0].name} (${getNameFromOfferType(business.listings![0].offerType!)})',
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold), fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 2, maxLines: 2,
@@ -494,8 +593,7 @@ class _BusinessHeaderState extends State<BusinessHeader> {
IconButton( IconButton(
icon: const Icon(Icons.link), icon: const Icon(Icons.link),
onPressed: () { onPressed: () {
launchUrl(Uri.parse( launchUrl(Uri.parse(business.listings![0].link!));
'https://${business.listings![0].link!}'));
}, },
), ),
IconButton( IconButton(
@@ -556,17 +654,33 @@ class _BusinessHeaderState extends State<BusinessHeader> {
); );
} }
Widget _businessListItem(Business business, JobType? jobType) { /// A mobile widget that displays basic info about a job
Widget _jobBusinessListItem(Business business, JobType? jobType) {
return Card( return Card(
child: ListTile( child: ListTile(
leading: ClipRRect( leading: Badge(
label: Text(getLetterFromOfferType(business.listings![0].offerType!)),
textColor: Colors.white,
isLabelVisible: true,
offset: const Offset(7, -5),
alignment: Alignment.topRight,
padding: business.listings![0].offerType! == OfferType.internship
? const EdgeInsets.symmetric(horizontal: 5)
: null,
backgroundColor:
getColorFromOfferType(business.listings![0].offerType!),
child: ClipRRect(
borderRadius: BorderRadius.circular(3.0), borderRadius: BorderRadius.circular(3.0),
child: Image.network('$apiAddress/logos/${business.id}', child: Image.network('$apiAddress/logos/${business.id}',
height: 24, width: 24, errorBuilder: (BuildContext context, height: 24, width: 24, errorBuilder: (BuildContext context,
Object exception, StackTrace? stackTrace) { Object exception, StackTrace? stackTrace) {
return Icon(getIconFromBusinessType(business.type!)); return Icon(
getIconFromJobType(business.listings![0].type!),
);
})), })),
title: Text(business.listings![0].name), ),
title: Text(
'${business.listings![0].name} (${getNameFromOfferType(business.listings![0].offerType!)})'),
subtitle: Text(business.listings![0].description, subtitle: Text(business.listings![0].description,
maxLines: 2, overflow: TextOverflow.ellipsis), maxLines: 2, overflow: TextOverflow.ellipsis),
onTap: () { onTap: () {
+81 -28
View File
@@ -37,7 +37,7 @@ class _SignInPageState extends State<SignInPage> {
heightFactor: 1.0, heightFactor: 1.0,
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(12, 50, 12, 50), padding: const EdgeInsets.fromLTRB(12, 50, 12, 50),
height: 475, height: 450,
width: 500, width: 500,
child: Card( child: Card(
child: Padding( child: Padding(
@@ -60,9 +60,12 @@ class _SignInPageState extends State<SignInPage> {
controller: _usernameController, controller: _usernameController,
autocorrect: false, autocorrect: false,
decoration: const InputDecoration( decoration: const InputDecoration(
prefixIcon: Icon(Icons.person_outline), prefixIcon: Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.person_outline),
),
labelText: 'Username', labelText: 'Username',
border: OutlineInputBorder()), ),
), ),
), ),
Padding( Padding(
@@ -107,11 +110,13 @@ class _SignInPageState extends State<SignInPage> {
autocorrect: false, autocorrect: false,
obscureText: obscurePassword, obscureText: obscurePassword,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.fingerprint), prefixIcon: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.fingerprint),
),
labelText: 'Password', labelText: 'Password',
border: const OutlineInputBorder(),
suffixIcon: Padding( suffixIcon: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.only(right: 16.0),
child: IconButton( child: IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
@@ -131,35 +136,45 @@ class _SignInPageState extends State<SignInPage> {
errorMessage!, errorMessage!,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
CheckboxListTile( Padding(
value: rememberMe, padding: const EdgeInsets.only(
onChanged: (value) async { top: 8.0, left: 8.0, right: 8.0),
setState(() { child: FilledButton(
rememberMe = value!; style: FilledButton.styleFrom(
}); shape: const RoundedRectangleBorder(
}, borderRadius:
title: const Text('Remember me'), BorderRadius.all(Radius.circular(6)))),
), child: SizedBox(
ElevatedButton.icon( width: 374,
style: ElevatedButton.styleFrom( height: 40,
backgroundColor: child: Row(
Theme.of(context).colorScheme.primary, mainAxisSize: MainAxisSize.min,
// padding: const EdgeInsets.only(left: 20.0, right: 20.0, top: 12.0, bottom: 12.0), mainAxisAlignment: MainAxisAlignment.center,
), children: [
icon: _isloading _isloading
? const SizedBox( ? const Padding(
padding: EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 3, strokeWidth: 3,
)) )),
: const Icon(Icons.done, color: Colors.white), )
label: const Text('Sign In', : const Padding(
style: TextStyle(color: Colors.white)), padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.done,
color: Colors.white),
),
const Text('Sign in',
style: TextStyle(
color: Colors.white, fontSize: 18)),
],
),
),
onPressed: () async { onPressed: () async {
setState(() { setState(() {
errorMessage = null;
_isloading = true; _isloading = true;
}); });
jwt = await signIn(username, password).timeout( jwt = await signIn(username, password).timeout(
@@ -189,6 +204,44 @@ class _SignInPageState extends State<SignInPage> {
} }
}, },
), ),
),
Expanded(
child: Align(
alignment: Alignment.bottomLeft,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
child: Row(
children: [
Checkbox(
value: rememberMe,
onChanged: (value) {
setState(() {
rememberMe = value!;
});
}),
const Padding(
padding: EdgeInsets.all(8.0),
child: Text('Remember me'),
)
],
),
onTap: () {
setState(() {
rememberMe = !rememberMe;
});
},
),
),
),
],
),
),
),
], ],
), ),
), ),
+35 -21
View File
@@ -7,7 +7,8 @@ import 'package:fbla_ui/shared/utils.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
var apiAddress = 'https://homelab.marinodev.com/fbla-api'; var apiAddress = 'https://homelab.marinodev.com/fbla-api';
// var apiAddress = 'http://192.168.0.114:8000/fbla-api'; // var apiAddress = 'http://192.168.0.114:8000/fbla-api'; // TODO
var client = http.Client(); var client = http.Client();
Future fetchBusinessData() async { Future fetchBusinessData() async {
@@ -50,31 +51,44 @@ Future fetchBusinessNames() async {
} }
} }
Future fetchBusinessDataOverviewJobs({List<JobType>? typeFilters}) async { Future fetchBusinessDataOverviewJobs(
{Iterable<JobType>? typeFilters, Iterable<OfferType>? offerFilters}) async {
try { try {
String? typeString = String uriString = '$apiAddress/businessdata/overview/jobs';
typeFilters?.map((jobType) => jobType.name).toList().join(','); if (typeFilters != null && typeFilters.isNotEmpty) {
Uri uri = uriString +=
Uri.parse('$apiAddress/businessdata/overview/jobs?filters=$typeString'); '?typeFilters=${typeFilters.map((jobType) => jobType.name).join(',')}';
if (typeFilters == null || typeFilters.isEmpty) { if (offerFilters != null && offerFilters.isNotEmpty) {
uri = Uri.parse('$apiAddress/businessdata/overview/jobs'); uriString +=
'&offerFilters=${offerFilters.map((offerType) => offerType.name).join(',')}';
} }
} else if (offerFilters != null && offerFilters.isNotEmpty) {
uriString +=
'?offerFilters=${offerFilters.map((offerType) => offerType.name).join(',')}';
}
Uri uri = Uri.parse(uriString);
var response = await http.get(uri).timeout(const Duration(seconds: 20)); var response = await http.get(uri).timeout(const Duration(seconds: 20));
if (response.statusCode == 200) { if (response.statusCode == 200) {
var decodedResponse = json.decode(response.body); List<Map<String, dynamic>> decodedResponse =
json.decode(response.body).cast<Map<String, dynamic>>();
List<Business> initialBusinesses =
decodedResponse.map((element) => Business.fromJson(element)).toList();
Map<JobType, List<Business>> groupedBusinesses = {}; Map<JobType, List<Business>> groupedBusinesses = {};
for (String stringType in decodedResponse.keys) { for (Business business in initialBusinesses) {
List<Business> businesses = []; for (JobListing job in business.listings!) {
List<Business> newBusinesses = groupedBusinesses[job.type!] ?? [];
for (Map<String, dynamic> map in decodedResponse[stringType]) { Business newBusiness = Business.copy(business);
Business business = Business.fromJson(map); newBusiness.listings =
businesses.add(business); newBusiness.listings!.where((element) => element == job).toList();
newBusinesses.add(newBusiness);
groupedBusinesses.addAll({job.type!: newBusinesses});
}
} }
groupedBusinesses
.addAll({JobType.values.byName(stringType): businesses});
}
return groupedBusinesses; return groupedBusinesses;
} else { } else {
return 'Error ${response.statusCode}! Please try again later!'; return 'Error ${response.statusCode}! Please try again later!';
@@ -225,6 +239,8 @@ Future createListing(JobListing listing) async {
"businessId": ${listing.businessId}, "businessId": ${listing.businessId},
"name": "${listing.name}", "name": "${listing.name}",
"description": "${listing.description.replaceAll('\n', '\\n')}", "description": "${listing.description.replaceAll('\n', '\\n')}",
"type": "${listing.type!.name}",
"offerType": "${listing.offerType!.name}",
"wage": "${listing.wage}", "wage": "${listing.wage}",
"link": "${listing.link}" "link": "${listing.link}"
} }
@@ -324,6 +340,7 @@ Future editListing(JobListing listing) async {
"name": "${listing.name}", "name": "${listing.name}",
"description": "${listing.description.replaceAll('\n', '\\n')}", "description": "${listing.description.replaceAll('\n', '\\n')}",
"type": "${listing.type!.name}", "type": "${listing.type!.name}",
"offerType": "${listing.offerType!.name}",
"wage": "${listing.wage}", "wage": "${listing.wage}",
"link": "${listing.link}" "link": "${listing.link}"
} }
@@ -366,9 +383,6 @@ Future marinoDevLogo() async {
Uri.parse('$apiAddress/marinodev'), Uri.parse('$apiAddress/marinodev'),
); );
// File logo = File ('${getTemporaryDirectory().toString()}/marinodev.svg');
// logo.writeAsBytes(response.bodyBytes);
return response.bodyBytes; return response.bodyBytes;
} }
+134 -27
View File
@@ -11,6 +11,68 @@ import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
Map<DataTypeBusiness, int> dataTypePriorityBusiness = {
DataTypeBusiness.logo: 0,
DataTypeBusiness.name: 1,
DataTypeBusiness.description: 2,
DataTypeBusiness.type: 3,
DataTypeBusiness.website: 4,
DataTypeBusiness.contactName: 5,
DataTypeBusiness.contactEmail: 6,
DataTypeBusiness.contactPhone: 7,
DataTypeBusiness.notes: 8
};
Map<DataTypeBusiness, String> dataTypeFriendlyBusiness = {
DataTypeBusiness.logo: 'Logo',
DataTypeBusiness.name: 'Name',
DataTypeBusiness.description: 'Description',
DataTypeBusiness.type: 'Type',
DataTypeBusiness.website: 'Website',
DataTypeBusiness.contactName: 'Contact Name',
DataTypeBusiness.contactEmail: 'Contact Email',
DataTypeBusiness.contactPhone: 'Contact Phone',
DataTypeBusiness.notes: 'Notes'
};
Map<DataTypeJob, int> dataTypePriorityJob = {
DataTypeJob.businessName: 1,
DataTypeJob.name: 2,
DataTypeJob.description: 3,
DataTypeJob.type: 4,
DataTypeJob.offerType: 5,
DataTypeJob.wage: 6,
DataTypeJob.link: 7,
};
Map<DataTypeJob, String> dataTypeFriendlyJob = {
DataTypeJob.businessName: 'Business Name',
DataTypeJob.name: 'Job Listing Name',
DataTypeJob.description: 'Description',
DataTypeJob.type: 'Job Type',
DataTypeJob.offerType: 'Offer Type',
DataTypeJob.wage: 'Wage Information',
DataTypeJob.link: 'Additional Info Link',
};
Set<DataTypeBusiness> sortDataTypesBusiness(Set<DataTypeBusiness> set) {
List<DataTypeBusiness> list = set.toList();
list.sort((a, b) {
return dataTypePriorityBusiness[a]!.compareTo(dataTypePriorityBusiness[b]!);
});
set = list.toSet();
return set;
}
Set<DataTypeJob> sortDataTypesJob(Set<DataTypeJob> set) {
List<DataTypeJob> list = set.toList();
list.sort((a, b) {
return dataTypePriorityJob[a]!.compareTo(dataTypePriorityJob[b]!);
});
set = list.toSet();
return set;
}
class _FilterBusinessDataTypeChips extends StatefulWidget { class _FilterBusinessDataTypeChips extends StatefulWidget {
final Set<DataTypeBusiness> selectedDataTypesBusiness; final Set<DataTypeBusiness> selectedDataTypesBusiness;
@@ -25,16 +87,12 @@ class _FilterBusinessDataTypeChipsState
extends State<_FilterBusinessDataTypeChips> { extends State<_FilterBusinessDataTypeChips> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<Padding> chips = []; List<Widget> chips = [];
for (var type in DataTypeBusiness.values) { for (var type in DataTypeBusiness.values) {
chips.add(Padding( chips.add(FilterChip(
padding:
const EdgeInsets.only(left: 3.0, right: 3.0, bottom: 3.0, top: 3.0),
child: FilterChip(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
side: side: BorderSide(color: Theme.of(context).colorScheme.secondary)),
BorderSide(color: Theme.of(context).colorScheme.secondary)),
label: Text(dataTypeFriendlyBusiness[type]!), label: Text(dataTypeFriendlyBusiness[type]!),
showCheckmark: false, showCheckmark: false,
selected: widget.selectedDataTypesBusiness.contains(type), selected: widget.selectedDataTypesBusiness.contains(type),
@@ -46,10 +104,11 @@ class _FilterBusinessDataTypeChipsState
widget.selectedDataTypesBusiness.remove(type); widget.selectedDataTypesBusiness.remove(type);
} }
}); });
}), }));
));
} }
return Wrap( return Wrap(
spacing: 6,
runSpacing: 6,
children: chips, children: chips,
); );
} }
@@ -74,6 +133,7 @@ class _FilterJobDataTypeChipsState extends State<_FilterJobDataTypeChips> {
padding: padding:
const EdgeInsets.only(left: 3.0, right: 3.0, bottom: 3.0, top: 3.0), const EdgeInsets.only(left: 3.0, right: 3.0, bottom: 3.0, top: 3.0),
child: FilterChip( child: FilterChip(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
side: side:
@@ -93,6 +153,8 @@ class _FilterJobDataTypeChipsState extends State<_FilterJobDataTypeChips> {
)); ));
} }
return Wrap( return Wrap(
spacing: 6,
runSpacing: 6,
children: chips, children: chips,
); );
} }
@@ -113,11 +175,13 @@ Future<void> generatePDF(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
contentPadding: const EdgeInsets.all(16),
scrollable: true,
title: const Text('Export Settings'), title: const Text('Export Settings'),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
height: 200,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
@@ -208,21 +272,42 @@ Future<void> generatePDF(
for (JobListing job in business.listings!) { for (JobListing job in business.listings!) {
List<pw.Widget> jobRow = []; List<pw.Widget> jobRow = [];
for (DataTypeJob dataType in dataTypesJob) { for (DataTypeJob dataType in dataTypesJob) {
if (dataType != DataTypeJob.businessName) { switch (dataType) {
var currentValue = case DataTypeJob.businessName:
jobValueFromDataType(job, dataType);
if (currentValue != null) {
jobRow.add(pw.Padding(
child: pw.Text(currentValue),
padding: const pw.EdgeInsets.all(4.0)));
} else {
jobRow.add(pw.Container());
}
} else {
jobRow.add(pw.Padding( jobRow.add(pw.Padding(
child: pw.Text(business.name!), child: pw.Text(business.name!),
padding: const pw.EdgeInsets.all(4.0))); padding: const pw.EdgeInsets.all(4.0)));
case DataTypeJob.type:
jobRow.add(pw.Padding(
child: pw.Text(getNameFromJobType(job.type!)),
padding: const pw.EdgeInsets.all(4.0)));
case DataTypeJob.offerType:
jobRow.add(pw.Padding(
child: pw.Text(
getNameFromOfferType(job.offerType!)),
padding: const pw.EdgeInsets.all(4.0)));
default:
jobRow.add(pw.Padding(
child: pw.Text(
jobValueFromDataType(job, dataType) ??
''),
padding: const pw.EdgeInsets.all(4.0)));
} }
// if (dataType != DataTypeJob.businessName) {
// var currentValue =
// jobValueFromDataType(job, dataType);
// if (currentValue != null) {
// jobRow.add(pw.Padding(
// child: pw.Text(currentValue),
// padding: const pw.EdgeInsets.all(4.0)));
// } else {
// jobRow.add(pw.Container());
// }
// } else {
// jobRow.add(pw.Padding(
// child: pw.Text(business.name!),
// padding: const pw.EdgeInsets.all(4.0)));
// }
} }
tableRows.add(pw.TableRow(children: jobRow)); tableRows.add(pw.TableRow(children: jobRow));
} }
@@ -344,6 +429,11 @@ Map<int, pw.TableColumnWidth> _businessColumnSizes(
map.addAll( map.addAll(
{sorted.indexOf(DataTypeBusiness.logo): const pw.FixedColumnWidth(32)}); {sorted.indexOf(DataTypeBusiness.logo): const pw.FixedColumnWidth(32)});
} }
if (sorted.contains(DataTypeBusiness.type)) {
space -= 68;
map.addAll(
{sorted.indexOf(DataTypeBusiness.type): const pw.FixedColumnWidth(68)});
}
if (dataTypes.contains(DataTypeBusiness.contactName)) { if (dataTypes.contains(DataTypeBusiness.contactName)) {
space -= 72; space -= 72;
map.addAll({ map.addAll({
@@ -369,7 +459,7 @@ Map<int, pw.TableColumnWidth> _businessColumnSizes(
leftNum += 1; leftNum += 1;
} }
if (dataTypes.contains(DataTypeBusiness.notes)) { if (dataTypes.contains(DataTypeBusiness.notes)) {
leftNum += 2; leftNum += 1;
} }
if (dataTypes.contains(DataTypeBusiness.description)) { if (dataTypes.contains(DataTypeBusiness.description)) {
leftNum += 3; leftNum += 3;
@@ -391,9 +481,8 @@ Map<int, pw.TableColumnWidth> _businessColumnSizes(
}); });
} }
if (dataTypes.contains(DataTypeBusiness.notes)) { if (dataTypes.contains(DataTypeBusiness.notes)) {
map.addAll({ map.addAll(
sorted.indexOf(DataTypeBusiness.notes): pw.FixedColumnWidth(leftNum * 2) {sorted.indexOf(DataTypeBusiness.notes): pw.FixedColumnWidth(leftNum)});
});
} }
if (dataTypes.contains(DataTypeBusiness.description)) { if (dataTypes.contains(DataTypeBusiness.description)) {
map.addAll({ map.addAll({
@@ -416,6 +505,13 @@ Map<int, pw.TableColumnWidth> _jobColumnSizes(Set<DataTypeJob> dataTypes) {
.first): const pw.FractionColumnWidth(0.2) .first): const pw.FractionColumnWidth(0.2)
}); });
} }
if (dataTypes.contains(DataTypeJob.type)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.type)
.first): const pw.FractionColumnWidth(0.1)
});
}
if (dataTypes.contains(DataTypeJob.name)) { if (dataTypes.contains(DataTypeJob.name)) {
map.addAll({ map.addAll({
sortedDataTypes.indexOf(sortedDataTypes sortedDataTypes.indexOf(sortedDataTypes
@@ -427,7 +523,14 @@ Map<int, pw.TableColumnWidth> _jobColumnSizes(Set<DataTypeJob> dataTypes) {
map.addAll({ map.addAll({
sortedDataTypes.indexOf(sortedDataTypes sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.description) .where((element) => element == DataTypeJob.description)
.first): const pw.FractionColumnWidth(0.4) .first): const pw.FractionColumnWidth(0.3)
});
}
if (dataTypes.contains(DataTypeJob.offerType)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.offerType)
.first): const pw.FractionColumnWidth(0.1)
}); });
} }
if (dataTypes.contains(DataTypeJob.wage)) { if (dataTypes.contains(DataTypeJob.wage)) {
@@ -456,7 +559,7 @@ dynamic businessValueFromDataType(
case DataTypeBusiness.description: case DataTypeBusiness.description:
return business.description; return business.description;
case DataTypeBusiness.type: case DataTypeBusiness.type:
return business.type; return getNameFromBusinessType(business.type!);
case DataTypeBusiness.website: case DataTypeBusiness.website:
return business.website; return business.website;
case DataTypeBusiness.contactName: case DataTypeBusiness.contactName:
@@ -478,6 +581,10 @@ dynamic jobValueFromDataType(JobListing job, DataTypeJob dataType) {
return job.name; return job.name;
case DataTypeJob.description: case DataTypeJob.description:
return job.description; return job.description;
case DataTypeJob.type:
return job.type;
case DataTypeJob.offerType:
return job.offerType;
case DataTypeJob.wage: case DataTypeJob.wage:
return job.wage; return job.wage;
case DataTypeJob.link: case DataTypeJob.link:
+90 -79
View File
@@ -5,80 +5,24 @@ enum DataTypeBusiness {
logo, logo,
name, name,
description, description,
type,
website, website,
contactName, contactName,
contactEmail, contactEmail,
contactPhone, contactPhone,
notes, notes,
type,
} }
enum DataTypeJob { enum DataTypeJob {
businessName, businessName,
name, name,
description, description,
type,
offerType,
wage, wage,
link, link,
} }
Map<DataTypeBusiness, int> dataTypePriorityBusiness = {
DataTypeBusiness.logo: 0,
DataTypeBusiness.name: 1,
DataTypeBusiness.description: 2,
DataTypeBusiness.type: 3,
DataTypeBusiness.website: 4,
DataTypeBusiness.contactName: 5,
DataTypeBusiness.contactEmail: 6,
DataTypeBusiness.contactPhone: 7,
DataTypeBusiness.notes: 8
};
Map<DataTypeBusiness, String> dataTypeFriendlyBusiness = {
DataTypeBusiness.logo: 'Logo',
DataTypeBusiness.name: 'Name',
DataTypeBusiness.description: 'Description',
DataTypeBusiness.type: 'Type',
DataTypeBusiness.website: 'Website',
DataTypeBusiness.contactName: 'Contact Name',
DataTypeBusiness.contactEmail: 'Contact Email',
DataTypeBusiness.contactPhone: 'Contact Phone',
DataTypeBusiness.notes: 'Notes'
};
Map<DataTypeJob, int> dataTypePriorityJob = {
DataTypeJob.businessName: 1,
DataTypeJob.name: 2,
DataTypeJob.description: 3,
DataTypeJob.wage: 4,
DataTypeJob.link: 5,
};
Map<DataTypeJob, String> dataTypeFriendlyJob = {
DataTypeJob.businessName: 'Business Name',
DataTypeJob.name: 'Job Listing Name',
DataTypeJob.description: 'Description',
DataTypeJob.wage: 'Wage',
DataTypeJob.link: 'Additional Info Link',
};
Set<DataTypeBusiness> sortDataTypesBusiness(Set<DataTypeBusiness> set) {
List<DataTypeBusiness> list = set.toList();
list.sort((a, b) {
return dataTypePriorityBusiness[a]!.compareTo(dataTypePriorityBusiness[b]!);
});
set = list.toSet();
return set;
}
Set<DataTypeJob> sortDataTypesJob(Set<DataTypeJob> set) {
List<DataTypeJob> list = set.toList();
list.sort((a, b) {
return dataTypePriorityJob[a]!.compareTo(dataTypePriorityJob[b]!);
});
set = list.toSet();
return set;
}
enum BusinessType { enum BusinessType {
food, food,
shop, shop,
@@ -88,7 +32,17 @@ enum BusinessType {
other, other,
} }
enum JobType { cashier, server, mechanic, other } enum JobType {
retail,
customerService,
foodService,
education,
maintenance,
manufacturing,
other,
}
enum OfferType { job, internship, apprenticeship }
class JobListing { class JobListing {
int? id; int? id;
@@ -96,17 +50,20 @@ class JobListing {
String name; String name;
String description; String description;
JobType? type; JobType? type;
OfferType? offerType;
String? wage; String? wage;
String? link; String? link;
JobListing( JobListing({
{this.id, this.id,
this.businessId, this.businessId,
required this.name, required this.name,
required this.description, required this.description,
this.type, this.type,
this.offerType,
this.wage, this.wage,
this.link}); this.link,
});
factory JobListing.copy(JobListing input) { factory JobListing.copy(JobListing input) {
return JobListing( return JobListing(
@@ -115,6 +72,7 @@ class JobListing {
name: input.name, name: input.name,
description: input.description, description: input.description,
type: input.type, type: input.type,
offerType: input.offerType,
wage: input.wage, wage: input.wage,
link: input.link, link: input.link,
); );
@@ -139,7 +97,7 @@ class Business {
{required this.id, {required this.id,
required this.name, required this.name,
required this.description, required this.description,
required this.website, this.website,
this.type, this.type,
this.contactName, this.contactName,
this.contactEmail, this.contactEmail,
@@ -161,7 +119,9 @@ class Business {
description: json['listings'][i]['description'], description: json['listings'][i]['description'],
type: JobType.values.byName(json['listings'][i]['type']), type: JobType.values.byName(json['listings'][i]['type']),
wage: json['listings'][i]['wage'], wage: json['listings'][i]['wage'],
link: json['listings'][i]['link'])); link: json['listings'][i]['link'],
offerType:
OfferType.values.byName(json['listings'][i]['offerType'])));
} }
} }
@@ -217,12 +177,18 @@ IconData getIconFromBusinessType(BusinessType type) {
IconData getIconFromJobType(JobType type) { IconData getIconFromJobType(JobType type) {
switch (type) { switch (type) {
case JobType.cashier: case JobType.retail:
return Icons.shopping_bag; return Icons.shopping_bag;
case JobType.server: case JobType.customerService:
return Icons.support_agent;
case JobType.foodService:
return Icons.restaurant; return Icons.restaurant;
case JobType.mechanic: case JobType.education:
return Icons.construction; return Icons.school;
case JobType.maintenance:
return Icons.handyman;
case JobType.manufacturing:
return Icons.factory;
case JobType.other: case JobType.other:
return Icons.work; return Icons.work;
} }
@@ -247,12 +213,18 @@ pw.IconData getPwIconFromBusinessType(BusinessType type) {
pw.IconData getPwIconFromJobType(JobType type) { pw.IconData getPwIconFromJobType(JobType type) {
switch (type) { switch (type) {
case JobType.cashier: case JobType.retail:
return const pw.IconData(0xf1cc); return const pw.IconData(0xf1cc);
case JobType.server: case JobType.customerService:
return const pw.IconData(0xf0e2);
case JobType.foodService:
return const pw.IconData(0xe56c); return const pw.IconData(0xe56c);
case JobType.mechanic: case JobType.education:
return const pw.IconData(0xea3c); return const pw.IconData(0xe80c);
case JobType.maintenance:
return const pw.IconData(0xf10b);
case JobType.manufacturing:
return const pw.IconData(0xebbc);
case JobType.other: case JobType.other:
return const pw.IconData(0xe8f9); return const pw.IconData(0xe8f9);
} }
@@ -277,17 +249,56 @@ String getNameFromBusinessType(BusinessType type) {
String getNameFromJobType(JobType type) { String getNameFromJobType(JobType type) {
switch (type) { switch (type) {
case JobType.cashier: case JobType.retail:
return 'Cashier'; return 'Retail';
case JobType.server: case JobType.customerService:
return 'Server'; return 'Customer Service';
case JobType.mechanic: case JobType.foodService:
return 'Mechanic'; return 'Food Service';
case JobType.education:
return 'Education';
case JobType.maintenance:
return 'Maintenance';
case JobType.manufacturing:
return 'Manufacturing';
case JobType.other: case JobType.other:
return 'Other'; return 'Other';
} }
} }
String getNameFromOfferType(OfferType type) {
switch (type) {
case OfferType.job:
return 'Job';
case OfferType.internship:
return 'Internship';
case OfferType.apprenticeship:
return 'Apprenticeship';
}
}
String getLetterFromOfferType(OfferType type) {
switch (type) {
case OfferType.job:
return 'J';
case OfferType.internship:
return 'I';
case OfferType.apprenticeship:
return 'A';
}
}
Color getColorFromOfferType(OfferType type) {
switch (type) {
case OfferType.job:
return Colors.blue;
case OfferType.internship:
return Colors.green.shade800;
case OfferType.apprenticeship:
return Colors.red;
}
}
IconData getIconFromThemeMode(ThemeMode theme) { IconData getIconFromThemeMode(ThemeMode theme) {
switch (theme) { switch (theme) {
case ThemeMode.dark: case ThemeMode.dark:
+95 -507
View File
@@ -6,488 +6,6 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
// class BusinessDisplayPanel extends StatefulWidget {
// final Map<JobType, List<Business>>? jobGroupedBusinesses;
// final Map<BusinessType, List<Business>>? businessGroupedBusinesses;
// final bool widescreen;
// final Set<Business>? selectedBusinesses;
//
// const BusinessDisplayPanel(
// {super.key,
// this.jobGroupedBusinesses,
// this.businessGroupedBusinesses,
// required this.widescreen,
// this.selectedBusinesses});
//
// @override
// State<BusinessDisplayPanel> createState() => _BusinessDisplayPanelState();
// }
//
// class _BusinessDisplayPanelState extends State<BusinessDisplayPanel> {
// @override
// Widget build(BuildContext context) {
// if ((widget.businessGroupedBusinesses?.keys ?? <BusinessType>[]).isEmpty &&
// (widget.jobGroupedBusinesses?.keys ?? <JobType>[]).isEmpty) {
// return const SliverToBoxAdapter(
// child: Center(
// child: Padding(
// padding: EdgeInsets.all(16.0),
// child: Text(
// 'No results found!\nPlease change your search filters.',
// textAlign: TextAlign.center,
// style: TextStyle(fontSize: 18),
// ),
// ),
// ),
// );
// }
//
// List<BusinessHeader> headers = [];
// if (widget.jobGroupedBusinesses != null) {
// for (JobType jobType in widget.jobGroupedBusinesses!.keys) {
// headers.add(BusinessHeader(
// jobType: jobType,
// widescreen: widget.widescreen,
// // selectable: widget.selectable,
// selectedBusinesses: widget.selectedBusinesses,
// // updateSelectedBusinessesCallback:
// // widget.updateSelectedBusinessesCallback,
// businesses: widget.jobGroupedBusinesses![jobType]!));
// }
// headers.sort((a, b) => a.jobType!.index.compareTo(b.jobType!.index));
// return MultiSliver(children: headers);
// } else if (widget.businessGroupedBusinesses != null) {
// for (BusinessType businessType
// in widget.businessGroupedBusinesses!.keys) {
// headers.add(BusinessHeader(
// businessType: businessType,
// widescreen: widget.widescreen,
// selectedBusinesses: widget.selectedBusinesses,
// businesses: widget.businessGroupedBusinesses![businessType]!));
// }
// headers.sort(
// (a, b) => a.businessType!.index.compareTo(b.businessType!.index));
// return MultiSliver(children: headers);
// }
// return const Text('Error with input data!');
// }
// }
//
// class BusinessHeader extends StatefulWidget {
// final JobType? jobType;
// final BusinessType? businessType;
// final List<Business> businesses;
// final Set<Business>? selectedBusinesses;
// final bool widescreen;
// final void Function()? updateSelectedBusinessesCallback;
//
// const BusinessHeader({
// super.key,
// this.jobType,
// this.businessType,
// required this.businesses,
// required this.widescreen,
// this.updateSelectedBusinessesCallback,
// this.selectedBusinesses,
// });
//
// @override
// State<BusinessHeader> createState() => _BusinessHeaderState();
// }
//
// class _BusinessHeaderState extends State<BusinessHeader> {
// refresh() {
// setState(() {});
// }
//
// @override
// Widget build(BuildContext context) {
// return SliverStickyHeader(
// header: Container(
// height: 55.0,
// color: Theme.of(context).colorScheme.primary,
// padding: const EdgeInsets.symmetric(horizontal: 16.0),
// alignment: Alignment.centerLeft,
// child: _getHeaderRow(widget.selectedBusinesses),
// ),
// sliver: _getChildSliver(
// widget.businesses, widget.widescreen, widget.selectedBusinesses),
// );
// }
//
// Widget _getHeaderRow(Set<Business>? selectedBusinesses) {
// if (selectedBusinesses != null) {
// return Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: [
// Row(
// children: [
// Padding(
// padding: const EdgeInsets.only(left: 4.0, right: 12.0),
// child: Icon(
// widget.jobType != null
// ? getIconFromJobType(widget.jobType!)
// : getIconFromBusinessType(widget.businessType!),
// color: Theme.of(context).colorScheme.onPrimary,
// )),
// Text(widget.jobType != null
// ? getNameFromJobType(widget.jobType!)
// : getNameFromBusinessType(widget.businessType!)),
// ],
// ),
// Padding(
// padding: const EdgeInsets.only(right: 12.0),
// child: Checkbox(
// checkColor: Theme.of(context).colorScheme.primary,
// activeColor: Theme.of(context).colorScheme.onPrimary,
// value: widget.selectedBusinesses!.containsAll(widget.businesses),
// onChanged: (value) {
// if (value!) {
// setState(() {
// widget.selectedBusinesses!.addAll(widget.businesses);
// });
// } else {
// setState(() {
// widget.selectedBusinesses!.removeAll(widget.businesses);
// });
// }
// },
// ),
// ),
// ],
// );
// } else {
// return Row(
// children: [
// Padding(
// padding: const EdgeInsets.only(left: 4.0, right: 12.0),
// child: Icon(
// widget.jobType != null
// ? getIconFromJobType(widget.jobType!)
// : getIconFromBusinessType(widget.businessType!),
// color: Theme.of(context).colorScheme.onPrimary,
// ),
// ),
// Text(
// widget.jobType != null
// ? getNameFromJobType(widget.jobType!)
// : getNameFromBusinessType(widget.businessType!),
// style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
// ),
// ],
// );
// }
// }
//
// Widget _getChildSliver(List<Business> businesses, bool widescreen,
// Set<Business>? selectedBusinesses) {
// if (widescreen) {
// return SliverPadding(
// padding: const EdgeInsets.all(4),
// sliver: SliverGrid(
// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
// mainAxisExtent: 250.0,
// maxCrossAxisExtent: 400.0,
// mainAxisSpacing: 4.0,
// crossAxisSpacing: 4.0,
// ),
// delegate: SliverChildBuilderDelegate(
// childCount: businesses.length,
// (BuildContext context, int index) {
// return BusinessCard(
// business: businesses[index],
// selectedBusinesses: selectedBusinesses,
// widescreen: widescreen,
// callback: refresh,
// jobType: widget.jobType,
// );
// },
// ),
// ),
// );
// } else {
// return SliverList(
// delegate: SliverChildBuilderDelegate(
// childCount: businesses.length,
// (BuildContext context, int index) {
// return BusinessCard(
// business: businesses[index],
// selectedBusinesses: selectedBusinesses,
// widescreen: widescreen,
// callback: refresh,
// jobType: widget.jobType,
// );
// },
// ),
// );
// }
// }
// }
//
// class BusinessCard extends StatefulWidget {
// final Business business;
// final bool widescreen;
// final Set<Business>? selectedBusinesses;
// final Function callback;
// final JobType? jobType;
// final BusinessType? businessType;
//
// const BusinessCard({
// super.key,
// required this.business,
// required this.widescreen,
// required this.callback,
// this.jobType,
// this.businessType,
// this.selectedBusinesses,
// });
//
// @override
// State<BusinessCard> createState() => _BusinessCardState();
// }
//
// class _BusinessCardState extends State<BusinessCard> {
// @override
// Widget build(BuildContext context) {
// if (widget.widescreen) {
// return _businessTile(widget.business, widget.selectedBusinesses,
// widget.jobType, widget.businessType);
// } else {
// return _businessListItem(widget.business, widget.selectedBusinesses,
// widget.callback, widget.jobType, widget.businessType);
// }
// }
//
// Widget _businessTile(Business business, Set<Business>? selectedBusinesses,
// JobType? jobType, BusinessType? businessType) {
// return MouseRegion(
// cursor: SystemMouseCursors.click,
// child: GestureDetector(
// onTap: () {
// Navigator.of(context).push(MaterialPageRoute(
// builder: (context) => BusinessDetail(
// id: business.id,
// name: business.name!,
// )));
// },
// child: Card(
// clipBehavior: Clip.antiAlias,
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.center,
// children: [
// _getTileRow(business, selectedBusinesses, widget.callback),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: Text(
// business.description!,
// maxLines: selectedBusinesses != null ? 7 : 5,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// const Spacer(),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: selectedBusinesses == null
// ? Row(
// mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// children: [
// IconButton(
// icon: const Icon(Icons.link),
// onPressed: () {
// launchUrl(
// Uri.parse('https://${business.website}'));
// },
// ),
// if (business.locationName != '')
// IconButton(
// icon: const Icon(Icons.location_on),
// onPressed: () {
// launchUrl(Uri.parse(Uri.encodeFull(
// 'https://www.google.com/maps/search/?api=1&query=${business.locationName}')));
// },
// ),
// if ((business.contactPhone != null) &&
// (business.contactPhone != ''))
// IconButton(
// icon: const Icon(Icons.phone),
// onPressed: () {
// showDialog(
// context: context,
// builder: (BuildContext context) {
// return AlertDialog(
// backgroundColor: Theme.of(context)
// .colorScheme
// .surface,
// title: Text((business.contactName ==
// null ||
// business.contactName == '')
// ? 'Contact ${business.name}?'
// : 'Contact ${business.contactName}'),
// content: Text((business.contactName ==
// null ||
// business.contactName == '')
// ? 'Would you like to call or text ${business.name}?'
// : 'Would you like to call or text ${business.contactName}?'),
// actions: [
// TextButton(
// child: const Text('Text'),
// onPressed: () {
// launchUrl(Uri.parse(
// 'sms:${business.contactPhone}'));
// Navigator.of(context).pop();
// }),
// TextButton(
// child: const Text('Call'),
// onPressed: () async {
// launchUrl(Uri.parse(
// 'tel:${business.contactPhone}'));
// Navigator.of(context).pop();
// }),
// ],
// );
// });
// },
// ),
// if (business.contactEmail != '')
// IconButton(
// icon: const Icon(Icons.email),
// onPressed: () {
// launchUrl(Uri.parse(
// 'mailto:${business.contactEmail}'));
// },
// ),
// ],
// )
// : null),
// ],
// ),
// ),
// ),
// );
// }
//
// Widget _getTileRow(
// Business business, Set<Business>? selectedBusinesses, Function callback) {
// if (selectedBusinesses != null) {
// return Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: [
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: ClipRRect(
// borderRadius: BorderRadius.circular(6.0),
// child: Image.network('$apiAddress/logos/${business.id}',
// height: 48, width: 48, errorBuilder: (BuildContext context,
// Object exception, StackTrace? stackTrace) {
// return Icon(
// getIconFromBusinessType(business.type!),
// size: 48,
// );
// }),
// ),
// ),
// Flexible(
// child: Padding(
// padding: const EdgeInsets.all(8.0),
// child: Text(
// business.name!,
// style:
// const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
// maxLines: 2,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// ),
// Padding(
// padding: const EdgeInsets.only(right: 24.0),
// child: _checkbox(callback, selectedBusinesses),
// )
// ],
// );
// } else {
// return Row(
// children: [
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: ClipRRect(
// borderRadius: BorderRadius.circular(6.0),
// child: Image.network('$apiAddress/logos/${business.id}',
// height: 48, width: 48, errorBuilder: (BuildContext context,
// Object exception, StackTrace? stackTrace) {
// return Icon(getIconFromBusinessType(business.type!),
// size: 48);
// }),
// )),
// Flexible(
// child: Padding(
// padding: const EdgeInsets.all(8.0),
// child: Text(
// business.name!,
// style:
// const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
// maxLines: 2,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// ),
// ],
// );
// }
// }
//
// Widget _businessListItem(Business business, Set<Business>? selectedBusinesses,
// Function callback, JobType? jobType, BusinessType? businessType) {
// return Card(
// child: ListTile(
// leading: ClipRRect(
// borderRadius: BorderRadius.circular(3.0),
// child: Image.network('$apiAddress/logos/${business.id}',
// height: 24, width: 24, errorBuilder: (BuildContext context,
// Object exception, StackTrace? stackTrace) {
// return Icon(getIconFromBusinessType(business.type!));
// })),
// title: Text(business.name!),
// subtitle: Text(business.description!,
// maxLines: 1, overflow: TextOverflow.ellipsis),
// trailing: _getCheckbox(selectedBusinesses, callback),
// onTap: () {
// Navigator.of(context).push(MaterialPageRoute(
// builder: (context) => BusinessDetail(
// id: business.id,
// name: business.name!,
// )));
// },
// ),
// );
// }
//
// Widget _checkbox(Function callback, Set<Business> selectedBusinesses) {
// return Checkbox(
// value: selectedBusinesses.contains(widget.business),
// onChanged: (value) {
// if (value!) {
// setState(() {
// selectedBusinesses.add(widget.business);
// });
// } else {
// setState(() {
// selectedBusinesses.remove(widget.business);
// });
// }
// callback();
// },
// );
// }
//
// Widget? _getCheckbox(Set<Business>? selectedBusinesses, Function callback) {
// if (selectedBusinesses != null) {
// return _checkbox(callback, selectedBusinesses);
// } else {
// return null;
// }
// }
// }
class BusinessSearchBar extends StatefulWidget { class BusinessSearchBar extends StatefulWidget {
final String searchTextHint; final String searchTextHint;
final Widget filterIconButton; final Widget filterIconButton;
@@ -504,6 +22,8 @@ class BusinessSearchBar extends StatefulWidget {
} }
class _BusinessSearchBarState extends State<BusinessSearchBar> { class _BusinessSearchBarState extends State<BusinessSearchBar> {
TextEditingController controller = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
@@ -511,6 +31,7 @@ class _BusinessSearchBarState extends State<BusinessSearchBar> {
height: 50, height: 50,
child: SearchBar( child: SearchBar(
hintText: widget.searchTextHint, hintText: widget.searchTextHint,
controller: controller,
backgroundColor: WidgetStateProperty.resolveWith((notNeeded) { backgroundColor: WidgetStateProperty.resolveWith((notNeeded) {
return Theme.of(context).colorScheme.surfaceContainer; return Theme.of(context).colorScheme.surfaceContainer;
}), }),
@@ -521,7 +42,17 @@ class _BusinessSearchBarState extends State<BusinessSearchBar> {
padding: EdgeInsets.only(left: 8.0), padding: EdgeInsets.only(left: 8.0),
child: Icon(Icons.search), child: Icon(Icons.search),
), ),
trailing: [widget.filterIconButton]), trailing: [
if (controller.text != '')
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.text = '';
widget.setSearchCallback('');
},
),
widget.filterIconButton
]),
); );
} }
} }
@@ -624,10 +155,19 @@ class _MainSliverAppBarState extends State<MainSliverAppBar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverAppBar( return SliverAppBar(
title: widget.widescreen title: widget.widescreen
? BusinessSearchBar( ? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: BusinessSearchBar(
setSearchCallback: widget.setSearch, setSearchCallback: widget.setSearch,
searchTextHint: widget.searchHintText, searchTextHint: widget.searchHintText,
filterIconButton: widget.filterIconButton, filterIconButton: widget.filterIconButton,
),
)
// const PreferredSize(
// preferredSize: Size(144, 0), child: SizedBox())
],
) )
: const Text('Job Link'), : const Text('Job Link'),
toolbarHeight: 70, toolbarHeight: 70,
@@ -723,29 +263,6 @@ class _MainSliverAppBarState extends State<MainSliverAppBar> {
}); });
}, },
), ),
// IconButton(
// icon: const Icon(Icons.picture_as_pdf),
// onPressed: () async {
// if (!_isPreviousData) {
// ScaffoldMessenger.of(context).clearSnackBars();
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// width: 300,
// behavior: SnackBarBehavior.floating,
// content: Text('There is no data!'),
// duration: Duration(seconds: 2),
// ),
// );
// } else {
// selectedDataTypesBusiness = <DataTypeBusiness>{};
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => ExportData(
// groupedBusinesses: overviewBusinesses)));
// }
// },
// ),
Padding( Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: IconButton( child: IconButton(
@@ -819,3 +336,74 @@ class _MainSliverAppBarState extends State<MainSliverAppBar> {
return null; return null;
} }
} }
class ContactInformationCard extends StatelessWidget {
final Business business;
ContactInformationCard({super.key, required this.business});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
child: Text(
business.contactName!,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
if (business.contactPhone != null)
ListTile(
leading: const Icon(Icons.phone),
title: Text(business.contactPhone!),
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
title: Text('Contact ${business.contactName}'),
content: Text(
'Would you like to call or text ${business.contactName}?'),
actions: [
TextButton(
child: const Text('Text'),
onPressed: () {
launchUrl(
Uri.parse('sms:${business.contactPhone}'));
Navigator.of(context).pop();
}),
TextButton(
child: const Text('Call'),
onPressed: () async {
launchUrl(
Uri.parse('tel:${business.contactPhone}'));
Navigator.of(context).pop();
}),
],
);
});
},
),
if (business.contactEmail != null)
ListTile(
leading: const Icon(Icons.email),
title: Text(business.contactEmail!),
onTap: () {
launchUrl(Uri.parse('mailto:${business.contactEmail}'));
},
),
],
),
);
}
}
+10 -10
View File
@@ -308,10 +308,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.5" version: "2.2.6"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -348,10 +348,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: pdf name: pdf
sha256: "243f05342fc0bdf140eba5b069398985cdbdd3dbb1d776cf43d5ea29cc570ba6" sha256: "81d5522bddc1ef5c28e8f0ee40b71708761753c163e0c93a40df56fd515ea0f0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.10.8" version: "3.11.0"
pdf_widget_wrapper: pdf_widget_wrapper:
dependency: transitive dependency: transitive
description: description:
@@ -372,10 +372,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -396,10 +396,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: printing name: printing
sha256: "1c99cab90ebcc1fff65831d264627d5b529359d563e53f33ab9b8117f2d280bc" sha256: cc4b256a5a89d5345488e3318897b595867f5181b8c5ed6fc63bfa5f2044aec3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.12.0" version: "5.13.1"
qr: qr:
dependency: transitive dependency: transitive
description: description:
@@ -412,10 +412,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: rive name: rive
sha256: "03bae056e67a5c98f9523c34f8ea63c15b323426a7e8d76d7ebb93066d4df6dc" sha256: "0342c9cd3c83ceeee4ad9246b98d628a2e9abd9d615acf69fa81fbbcf84a36ae"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.5" version: "0.13.8"
rive_common: rive_common:
dependency: transitive dependency: transitive
description: description: