From 7f7f3d5d63a00bc5cdfc0816410641dc9451398b Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Mon, 12 Aug 2024 13:49:31 -0400 Subject: [PATCH 1/3] Revised Action Menu Contact Me Confirmation Dialog About Project Button --- CHANGELOG.md | 5 + lib/common/strings.dart | 8 ++ lib/common/urls.dart | 12 ++- lib/pages/home/widgets/action_menu.dart | 117 +++++++++++++++++++----- lib/pages/home/widgets/app_version.dart | 2 +- pubspec.yaml | 2 +- 6 files changed, 116 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba166ce..b91e78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.6.2 + +- REVISED: Added a confirmation dialog to the `Contact Me` button in the action menu. +- REVISED: Added an `About` button to the action menu. + ## 2.6.1 - REVISED: Disabled the tilt effect on mobile devices. diff --git a/lib/common/strings.dart b/lib/common/strings.dart index a3b7520..9e29740 100644 --- a/lib/common/strings.dart +++ b/lib/common/strings.dart @@ -4,6 +4,7 @@ class Strings { static const String currentLocation = 'VA, USA'; static const String lastUpdated = 'Updated AUG 2024'; + static const String contactEmail = 'plguerra@outlook.com'; static const List headerSubtitles = [ 'Software Engineer • Innovator • Technologist', @@ -36,6 +37,7 @@ class Strings { static const String prev = 'Prev.'; static const String next = 'Next'; static const String viewAllMedia = 'View all Media'; + static const String about = 'About'; static const String viewSourceCode = 'View Source Code'; static const String professionalExperiences = 'Professional Experiences'; static const String previousProject = 'Previous Project'; @@ -55,4 +57,10 @@ class Strings { static const String selectACategoryToViewTheEntries = 'Select a category to view the entries.'; static const String expected = 'Expected'; + static const String contactMeMessage = + 'Thank you for your interest in contacting me, please feel free to reach out to me via email at:'; + static const String copyToClipboard = 'Copy to Clipboard'; + static const String emailCopied = 'Email copied to clipboard'; + static const String openEmailApp = 'Open Email App'; + static const String close = 'Close'; } diff --git a/lib/common/urls.dart b/lib/common/urls.dart index a156699..0b22ce4 100644 --- a/lib/common/urls.dart +++ b/lib/common/urls.dart @@ -1,3 +1,5 @@ +import 'strings.dart'; + /// A class that contains all the urls used in the app. class Urls { Urls._(); @@ -6,11 +8,13 @@ class Urls { static const String portfolioBase = 'https://plguerradesigns.github.io/portfolio/#'; - static const String projectIssues = - 'https://github.com/PLGuerraDesigns/portfolio/issues'; static const String projectSourceCode = 'https://github.com/PLGuerraDesigns/portfolio'; - static const String changelog = + static const String projectReadme = + 'https://github.com/PLGuerraDesigns/portfolio/blob/master/README.md'; + static const String projectIssues = + 'https://github.com/PLGuerraDesigns/portfolio/issues'; + static const String projectChangelog = 'https://github.com/PLGuerraDesigns/portfolio/blob/master/CHANGELOG.md'; static const String github = 'https://github.com/PLGuerraDesigns'; @@ -18,7 +22,7 @@ class Urls { 'https://www.thingiverse.com/plg_designs/designs'; static const String linkedin = 'https://www.linkedin.com/in/plguerra/'; static const String youtube = 'https://www.youtube.com/@plguerra'; - static const String contactEmail = 'mailto:plguerra@outlook.com'; + static const String openEmail = 'mailto:${Strings.contactEmail}'; static const String googleSearchBase = 'https://www.google.com/search?q='; diff --git a/lib/pages/home/widgets/action_menu.dart b/lib/pages/home/widgets/action_menu.dart index 96207c2..f339177 100644 --- a/lib/pages/home/widgets/action_menu.dart +++ b/lib/pages/home/widgets/action_menu.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../common/strings.dart'; import '../../../common/urls.dart'; @@ -17,14 +18,82 @@ class ActionMenu extends StatelessWidget { /// Whether the menu should be compact. final bool compact; - Widget _icon({ + /// The icon for the action button. + Widget _action({ required BuildContext context, + required String title, required IconData iconData, + required Function() onTap, }) { - return Icon( - iconData, - size: 46, - color: Theme.of(context).colorScheme.onSurface, + return FrostedActionButton( + icon: Icon( + iconData, + size: 46, + color: Theme.of(context).colorScheme.onSurface, + ), + title: title, + onTap: () { + // Close drawer if in compact mode + if (compact) { + Navigator.of(context).pop(); + } + onTap(); + }, + ); + } + + /// Shows a dialog prompting the user to confirm + /// how they would like to proceed. + Future _contactMeConfirmationDialog({ + required BuildContext context, + }) async { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text(Strings.contactMe), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text(Strings.contactMeMessage), + Tooltip( + message: Strings.copyToClipboard, + child: TextButton( + onPressed: () { + Clipboard.setData( + const ClipboardData(text: Strings.contactEmail)); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(Strings.emailCopied), + ), + ); + }, + child: const Text( + Strings.contactEmail, + ), + ), + ), + ], + ), + actions: [ + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + RedirectHandler.openUrl(Urls.openEmail); + }, + child: Text(Strings.openEmailApp.toUpperCase()), + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(Strings.close.toUpperCase()), + ), + ], + ); + }, ); } @@ -43,30 +112,30 @@ class ActionMenu extends StatelessWidget { ), childrenDelegate: SliverChildListDelegate( [ - FrostedActionButton( - icon: _icon( - context: context, - iconData: Icons.quick_contacts_mail_rounded, - ), - title: Strings.contactMe, - onTap: () => RedirectHandler.openUrl(Urls.contactEmail), + _action( + context: context, + title: Strings.about, + iconData: Icons.help, + onTap: () => RedirectHandler.openUrl(Urls.projectReadme), ), - FrostedActionButton( - icon: _icon( - context: context, - iconData: Icons.bug_report_rounded, - ), - title: Strings.reportAnIssue, - onTap: () => RedirectHandler.openUrl(Urls.projectIssues), + _action( + context: context, + iconData: Icons.quick_contacts_mail_rounded, + title: Strings.contactMe, + onTap: () => _contactMeConfirmationDialog(context: context), ), - FrostedActionButton( - icon: _icon( - context: context, - iconData: Icons.code, - ), + _action( + context: context, + iconData: Icons.code, title: Strings.viewSourceCode, onTap: () => RedirectHandler.openUrl(Urls.projectSourceCode), ), + _action( + context: context, + iconData: Icons.bug_report_rounded, + title: Strings.reportAnIssue, + onTap: () => RedirectHandler.openUrl(Urls.projectIssues), + ), ], ), ), diff --git a/lib/pages/home/widgets/app_version.dart b/lib/pages/home/widgets/app_version.dart index 3c0083d..7353588 100644 --- a/lib/pages/home/widgets/app_version.dart +++ b/lib/pages/home/widgets/app_version.dart @@ -22,7 +22,7 @@ class AppVersion extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { return TextButton( - onPressed: () => RedirectHandler.openUrl(Urls.changelog), + onPressed: () => RedirectHandler.openUrl(Urls.projectChangelog), child: Text( snapshot.data!, style: Theme.of(context).textTheme.labelSmall!.copyWith( diff --git a/pubspec.yaml b/pubspec.yaml index d0b61f2..107174b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plg_portfolio description: Pablo L. Guerra's web-app portfolio powered by Flutter. publish_to: "none" -version: 2.6.1+61 +version: 2.6.2+62 environment: sdk: ">=3.1.1 <4.0.0" From 4d4b32c0bde3e00bfa734fb74522ef1163b86bc6 Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Thu, 15 Aug 2024 18:19:33 -0400 Subject: [PATCH 2/3] Projects and Prof Exp Menu Descriptions --- CHANGELOG.md | 4 + lib/common/routing/app_router.dart | 6 +- lib/common/strings.dart | 6 +- .../personal_projects/projects_menu.dart | 78 +++++++++++ .../projects_menu.screen.dart | 32 ----- .../widgets/project_thumbnail.dart | 48 +++---- .../widgets/projects_menu.dart | 104 --------------- lib/pages/professional_exp_menu.dart | 97 ++++++++++++++ .../prof_exp_menu.screen.dart | 33 ----- .../widgets/prof_exp_menu.dart | 91 ------------- lib/widgets/frosted_grid_menu.dart | 124 ++++++++++++++++++ lib/widgets/tilt_handler.dart | 12 +- pubspec.yaml | 2 +- 13 files changed, 333 insertions(+), 304 deletions(-) create mode 100644 lib/pages/personal_projects/projects_menu.dart delete mode 100644 lib/pages/personal_projects/projects_menu.screen.dart delete mode 100644 lib/pages/personal_projects/widgets/projects_menu.dart create mode 100644 lib/pages/professional_exp_menu.dart delete mode 100644 lib/pages/professional_experiences/prof_exp_menu.screen.dart delete mode 100644 lib/pages/professional_experiences/widgets/prof_exp_menu.dart create mode 100644 lib/widgets/frosted_grid_menu.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b91e78b..34f10a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.6.3 + +- REVISED: Added a description to the Professional Experience and Personal Projects menu sections. + ## 2.6.2 - REVISED: Added a confirmation dialog to the `Contact Me` button in the action menu. diff --git a/lib/common/routing/app_router.dart b/lib/common/routing/app_router.dart index 0f92a7a..8078d79 100644 --- a/lib/common/routing/app_router.dart +++ b/lib/common/routing/app_router.dart @@ -8,8 +8,8 @@ import '../../pages/details/details.screen.dart'; import '../../pages/error.dart'; import '../../pages/home/home.screen.dart'; import '../../pages/loading.dart'; -import '../../pages/personal_projects/projects_menu.screen.dart'; -import '../../pages/professional_experiences/prof_exp_menu.screen.dart'; +import '../../pages/personal_projects/projects_menu.dart'; +import '../../pages/professional_exp_menu.dart'; import 'routes.dart'; /// A class that configures the app's routing. @@ -61,7 +61,7 @@ class AppRouter { GoRoute( path: Routes.professional, builder: (BuildContext context, GoRouterState state) => - const ProfessionalExpMenuScreen(), + const ProfessionalExperienceMenuScreen(), routes: [ GoRoute( path: '${Routes.details}/:title', diff --git a/lib/common/strings.dart b/lib/common/strings.dart index 9e29740..d3542b6 100644 --- a/lib/common/strings.dart +++ b/lib/common/strings.dart @@ -40,9 +40,11 @@ class Strings { static const String about = 'About'; static const String viewSourceCode = 'View Source Code'; static const String professionalExperiences = 'Professional Experiences'; - static const String previousProject = 'Previous Project'; - static const String nextProject = 'Next Project'; + static const String professionalExperiencesExplained = + 'These are projects I have undertaken professionally, whether as a full-time, part-time employee, or contractor.'; static const String personalProjects = 'Personal Projects'; + static const String personalProjectsExplained = + 'These are projects I’ve undertaken both in my free time and during my studies to explore new technologies, engineering principles, and artistic expressions.'; static const String contactMe = 'Contact Me'; static const String reportAnIssue = 'Report an Issue'; static const String moreInfo = 'More Info'; diff --git a/lib/pages/personal_projects/projects_menu.dart b/lib/pages/personal_projects/projects_menu.dart new file mode 100644 index 0000000..804827d --- /dev/null +++ b/lib/pages/personal_projects/projects_menu.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../common/routing/routes.dart'; +import '../../common/strings.dart'; +import '../../models/app_state.dart'; +import '../../models/project.dart'; +import '../../widgets/frosted_grid_menu.dart'; +import '../../widgets/generic_app_bar.dart'; +import 'widgets/project_thumbnail.dart'; + +/// A screen that displays a collection of personal projects. +class ProjectsMenuScreen extends StatefulWidget { + const ProjectsMenuScreen({super.key}); + + @override + State createState() => _ProjectsMenuScreenState(); +} + +class _ProjectsMenuScreenState extends State { + /// The controller for the scroll view. + static final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: GenericAppBar.build( + context: context, + title: Strings.personalProjects, + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: Consumer( + builder: (BuildContext context, AppState appState, Widget? child) { + // ! This should be handled in AppRouter but redirect isn't being called + // ! on pop, so we're handling it here until it's fixed in GoRouter. + if (appState.currentRoute != Routes.personalProjects) { + appState.currentRoute = Routes.personalProjects; + } + return FutureBuilder( + future: appState.loadProjects(), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + List? children; + if (snapshot.connectionState == ConnectionState.done) { + children = appState.projects + .map( + (Project project) => ProjectThumbnail( + project: project, + ), + ) + .toList(); + } + + return FrostedGridMenu( + title: Strings.personalProjectsExplained, + scrollController: _scrollController, + crossAxisCount: MediaQuery.of(context).orientation == + Orientation.portrait + ? 1 + : 5, + aspectRatio: MediaQuery.of(context).orientation == + Orientation.portrait + ? 1.175 + : 1.0, + children: children, + ); + }, + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/personal_projects/projects_menu.screen.dart b/lib/pages/personal_projects/projects_menu.screen.dart deleted file mode 100644 index 45bf698..0000000 --- a/lib/pages/personal_projects/projects_menu.screen.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../common/strings.dart'; -import '../../widgets/generic_app_bar.dart'; -import 'widgets/projects_menu.dart'; - -/// A screen that displays a collection of personal projects. -class ProjectsMenuScreen extends StatefulWidget { - const ProjectsMenuScreen({super.key}); - - @override - State createState() => _ProjectsMenuScreenState(); -} - -class _ProjectsMenuScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: GenericAppBar.build( - context: context, - title: Strings.personalProjects, - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: const ProjectsMenu(), - ), - ), - ); - } -} diff --git a/lib/pages/personal_projects/widgets/project_thumbnail.dart b/lib/pages/personal_projects/widgets/project_thumbnail.dart index 8467cdd..5fa864a 100644 --- a/lib/pages/personal_projects/widgets/project_thumbnail.dart +++ b/lib/pages/personal_projects/widgets/project_thumbnail.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../common/routing/routes.dart'; +import '../../../models/project.dart'; import '../../../widgets/hover_scale_handler.dart'; import '../../../widgets/tilt_handler.dart'; @@ -8,39 +11,30 @@ import '../../../widgets/tilt_handler.dart'; class ProjectThumbnail extends StatefulWidget { const ProjectThumbnail({ super.key, - required this.title, - required this.subtitle, - required this.imagePath, - required this.onTap, - this.compact = false, + required this.project, }); - /// the title of the project. - final String title; - - /// The subtitle of the project. - final String subtitle; - - /// The path to the image. - final String imagePath; - - /// The action to perform when the thumbnail is tapped. - final Function()? onTap; - - /// Whether the thumbnail should be compact. - final bool compact; + /// The project to display. + final Project project; @override State createState() => _ProjectThumbnailState(); } class _ProjectThumbnailState extends State { + /// The project to display. + Project get project => widget.project; + @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8.0), child: HoverScaleHandler( - onTap: widget.onTap, + onTap: () => context.go( + Routes.projectDetails( + titleAsPath: project.titleAsPath, + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -51,7 +45,7 @@ class _ProjectThumbnailState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.asset( - widget.imagePath, + project.thumbnailPath, fit: BoxFit.cover, errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { @@ -70,18 +64,14 @@ class _ProjectThumbnailState extends State { ), const SizedBox(height: 8.0), Text( - widget.title, - style: widget.compact - ? Theme.of(context).textTheme.bodyMedium - : Theme.of(context).textTheme.titleMedium, + project.title, + style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( - widget.subtitle, - style: widget.compact - ? Theme.of(context).textTheme.labelMedium - : Theme.of(context).textTheme.bodyMedium, + project.subtitle, + style: Theme.of(context).textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/pages/personal_projects/widgets/projects_menu.dart b/lib/pages/personal_projects/widgets/projects_menu.dart deleted file mode 100644 index 14508d5..0000000 --- a/lib/pages/personal_projects/widgets/projects_menu.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; - -import '../../../common/routing/routes.dart'; -import '../../../models/app_state.dart'; -import '../../../widgets/frosted_container.dart'; -import '../../../widgets/spinner.dart'; -import 'project_thumbnail.dart'; - -class ProjectsMenu extends StatelessWidget { - const ProjectsMenu({ - super.key, - }); - - /// The controller for the scroll view. - static final ScrollController _scrollController = ScrollController(); - - /// Returns a [GridView] with the projects. - GridView _gridView({ - required AppState appState, - required Orientation orientation, - }) { - const int landscapeCrossAxisCount = 5; - const int portraitCrossAxisCount = 1; - const double portraitAspectRatio = 1.175; - const double landscapeAspectRatio = 1.0; - - return GridView.custom( - padding: const EdgeInsets.all(8), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: orientation == Orientation.portrait - ? portraitCrossAxisCount - : landscapeCrossAxisCount, - crossAxisSpacing: 8, - childAspectRatio: orientation == Orientation.portrait - ? portraitAspectRatio - : landscapeAspectRatio, - ), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return ProjectThumbnail( - title: appState.projects[index].title, - subtitle: appState.projects[index].subtitle, - imagePath: appState.projects[index].thumbnailPath, - onTap: () => context.go( - Routes.projectDetails( - titleAsPath: appState.projects[index].titleAsPath, - ), - ), - ); - }, - childCount: appState.projects.length, - ), - ); - } - - @override - Widget build(BuildContext context) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - // ! This should be handled in AppRouter but redirect isn't being called - // ! on pop, so we're handling it here until it's fixed in GoRouter. - if (appState.currentRoute != Routes.personalProjects) { - appState.currentRoute = Routes.personalProjects; - } - - return FrostedContainer( - padding: EdgeInsets.zero, - child: FutureBuilder( - future: appState.loadProjects(), - builder: - (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - padding: orientation == Orientation.portrait - ? const EdgeInsets.only(right: 8) - : EdgeInsets.zero, - controller: _scrollController, - child: _gridView( - appState: appState, - orientation: orientation, - ), - ), - ); - } else { - return const Spinner(); - } - }, - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/pages/professional_exp_menu.dart b/lib/pages/professional_exp_menu.dart new file mode 100644 index 0000000..d3f3ff6 --- /dev/null +++ b/lib/pages/professional_exp_menu.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../common/routing/routes.dart'; +import '../common/strings.dart'; +import '../models/app_state.dart'; +import '../models/professional_experience.dart'; +import '../widgets/floating_thumbnail.dart'; +import '../widgets/frosted_grid_menu.dart'; +import '../widgets/generic_app_bar.dart'; + +/// A screen that displays a collection of professional experiences. +class ProfessionalExperienceMenuScreen extends StatefulWidget { + const ProfessionalExperienceMenuScreen({super.key}); + + @override + State createState() => + _ProfessionalExperienceMenuScreenState(); +} + +class _ProfessionalExperienceMenuScreenState + extends State { + /// The controller for the scroll view. + static final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: GenericAppBar.build( + context: context, + title: Strings.professionalExperiences, + ), + body: OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: Consumer( + builder: + (BuildContext context, AppState appState, Widget? child) { + // ! This should be handled in AppRouter but redirect isn't being called + // ! on pop, so we're handling it here until it's fixed in GoRouter. + if (appState.currentRoute != Routes.professionalExperiences) { + appState.currentRoute = Routes.professionalExperiences; + } + return FutureBuilder( + future: appState.loadProfessionalExperiences(), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + List? children; + if (snapshot.connectionState == ConnectionState.done) { + children = appState.professionalExperiences + .map( + (ProfessionalExperience exp) => FloatingThumbnail( + title: exp.company, + subtitle: exp.role, + image: exp.thumbnailPath, + logoPath: exp.logoPath, + frosted: true, + onTap: () => context.go( + Routes.professionalExpDetails( + titleAsPath: exp.titleAsPath, + ), + ), + ), + ) + .toList(); + } + + return FrostedGridMenu( + title: Strings.professionalExperiencesExplained, + scrollController: _scrollController, + mainAxisSpacing: + orientation == Orientation.portrait ? 16 : 0, + crossAxisSpacing: 16.0, + crossAxisCount: + orientation == Orientation.portrait ? 1 : 3, + children: children, + ); + }, + ); + }, + ), + ), + ); + }), + ); + } +} diff --git a/lib/pages/professional_experiences/prof_exp_menu.screen.dart b/lib/pages/professional_experiences/prof_exp_menu.screen.dart deleted file mode 100644 index 9123db4..0000000 --- a/lib/pages/professional_experiences/prof_exp_menu.screen.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../common/strings.dart'; -import '../../widgets/generic_app_bar.dart'; -import 'widgets/prof_exp_menu.dart'; - -/// A screen that displays a collection of professional experiences. -class ProfessionalExpMenuScreen extends StatefulWidget { - const ProfessionalExpMenuScreen({super.key}); - - @override - State createState() => - _ProfessionalExpMenuScreenState(); -} - -class _ProfessionalExpMenuScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: GenericAppBar.build( - context: context, - title: Strings.professionalExperiences, - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: const ProfessionalExpMenu(), - ), - ), - ); - } -} diff --git a/lib/pages/professional_experiences/widgets/prof_exp_menu.dart b/lib/pages/professional_experiences/widgets/prof_exp_menu.dart deleted file mode 100644 index 9f6ca52..0000000 --- a/lib/pages/professional_experiences/widgets/prof_exp_menu.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; - -import '../../../common/routing/routes.dart'; -import '../../../models/app_state.dart'; -import '../../../widgets/floating_thumbnail.dart'; -import '../../../widgets/frosted_container.dart'; -import '../../../widgets/spinner.dart'; - -class ProfessionalExpMenu extends StatelessWidget { - const ProfessionalExpMenu({ - super.key, - }); - - /// The controller for the scroll view. - static final ScrollController _scrollController = ScrollController(); - - /// Returns a [GridView] with the professional experiences. - GridView _gridView(Orientation orientation, AppState appState) { - return GridView.custom( - padding: const EdgeInsets.all(16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: orientation == Orientation.portrait ? 1 : 3, - crossAxisSpacing: 16, - mainAxisSpacing: orientation == Orientation.portrait ? 16 : 0, - ), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return FloatingThumbnail( - title: appState.professionalExperiences[index].company, - subtitle: appState.professionalExperiences[index].role, - image: appState.professionalExperiences[index].thumbnailPath, - logoPath: appState.professionalExperiences[index].logoPath, - frosted: true, - onTap: () => context.go( - Routes.professionalExpDetails( - titleAsPath: - appState.professionalExperiences[index].titleAsPath, - ), - ), - ); - }, - childCount: appState.professionalExperiences.length, - )); - } - - @override - Widget build(BuildContext context) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - // ! This should be handled in AppRouter but redirect isn't being called - // ! on pop, so we're handling it here until it's fixed in GoRouter. - if (appState.currentRoute != Routes.professionalExperiences) { - appState.currentRoute = Routes.professionalExperiences; - } - - return FrostedContainer( - padding: EdgeInsets.zero, - child: FutureBuilder( - future: appState.loadProfessionalExperiences(), - builder: - (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - padding: orientation == Orientation.portrait - ? const EdgeInsets.only(right: 8) - : EdgeInsets.zero, - controller: _scrollController, - child: _gridView(orientation, appState), - ), - ); - } else { - return const Spinner(); - } - }, - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/widgets/frosted_grid_menu.dart b/lib/widgets/frosted_grid_menu.dart new file mode 100644 index 0000000..1063bd2 --- /dev/null +++ b/lib/widgets/frosted_grid_menu.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import 'frosted_container.dart'; +import 'spinner.dart'; + +/// A grid menu with a frosted glass background. +/// +/// Displays a title and a grid of children. +class FrostedGridMenu extends StatelessWidget { + const FrostedGridMenu({ + super.key, + required this.title, + required this.children, + required this.scrollController, + this.crossAxisSpacing = 0.0, + this.mainAxisSpacing = 0.0, + this.crossAxisCount = 3, + this.aspectRatio = 1.0, + }); + + /// The title of the grid menu. + final String title; + + /// The children of the grid menu. + final List? children; + + /// The scroll controller for the grid menu. + final ScrollController scrollController; + + /// The main axis spacing. + final double mainAxisSpacing; + + /// The cross axis spacing. + final double crossAxisSpacing; + + /// The cross axis count. + final int crossAxisCount; + + /// The aspect ratio. + final double aspectRatio; + + /// Returns the header of the grid menu. + Widget _header({ + required BuildContext context, + required bool compact, + }) { + return Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: compact + ? Theme.of(context).textTheme.bodyMedium + : Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + const Divider(), + ], + ), + ); + } + + /// Returns a [GridView] with custom settings. + GridView _gridView() { + return GridView.custom( + padding: const EdgeInsets.all(16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + crossAxisCount: crossAxisCount, + childAspectRatio: aspectRatio, + ), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childrenDelegate: SliverChildBuilderDelegate( + childCount: children!.length, + (BuildContext context, int index) { + return children![index]; + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return FrostedContainer( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header( + context: context, + compact: orientation == Orientation.portrait, + ), + if (children == null) + const Expanded(child: Spinner()) + else + Expanded( + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + padding: orientation == Orientation.portrait + ? const EdgeInsets.only(right: 8) + : EdgeInsets.zero, + controller: scrollController, + child: _gridView(), + ), + ), + ) + ], + ), + ); + }); + } +} diff --git a/lib/widgets/tilt_handler.dart b/lib/widgets/tilt_handler.dart index 5a13258..4f7ef4b 100644 --- a/lib/widgets/tilt_handler.dart +++ b/lib/widgets/tilt_handler.dart @@ -56,14 +56,6 @@ class _TiltHandlerState extends State aroundAnimationController.repeat(); } - /// Stop all animations. - void stopAllAnimation() { - aroundAnimationController.stop(); - tiltStreamController.add( - const TiltStreamModel(position: Offset.zero, gestureUse: false), - ); - } - @override void initState() { super.initState(); @@ -80,8 +72,10 @@ class _TiltHandlerState extends State @override void dispose() { + if (widget.selfTilt) { + aroundAnimationController.dispose(); + } tiltStreamController.close(); - aroundAnimationController.dispose(); super.dispose(); } diff --git a/pubspec.yaml b/pubspec.yaml index 107174b..03b4ec9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plg_portfolio description: Pablo L. Guerra's web-app portfolio powered by Flutter. publish_to: "none" -version: 2.6.2+62 +version: 2.6.3+63 environment: sdk: ">=3.1.1 <4.0.0" From e79f1c35a1e34d8a48d5506ef4a116ec97099860 Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Thu, 15 Aug 2024 19:51:12 -0400 Subject: [PATCH 3/3] Projects Filter Menu --- CHANGELOG.md | 4 ++ assets/json/projects.json | 25 ++++++++ lib/common/strings.dart | 5 ++ lib/models/app_state.dart | 52 ++++++++++++++- .../personal_projects/projects_menu.dart | 48 +++++++++++++- .../widgets/filter_menu.dart | 64 +++++++++++++++++++ lib/widgets/frosted_grid_menu.dart | 20 +++++- pubspec.yaml | 2 +- 8 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 lib/pages/personal_projects/widgets/filter_menu.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f10a1..4aa0d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.7.0 + +- NEW: Added a filter menu to the Personal Projects section. + ## 2.6.3 - REVISED: Added a description to the Professional Experience and Personal Projects menu sections. diff --git a/assets/json/projects.json b/assets/json/projects.json index b851cbf..159cf2b 100644 --- a/assets/json/projects.json +++ b/assets/json/projects.json @@ -214,6 +214,8 @@ } ], "tags": [ + "Academic", + "Software", "Flutter", "Dart", "Progressive Web App", @@ -276,6 +278,7 @@ } ], "tags": [ + "Software", "Flutter", "Syncfusion", "Dart", @@ -346,6 +349,8 @@ } ], "tags": [ + "Academic", + "Software", "Flutter", "Flame", "Supabase", @@ -535,6 +540,8 @@ } ], "tags": [ + "Software", + "Electronics", "Adobe Photoshop", "Adobe Illustrator", "Autodesk Fusion 360", @@ -644,6 +651,8 @@ } ], "tags": [ + "Academic", + "Software", "Adobe Photoshop", "Adobe Illustrator", "Adobe XD", @@ -720,6 +729,7 @@ } ], "tags": [ + "Electronics", "Autodesk Fusion 360", "CAD Design", "3D Printing", @@ -879,6 +889,8 @@ ], "externalLinks": [], "tags": [ + "Software", + "Electronics", "Fiberglass", "Painting", "DIY", @@ -975,6 +987,8 @@ } ], "tags": [ + "Software", + "Electronics", "Autodesk Fusion 360", "CAD Design", "3D Printing", @@ -1048,6 +1062,8 @@ } ], "tags": [ + "Software", + "Electronics", "Head Tracking", "Handicap", "MPU 6050", @@ -1088,6 +1104,8 @@ } ], "tags": [ + "Academic", + "Software", "Adobe Photoshop", "Calculator", "UARK CSCE 4623", @@ -1125,6 +1143,8 @@ } ], "tags": [ + "Software", + "Electronics", "Autodesk Fusion 360", "Cherry MX Switches", "3D Printing", @@ -1163,6 +1183,8 @@ } ], "tags": [ + "Software", + "Electronics", "Autodesk Fusion 360", "3D Printing", "Internet of Things", @@ -1253,6 +1275,9 @@ ], "externalLinks": [], "tags": [ + "Academic", + "Software", + "Electronics", "Highschool Science Fair", "Fiberglass", "Mechanical Arm", diff --git a/lib/common/strings.dart b/lib/common/strings.dart index d3542b6..5673d06 100644 --- a/lib/common/strings.dart +++ b/lib/common/strings.dart @@ -65,4 +65,9 @@ class Strings { static const String emailCopied = 'Email copied to clipboard'; static const String openEmailApp = 'Open Email App'; static const String close = 'Close'; + + static const String software = 'Software'; + static const String electronics = 'Electronics'; + static const String threeDPrinting = '3D Printing'; + static const String academic = 'Academic'; } diff --git a/lib/models/app_state.dart b/lib/models/app_state.dart index 36e2a27..3e15210 100644 --- a/lib/models/app_state.dart +++ b/lib/models/app_state.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../common/asset_paths.dart'; import '../common/routing/routes.dart'; +import '../common/strings.dart'; import 'education.dart'; import 'professional_experience.dart'; import 'project.dart'; @@ -22,6 +23,37 @@ class AppState extends ChangeNotifier { final GlobalKey _navigatorKey = GlobalKey(); GlobalKey get navigatorKey => _navigatorKey; + /// The list of project filter options. + final List projectFilterOptions = [ + Strings.software, + Strings.electronics, + Strings.threeDPrinting, + Strings.academic, + ]; + + /// The list of selected project filters. + List get selectedProjectFilters => _selectedProjectFilters; + final List _selectedProjectFilters = [ + Strings.software, + Strings.electronics, + Strings.threeDPrinting, + Strings.academic, + ]; + + /// Toggles the selected project filter. + void toggleSelectedProjectFilter(String filter) { + if (selectedProjectFilters.length == projectFilterOptions.length) { + _selectedProjectFilters.clear(); + } + + if (_selectedProjectFilters.contains(filter)) { + _selectedProjectFilters.remove(filter); + } else { + _selectedProjectFilters.add(filter); + } + notifyListeners(); + } + /// Whether the project data has been loaded. bool _projectsLoaded = false; bool get projectsLoaded => _projectsLoaded; @@ -30,6 +62,20 @@ class AppState extends ChangeNotifier { List get projects => _projects; List _projects = []; + /// The list of filtered projects. + List get filteredProjects { + final List filteredProjects = []; + for (final Project project in _projects) { + for (final String filter in selectedProjectFilters) { + if (project.tags.contains(filter)) { + filteredProjects.add(project); + break; + } + } + } + return filteredProjects; + } + /// Returns the project for the given title in path format. Project getProjectByTitlePath(String titleAsPath) { for (final Project project in _projects) { @@ -101,14 +147,16 @@ class AppState extends ChangeNotifier { await rootBundle.loadString(AssetPaths.projectsJsonData).then( (String data) { final dynamic jsonResult = json.decode(data); - for (final dynamic project in jsonResult as List) { - _projects.add(Project.fromJson(project as Map)); + for (final dynamic projectAsJson in jsonResult as List) { + _projects + .add(Project.fromJson(projectAsJson as Map)); } }, ); _projects.sort((Project a, Project b) { return b.startDate.compareTo(a.startDate); }); + _projectsLoaded = true; } diff --git a/lib/pages/personal_projects/projects_menu.dart b/lib/pages/personal_projects/projects_menu.dart index 804827d..e0921d2 100644 --- a/lib/pages/personal_projects/projects_menu.dart +++ b/lib/pages/personal_projects/projects_menu.dart @@ -7,6 +7,7 @@ import '../../models/app_state.dart'; import '../../models/project.dart'; import '../../widgets/frosted_grid_menu.dart'; import '../../widgets/generic_app_bar.dart'; +import 'widgets/filter_menu.dart'; import 'widgets/project_thumbnail.dart'; /// A screen that displays a collection of personal projects. @@ -19,7 +20,41 @@ class ProjectsMenuScreen extends StatefulWidget { class _ProjectsMenuScreenState extends State { /// The controller for the scroll view. - static final ScrollController _scrollController = ScrollController(); + static final ScrollController _pageScrollController = ScrollController(); + + /// The controller for the filter menu. + static final ScrollController _filterScrollController = ScrollController(); + + /// Returns the filter menu. + Widget _filterMenu({ + required List filterOptions, + required List selectedFilters, + required Function(String filter) onToggleFilter, + required int itemCount, + required int totalItemCount, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FilterMenu( + filterOptions: filterOptions, + selectedFilters: selectedFilters, + onToggleFilter: onToggleFilter, + scrollController: _filterScrollController, + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + '$itemCount of $totalItemCount', + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + ], + ); + } @override Widget build(BuildContext context) { @@ -45,7 +80,7 @@ class _ProjectsMenuScreenState extends State { (BuildContext context, AsyncSnapshot snapshot) { List? children; if (snapshot.connectionState == ConnectionState.done) { - children = appState.projects + children = appState.filteredProjects .map( (Project project) => ProjectThumbnail( project: project, @@ -56,7 +91,7 @@ class _ProjectsMenuScreenState extends State { return FrostedGridMenu( title: Strings.personalProjectsExplained, - scrollController: _scrollController, + scrollController: _pageScrollController, crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait ? 1 @@ -65,6 +100,13 @@ class _ProjectsMenuScreenState extends State { Orientation.portrait ? 1.175 : 1.0, + subtitle: _filterMenu( + filterOptions: appState.projectFilterOptions, + selectedFilters: appState.selectedProjectFilters, + onToggleFilter: appState.toggleSelectedProjectFilter, + itemCount: appState.filteredProjects.length, + totalItemCount: appState.projects.length, + ), children: children, ); }, diff --git a/lib/pages/personal_projects/widgets/filter_menu.dart b/lib/pages/personal_projects/widgets/filter_menu.dart new file mode 100644 index 0000000..fc015d0 --- /dev/null +++ b/lib/pages/personal_projects/widgets/filter_menu.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../widgets/custom_filter_chip.dart'; + +/// A list of [FilterChip] widgets for filtering a list of items. +class FilterMenu extends StatelessWidget { + const FilterMenu({ + super.key, + required this.filterOptions, + required this.selectedFilters, + required this.onToggleFilter, + required this.scrollController, + }); + + /// The list of filter option labels. + final List filterOptions; + + /// The list of selected filters. + final List selectedFilters; + + /// Callback for when a filter is selected. + final void Function(String filter) onToggleFilter; + + /// The controller for the scroll view. + final ScrollController scrollController; + + /// The filter option widget. + Widget _option({ + required String label, + required bool selected, + }) { + return CustomFilterChip( + label: label, + selected: selected, + onSelected: (_) => onToggleFilter(label), + ); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row(children: [ + Text( + Strings.show.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context).hintColor.withOpacity(0.5), + ), + ), + const SizedBox(width: 8), + ...filterOptions + .map( + (String filter) => _option( + label: filter, + selected: selectedFilters.contains(filter), + ), + ) + .toList(), + ]), + ); + } +} diff --git a/lib/widgets/frosted_grid_menu.dart b/lib/widgets/frosted_grid_menu.dart index 1063bd2..71aa83f 100644 --- a/lib/widgets/frosted_grid_menu.dart +++ b/lib/widgets/frosted_grid_menu.dart @@ -16,6 +16,7 @@ class FrostedGridMenu extends StatelessWidget { this.mainAxisSpacing = 0.0, this.crossAxisCount = 3, this.aspectRatio = 1.0, + this.subtitle, }); /// The title of the grid menu. @@ -39,6 +40,9 @@ class FrostedGridMenu extends StatelessWidget { /// The aspect ratio. final double aspectRatio; + /// The subtitle widget of the grid menu. + final Widget? subtitle; + /// Returns the header of the grid menu. Widget _header({ required BuildContext context, @@ -112,7 +116,21 @@ class FrostedGridMenu extends StatelessWidget { ? const EdgeInsets.only(right: 8) : EdgeInsets.zero, controller: scrollController, - child: _gridView(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (subtitle != null) + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + ), + child: subtitle, + ), + _gridView(), + ], + ), ), ), ) diff --git a/pubspec.yaml b/pubspec.yaml index 03b4ec9..dcb7e6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plg_portfolio description: Pablo L. Guerra's web-app portfolio powered by Flutter. publish_to: "none" -version: 2.6.3+63 +version: 2.7.0+64 environment: sdk: ">=3.1.1 <4.0.0"