diff --git a/fbla-api/lib/fbla_api.dart b/fbla-api/lib/fbla_api.dart index 814e13d..2424a61 100644 --- a/fbla-api/lib/fbla_api.dart +++ b/fbla-api/lib/fbla_api.dart @@ -28,6 +28,7 @@ class Business { int id; String name; String description; + BusinessType? type; String? website; String? contactName; String? contactEmail; @@ -40,6 +41,7 @@ class Business { {required this.id, required this.name, required this.description, + this.type, this.website, this.contactName, this.contactEmail, @@ -49,11 +51,21 @@ class Business { this.locationAddress}); factory Business.fromJson(Map json) { + bool typeValid = true; + try { + BusinessType.values.byName(json['type']); + } catch (e) { + typeValid = false; + } + return Business( id: json['id'], name: json['name'], description: json['description'], website: json['website'], + type: typeValid + ? BusinessType.values.byName(json['type']) + : BusinessType.other, contactName: json['contactName'], contactEmail: json['contactEmail'], contactPhone: json['contactPhone'], @@ -151,12 +163,64 @@ void main() async { headers: {'Access-Control-Allow-Origin': '*'}, ); }); - app.get('/fbla-api/businessdata/overview', (Request request) async { + app.get('/fbla-api/businessdata/overview/jobs', (Request request) async { print('business overview request received'); var filters = request.url.queryParameters['filters']?.split(',') ?? JobType.values.asNameMap().keys; + Map output = {}; + + for (int i = 0; i < filters.length; i++) { + var postgresResult = (await postgres.query(''' + SELECT json_agg( + json_build_object( + 'id', b.id, + 'name', b.name, + 'contactName', b."contactName", + 'contactEmail', b."contactEmail", + 'contactPhone', b."contactPhone", + 'locationName', b."locationName", + 'listings', ( + SELECT json_agg( + json_build_object( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'type', l.type, + 'wage', l.wage, + 'link', l.link + ) + ) + FROM listings l + WHERE l."businessId" = b.id AND l.type = '${filters.elementAt(i)}' + ) + ) + ) + FROM businesses b + WHERE b.id IN (SELECT "businessId" FROM public.listings WHERE type='${filters.elementAt(i)}') + GROUP BY b.id; + ''')); + + if (postgresResult.isNotEmpty) { + output.addAll({filters.elementAt(i): postgresResult[0][0]}); + } + } + + return Response.ok( + json.encode(output), + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'text/plain' + }, + ); + }); + app.get('/fbla-api/businessdata/overview/types', (Request request) async { + print('business overview request received'); + + var filters = request.url.queryParameters['filters']?.split(',') ?? + BusinessType.values.asNameMap().keys; + // List>>> this is the real type lol Map output = {}; @@ -172,7 +236,7 @@ void main() async { 'contactPhone', "contactPhone", 'locationName', "locationName" ) - ) FROM public.businesses WHERE id IN (SELECT "businessId" FROM public.listings WHERE type='${filters.elementAt(i)}') + ) FROM public.businesses WHERE type='${filters.elementAt(i)}' '''))[0][0]; if (postgresResult != null) { @@ -180,6 +244,7 @@ void main() async { } } + // await Future.delayed(Duration(seconds: 5)); return Response.ok( json.encode(output), headers: { @@ -218,6 +283,7 @@ void main() async { 'id', b.id, 'name', b.name, 'description', b.description, + 'type', b.type, 'website', b.website, 'contactName', b."contactName", 'contactEmail', b."contactEmail", @@ -226,17 +292,20 @@ void main() async { 'locationName', b."locationName", 'locationAddress', b."locationAddress", 'listings', - 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 - ) + 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 LEFT JOIN listings l ON b.id = l."businessId" @@ -273,24 +342,27 @@ void main() async { 'name', b.name, 'description', b.description, 'website', b.website, + 'type', b.type, 'contactName', b."contactName", 'contactEmail', b."contactEmail", 'contactPhone', b."contactPhone", 'notes', b.notes, 'locationName', b."locationName", 'locationAddress', b."locationAddress", - 'listings', - 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 - ) + '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 LEFT JOIN listings l ON b.id = l."businessId" @@ -362,9 +434,10 @@ void main() async { Business business = Business.fromJson(json); await postgres.query(''' - INSERT INTO businesses (name, description, website, "contactName", "contactPhone", "contactEmail", notes, "locationName", "locationAddress") - VALUES ('${business.name.replaceAll("'", "''")}', '${business.description.replaceAll("'", "''")}', '${business.website ?? 'NULL'}', '${business.contactName?.replaceAll("'", "''") ?? 'NULL'}', '${business.contactPhone ?? 'NULL'}', '${business.contactEmail ?? 'NULL'}', '${business.notes?.replaceAll("'", "''") ?? 'NULL'}', '${business.locationName?.replaceAll("'", "''") ?? 'NULL'}', '${business.locationAddress?.replaceAll("'", "''") ?? 'NULL'}') - '''); + INSERT INTO businesses (name, description, website, type, "contactName", "contactPhone", "contactEmail", notes, "locationName", "locationAddress") + VALUES ('${business.name.replaceAll("'", "''")}', '${business.description.replaceAll("'", "''")}', '${business.website ?? 'NULL'}', '${business.type?.name}', '${business.contactName?.replaceAll("'", "''") ?? 'NULL'}', '${business.contactPhone ?? 'NULL'}', '${business.contactEmail ?? 'NULL'}', '${business.notes?.replaceAll("'", "''") ?? 'NULL'}', '${business.locationName?.replaceAll("'", "''") ?? 'NULL'}', '${business.locationAddress?.replaceAll("'", "''") ?? 'NULL'}') + ''' + .replaceAll("'null'", 'NULL')); final dbBusiness = await postgres.query('''SELECT * FROM public.businesses ORDER BY id DESC LIMIT 1'''); @@ -403,8 +476,9 @@ void main() async { await postgres.query(''' INSERT INTO listings ("businessId", name, description, type, wage, link) - 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'}') + ''' + .replaceAll("'null'", 'NULL')); final dbListing = await postgres.query('''SELECT id FROM public.listings ORDER BY id DESC LIMIT 1'''); @@ -500,7 +574,8 @@ void main() async { UPDATE businesses SET name = '${business.name.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text, description = '${business.description.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text, website = '${business.website!}'::text, "contactName" = '${business.contactName!.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text, "contactPhone" = '${business.contactPhone!}'::text, "contactEmail" = '${business.contactEmail!}'::text, notes = '${business.notes!.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text, "locationName" = '${business.locationName!.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text, "locationAddress" = '${business.locationAddress!.replaceAll("'", "''").replaceAll("\"", "\"\"")}'::text WHERE id = ${business.id}; - '''); + ''' + .replaceAll("'null'", 'NULL')); var logoResponse = await http.get( Uri.http('logo.clearbit.com', '/${business.website}'), @@ -546,7 +621,8 @@ void main() async { 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 id = ${listing.id}; - '''); + ''' + .replaceAll("'null'", 'NULL')); return Response.ok( listing.id.toString(), diff --git a/fbla_ui/assets/MarinoDev.svg b/fbla_ui/assets/MarinoDev.svg new file mode 100644 index 0000000..2421e2e --- /dev/null +++ b/fbla_ui/assets/MarinoDev.svg @@ -0,0 +1,63 @@ + + + + diff --git a/fbla_ui/assets/Triangle256.png b/fbla_ui/assets/Triangle256.png new file mode 100644 index 0000000..3fd2bbd Binary files /dev/null and b/fbla_ui/assets/Triangle256.png differ diff --git a/fbla_ui/lib/home.dart b/fbla_ui/lib/home.dart index b55710e..e4ac913 100644 --- a/fbla_ui/lib/home.dart +++ b/fbla_ui/lib/home.dart @@ -1,80 +1,50 @@ -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; -import 'package:fbla_ui/api_logic.dart'; -import 'package:fbla_ui/main.dart'; +import 'package:fbla_ui/pages/businesses_overview.dart'; import 'package:fbla_ui/pages/create_edit_business.dart'; -import 'package:fbla_ui/pages/export_data.dart'; -import 'package:fbla_ui/pages/signin_page.dart'; -import 'package:fbla_ui/shared.dart'; +import 'package:fbla_ui/pages/create_edit_listing.dart'; +import 'package:fbla_ui/pages/listings_overview.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:flutter/material.dart'; -import 'package:rive/rive.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; class Home extends StatefulWidget { final void Function() themeCallback; + final int? initialPage; - const Home({super.key, required this.themeCallback}); + const Home({super.key, required this.themeCallback, this.initialPage}); @override State createState() => _HomeState(); } class _HomeState extends State { - late Future refreshBusinessDataOverviewFuture; - bool _isPreviousData = false; - late Map> overviewBusinesses; Set jobTypeFilters = {}; + Set businessTypeFilters = {}; String searchQuery = ''; - Set selectedDataTypesJob = {}; - Set selectedDataTypesBusiness = {}; + late Future refreshBusinessDataOverviewJobFuture; + late Future refreshBusinessDataOverviewBusinessFuture; + int currentPageIndex = 0; + late dynamic previousJobData; + ScrollController scrollControllerBusinesses = ScrollController(); + ScrollController scrollControllerJobs = ScrollController(); - Future _setFilters(Set filters) async { + void _updateLoggedIn(bool updated) { setState(() { - jobTypeFilters = filters; + loggedIn = updated; }); - _updateOverviewBusinesses(); - } - - Future _updateOverviewBusinesses() async { - var refreshedData = - fetchBusinessDataOverview(typeFilters: jobTypeFilters.toList()); - await refreshedData; - setState(() { - refreshBusinessDataOverviewFuture = refreshedData; - }); - } - - Map> _filterBySearch( - Map> businesses) { - Map> filteredBusinesses = businesses; - - for (JobType jobType in businesses.keys) { - filteredBusinesses[jobType]!.removeWhere((tmpBusiness) => !tmpBusiness - .name - .replaceAll(RegExp(r'[^a-zA-Z]'), '') - .toLowerCase() - .contains(searchQuery - .replaceAll(RegExp(r'[^a-zA-Z]'), '') - .toLowerCase() - .trim())); - } - filteredBusinesses.removeWhere((key, value) => value.isEmpty); - return filteredBusinesses; - } - - Future _setSearch(String search) async { - setState(() { - searchQuery = search; - }); - _updateOverviewBusinesses(); } @override void initState() { super.initState(); - refreshBusinessDataOverviewFuture = fetchBusinessDataOverview(); + currentPageIndex = widget.initialPage ?? 0; + initialLogin(); + refreshBusinessDataOverviewJobFuture = fetchBusinessDataOverviewJobs(); + refreshBusinessDataOverviewBusinessFuture = + fetchBusinessDataOverviewTypes(); } Future initialLogin() async { @@ -94,319 +64,321 @@ class _HomeState extends State { } } - void setStateCallback() { + Future _updateOverviewBusinessesJobsCallback( + Set? newFilters) async { + if (newFilters != null) { + jobTypeFilters = Set.from(newFilters); + } + var refreshedData = + fetchBusinessDataOverviewJobs(typeFilters: jobTypeFilters.toList()); + await refreshedData; setState(() { - loggedIn = loggedIn; + refreshBusinessDataOverviewJobFuture = refreshedData; + }); + } + + Future _updateOverviewBusinessesBusinessCallback( + Set? newFilters) async { + if (newFilters != null) { + businessTypeFilters = Set.from(newFilters); + } + var refreshedData = fetchBusinessDataOverviewTypes( + typeFilters: businessTypeFilters.toList()); + await refreshedData; + setState(() { + refreshBusinessDataOverviewBusinessFuture = refreshedData; }); } @override Widget build(BuildContext context) { - bool widescreen = MediaQuery.sizeOf(context).width >= 1000; + bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth; return Scaffold( - // backgroundColor: Theme.of(context).scaffoldBackgroundColor, - floatingActionButton: _getFAB(), + // floatingActionButton: _getFAB(widescreen, scrollControllerBusinesses, + // scrollControllerJobs, currentPageIndex), + bottomNavigationBar: _getNavigationBar(widescreen), body: RefreshIndicator( - edgeOffset: 120, + edgeOffset: 145, onRefresh: () async { - _updateOverviewBusinesses(); + _updateOverviewBusinessesJobsCallback(null); + _updateOverviewBusinessesBusinessCallback(null); }, - child: CustomScrollView( - slivers: [ - SliverAppBar( - title: widescreen - ? BusinessSearchBar( - filters: jobTypeFilters, - setFiltersCallback: _setFilters, - setSearchCallback: _setSearch) - : const Text('Job Link'), - toolbarHeight: 70, - pinned: true, - scrolledUnderElevation: 0, - centerTitle: true, - expandedHeight: widescreen ? 70 : 120, - bottom: _getBottom(), - leading: IconButton( - icon: getIconFromThemeMode(themeMode), - onPressed: () { - setState(() { - widget.themeCallback(); - }); - }, - ), - actions: [ - IconButton( - icon: const Icon(Icons.help), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('About'), - backgroundColor: - Theme.of(context).colorScheme.surface, - content: SizedBox( - width: 500, - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Welcome to my FBLA 2024 Coding and Programming submission!\n\n' - 'MarinoDev Job Link aims to provide comprehensive details of businesses and community partners' - ' for Waukesha West High School\'s Career and Technical Education Department.\n\n'), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - child: const Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text('Git Repo:'), - Text( - 'https://git.marinodev.com/MarinoDev/FBLA24\n', - style: TextStyle( - color: Colors.blue)), - ], - ), - onTap: () { - launchUrl(Uri.https( - 'git.marinodev.com', - '/MarinoDev/FBLA24')); - }, - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - child: const Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Please direct any questions to'), - Text('drake@marinodev.com', - style: TextStyle( - color: Colors.blue)), - ], - ), - onTap: () { - launchUrl(Uri.parse( - 'mailto:drake@marinodev.com')); - }, - ), - ) - ], - ), - ), - ), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () { - Navigator.of(context).pop(); - }), - ], - ); - }); - }, - ), - 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 = {}; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExportData( - groupedBusinesses: overviewBusinesses))); - } - }, - ), - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: IconButton( - icon: loggedIn - ? const Icon(Icons.account_circle) - : const Icon(Icons.login), - onPressed: () { - if (loggedIn) { - var payload = JWT.decode(jwt).payload; - - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - backgroundColor: - Theme.of(context).colorScheme.surface, - title: Text('Hi, ${payload['username']}!'), - content: Text( - 'You are logged in as an admin with username ${payload['username']}.'), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.of(context).pop(); - }), - TextButton( - child: const Text('Logout'), - onPressed: () async { - final prefs = await SharedPreferences - .getInstance(); - prefs.setBool('rememberMe', false); - prefs.setString('username', ''); - prefs.setString('password', ''); - - setState(() { - loggedIn = false; - }); - Navigator.of(context).pop(); - }), - ], - ); - }); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SignInPage( - refreshAccount: setStateCallback))); - } - }, - ), - ), - ], - ), - FutureBuilder( - future: refreshBusinessDataOverviewFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasData) { - if (snapshot.data.runtimeType == String) { - _isPreviousData = false; - return SliverToBoxAdapter( - child: 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: () { - _updateOverviewBusinesses(); - }, - ), - ), - ]), - )); - } - - overviewBusinesses = snapshot.data; - _isPreviousData = true; - - return BusinessDisplayPanel( - groupedBusinesses: - _filterBySearch(overviewBusinesses), - widescreen: widescreen, - selectable: false); - } else if (snapshot.hasError) { - return SliverToBoxAdapter( - child: 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) { - if (_isPreviousData) { - return BusinessDisplayPanel( - groupedBusinesses: - _filterBySearch(overviewBusinesses), - widescreen: widescreen, - selectable: false); - } else { - return SliverToBoxAdapter( - child: 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 SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - '\nError: ${snapshot.error}', - style: const TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), + child: widescreen + ? Row( + children: [ + _getNavigationRail(), + Expanded( + child: _ContentPane( + themeCallback: widget.themeCallback, + searchQuery: searchQuery, + currentPageIndex: currentPageIndex, + refreshBusinessDataOverviewBusinessFuture: + refreshBusinessDataOverviewBusinessFuture, + refreshBusinessDataOverviewJobFuture: + refreshBusinessDataOverviewJobFuture, + updateOverviewBusinessesBusinessCallback: + _updateOverviewBusinessesBusinessCallback, + updateOverviewBusinessesJobsCallback: + _updateOverviewBusinessesJobsCallback, + updateLoggedIn: _updateLoggedIn, ), - ); - }), - const SliverToBoxAdapter( - child: SizedBox( - height: 80, + ) + ], + ) + : _ContentPane( + themeCallback: widget.themeCallback, + searchQuery: searchQuery, + currentPageIndex: currentPageIndex, + refreshBusinessDataOverviewBusinessFuture: + refreshBusinessDataOverviewBusinessFuture, + refreshBusinessDataOverviewJobFuture: + refreshBusinessDataOverviewJobFuture, + updateOverviewBusinessesBusinessCallback: + _updateOverviewBusinessesBusinessCallback, + updateOverviewBusinessesJobsCallback: + _updateOverviewBusinessesJobsCallback, + updateLoggedIn: _updateLoggedIn, ), - ) - ], - ), ), ); } - Widget? _getFAB() { - if (loggedIn) { - return FloatingActionButton( - child: const Icon(Icons.add_business), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CreateEditBusiness())); + Widget? _getNavigationBar(bool widescreen) { + if (!widescreen) { + return NavigationBar( + selectedIndex: currentPageIndex, + indicatorColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); }, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.business_outlined), + selectedIcon: Icon( + Icons.business, + color: Theme.of(context).colorScheme.onSurface, + ), + label: 'Businesses'), + NavigationDestination( + icon: const Icon(Icons.work_outline), + selectedIcon: Icon( + Icons.work, + color: Theme.of(context).colorScheme.onSurface, + ), + label: 'Job Listings'), + // NavigationDestination( + // icon: const Icon(Icons.description_outlined), + // selectedIcon: Icon( + // Icons.description, + // color: Theme.of(context).colorScheme.onSurface, + // ), + // label: 'Export Data') + ], ); } return null; } - PreferredSizeWidget? _getBottom() { - if (MediaQuery.sizeOf(context).width <= 1000) { - return PreferredSize( - preferredSize: const Size.fromHeight(0), - child: SizedBox( - // color: Theme.of(context).colorScheme.background, - height: 70, - child: Padding( - padding: const EdgeInsets.all(10), - child: BusinessSearchBar( - filters: jobTypeFilters, - setFiltersCallback: _setFilters, - setSearchCallback: _setSearch), + Widget _getNavigationRail() { + return Row( + children: [ + NavigationRail( + selectedIndex: currentPageIndex, + groupAlignment: -1, + indicatorColor: + Theme.of(context).colorScheme.primary.withOpacity(0.5), + trailing: Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: IconButton( + iconSize: 30, + icon: Icon( + getIconFromThemeMode(themeMode), + ), + onPressed: () { + setState(() { + widget.themeCallback(); + }); + }, + ), + ), + ), ), + leading: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 2.0, bottom: 8.0), + child: Image.asset( + 'assets/Triangle256.png', + height: 50, + ), + ), + if (loggedIn) + FloatingActionButton( + child: Icon(Icons.add), + heroTag: 'Homepage', + onPressed: () { + if (currentPageIndex == 0) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const CreateEditBusiness())); + } else if (currentPageIndex == 1) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const CreateEditJobListing())); + } + }, + ) + ], + ), + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + labelType: NavigationRailLabelType.all, + destinations: [ + NavigationRailDestination( + icon: const Icon(Icons.business_outlined), + selectedIcon: Icon( + Icons.business, + color: Theme.of(context).colorScheme.onSurface, + ), + label: const Text('Businesses')), + NavigationRailDestination( + icon: const Icon(Icons.work_outline), + selectedIcon: Icon( + Icons.work, + color: Theme.of(context).colorScheme.onSurface, + ), + 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')) + ], ), - ); - } - return null; + // 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 { + final String searchQuery; + final Future refreshBusinessDataOverviewBusinessFuture; + final Future Function(Set) + updateOverviewBusinessesBusinessCallback; + final void Function() themeCallback; + final Future refreshBusinessDataOverviewJobFuture; + final Future Function(Set) + updateOverviewBusinessesJobsCallback; + final int currentPageIndex; + final void Function(bool) updateLoggedIn; + + const _ContentPane({ + required this.searchQuery, + required this.refreshBusinessDataOverviewBusinessFuture, + required this.updateOverviewBusinessesBusinessCallback, + required this.themeCallback, + required this.refreshBusinessDataOverviewJobFuture, + required this.updateOverviewBusinessesJobsCallback, + required this.currentPageIndex, + required this.updateLoggedIn, + }); + + @override + Widget build(BuildContext context) { + return IndexedStack( + index: currentPageIndex, + children: [ + BusinessesOverview( + searchQuery: searchQuery, + refreshBusinessDataOverviewFuture: + refreshBusinessDataOverviewBusinessFuture, + updateBusinessesCallback: updateOverviewBusinessesBusinessCallback, + themeCallback: themeCallback, + updateLoggedIn: updateLoggedIn, + ), + JobsOverview( + searchQuery: searchQuery, + refreshJobDataOverviewFuture: refreshBusinessDataOverviewJobFuture, + updateBusinessesCallback: updateOverviewBusinessesJobsCallback, + themeCallback: themeCallback, + 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 getRotationAnimation({required Animation parent}) { +// return Tween(begin: 0.0, end: 1.0).animate(parent); +// throw UnimplementedError(); +// } +// +// @override +// Animation getScaleAnimation({required Animation parent}) { +// return Tween(begin: 0.0, end: 1.0).animate(parent); +// throw UnimplementedError(); +// } +// } diff --git a/fbla_ui/lib/main.dart b/fbla_ui/lib/main.dart index 9428d88..4ae9554 100644 --- a/fbla_ui/lib/main.dart +++ b/fbla_ui/lib/main.dart @@ -1,10 +1,9 @@ import 'package:fbla_ui/home.dart'; +import 'package:fbla_ui/shared/global_vars.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -ThemeMode themeMode = ThemeMode.system; - void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,9 +22,9 @@ void main() async { } class MainApp extends StatefulWidget { - final bool? isDark; + final int? initialPage; - const MainApp({super.key, this.isDark}); + const MainApp({super.key, this.initialPage}); @override State createState() => _MainAppState(); @@ -72,7 +71,7 @@ class _MainAppState extends State { darkTheme: ThemeData( colorScheme: ColorScheme.dark( brightness: Brightness.dark, - primary: Colors.blue, + primary: Colors.blue.shade700, onPrimary: Colors.white, secondary: Colors.blue.shade900, surface: const Color.fromARGB(255, 31, 31, 31), @@ -86,7 +85,7 @@ class _MainAppState extends State { theme: ThemeData( colorScheme: ColorScheme.light( brightness: Brightness.light, - primary: Colors.blue, + primary: Colors.blue.shade700, onPrimary: Colors.white, secondary: Colors.blue.shade200, surface: Colors.grey.shade200, @@ -98,7 +97,7 @@ class _MainAppState extends State { const InputDecorationTheme(border: UnderlineInputBorder()), useMaterial3: true, ), - home: Home(themeCallback: _switchTheme), + home: Home(themeCallback: _switchTheme, initialPage: widget.initialPage), ); } } diff --git a/fbla_ui/lib/pages/business_detail.dart b/fbla_ui/lib/pages/business_detail.dart index 669b961..83fa969 100644 --- a/fbla_ui/lib/pages/business_detail.dart +++ b/fbla_ui/lib/pages/business_detail.dart @@ -1,24 +1,20 @@ -import 'package:fbla_ui/api_logic.dart'; import 'package:fbla_ui/main.dart'; import 'package:fbla_ui/pages/create_edit_business.dart'; import 'package:fbla_ui/pages/create_edit_listing.dart'; import 'package:fbla_ui/pages/listing_detail.dart'; -import 'package:fbla_ui/pages/signin_page.dart'; -import 'package:fbla_ui/shared.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/global_vars.dart'; import 'package:flutter/material.dart'; import 'package:rive/rive.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../shared/utils.dart'; + class BusinessDetail extends StatefulWidget { final int id; final String name; - final JobType clickFromType; - const BusinessDetail( - {super.key, - required this.id, - required this.name, - required this.clickFromType}); + const BusinessDetail({super.key, required this.id, required this.name}); @override State createState() => _CreateBusinessDetailState(); @@ -45,7 +41,7 @@ class _CreateBusinessDetailState extends State { return Scaffold( appBar: AppBar( title: Text(snapshot.data.name), - actions: _getActions(snapshot.data, widget.clickFromType), + actions: _getActions(snapshot.data), ), body: _detailBody(snapshot.data), ); @@ -120,12 +116,12 @@ class _CreateBusinessDetailState extends State { child: Column( children: [ ListTile( - title: Text(business.name, + title: Text(business.name!, textAlign: TextAlign.left, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold)), subtitle: Text( - business.description, + business.description!, textAlign: TextAlign.left, ), leading: ClipRRect( @@ -134,8 +130,8 @@ class _CreateBusinessDetailState extends State { width: 48, height: 48, errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - return getIconFromJobType(widget.clickFromType, 48, - Theme.of(context).colorScheme.onSurface); + return Icon(getIconFromBusinessType(business.type!), + size: 48); }), ), ), @@ -143,13 +139,13 @@ class _CreateBusinessDetailState extends State { leading: const Icon(Icons.link), title: const Text('Website'), subtitle: Text( - business.website + business.website! .replaceAll('https://', '') .replaceAll('http://', '') .replaceAll('www.', ''), style: const TextStyle(color: Colors.blue)), onTap: () { - launchUrl(Uri.parse(business.website)); + launchUrl(Uri.parse(business.website!)); }, ), ], @@ -157,16 +153,17 @@ class _CreateBusinessDetailState extends State { ), ), // Available positions - 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) - ]), - ), + if (business.listings != null) + 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( clipBehavior: Clip.antiAlias, @@ -185,9 +182,8 @@ class _CreateBusinessDetailState extends State { ), ], ), - Visibility( - visible: business.contactPhone != null, - child: ListTile( + if (business.contactPhone != null) + ListTile( leading: const Icon(Icons.phone), title: Text(business.contactPhone!), // maybe replace ! with ?? ''. same is true for below @@ -221,36 +217,33 @@ class _CreateBusinessDetailState extends State { }); }, ), - ), - ListTile( - leading: const Icon(Icons.email), - title: Text(business.contactEmail), - onTap: () { - launchUrl(Uri.parse('mailto:${business.contactEmail}')); - }, - ), + if (business.contactEmail != null) + ListTile( + leading: const Icon(Icons.email), + title: Text(business.contactEmail!), + onTap: () { + launchUrl(Uri.parse('mailto:${business.contactEmail}')); + }, + ), ], ), ), // Location - Visibility( - child: Card( - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: const Icon(Icons.location_on), - title: Text(business.locationName), - subtitle: Text(business.locationAddress!), - onTap: () { - launchUrl(Uri.parse(Uri.encodeFull( - 'https://www.google.com/maps/search/?api=1&query=${business.locationName}'))); - }, - ), + Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: const Icon(Icons.location_on), + title: Text(business.locationName), + subtitle: Text(business.locationAddress!), + onTap: () { + launchUrl(Uri.parse(Uri.encodeFull( + 'https://www.google.com/maps/search/?api=1&query=${business.locationName}'))); + }, ), ), // Notes - Visibility( - visible: business.notes != null && business.notes != '', - child: Card( + if (business.notes != null && business.notes != '') + Card( child: ListTile( leading: const Icon(Icons.notes), title: const Text( @@ -260,12 +253,11 @@ class _CreateBusinessDetailState extends State { subtitle: Text(business.notes!), ), ), - ), ], ); } - List? _getActions(Business business, JobType clickFromType) { + List? _getActions(Business business) { if (loggedIn) { return [ IconButton( @@ -274,7 +266,6 @@ class _CreateBusinessDetailState extends State { Navigator.of(context).push(MaterialPageRoute( builder: (context) => CreateEditBusiness( inputBusiness: business, - clickFromType: clickFromType, ))); }, ), @@ -354,8 +345,7 @@ class _JobListItem extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - leading: getIconFromJobType( - jobListing.type, 24, Theme.of(context).colorScheme.onSurface), + leading: Icon(getIconFromJobType(jobListing.type!)), title: Text(jobListing.name), subtitle: Text( jobListing.description, diff --git a/fbla_ui/lib/pages/businesses_overview.dart b/fbla_ui/lib/pages/businesses_overview.dart new file mode 100644 index 0000000..9820af9 --- /dev/null +++ b/fbla_ui/lib/pages/businesses_overview.dart @@ -0,0 +1,581 @@ +import 'package:fbla_ui/pages/business_detail.dart'; +import 'package:fbla_ui/pages/create_edit_business.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/export.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/rendering.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:rive/rive.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class BusinessesOverview extends StatefulWidget { + final String searchQuery; + final Future refreshBusinessDataOverviewFuture; + final Future Function(Set) updateBusinessesCallback; + final void Function() themeCallback; + final void Function(bool) updateLoggedIn; + + const BusinessesOverview({ + super.key, + required this.searchQuery, + required this.refreshBusinessDataOverviewFuture, + required this.updateBusinessesCallback, + required this.themeCallback, + required this.updateLoggedIn, + }); + + @override + State createState() => _BusinessesOverviewState(); +} + +class _BusinessesOverviewState extends State { + bool _isPreviousData = false; + late Map> overviewBusinesses; + Set businessTypeFilters = {}; + String searchQuery = ''; + ScrollController controller = ScrollController(); + bool _extended = true; + double prevPixelPosition = 0; + + Map> _filterBySearch( + Map> businesses, String query) { + Map> filteredBusinesses = {}; + + for (BusinessType businessType in businesses.keys) { + filteredBusinesses[businessType] = List.from(businesses[businessType]! + .where((element) => element.name! + .replaceAll(RegExp(r'[^a-zA-Z]'), '') + .toLowerCase() + .contains(query + .replaceAll(RegExp(r'[^a-zA-Z]'), '') + .toLowerCase() + .trim()))); + } + + filteredBusinesses.removeWhere((key, value) => value.isEmpty); + return filteredBusinesses; + } + + void _setSearch(String search) async { + setState(() { + searchQuery = search; + }); + } + + void _setFilters(Set filters) async { + businessTypeFilters = Set.from(filters); + widget.updateBusinessesCallback(businessTypeFilters); + } + + void _scrollListener() { + if ((prevPixelPosition - controller.position.pixels).abs() > 10) { + setState(() { + _extended = + controller.position.userScrollDirection == ScrollDirection.forward; + }); + } + prevPixelPosition = controller.position.pixels; + } + + void _generatePDF() { + List allBusinesses = []; + for (List businessList + in _filterBySearch(overviewBusinesses, searchQuery).values) { + allBusinesses.addAll(businessList); + } + + generatePDF( + context: context, + documentTypeIndex: 0, + selectedBusinesses: Set.from(allBusinesses)); + } + + @override + void initState() { + super.initState(); + + controller.addListener(_scrollListener); + } + + @override + Widget build(BuildContext context) { + bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth; + return Scaffold( + floatingActionButton: _getFAB(widescreen), + body: CustomScrollView( + controller: controller, + slivers: [ + MainSliverAppBar( + widescreen: widescreen, + setSearch: _setSearch, + searchHintText: 'Search Businesses', + themeCallback: widget.themeCallback, + filterIconButton: _filterIconButton( + businessTypeFilters, + ), + updateLoggedIn: widget.updateLoggedIn, + generatePDF: _generatePDF, + ), + FutureBuilder( + future: widget.refreshBusinessDataOverviewFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasData) { + if (snapshot.data.runtimeType == String) { + _isPreviousData = false; + return SliverToBoxAdapter( + child: 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: () { + widget.updateBusinessesCallback( + businessTypeFilters); + }, + ), + ), + ]), + )); + } + + overviewBusinesses = snapshot.data; + _isPreviousData = true; + + return BusinessDisplayPanel( + groupedBusinesses: + _filterBySearch(overviewBusinesses, searchQuery), + widescreen: widescreen, + ); + } else if (snapshot.hasError) { + return SliverToBoxAdapter( + child: 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) { + if (_isPreviousData) { + return BusinessDisplayPanel( + groupedBusinesses: + _filterBySearch(overviewBusinesses, searchQuery), + widescreen: widescreen, + ); + } else { + return SliverToBoxAdapter( + child: 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 SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '\nError: ${snapshot.error}', + style: const TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + ), + ); + }), + ], + ), + ); + } + + Widget _filterIconButton(Set filters) { + Set selectedChips = Set.from(filters); + + return IconButton( + icon: Icon( + Icons.filter_list, + color: filters.isNotEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + void setDialogState(Set newFilters) { + setState(() { + filters = newFilters; + }); + } + + List chips = []; + for (var type in BusinessType.values) { + chips.add(Padding( + padding: const EdgeInsets.all(4), + child: FilterChip( + showCheckmark: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20)), + label: Text(getNameFromBusinessType(type)), + selected: selectedChips.contains(type), + onSelected: (bool selected) { + if (selected) { + selectedChips.add(type); + } else { + selectedChips.remove(type); + } + setDialogState(filters); + }), + )); + } + + return AlertDialog( + title: const Text('Filter Options'), + content: SizedBox( + width: 400, + child: Wrap( + children: chips, + ), + ), + actions: [ + TextButton( + child: const Text('Reset'), + onPressed: () { + _setFilters({}); + // selectedChips = {}; + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Cancel'), + onPressed: () { + // selectedChips = Set.from(filters); + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Apply'), + onPressed: () { + _setFilters(selectedChips); + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + }); + }); + } + + Widget? _getFAB(bool widescreen) { + if (!widescreen && loggedIn) { + return FloatingActionButton.extended( + extendedIconLabelSpacing: _extended ? 8.0 : 0, + extendedPadding: const EdgeInsets.symmetric(horizontal: 16), + icon: const Icon(Icons.add), + label: AnimatedSize( + curve: Easing.standard, + duration: const Duration(milliseconds: 300), + child: _extended ? const Text('Add Business') : Container(), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreateEditBusiness())); + }, + ); + } + return null; + } +} + +class BusinessDisplayPanel extends StatefulWidget { + final Map> groupedBusinesses; + final bool widescreen; + + const BusinessDisplayPanel({ + super.key, + required this.groupedBusinesses, + required this.widescreen, + }); + + @override + State createState() => _BusinessDisplayPanelState(); +} + +class _BusinessDisplayPanelState extends State { + @override + Widget build(BuildContext context) { + if (widget.groupedBusinesses.keys.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 headers = []; + for (BusinessType businessType in widget.groupedBusinesses.keys) { + headers.add(BusinessHeader( + businessType: businessType, + widescreen: widget.widescreen, + businesses: widget.groupedBusinesses[businessType]!)); + } + headers + .sort((a, b) => a.businessType.index.compareTo(b.businessType.index)); + return MultiSliver(children: headers); + } +} + +class BusinessHeader extends StatefulWidget { + final BusinessType businessType; + final List businesses; + final bool widescreen; + + const BusinessHeader({ + super.key, + required this.businessType, + required this.businesses, + required this.widescreen, + }); + + @override + State createState() => _BusinessHeaderState(); +} + +class _BusinessHeaderState extends State { + @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(), + ), + sliver: _getChildSliver(widget.businesses, widget.widescreen), + ); + } + + Widget _getHeaderRow() { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 4.0, right: 12.0), + child: Icon( + getIconFromBusinessType(widget.businessType), + color: Theme.of(context).colorScheme.onPrimary, + )), + Text(getNameFromBusinessType(widget.businessType)), + ], + ); + } + + Widget _getChildSliver(List businesses, bool widescreen) { + 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 _businessTile( + businesses[index], + widget.businessType, + ); + }, + ), + ), + ); + } else { + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: businesses.length, + (BuildContext context, int index) { + return _businessListItem( + businesses[index], + widget.businessType, + ); + }, + ), + ); + } + } + + Widget _businessTile(Business business, BusinessType jobType) { + 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: [ + 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, + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + business.description!, + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.link), + onPressed: () { + launchUrl(Uri.parse('https://${business.website}')); + }, + ), + 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) + IconButton( + icon: const Icon(Icons.phone), + onPressed: () { + 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) + IconButton( + icon: const Icon(Icons.email), + onPressed: () { + launchUrl( + Uri.parse('mailto:${business.contactEmail}')); + }, + ), + ], + )), + ], + ), + ), + ), + ); + } + + Widget _businessListItem(Business business, BusinessType? jobType) { + 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: 2, overflow: TextOverflow.ellipsis), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BusinessDetail( + id: business.id, + name: business.name!, + ))); + }, + ), + ); + } +} diff --git a/fbla_ui/lib/pages/create_edit_business.dart b/fbla_ui/lib/pages/create_edit_business.dart index 4f5b6a9..b7a443c 100644 --- a/fbla_ui/lib/pages/create_edit_business.dart +++ b/fbla_ui/lib/pages/create_edit_business.dart @@ -1,14 +1,13 @@ -import 'package:fbla_ui/api_logic.dart'; import 'package:fbla_ui/main.dart'; -import 'package:fbla_ui/shared.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class CreateEditBusiness extends StatefulWidget { final Business? inputBusiness; - final JobType? clickFromType; - const CreateEditBusiness({super.key, this.inputBusiness, this.clickFromType}); + const CreateEditBusiness({super.key, this.inputBusiness}); @override State createState() => _CreateEditBusinessState(); @@ -25,19 +24,23 @@ class _CreateEditBusinessState extends State { late TextEditingController _locationNameController; late TextEditingController _locationAddressController; + // late TextEditingController _businessTypeController; + Business business = Business( id: 0, name: 'Business', description: 'Add details about the business below.', + type: null, website: '', - contactName: '', - contactEmail: '', - contactPhone: '', - notes: '', + contactName: null, + contactEmail: null, + contactPhone: null, + notes: null, locationName: '', - locationAddress: '', + locationAddress: null, ); bool _isLoading = false; + String? dropDownErrorText; @override void initState() { @@ -47,11 +50,16 @@ class _CreateEditBusinessState extends State { _nameController = TextEditingController(text: business.name); _descriptionController = TextEditingController(text: business.description); + business.type = widget.inputBusiness?.type; } else { _nameController = TextEditingController(); _descriptionController = TextEditingController(); } - _websiteController = TextEditingController(text: business.website); + _websiteController = TextEditingController( + text: business.website! + .replaceAll('https://', '') + .replaceAll('http://', '') + .replaceAll('www.', '')); _contactNameController = TextEditingController(text: business.contactName); _contactPhoneController = TextEditingController(text: business.contactPhone); @@ -65,7 +73,6 @@ class _CreateEditBusinessState extends State { } final formKey = GlobalKey(); - final TextEditingController businessTypeController = TextEditingController(); @override Widget build(BuildContext context) { @@ -91,44 +98,51 @@ class _CreateEditBusinessState extends State { ) : const Icon(Icons.save), onPressed: () async { - if (formKey.currentState!.validate()) { - formKey.currentState?.save(); + if (business.type == null) { setState(() { - _isLoading = true; + dropDownErrorText = 'Business type is required'; }); - String? result; - // if (business.contactName == '') { - // business.contactName = 'Contact ${business.name}'; - // } - 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())); - } + formKey.currentState!.validate(); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Check field inputs!'), - width: 200, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); + 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)), + ), + ); + } } }, ), @@ -136,16 +150,16 @@ class _CreateEditBusinessState extends State { children: [ Center( child: SizedBox( - width: 1000, + width: 800, child: Column( children: [ ListTile( - title: Text(business.name, + title: Text(business.name!, textAlign: TextAlign.left, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold)), subtitle: Text( - business.description, + business.description!, textAlign: TextAlign.left, ), leading: ClipRRect( @@ -156,10 +170,11 @@ class _CreateEditBusinessState extends State { 'https://logo.clearbit.com/${business.website}', errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - return getIconFromJobType( - widget.clickFromType ?? JobType.other, - 48, - Theme.of(context).colorScheme.onSurface); + return Icon( + getIconFromBusinessType(business.type != null + ? business.type! + : BusinessType.other), + size: 48); }), ), ), @@ -186,44 +201,13 @@ class _CreateEditBusinessState extends State { labelText: 'Business Name (required)', ), validator: (value) { - if (value != null && value.isEmpty) { + if (value != null && value.trim().isEmpty) { return 'Name is required'; } return null; }, ), ), - Padding( - padding: const EdgeInsets.only( - left: 8.0, right: 8.0, bottom: 8.0), - child: TextFormField( - controller: _websiteController, - autovalidateMode: - AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.url, - onChanged: (inputUrl) { - business.website = Uri.encodeFull(inputUrl); - if (!business.website.contains('http://') && - !business.website - .contains('https://')) { - business.website = - 'https://${business.website}'; - } - }, - onTapOutside: (PointerDownEvent event) { - FocusScope.of(context).unfocus(); - }, - decoration: const InputDecoration( - labelText: 'Website (required)', - ), - validator: (value) { - if (value != null && value.isEmpty) { - return 'Website is required'; - } - return null; - }, - ), - ), Padding( padding: const EdgeInsets.only( left: 8.0, right: 8.0), @@ -246,13 +230,82 @@ class _CreateEditBusinessState extends State { 'Business Description (required)', ), validator: (value) { - if (value != null && value.isEmpty) { + if (value != null && value.trim().isEmpty) { return 'Description is required'; } return null; }, ), ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, right: 8.0, bottom: 16.0), + child: TextFormField( + controller: _websiteController, + autovalidateMode: + AutovalidateMode.onUserInteraction, + keyboardType: TextInputType.url, + onChanged: (inputUrl) { + business.website = Uri.encodeFull(inputUrl); + if (!business.website! + .contains('http://') && + !business.website! + .contains('https://')) { + business.website = + 'https://${business.website}'; + } + }, + onTapOutside: (PointerDownEvent event) { + FocusScope.of(context).unfocus(); + }, + decoration: const InputDecoration( + labelText: 'Website (required)', + ), + validator: (value) { + if (value != null && + !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'; + } + if (value != null && value.trim().isEmpty) { + return 'Website is required'; + } + return null; + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, right: 8.0, bottom: 8.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + const Text('Type of Business', + style: TextStyle(fontSize: 16)), + DropdownMenu( + initialSelection: business.type, + label: const Text('Business Type'), + errorText: dropDownErrorText, + dropdownMenuEntries: [ + for (BusinessType type + in BusinessType.values) + DropdownMenuEntry( + value: type, + label: getNameFromBusinessType( + type)), + ], + onSelected: (inputType) { + setState(() { + business.type = inputType!; + dropDownErrorText = null; + }); + }, + ), + ], + ), + ), + // Padding( // padding: const EdgeInsets.only( // left: 8.0, right: 8.0, bottom: 16.0), @@ -325,43 +378,6 @@ class _CreateEditBusinessState extends State { // ], // ), // ), - Padding( - padding: const EdgeInsets.only( - left: 8.0, right: 8.0, bottom: 8.0), - child: TextFormField( - controller: _locationNameController, - onChanged: (inputName) { - setState(() { - business.locationName = inputName; - }); - }, - onTapOutside: (PointerDownEvent event) { - FocusScope.of(context).unfocus(); - }, - decoration: const InputDecoration( - labelText: 'Location Name', - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 8.0, right: 8.0, bottom: 16.0), - child: TextFormField( - controller: _locationAddressController, - onChanged: (inputAddr) { - setState(() { - business.locationAddress = inputAddr; - }); - }, - onTapOutside: (PointerDownEvent event) { - FocusScope.of(context).unfocus(); - }, - decoration: const InputDecoration( - labelText: 'Location Address', - ), - ), - ), - Padding( padding: const EdgeInsets.only( left: 8.0, right: 8.0, bottom: 8.0), @@ -374,8 +390,17 @@ class _CreateEditBusinessState extends State { FocusScope.of(context).unfocus(); }, decoration: const InputDecoration( - labelText: 'Contact Information Name', + labelText: + 'Contact Information Name (required)', ), + autovalidateMode: + AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Contact name is required'; + } + return null; + }, ), ), Padding( @@ -385,15 +410,31 @@ class _CreateEditBusinessState extends State { controller: _contactPhoneController, inputFormatters: [PhoneFormatter()], keyboardType: TextInputType.phone, - onSaved: (inputText) { - business.contactPhone = inputText!; + autovalidateMode: + AutovalidateMode.onUserInteraction, + onChanged: (inputText) { + if (inputText.trim().isEmpty) { + business.contactPhone = null; + } else { + business.contactPhone = inputText.trim(); + } }, onTapOutside: (PointerDownEvent event) { FocusScope.of(context).unfocus(); }, decoration: const InputDecoration( - labelText: 'Contact Phone # (optional)', + labelText: 'Contact Phone #', ), + validator: (value) { + if (business.contactEmail == null && + (value == null || value.isEmpty)) { + return 'At least one contact method is required'; + } + if (value != null && value.length != 14) { + return 'Enter a valid phone number'; + } + return null; + }, ), ), Padding( @@ -402,9 +443,15 @@ class _CreateEditBusinessState extends State { child: TextFormField( controller: _contactEmailController, keyboardType: TextInputType.emailAddress, - onSaved: (inputText) { - business.contactEmail = inputText!; + onChanged: (inputText) { + if (inputText.trim().isEmpty) { + business.contactEmail = null; + } else { + business.contactEmail = inputText.trim(); + } }, + autovalidateMode: + AutovalidateMode.onUserInteraction, onTapOutside: (PointerDownEvent event) { FocusScope.of(context).unfocus(); }, @@ -412,10 +459,16 @@ class _CreateEditBusinessState extends State { labelText: 'Contact Email', ), validator: (value) { + value = value?.trim(); + if (value != null && value.isEmpty) { + value = null; + } + if (value == null && + business.contactPhone == null) { + return 'At least one contact method is required'; + } if (value != null) { - if (value.isEmpty) { - return null; - } else if (!RegExp( + if (!RegExp( r'^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$') .hasMatch(value)) { return 'Enter a valid Email'; @@ -427,6 +480,58 @@ class _CreateEditBusinessState extends State { }, ), ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, right: 8.0, bottom: 8.0), + child: TextFormField( + controller: _locationNameController, + onChanged: (inputName) { + setState(() { + business.locationName = inputName.trim(); + }); + }, + autovalidateMode: + AutovalidateMode.onUserInteraction, + onTapOutside: (PointerDownEvent event) { + FocusScope.of(context).unfocus(); + }, + decoration: const InputDecoration( + labelText: 'Location Name (required)', + ), + validator: (value) { + if (value != null && value.trim().isEmpty) { + return 'Location name is required'; + } + return null; + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, right: 8.0, bottom: 16.0), + child: TextFormField( + controller: _locationAddressController, + onChanged: (inputAddr) { + setState(() { + business.locationAddress = inputAddr; + }); + }, + autovalidateMode: + AutovalidateMode.onUserInteraction, + onTapOutside: (PointerDownEvent event) { + FocusScope.of(context).unfocus(); + }, + decoration: const InputDecoration( + labelText: 'Location Address (required)', + ), + validator: (value) { + if (value != null && value.trim().isEmpty) { + return 'Location Address is required'; + } + return null; + }, + ), + ), Padding( padding: const EdgeInsets.only( left: 8.0, right: 8.0, bottom: 8.0), @@ -435,7 +540,12 @@ class _CreateEditBusinessState extends State { maxLength: 300, maxLines: null, onSaved: (inputText) { - business.notes = inputText!; + if (inputText == null || + inputText.trim().isEmpty) { + business.notes = null; + } else { + business.notes = inputText.trim(); + } }, onTapOutside: (PointerDownEvent event) { FocusScope.of(context).unfocus(); diff --git a/fbla_ui/lib/pages/create_edit_listing.dart b/fbla_ui/lib/pages/create_edit_listing.dart index b98432d..0078e40 100644 --- a/fbla_ui/lib/pages/create_edit_listing.dart +++ b/fbla_ui/lib/pages/create_edit_listing.dart @@ -1,15 +1,15 @@ -import 'package:fbla_ui/api_logic.dart'; import 'package:fbla_ui/main.dart'; -import 'package:fbla_ui/shared.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/utils.dart'; import 'package:flutter/material.dart'; import 'package:rive/rive.dart'; class CreateEditJobListing extends StatefulWidget { final JobListing? inputJobListing; - final Business inputBusiness; + final Business? inputBusiness; const CreateEditJobListing( - {super.key, this.inputJobListing, required this.inputBusiness}); + {super.key, this.inputJobListing, this.inputBusiness}); @override State createState() => _CreateEditJobListingState(); @@ -22,14 +22,15 @@ class _CreateEditJobListingState extends State { late TextEditingController _wageController; late TextEditingController _linkController; List nameMapping = []; - String? businessErrorText; + String? typeDropdownErrorText; + String? businessDropdownErrorText; JobListing listing = JobListing( id: null, businessId: null, name: 'Job Listing', description: 'Add details about the business below.', - type: JobType.other, + type: null, wage: null, link: null); bool _isLoading = false; @@ -46,17 +47,21 @@ class _CreateEditJobListingState extends State { _descriptionController = TextEditingController(); } _wageController = TextEditingController(text: listing.wage); - _linkController = TextEditingController(text: listing.link); + _linkController = TextEditingController( + text: listing.link + ?.replaceAll('https://', '') + .replaceAll('http://', '') + .replaceAll('www.', '')); getBusinessNameMapping = fetchBusinessNames(); } final formKey = GlobalKey(); - final TextEditingController jobTypeController = TextEditingController(); - final TextEditingController businessController = TextEditingController(); @override Widget build(BuildContext context) { - listing.businessId = widget.inputBusiness.id; + if (widget.inputBusiness != null) { + listing.businessId = widget.inputBusiness!.id; + } return PopScope( canPop: !_isLoading, onPopInvoked: _handlePop, @@ -69,54 +74,74 @@ class _CreateEditJobListingState extends State { : const Text('Add New Job Listing'), ), floatingActionButton: FloatingActionButton( - child: _isLoading - ? const Padding( - padding: EdgeInsets.all(16.0), - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 3.0, - ), - ) - : const Icon(Icons.save), - onPressed: () async { - if (formKey.currentState!.validate()) { - formKey.currentState?.save(); - setState(() { - _isLoading = true; - }); - String? result; - if (widget.inputJobListing != null) { - result = await editListing(listing); + child: _isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3.0, + ), + ) + : const Icon(Icons.save), + onPressed: () async { + if (listing.type == null || listing.businessId == null) { + if (listing.type == null) { + setState(() { + typeDropdownErrorText = 'Job type is required'; + }); + formKey.currentState!.validate(); + } + if (listing.businessId == null) { + setState(() { + businessDropdownErrorText = 'Business is required'; + }); + formKey.currentState!.validate(); + } } else { - result = await createListing(listing); + setState(() { + typeDropdownErrorText = null; + businessDropdownErrorText = null; + }); + if (formKey.currentState!.validate()) { + formKey.currentState?.save(); + setState(() { + _isLoading = true; + }); + String? result; + if (widget.inputJobListing != null) { + result = await editListing(listing); + } else { + result = await createListing(listing); + } + 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( + initialPage: 1, + ))); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Check field inputs!'), + width: 200, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } } - 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)), - ), - ); - } - }, - ), + }), body: FutureBuilder( future: getBusinessNameMapping, builder: (context, snapshot) { @@ -152,7 +177,7 @@ class _CreateEditJobListingState extends State { children: [ Center( child: SizedBox( - width: 1000, + width: 800, child: Column( children: [ ListTile( @@ -176,12 +201,11 @@ class _CreateEditJobListingState extends State { errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - return getIconFromJobType( - listing.type, - 48, - Theme.of(context) - .colorScheme - .onSurface); + return Icon( + getIconFromJobType( + listing.type ?? JobType.other, + ), + size: 48); }), ), ), @@ -204,8 +228,9 @@ class _CreateEditJobListingState extends State { TextStyle(fontSize: 16)), DropdownMenu( initialSelection: listing.type, - controller: jobTypeController, label: const Text('Job Type'), + errorText: + typeDropdownErrorText, dropdownMenuEntries: [ for (JobType type in JobType.values) @@ -218,6 +243,8 @@ class _CreateEditJobListingState extends State { onSelected: (inputType) { setState(() { listing.type = inputType!; + typeDropdownErrorText = + null; }); }, ), @@ -239,9 +266,10 @@ class _CreateEditJobListingState extends State { style: TextStyle(fontSize: 16)), DropdownMenu( + errorText: + businessDropdownErrorText, initialSelection: - widget.inputBusiness.id, - controller: businessController, + widget.inputBusiness?.id, label: const Text('Business'), dropdownMenuEntries: [ for (Map map @@ -254,6 +282,8 @@ class _CreateEditJobListingState extends State { setState(() { listing.businessId = inputType!; + businessDropdownErrorText = + null; }); }, ), @@ -353,8 +383,7 @@ class _CreateEditJobListingState extends State { .onUserInteraction, keyboardType: TextInputType.url, onChanged: (inputUrl) { - if (listing.link != null && - listing.link != '') { + if (inputUrl != '') { listing.link = Uri.encodeFull(inputUrl); if (!listing.link! @@ -365,6 +394,16 @@ class _CreateEditJobListingState extends State { '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) { diff --git a/fbla_ui/lib/pages/listing_detail.dart b/fbla_ui/lib/pages/listing_detail.dart index ddd68fb..ba13176 100644 --- a/fbla_ui/lib/pages/listing_detail.dart +++ b/fbla_ui/lib/pages/listing_detail.dart @@ -1,8 +1,8 @@ -import 'package:fbla_ui/api_logic.dart'; import 'package:fbla_ui/main.dart'; import 'package:fbla_ui/pages/create_edit_listing.dart'; -import 'package:fbla_ui/pages/signin_page.dart'; -import 'package:fbla_ui/shared.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:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -39,30 +39,43 @@ class _CreateBusinessDetailState extends State { clipBehavior: Clip.antiAlias, 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( - '$apiAddress/logos/${listing.businessId}', - width: 48, - height: 48, errorBuilder: (BuildContext context, - Object exception, StackTrace? stackTrace) { - return getIconFromJobType(listing.type, 48, - Theme.of(context).colorScheme.onSurface); - }), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: 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!), + 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, + ), + ], + ), + ], ), ), - Visibility( - visible: listing.link != null && listing.link != '', - child: ListTile( + if (listing.link != null && listing.link != '') + ListTile( leading: const Icon(Icons.link), title: const Text('More Information'), subtitle: Text( @@ -75,7 +88,6 @@ class _CreateBusinessDetailState extends State { launchUrl(Uri.parse(listing.link!)); }, ), - ), ], ), ), @@ -108,9 +120,8 @@ class _CreateBusinessDetailState extends State { ), ], ), - Visibility( - visible: widget.fromBusiness.contactPhone != null, - child: ListTile( + if (widget.fromBusiness.contactPhone != null) + ListTile( leading: const Icon(Icons.phone), title: Text(widget.fromBusiness.contactPhone!), // maybe replace ! with ?? ''. same is true for below @@ -145,15 +156,15 @@ class _CreateBusinessDetailState extends State { }); }, ), - ), - ListTile( - leading: const Icon(Icons.email), - title: Text(widget.fromBusiness.contactEmail), - onTap: () { - launchUrl( - Uri.parse('mailto:${widget.fromBusiness.contactEmail}')); - }, - ), + if (widget.fromBusiness.contactEmail != null) + ListTile( + leading: const Icon(Icons.email), + title: Text(widget.fromBusiness.contactEmail!), + onTap: () { + launchUrl(Uri.parse( + 'mailto:${widget.fromBusiness.contactEmail}')); + }, + ), ], ), ), diff --git a/fbla_ui/lib/pages/listings_overview.dart b/fbla_ui/lib/pages/listings_overview.dart new file mode 100644 index 0000000..682280f --- /dev/null +++ b/fbla_ui/lib/pages/listings_overview.dart @@ -0,0 +1,582 @@ +import 'package:fbla_ui/pages/create_edit_listing.dart'; +import 'package:fbla_ui/pages/listing_detail.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/export.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/rendering.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:rive/rive.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class JobsOverview extends StatefulWidget { + final String searchQuery; + final Future refreshJobDataOverviewFuture; + final Future Function(Set) updateBusinessesCallback; + final void Function() themeCallback; + final void Function(bool) updateLoggedIn; + + const JobsOverview({ + super.key, + required this.searchQuery, + required this.refreshJobDataOverviewFuture, + required this.updateBusinessesCallback, + required this.themeCallback, + required this.updateLoggedIn, + }); + + @override + State createState() => _JobsOverviewState(); +} + +class _JobsOverviewState extends State { + bool _isPreviousData = false; + late Map> overviewBusinesses; + Set jobTypeFilters = {}; + String searchQuery = ''; + ScrollController controller = ScrollController(); + bool _extended = true; + double prevPixelPosition = 0; + + Map> _filterBySearch( + Map> businesses, String query) { + Map> filteredBusinesses = {}; + + for (JobType jobType in businesses.keys) { + filteredBusinesses[jobType] = List.from(businesses[jobType]!.where( + (element) => element.listings![0].name + .replaceAll(RegExp(r'[^a-zA-Z]'), '') + .toLowerCase() + .contains(query + .replaceAll(RegExp(r'[^a-zA-Z]'), '') + .toLowerCase() + .trim()))); + } + + filteredBusinesses.removeWhere((key, value) => value.isEmpty); + return filteredBusinesses; + } + + void _setSearch(String search) async { + setState(() { + searchQuery = search; + }); + } + + void _setFilters(Set filters) async { + jobTypeFilters = Set.from(filters); + widget.updateBusinessesCallback(jobTypeFilters); + } + + void _scrollListener() { + if ((prevPixelPosition - controller.position.pixels).abs() > 10) { + setState(() { + _extended = + controller.position.userScrollDirection == ScrollDirection.forward; + }); + } + prevPixelPosition = controller.position.pixels; + } + + void _generatePDF() { + List allJobs = []; + for (List businesses + in _filterBySearch(overviewBusinesses, searchQuery).values) { + allJobs.addAll(businesses); + } + + generatePDF( + context: context, + documentTypeIndex: 1, + selectedJobs: Set.from(allJobs)); + } + + @override + void initState() { + super.initState(); + + controller.addListener(_scrollListener); + } + + @override + Widget build(BuildContext context) { + bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth; + return Scaffold( + floatingActionButton: _getFAB(widescreen), + body: CustomScrollView( + controller: controller, + slivers: [ + MainSliverAppBar( + widescreen: widescreen, + setSearch: _setSearch, + searchHintText: 'Search Job Listings', + themeCallback: widget.themeCallback, + filterIconButton: _filterIconButton( + jobTypeFilters, + ), + updateLoggedIn: widget.updateLoggedIn, + generatePDF: _generatePDF, + ), + FutureBuilder( + future: widget.refreshJobDataOverviewFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasData) { + if (snapshot.data.runtimeType == String) { + _isPreviousData = false; + return SliverToBoxAdapter( + child: 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: () { + widget.updateBusinessesCallback(jobTypeFilters); + }, + ), + ), + ]), + )); + } + + overviewBusinesses = snapshot.data; + _isPreviousData = true; + + return JobDisplayPanel( + jobGroupedBusinesses: + _filterBySearch(overviewBusinesses, searchQuery), + widescreen: widescreen, + ); + } else if (snapshot.hasError) { + return SliverToBoxAdapter( + child: 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) { + if (_isPreviousData) { + return JobDisplayPanel( + jobGroupedBusinesses: + _filterBySearch(overviewBusinesses, searchQuery), + widescreen: widescreen, + ); + } else { + return SliverToBoxAdapter( + child: 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 SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '\nError: ${snapshot.error}', + style: const TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + ), + ); + }), + ], + ), + ); + } + + Widget _filterIconButton(Set filters) { + Set selectedChips = Set.from(filters); + + return IconButton( + icon: Icon( + Icons.filter_list, + color: filters.isNotEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + void setDialogState(Set newFilters) { + setState(() { + filters = newFilters; + }); + } + + List chips = []; + for (var type in JobType.values) { + chips.add(Padding( + padding: const EdgeInsets.all(4), + child: FilterChip( + showCheckmark: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20)), + label: Text(getNameFromJobType(type)), + selected: selectedChips.contains(type), + onSelected: (bool selected) { + if (selected) { + selectedChips.add(type); + } else { + selectedChips.remove(type); + } + setDialogState(filters); + }), + )); + } + + return AlertDialog( + title: const Text('Filter Options'), + content: SizedBox( + width: 400, + child: Wrap( + children: chips, + ), + ), + actions: [ + TextButton( + child: const Text('Reset'), + onPressed: () { + _setFilters({}); + // selectedChips = {}; + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Cancel'), + onPressed: () { + // selectedChips = Set.from(filters); + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Apply'), + onPressed: () { + _setFilters(selectedChips); + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + }); + }); + } + + Widget? _getFAB(bool widescreen) { + if (!widescreen && loggedIn) { + return FloatingActionButton.extended( + extendedIconLabelSpacing: _extended ? 8.0 : 0, + extendedPadding: const EdgeInsets.symmetric(horizontal: 16), + icon: const Icon(Icons.add), + label: AnimatedSize( + curve: Easing.standard, + duration: const Duration(milliseconds: 300), + child: _extended ? const Text('Add Job Listing') : Container(), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreateEditJobListing())); + }, + ); + } + return null; + } +} + +class JobDisplayPanel extends StatefulWidget { + final Map> jobGroupedBusinesses; + final bool widescreen; + + const JobDisplayPanel({ + super.key, + required this.jobGroupedBusinesses, + required this.widescreen, + }); + + @override + State createState() => _JobDisplayPanelState(); +} + +class _JobDisplayPanelState extends State { + @override + Widget build(BuildContext context) { + if (widget.jobGroupedBusinesses.keys.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 headers = []; + for (JobType jobType in widget.jobGroupedBusinesses.keys) { + headers.add(BusinessHeader( + jobType: jobType, + widescreen: widget.widescreen, + businesses: widget.jobGroupedBusinesses[jobType]!)); + } + headers.sort((a, b) => a.jobType.index.compareTo(b.jobType.index)); + return MultiSliver(children: headers); + } +} + +class BusinessHeader extends StatefulWidget { + final JobType jobType; + final List businesses; + final bool widescreen; + + const BusinessHeader({ + super.key, + required this.jobType, + required this.businesses, + required this.widescreen, + }); + + @override + State createState() => _BusinessHeaderState(); +} + +class _BusinessHeaderState extends State { + @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(), + ), + sliver: _getChildSliver(widget.businesses, widget.widescreen), + ); + } + + Widget _getHeaderRow() { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 4.0, right: 12.0), + child: Icon( + getIconFromJobType(widget.jobType), + color: Theme.of(context).colorScheme.onPrimary, + )), + Text(getNameFromJobType(widget.jobType)), + ], + ); + } + + Widget _getChildSliver(List businesses, bool widescreen) { + 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 _businessTile( + businesses[index], + widget.jobType, + ); + }, + ), + ), + ); + } else { + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: businesses.length, + (BuildContext context, int index) { + return _businessListItem( + businesses[index], + widget.jobType, + ); + }, + ), + ); + } + } + + Widget _businessTile(Business business, JobType jobType) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => JobListingDetail( + listing: business.listings![0], + fromBusiness: business, + ))); + }, + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 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.listings![0].name, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + business.listings![0].description, + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (business.listings![0].link != null && + business.listings![0].link!.isNotEmpty) + IconButton( + icon: const Icon(Icons.link), + onPressed: () { + launchUrl(Uri.parse( + 'https://${business.listings![0].link!}')); + }, + ), + 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) + IconButton( + icon: const Icon(Icons.phone), + onPressed: () { + 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) + IconButton( + icon: const Icon(Icons.email), + onPressed: () { + launchUrl( + Uri.parse('mailto:${business.contactEmail}')); + }, + ), + ], + )), + ], + ), + ), + ), + ); + } + + Widget _businessListItem(Business business, JobType? jobType) { + 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.listings![0].name), + subtitle: Text(business.listings![0].description, + maxLines: 2, overflow: TextOverflow.ellipsis), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => JobListingDetail( + listing: business.listings![0], + fromBusiness: business, + ))); + }, + ), + ); + } +} diff --git a/fbla_ui/lib/pages/signin_page.dart b/fbla_ui/lib/pages/signin_page.dart index c3ffa81..339bb6c 100644 --- a/fbla_ui/lib/pages/signin_page.dart +++ b/fbla_ui/lib/pages/signin_page.dart @@ -1,12 +1,10 @@ -import 'package:fbla_ui/api_logic.dart'; -import 'package:fbla_ui/shared.dart'; +import 'package:fbla_ui/shared/api_logic.dart'; +import 'package:fbla_ui/shared/global_vars.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -bool loggedIn = false; - class SignInPage extends StatefulWidget { - final void Function() refreshAccount; + final void Function(bool) refreshAccount; const SignInPage({super.key, required this.refreshAccount}); @@ -96,8 +94,7 @@ class _SignInPageState extends State { await prefs.setString('username', username); await prefs.setString('password', password); await prefs.setBool('rememberMe', rememberMe); - loggedIn = true; - widget.refreshAccount(); + widget.refreshAccount(true); Navigator.of(context).pop(); } else { setState(() { @@ -182,8 +179,7 @@ class _SignInPageState extends State { await prefs.setString('username', username); await prefs.setString('password', password); await prefs.setBool('rememberMe', rememberMe); - loggedIn = true; - widget.refreshAccount(); + widget.refreshAccount(true); Navigator.of(context).pop(); } else { setState(() { diff --git a/fbla_ui/pubspec.yaml b/fbla_ui/pubspec.yaml index a130b49..9fa2d1e 100644 --- a/fbla_ui/pubspec.yaml +++ b/fbla_ui/pubspec.yaml @@ -73,6 +73,8 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/mdev_triangle_loading.riv + - assets/MarinoDev.svg + - assets/Triangle256.png # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg