From d72ee93f290ae2b9183cf064094d51d9c1292b1c Mon Sep 17 00:00:00 2001 From: drake Date: Thu, 20 Jun 2024 13:18:54 -0500 Subject: [PATCH] v0.2.0 beta - Major screen changes --- fbla-api/lib/fbla_api.dart | 136 +++- fbla_ui/assets/MarinoDev.svg | 63 ++ fbla_ui/assets/Triangle256.png | Bin 0 -> 9755 bytes fbla_ui/lib/home.dart | 656 ++++++++++---------- fbla_ui/lib/main.dart | 13 +- fbla_ui/lib/pages/business_detail.dart | 104 ++-- fbla_ui/lib/pages/businesses_overview.dart | 581 +++++++++++++++++ fbla_ui/lib/pages/create_edit_business.dart | 376 +++++++---- fbla_ui/lib/pages/create_edit_listing.dart | 175 ++++-- fbla_ui/lib/pages/listing_detail.dart | 87 +-- fbla_ui/lib/pages/listings_overview.dart | 582 +++++++++++++++++ fbla_ui/lib/pages/signin_page.dart | 14 +- fbla_ui/pubspec.yaml | 2 + 13 files changed, 2105 insertions(+), 684 deletions(-) create mode 100644 fbla_ui/assets/MarinoDev.svg create mode 100644 fbla_ui/assets/Triangle256.png create mode 100644 fbla_ui/lib/pages/businesses_overview.dart create mode 100644 fbla_ui/lib/pages/listings_overview.dart 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 0000000000000000000000000000000000000000..3fd2bbd16d5fc26400fce971e1d6ef5bbef3cb6c GIT binary patch literal 9755 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 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