From dcadf02b35d315ba1c8ee76f6fbe31f928fb84fd Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Wed, 7 Aug 2024 14:09:17 -0400 Subject: [PATCH 1/3] Major Refactor --- .vscode/settings.json | 57 +- lib/common/asset_paths.dart | 17 + lib/common/routing/app_router.dart | 147 +++++ lib/common/routing/routes.dart | 22 + lib/common/strings.dart | 60 +- lib/common/theming/color_schemes.dart | 76 +++ lib/common/theming/text_themes.dart | 218 +++++++ lib/common/theming/theme_notifier.dart | 47 ++ lib/common/urls.dart | 21 + lib/main.dart | 24 +- lib/models/app_state.dart | 112 +--- lib/models/education.dart | 5 +- lib/models/professional_experience.dart | 24 +- lib/models/project.dart | 24 +- lib/pages/details/details.controller.dart | 97 ++++ lib/pages/details/details.model.dart | 50 ++ lib/pages/details/details.screen.dart | 242 ++++++++ .../details/widgets/app_bar_actions.dart | 70 +++ lib/pages/details/widgets/info_header.dart | 86 +++ .../widgets/media_player}/media_browser.dart | 6 +- .../widgets/media_player}/media_player.dart | 28 +- lib/pages/details/widgets/more_info.dart | 52 ++ lib/pages/details/widgets/tags_menu.dart | 43 ++ lib/pages/error.dart | 42 ++ lib/pages/home/home.controller.dart | 98 ++++ lib/pages/home/home.screen.dart | 155 +++++ lib/pages/home/widgets/action_menu.dart | 64 ++ .../home/widgets/app_bar.dart} | 41 +- .../home/widgets/custom_icon_button.dart | 48 ++ lib/pages/home/widgets/drawer.dart | 59 ++ lib/pages/home/widgets/header.dart | 132 +++++ .../widgets/powered_by_flutter_button.dart | 23 + .../professional_vs_personal_menu.dart | 79 +++ .../home/widgets/social_icon_button.dart | 36 ++ .../home/widgets/social_media_buttons.dart | 35 ++ lib/pages/home/widgets/theme_mode_button.dart | 28 + lib/pages/home/widgets/time_line_entry.dart | 127 ++++ lib/pages/home/widgets/timeline.dart | 174 ++++++ lib/{screens => pages}/loading.dart | 22 +- .../projects_menu.screen.dart | 32 + .../widgets/projects_menu.dart | 104 ++++ .../widgets/thumbnail_item.dart | 89 +++ .../prof_exp_menu.screen.dart | 33 ++ .../widgets/prof_exp_menu.dart | 85 +++ lib/routes/app_router.dart | 213 ------- lib/screens/details.dart | 460 --------------- lib/screens/home.dart | 546 ------------------ lib/screens/personal.dart | 118 ---- lib/screens/professional.dart | 117 ---- lib/widgets/custom_filter_chip.dart | 1 + lib/widgets/floating_thumbnail.dart | 4 +- lib/widgets/generic_app_bar.dart | 20 + lib/widgets/spinner.dart | 21 + pubspec.yaml | 2 +- 54 files changed, 2833 insertions(+), 1703 deletions(-) create mode 100644 lib/common/asset_paths.dart create mode 100644 lib/common/routing/app_router.dart create mode 100644 lib/common/routing/routes.dart create mode 100644 lib/common/theming/color_schemes.dart create mode 100644 lib/common/theming/text_themes.dart create mode 100644 lib/common/theming/theme_notifier.dart create mode 100644 lib/common/urls.dart create mode 100644 lib/pages/details/details.controller.dart create mode 100644 lib/pages/details/details.model.dart create mode 100644 lib/pages/details/details.screen.dart create mode 100644 lib/pages/details/widgets/app_bar_actions.dart create mode 100644 lib/pages/details/widgets/info_header.dart rename lib/{widgets => pages/details/widgets/media_player}/media_browser.dart (97%) rename lib/{widgets => pages/details/widgets/media_player}/media_player.dart (94%) create mode 100644 lib/pages/details/widgets/more_info.dart create mode 100644 lib/pages/details/widgets/tags_menu.dart create mode 100644 lib/pages/error.dart create mode 100644 lib/pages/home/home.controller.dart create mode 100644 lib/pages/home/home.screen.dart create mode 100644 lib/pages/home/widgets/action_menu.dart rename lib/{widgets/custom_app_bars.dart => pages/home/widgets/app_bar.dart} (51%) create mode 100644 lib/pages/home/widgets/custom_icon_button.dart create mode 100644 lib/pages/home/widgets/drawer.dart create mode 100644 lib/pages/home/widgets/header.dart create mode 100644 lib/pages/home/widgets/powered_by_flutter_button.dart create mode 100644 lib/pages/home/widgets/professional_vs_personal_menu.dart create mode 100644 lib/pages/home/widgets/social_icon_button.dart create mode 100644 lib/pages/home/widgets/social_media_buttons.dart create mode 100644 lib/pages/home/widgets/theme_mode_button.dart create mode 100644 lib/pages/home/widgets/time_line_entry.dart create mode 100644 lib/pages/home/widgets/timeline.dart rename lib/{screens => pages}/loading.dart (69%) create mode 100644 lib/pages/personal_projects/projects_menu.screen.dart create mode 100644 lib/pages/personal_projects/widgets/projects_menu.dart create mode 100644 lib/pages/personal_projects/widgets/thumbnail_item.dart create mode 100644 lib/pages/professional_experiences/prof_exp_menu.screen.dart create mode 100644 lib/pages/professional_experiences/widgets/prof_exp_menu.dart delete mode 100644 lib/routes/app_router.dart delete mode 100644 lib/screens/details.dart delete mode 100644 lib/screens/home.dart delete mode 100644 lib/screens/personal.dart delete mode 100644 lib/screens/professional.dart create mode 100644 lib/widgets/generic_app_bar.dart create mode 100644 lib/widgets/spinner.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e685df..eb5d906 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,29 +1,30 @@ { - "cSpell.words": [ - "Adafruit", - "Blynk", - "Chewie", - "endstop", - "Fullscreen", - "handroid", - "iframe", - "Instructables", - "LTRB", - "microcontroller", - "missioned", - "Photoshop", - "pressable", - "proto", - "pushbuttons", - "safedrive", - "Soendergaard", - "spackling", - "thingiverse", - "Thrustmaster", - "thumbstick", - "thumbsticks", - "UARK", - "Viktor", - "wirelessly" - ] -} \ No newline at end of file + "cSpell.words": [ + "Adafruit", + "Blynk", + "Chewie", + "endstop", + "Fullscreen", + "handroid", + "iframe", + "Instructables", + "LTRB", + "microcontroller", + "missioned", + "Photoshop", + "pressable", + "proto", + "pushbuttons", + "safedrive", + "Soendergaard", + "spackling", + "thingiverse", + "Thrustmaster", + "thumbstick", + "thumbsticks", + "UARK", + "Viktor", + "webp", + "wirelessly" + ] +} diff --git a/lib/common/asset_paths.dart b/lib/common/asset_paths.dart new file mode 100644 index 0000000..22d15b7 --- /dev/null +++ b/lib/common/asset_paths.dart @@ -0,0 +1,17 @@ +/// The paths to the assets used in the app. +class AssetPaths { + AssetPaths._(); + + static const String socialIconsBase = 'assets/images/icons'; + + static const String projectsJsonData = 'assets/json/projects.json'; + static const String professionalExperienceJsonData = + 'assets/json/professional_experience.json'; + static const String educationJsonData = 'assets/json/education.json'; + + static const String profilePhoto = 'assets/images/home/profile_dark.webp'; + static const String professionalExperiencePhoto = + 'assets/images/home/professional.webp'; + static const String personalExperiencePhoto = + 'assets/images/home/personal.webp'; +} diff --git a/lib/common/routing/app_router.dart b/lib/common/routing/app_router.dart new file mode 100644 index 0000000..61d7904 --- /dev/null +++ b/lib/common/routing/app_router.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/app_state.dart'; +import '../../models/professional_experience.dart'; +import '../../models/project.dart'; +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 'routes.dart'; + +/// A class that configures the app's routing. +class AppRouter { + AppRouter( + this.appState, + ); + + /// The application state. + AppState appState; + + /// Returns a [GoRouter] instance with the app's routing configuration. + GoRouter configureRouter() { + return GoRouter( + navigatorKey: appState.navigatorKey, + initialLocation: appState.currentRoute, + refreshListenable: appState, + routes: [ + GoRoute( + path: '${Routes.loading}/:from/:goto', + builder: (BuildContext context, GoRouterState state) => LoadingScreen( + from: state.pathParameters['from']!, + to: state.pathParameters['goto']!, + loadFunction: (BuildContext context) async { + if (state.pathParameters['from'] == Routes.professional && + !appState.professionalExperiencesLoaded) { + await appState.loadProfessionalExperiences(); + } + if (state.pathParameters['from'] == Routes.personal && + !appState.projectsLoaded && + context.mounted) { + await appState.loadProjects(); + } + }, + ), + ), + GoRoute( + path: Routes.home, + pageBuilder: (BuildContext context, GoRouterState state) => + CustomTransitionPage( + child: const HomeScreen(), + transitionsBuilder: (BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child) => + FadeTransition(opacity: animation, child: child), + ), + routes: [ + GoRoute( + path: Routes.professional, + builder: (BuildContext context, GoRouterState state) => + const ProfessionalExpMenuScreen(), + routes: [ + GoRoute( + path: '${Routes.details}/:title', + builder: (BuildContext context, GoRouterState state) { + final ProfessionalExperience professionalExperience = + appState.getProfessionalExperienceByTitlePath( + state.pathParameters['title']!); + return DetailsScreen( + key: UniqueKey(), + details: professionalExperience.details, + ); + }), + ], + ), + GoRoute( + path: Routes.personal, + builder: (BuildContext context, GoRouterState state) => + const ProjectsMenuScreen(), + routes: [ + GoRoute( + path: '${Routes.details}/:title', + builder: (BuildContext context, GoRouterState state) { + final Project project = appState + .getProjectByTitlePath(state.pathParameters['title']!); + return DetailsScreen( + key: UniqueKey(), + details: project.details, + ); + }, + ), + ], + ), + ], + ), + ], + redirect: (BuildContext context, GoRouterState state) { + final String location = state.uri.toString(); + final String goto = location.split('/').last; + + final bool isAtProfessionalDetails = + location.contains(Routes.professional) && + location.contains(Routes.details); + // Redirect to loading screen if the user is trying to access the + // professional experience details page and the professional experiences + // haven't been loaded yet. + if (isAtProfessionalDetails && + !appState.professionalExperiencesLoaded) { + return '${Routes.loading}/${Routes.professional}/$goto'; + } + + // Redirect to professional experience menu if the professional + // experience route doesn't exist. + if (isAtProfessionalDetails && + appState.professionalExperiencesLoaded && + !appState.isValidProfessionalExperience(goto)) { + return '${Routes.home}/${Routes.professional}'; + } + + final bool isAtPersonalDetails = location.contains(Routes.personal) && + location.contains(Routes.details); + // Redirect to loading screen if the user is trying to access the + // personal project details page and the projects haven't been + // loaded yet. + if (isAtPersonalDetails && !appState.projectsLoaded) { + return '${Routes.loading}/${Routes.personal}/$goto'; + } + + // Redirect to personal projects menu if the personal projects route + // doesn't exist. + if (location.contains(Routes.personal) && + !appState.isValidProject(goto) && + appState.projectsLoaded) { + return '${Routes.home}/${Routes.personal}'; + } + + return null; + }, + errorBuilder: (BuildContext context, GoRouterState state) { + return const ErrorScreen(); + }, + ); + } +} diff --git a/lib/common/routing/routes.dart b/lib/common/routing/routes.dart new file mode 100644 index 0000000..3e7c3c2 --- /dev/null +++ b/lib/common/routing/routes.dart @@ -0,0 +1,22 @@ +/// The routes of the application. +class Routes { + Routes._(); + + static const String home = '/home'; + static const String loading = '/loading'; + static const String professional = 'professional-experiences'; + static const String personal = 'personal-projects'; + static const String details = 'details'; + + static const String professionalExperiences = '$home/$professional'; + static const String personalProjects = '$home/$personal'; + + static String loadingRedirect({required String from, required String to}) => + '$home/$from/$details/$to'; + + static String projectDetails({required String titleAsPath}) => + '$home/$personal/$details/$titleAsPath'; + + static String professionalExpDetails({required String titleAsPath}) => + '$home/$professional/$details/$titleAsPath'; +} diff --git a/lib/common/strings.dart b/lib/common/strings.dart index 9d302b3..795175c 100644 --- a/lib/common/strings.dart +++ b/lib/common/strings.dart @@ -1,56 +1,15 @@ -import 'package:flutter/material.dart'; - +/// User facing strings used in the app. class Strings { Strings._(); static const String currentLocation = 'VA, USA'; - static const String lastUpdated = 'Updated JULY 2024'; - - // Routes - static const String loadingRoute = '/loading'; - static const String homeRoute = '/home'; - static const String professionalSubRoute = 'professional'; - static const String personalSubRoute = 'personal'; - static const String detailsSubRoute = 'details'; - - // URLs - static const String flutterUrl = 'https://flutter.dev/'; - static const String githubUrl = 'https://github.com/PLGuerraDesigns'; - static const String thingiverseUrl = - 'https://www.thingiverse.com/plg_designs/designs'; - static const String linkedinUrl = 'https://www.linkedin.com/in/plguerra/'; - static const String aicUrl = 'https://aicbelize.com/AIC-Mobile-App'; - static const String ambotsUrl = 'https://www.ambots.net/'; - static const String am3LabUrl = 'https://wordpressua.uark.edu/am3/'; - - static const String issuesUrl = - 'https://github.com/PLGuerraDesigns/portfolio/issues'; - static const String contactEmailUrl = 'mailto:plguerra@outlook.com'; - static const String sourceCodeUrl = - 'https://github.com/PLGuerraDesigns/portfolio'; - static const String flutterResumeBuilderUrl = - 'https://plguerradesigns.github.io/flutter_resume_builder/'; + static const String lastUpdated = 'Updated AUG 2024'; - // Assets - static const String projectsJsonPath = 'assets/json/projects.json'; - static const String professionalExperienceJsonPath = - 'assets/json/professional_experience.json'; - static const String educationJsonPath = 'assets/json/education.json'; - static const String socialAssetsBasePath = 'assets/images/icons'; - static const String resumeBuilderIconPath = - '$socialAssetsBasePath/resume_builder.webp'; - static String profilePhotoPath(Brightness brightness) => - 'assets/images/home/profile_dark.webp'; - static const String professionalExperiencePhotoPath = - 'assets/images/home/professional.webp'; - static const String personalExperiencePhotoPath = - 'assets/images/home/personal.webp'; - static const String aicLogoPath = - 'assets/images/professional/atlantic_insurance/logo.webp'; - static const String ambotsLogoPath = - 'assets/images/professional/ambots/logo.webp'; - - // General + static const String appName = 'PLG Portfolio'; + static const String name = 'Pablo L. Guerra'; + static const String subtitle = + 'Software Engineer • Innovator • Technologist'; + static const String motto = 'Men for Others'; static const String explore = 'Explore'; static const String expand = 'Expand'; static const String collapse = 'Collapse'; @@ -58,11 +17,6 @@ class Strings { static const String github = 'GitHub'; static const String thingiverse = 'Thingiverse'; static const String linkedin = 'LinkedIn'; - static const String appName = 'PLG Portfolio'; - static const String name = 'Pablo L. Guerra'; - static const String subtitle = - 'Software Engineer • Innovator • Technologist'; - static const String motto = 'Men for Others'; static const String poweredByFlutter = 'Powered by Flutter'; static const String professional = 'Professional'; static const String personal = 'Personal'; diff --git a/lib/common/theming/color_schemes.dart b/lib/common/theming/color_schemes.dart new file mode 100644 index 0000000..6b9c3b1 --- /dev/null +++ b/lib/common/theming/color_schemes.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// The color scheme for the application. +class PortfolioColorSchemes { + PortfolioColorSchemes._(); + + /// The light mode color scheme for the application. + static const ColorScheme light = ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF006686), + onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFBFE8FF), + onPrimaryContainer: Color(0xFF001F2B), + secondary: Color(0xFF006971), + onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFF85F3FF), + onSecondaryContainer: Color(0xFF002023), + tertiary: Color(0xFF006B5B), + onTertiary: Color(0xFFFFFFFF), + tertiaryContainer: Color(0xFF79F8DC), + onTertiaryContainer: Color(0xFF00201A), + error: Color(0xFFA1395A), + onError: Color(0xFFFFFFFF), + errorContainer: Color(0xFFFFD9E0), + onErrorContainer: Color(0xFF3F0019), + outline: Color(0xFF70787D), + background: Color(0xFFFCFDF7), + onBackground: Color(0xFF1A1C19), + surface: Color(0xFFF9FAF4), + onSurface: Color(0xFF1A1C19), + surfaceVariant: Color(0xFFDCE3E9), + onSurfaceVariant: Color(0xFF40484C), + inverseSurface: Color(0xFF2F312D), + onInverseSurface: Color(0xFFF0F1EB), + inversePrimary: Color(0xFF6ED2FF), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFF006686), + outlineVariant: Color(0xFFC0C7CD), + scrim: Color(0xFF000000), + ); + + /// The dark mode color scheme for the application. + static const ColorScheme dark = ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFF6ED2FF), + onPrimary: Color(0xFF003547), + primaryContainer: Color(0xFF004D65), + onPrimaryContainer: Color(0xFFBFE8FF), + secondary: Color(0xFF4DD9E6), + onSecondary: Color(0xFF00363B), + secondaryContainer: Color(0xFF004F55), + onSecondaryContainer: Color(0xFF85F3FF), + tertiary: Color(0xFF59DBC1), + onTertiary: Color(0xFF00382E), + tertiaryContainer: Color(0xFF005144), + onTertiaryContainer: Color(0xFF79F8DC), + error: Color(0xFFFFB1C3), + onError: Color(0xFF64052D), + errorContainer: Color(0xFF822143), + onErrorContainer: Color(0xFFFFD9E0), + outline: Color(0xFF8A9297), + background: Color(0xFF1A1C19), + onBackground: Color(0xFFE2E3DD), + surface: Color(0xFF121411), + onSurface: Color(0xFFC6C7C1), + surfaceVariant: Color(0xFF40484C), + onSurfaceVariant: Color(0xFFC0C7CD), + inverseSurface: Color(0xFFE2E3DD), + onInverseSurface: Color(0xFF1A1C19), + inversePrimary: Color(0xFF006686), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFF6ED2FF), + outlineVariant: Color(0xFF40484C), + scrim: Color(0xFF000000), + ); +} diff --git a/lib/common/theming/text_themes.dart b/lib/common/theming/text_themes.dart new file mode 100644 index 0000000..a673e06 --- /dev/null +++ b/lib/common/theming/text_themes.dart @@ -0,0 +1,218 @@ +// As specified by the Material Design guidelines. +// https://m3.material.io/styles/typography/type-scale-tokens#7ab6f2b4-6217-4182-9ae1-3371a74f8b00 + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'color_schemes.dart'; + +/// Text themes for the application. +class PortfolioTextThemes { + PortfolioTextThemes._(); + + /// A light background text theme. + static final TextTheme light = TextTheme( + displayLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 57, + height: 64 / 57, + letterSpacing: -0.25, + color: PortfolioColorSchemes.light.onSurface, + ), + displayMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 45, + height: 52 / 45, + color: PortfolioColorSchemes.light.onSurface, + ), + displaySmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 36, + height: 44 / 36, + color: PortfolioColorSchemes.light.onSurface, + ), + headlineLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 32, + height: 40 / 32, + color: PortfolioColorSchemes.light.onSurface, + ), + headlineMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 28, + height: 36 / 28, + color: PortfolioColorSchemes.light.onSurface, + ), + headlineSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 24, + height: 32 / 24, + color: PortfolioColorSchemes.light.onSurface, + ), + titleLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 22, + height: 28 / 22, + color: PortfolioColorSchemes.light.onSurface, + ), + titleMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.15, + color: PortfolioColorSchemes.light.onSurface, + ), + titleSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w600, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.1, + color: PortfolioColorSchemes.light.onSurface, + ), + bodyLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.5, + color: PortfolioColorSchemes.light.onSurface, + ), + bodyMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.25, + color: PortfolioColorSchemes.light.onSurface, + ), + bodySmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 12, + height: 16 / 12, + letterSpacing: 0.4, + color: PortfolioColorSchemes.light.onSurface, + ), + labelLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.1, + color: PortfolioColorSchemes.light.onSurface, + ), + labelMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 12, + height: 16 / 12, + letterSpacing: 0.5, + color: PortfolioColorSchemes.light.onSurface, + ), + labelSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 11, + height: 16 / 11, + letterSpacing: 0.5, + color: PortfolioColorSchemes.light.onSurface, + ), + ); + + /// A dark background text theme. + static final TextTheme dark = TextTheme( + displayLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 57, + height: 64 / 57, + letterSpacing: -0.25, + color: PortfolioColorSchemes.dark.onSurface, + ), + displayMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 45, + height: 52 / 45, + color: PortfolioColorSchemes.dark.onSurface, + ), + displaySmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 36, + height: 44 / 36, + color: PortfolioColorSchemes.dark.onSurface, + ), + headlineLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 32, + height: 40 / 32, + color: PortfolioColorSchemes.dark.onSurface, + ), + headlineMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 28, + height: 36 / 28, + color: PortfolioColorSchemes.dark.onSurface, + ), + headlineSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 24, + height: 32 / 24, + color: PortfolioColorSchemes.dark.onSurface, + ), + titleLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 22, + height: 28 / 22, + color: PortfolioColorSchemes.dark.onSurface, + ), + titleMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.15, + color: PortfolioColorSchemes.dark.onSurface, + ), + titleSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w600, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.1, + color: PortfolioColorSchemes.dark.onSurface, + ), + bodyLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.5, + color: PortfolioColorSchemes.dark.onSurface, + ), + bodyMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.25, + color: PortfolioColorSchemes.dark.onSurface, + ), + bodySmall: GoogleFonts.roboto( + fontWeight: FontWeight.w400, + fontSize: 12, + height: 16 / 12, + letterSpacing: 0.4, + color: PortfolioColorSchemes.dark.onSurface, + ), + labelLarge: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 14, + height: 20 / 14, + letterSpacing: 0.1, + color: PortfolioColorSchemes.dark.onSurface, + ), + labelMedium: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 12, + height: 16 / 12, + letterSpacing: 0.5, + color: PortfolioColorSchemes.dark.onSurface, + ), + labelSmall: GoogleFonts.roboto( + fontWeight: FontWeight.w500, + fontSize: 11, + height: 16 / 11, + letterSpacing: 0.5, + color: PortfolioColorSchemes.dark.onSurface, + ), + ); +} diff --git a/lib/common/theming/theme_notifier.dart b/lib/common/theming/theme_notifier.dart new file mode 100644 index 0000000..9e5ccc3 --- /dev/null +++ b/lib/common/theming/theme_notifier.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'color_schemes.dart'; +import 'text_themes.dart'; + +/// Text theme for the application. +class ThemeNotifier extends ChangeNotifier { + /// Whether the theme mode has been loaded. + bool _themeLoaded = false; + bool get themeLoaded => _themeLoaded; + + /// Whether the application is in dark mode. + bool _isDarkMode = true; + bool get isDarkMode => _isDarkMode; + + /// The current theme mode. + ThemeMode get themeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light; + + /// The dark mode theme data. + ThemeData get darkTheme => ThemeData( + useMaterial3: true, + colorScheme: PortfolioColorSchemes.dark, + textTheme: PortfolioTextThemes.dark, + ); + + /// The light mode theme data. + ThemeData get lightTheme => ThemeData( + useMaterial3: true, + colorScheme: PortfolioColorSchemes.light, + textTheme: PortfolioTextThemes.light, + ); + + /// Loads the initial theme based on the system theme. + void loadInitialTheme(BuildContext context) { + final Brightness platformBrightness = + MediaQuery.of(context).platformBrightness; + _isDarkMode = platformBrightness == Brightness.dark; + _themeLoaded = true; + notifyListeners(); + } + + /// Sets the user's theme preference in shared preferences. + void toggleThemeMode() { + _isDarkMode = !_isDarkMode; + notifyListeners(); + } +} diff --git a/lib/common/urls.dart b/lib/common/urls.dart new file mode 100644 index 0000000..07dbd00 --- /dev/null +++ b/lib/common/urls.dart @@ -0,0 +1,21 @@ +/// A class that contains all the urls used in the app. +class Urls { + Urls._(); + + static const String flutter = 'https://flutter.dev/'; + + 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 github = 'https://github.com/PLGuerraDesigns'; + static const String thingiverse = + 'https://www.thingiverse.com/plg_designs/designs'; + static const String linkedin = 'https://www.linkedin.com/in/plguerra/'; + static const String contactEmail = 'mailto:plguerra@outlook.com'; + + static const String googleSearchBase = 'https://www.google.com/search?q='; +} diff --git a/lib/main.dart b/lib/main.dart index e2ff135..6f66074 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'common/color_schemes.dart'; +import 'common/routing/app_router.dart'; import 'common/strings.dart'; -import 'common/theme.dart'; +import 'common/theming/theme_notifier.dart'; import 'models/app_state.dart'; -import 'routes/app_router.dart'; void main() { runApp(const PortfolioApp()); @@ -22,6 +21,8 @@ class PortfolioApp extends StatefulWidget { class _PortfolioAppState extends State { /// The theme notifier to listen to theme changes. late final ThemeNotifier themeNotifier = ThemeNotifier(); + + /// The application state. final AppState _appState = AppState(); @override @@ -48,20 +49,9 @@ class _PortfolioAppState extends State { ], child: MaterialApp.router( title: Strings.appName, - themeMode: themeNotifier.isDarkMode ? ThemeMode.dark : ThemeMode.light, - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - textTheme: lightTextTheme, - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - textTheme: darkTextTheme, - ), - builder: (BuildContext context, Widget? child) { - return child!; - }, + theme: themeNotifier.lightTheme, + darkTheme: themeNotifier.darkTheme, + themeMode: themeNotifier.themeMode, routerConfig: AppRouter(_appState).configureRouter(), ), ); diff --git a/lib/models/app_state.dart b/lib/models/app_state.dart index 104d7fd..31354a6 100644 --- a/lib/models/app_state.dart +++ b/lib/models/app_state.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../common/strings.dart'; -import '../widgets/time_line_entry.dart'; +import '../common/asset_paths.dart'; +import '../common/routing/routes.dart'; import 'education.dart'; import 'professional_experience.dart'; import 'project.dart'; @@ -12,20 +12,17 @@ import 'project.dart'; /// The application state. class AppState extends ChangeNotifier { /// The current route. - String currentRoute = Strings.homeRoute; + String currentRoute = Routes.home; - /// Whether the media browser is visible. - bool _mediaBrowserVisible = false; - - bool get mediaBrowserVisible => _mediaBrowserVisible; + /// Whether the media browser is open. + bool _mediaBrowserOpen = false; + bool get mediaBrowserOpen => _mediaBrowserOpen; /// The navigator key. final GlobalKey _navigatorKey = GlobalKey(); - - /// The navigator key. GlobalKey get navigatorKey => _navigatorKey; - /// Whether the projects have been loaded. + /// Whether the project data has been loaded. bool _projectsLoaded = false; bool get projectsLoaded => _projectsLoaded; @@ -33,19 +30,7 @@ class AppState extends ChangeNotifier { List get projects => _projects; List _projects = []; - /// Whether the professional experiences are visible in the timeline. - bool _professionalExperiencesVisible = true; - bool get professionalExperiencesVisible => _professionalExperiencesVisible; - - /// Whether the projects are visible in the timeline. - bool _projectsVisible = false; - bool get projectsVisible => _projectsVisible; - - /// Whether the education is visible in the timeline. - bool _educationVisible = true; - bool get educationVisible => _educationVisible; - - /// Returns the project with the given title as a path. + /// Returns the project for the given title in path format. Project getProjectByTitlePath(String titleAsPath) { for (final Project project in _projects) { if (project.titleAsPath == titleAsPath) { @@ -55,7 +40,7 @@ class AppState extends ChangeNotifier { throw Exception('Project not found.'); } - /// Whether the professional experiences have been loaded. + /// Whether the professional experience data has been loaded. bool professionalExperiencesLoaded = false; /// The list of professional experiences. @@ -64,7 +49,7 @@ class AppState extends ChangeNotifier { List _professionalExperiences = []; - /// Returns the professional experience with the given title as a path. + /// Returns the professional experience for the given title in path format. ProfessionalExperience getProfessionalExperienceByTitlePath( String titleAsPath) { for (final ProfessionalExperience professionalExperience @@ -76,7 +61,7 @@ class AppState extends ChangeNotifier { throw Exception('Professional experience not found.'); } - /// Whether the education has been loaded. + /// Whether the education data has been loaded. bool _educationLoaded = false; bool get educationLoaded => _educationLoaded; @@ -84,10 +69,10 @@ class AppState extends ChangeNotifier { List get education => _education; List _education = []; - /// Loads the education from the JSON file. + /// Loads the education data from the JSON file. Future loadEducation() async { _education = []; - await rootBundle.loadString(Strings.educationJsonPath).then( + await rootBundle.loadString(AssetPaths.educationJsonData).then( (String data) { final dynamic jsonResult = json.decode(data); for (final dynamic education in jsonResult as List) { @@ -101,10 +86,14 @@ class AppState extends ChangeNotifier { _educationLoaded = true; } - /// Loads the projects from the JSON file. + /// Loads the project data from the JSON file. Future loadProjects() async { + if (projectsLoaded) { + return; + } + _projects = []; - await rootBundle.loadString(Strings.projectsJsonPath).then( + await rootBundle.loadString(AssetPaths.projectsJsonData).then( (String data) { final dynamic jsonResult = json.decode(data); for (final dynamic project in jsonResult as List) { @@ -118,10 +107,14 @@ class AppState extends ChangeNotifier { _projectsLoaded = true; } - /// Loads the professional experiences from the JSON file. + /// Loads the professional experience data from the JSON file. Future loadProfessionalExperiences() async { + if (professionalExperiencesLoaded) { + return; + } + _professionalExperiences = []; - await rootBundle.loadString(Strings.professionalExperienceJsonPath).then( + await rootBundle.loadString(AssetPaths.professionalExperienceJsonData).then( (String data) { final dynamic jsonResult = json.decode(data); for (final dynamic professionalExperience @@ -140,44 +133,9 @@ class AppState extends ChangeNotifier { professionalExperiencesLoaded = true; } - /// Loads the timeline entries. - Future> loadTimelineEntries() async { - final List entries = []; - - if (_professionalExperiences.isEmpty) { - await loadProfessionalExperiences(); - } - if (_professionalExperiencesVisible) { - entries.addAll(_professionalExperiences.map( - (ProfessionalExperience professionalExperience) => - professionalExperience.timelineEntry)); - } - - if (_education.isEmpty) { - await loadEducation(); - } - if (_educationVisible) { - entries.addAll( - _education.map((Education education) => education.timelineEntry)); - } - - if (_projects.isEmpty) { - await loadProjects(); - } - if (_projectsVisible) { - entries.addAll(_projects.map((Project project) => project.timelineEntry)); - } - - entries.sort((TimelineEntry a, TimelineEntry b) { - return b.startDate.compareTo(a.startDate); - }); - - return entries; - } - /// Toggles the visibility of the media browser. void toggleMediaBrowserVisibility() { - _mediaBrowserVisible = !_mediaBrowserVisible; + _mediaBrowserOpen = !_mediaBrowserOpen; notifyListeners(); } @@ -245,22 +203,4 @@ class AppState extends ChangeNotifier { } return _projects[index + 1].titleAsPath; } - - /// Toggles the visibility of the professional experiences. - void toggleProfessionalExperienceVisibility() { - _professionalExperiencesVisible = !_professionalExperiencesVisible; - notifyListeners(); - } - - /// Toggles the visibility of the projects. - void toggleProjectsVisibility() { - _projectsVisible = !_projectsVisible; - notifyListeners(); - } - - /// Toggles the visibility of the education. - void toggleEducationVisibility() { - _educationVisible = !_educationVisible; - notifyListeners(); - } } diff --git a/lib/models/education.dart b/lib/models/education.dart index b18e489..431537c 100644 --- a/lib/models/education.dart +++ b/lib/models/education.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../common/strings.dart'; -import '../widgets/time_line_entry.dart'; +import '../pages/home/widgets/time_line_entry.dart'; +/// A class representing an education entry. class Education { Education({ required this.school, @@ -15,7 +16,7 @@ class Education { required this.url, }); - /// Creates am [Education] entry from a JSON object. + /// Creates an [Education] entry from a JSON object. factory Education.fromJson(Map json) { return Education( school: json['school'].toString(), diff --git a/lib/models/professional_experience.dart b/lib/models/professional_experience.dart index 87cb4a8..de0c2fd 100644 --- a/lib/models/professional_experience.dart +++ b/lib/models/professional_experience.dart @@ -3,8 +3,11 @@ import 'package:intl/intl.dart'; import 'package:unorm_dart/unorm_dart.dart' as unorm; import '../common/enums.dart'; +import '../common/routing/routes.dart'; import '../common/strings.dart'; -import '../widgets/time_line_entry.dart'; +import '../common/urls.dart'; +import '../pages/details/details.model.dart'; +import '../pages/home/widgets/time_line_entry.dart'; import 'media_item.dart'; class ProfessionalExperience { @@ -65,7 +68,7 @@ class ProfessionalExperience { /// The name of the company and role as a path String get titleAsPath => unorm.nfkc( - '${company.toLowerCase().replaceAll(' ', '_')}_${role.toLowerCase().replaceAll(' ', '_')}'); + '${company.toLowerCase().replaceAll(' ', '-')}_${role.toLowerCase().replaceAll(' ', '-')}'); /// The role in the company. final String role; @@ -124,7 +127,22 @@ class ProfessionalExperience { ? Strings.present.toUpperCase() : '$finalDateString', urlString: - 'https://plguerradesigns.github.io/portfolio/#/home/professional/details/$titleAsPath', + '${Urls.portfolioBase}${Routes.professionalExpDetails(titleAsPath: titleAsPath)}', coverImage: false, ); + + /// The details for the professional experience. + Details get details => Details( + title: company, + titleAsPath: titleAsPath, + subtitle: role, + appBarTitle: Strings.professionalExperiences, + logoPath: logoPath, + description: description, + externalLinks: externalLinks, + startDate: startDateString, + endDate: finalDateString, + mediaItems: mediaItems, + tags: const [], + ); } diff --git a/lib/models/project.dart b/lib/models/project.dart index 69c61cd..fea689e 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../common/enums.dart'; +import '../common/routing/routes.dart'; import '../common/strings.dart'; -import '../widgets/time_line_entry.dart'; +import '../common/urls.dart'; +import '../pages/details/details.model.dart'; +import '../pages/home/widgets/time_line_entry.dart'; import 'media_item.dart'; class Project { @@ -59,7 +62,7 @@ class Project { final String subtitle; /// The title of the project as a path. - String get titleAsPath => title.toLowerCase().replaceAll(' ', '_'); + String get titleAsPath => title.toLowerCase().replaceAll(' ', '-'); /// The start date of the project. final DateTime startDate; @@ -108,6 +111,21 @@ class Project { finalDateString: finalDateString, description: subtitle, urlString: - 'https://plguerradesigns.github.io/portfolio/#/home/personal/details/$titleAsPath', + '${Urls.portfolioBase}${Routes.projectDetails(titleAsPath: titleAsPath)}', + ); + + /// The details for the project. + Details get details => Details( + logoPath: null, + title: title, + titleAsPath: titleAsPath, + appBarTitle: Strings.projects, + subtitle: subtitle, + description: description, + externalLinks: externalLinks, + startDate: startDateString, + endDate: finalDateString, + mediaItems: mediaItems, + tags: tags, ); } diff --git a/lib/pages/details/details.controller.dart b/lib/pages/details/details.controller.dart new file mode 100644 index 0000000..5ea28d4 --- /dev/null +++ b/lib/pages/details/details.controller.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../common/routing/routes.dart'; +import '../../common/strings.dart'; +import '../../models/app_state.dart'; +import '../../models/media_item.dart'; +import 'details.model.dart'; + +class DetailsController extends ChangeNotifier { + DetailsController({ + required Details details, + required AppState appState, + }) { + _details = details; + _appState = appState; + } + + /// The application state. + late final AppState _appState; + + /// The content details. + late final Details _details; + + /// The controller for the scroll view. + final ScrollController screenScrollController = ScrollController(); + + /// The index of the current media item. + int _currentMediaIndex = 0; + int get currentMediaIndex => _currentMediaIndex; + + /// The title of the app bar. + String get appBarTitle => _details.appBarTitle; + + /// The title of the project/experience. + String get title => _details.title; + + /// The description of the project/experience. + String get description => _details.description; + + /// The external links to display. + List> get externalLinks => _details.externalLinks; + + /// The tags to display. + List get tags => _details.tags; + + /// The list of media items to display. + List get mediaItems => _details.mediaItems; + + /// Whether the media browser is open. + bool get mediaBrowserOpen => _appState.mediaBrowserOpen; + + /// Callback to toggle the media browser. + void toggleMediaBrowser() { + _appState.toggleMediaBrowserVisibility(); + notifyListeners(); + } + + /// The callback for navigating to the previous project/experience. + void onPreviousPressed(BuildContext context) { + String route = ''; + if (_details.appBarTitle == Strings.professionalExperiences) { + route = Routes.professionalExpDetails( + titleAsPath: + _appState.previousProfessionalExperience(_details.titleAsPath), + ); + } else { + route = Routes.projectDetails( + titleAsPath: _appState.previousProject(_details.titleAsPath), + ); + } + + context.go(route); + } + + /// The callback for navigating to the next project/experience. + void onNextPressed(BuildContext context) { + String route = ''; + if (_details.appBarTitle == Strings.professionalExperiences) { + route = Routes.professionalExpDetails( + titleAsPath: _appState.nextProfessionalExperience(_details.titleAsPath), + ); + } else { + route = Routes.projectDetails( + titleAsPath: _appState.nextProject(_details.titleAsPath), + ); + } + + context.go(route); + } + + /// The callback for selecting a media item. + void onMediaItemSelected(int index) { + _currentMediaIndex = index; + notifyListeners(); + } +} diff --git a/lib/pages/details/details.model.dart b/lib/pages/details/details.model.dart new file mode 100644 index 0000000..14eafc3 --- /dev/null +++ b/lib/pages/details/details.model.dart @@ -0,0 +1,50 @@ +import '../../models/media_item.dart'; + +class Details { + Details({ + required this.appBarTitle, + required this.title, + required this.titleAsPath, + required this.subtitle, + required this.description, + required this.mediaItems, + required this.tags, + required this.externalLinks, + required this.logoPath, + required this.startDate, + required this.endDate, + }); + + /// The title of the app bar. + final String appBarTitle; + + /// The path to the logo image to display. + final String? logoPath; + + /// The title of the project/experience. + final String title; + + /// The title as a path. + final String titleAsPath; + + /// The subtitle of the project/experience. + final String subtitle; + + /// The start date of the project/experience. + final String? startDate; + + /// The end date of the project/experience. + final String? endDate; + + /// The description of the project/experience. + final String description; + + /// The list of media items to display. + final List mediaItems; + + /// The tags to display. + final List tags; + + /// The external links to display. + final List> externalLinks; +} diff --git a/lib/pages/details/details.screen.dart b/lib/pages/details/details.screen.dart new file mode 100644 index 0000000..bbbb45d --- /dev/null +++ b/lib/pages/details/details.screen.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/app_state.dart'; +import '../../widgets/frosted_container.dart'; +import '../../widgets/generic_app_bar.dart'; +import 'details.controller.dart'; +import 'details.model.dart'; +import 'widgets/app_bar_actions.dart'; +import 'widgets/info_header.dart'; +import 'widgets/media_player/media_player.dart'; +import 'widgets/more_info.dart'; +import 'widgets/tags_menu.dart'; + +/// A screen that displays details about a project/experience. +class DetailsScreen extends StatefulWidget { + const DetailsScreen({ + super.key, + required this.details, + }); + + final Details details; + + @override + DetailsScreenState createState() => DetailsScreenState(); +} + +class DetailsScreenState extends State { + late DetailsController _controller; + + /// Arranges the widgets in a column for portrait orientation. + Widget _portraitView() { + return Consumer( + builder: + (BuildContext context, DetailsController controller, Widget? child) { + return Stack( + children: [ + AnimatedOpacity( + opacity: controller.mediaBrowserOpen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 250), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: SizedBox( + width: MediaQuery.of(context).size.width, + ), + ), + const SizedBox( + height: 64, + ), + Expanded( + child: Scrollbar( + controller: controller.screenScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + clipBehavior: Clip.none, + controller: controller.screenScrollController, + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + top: 8.0, + right: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoHeader( + details: widget.details, + compact: true, + ), + const Divider(height: 32), + SelectableText( + controller.description, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.justify, + ), + if (controller.externalLinks.isNotEmpty) + MoreInfo( + externalLinks: controller.externalLinks), + const Divider(height: 32), + if (controller.tags.isNotEmpty) + SizedBox( + width: + MediaQuery.of(context).size.width * 0.2, + child: TagsMenu(tags: controller.tags), + ), + const SizedBox(height: 100.00), + ], + ), + ), + ), + ), + ), + ], + ), + ), + // A gradient overlay to fade out the text under the media player. + Container( + height: MediaQuery.of(context).size.width * 0.69 + 24, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16.0), + bottomRight: Radius.circular(16.0), + ), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surface.withOpacity(0.95), + Theme.of(context).colorScheme.surface.withOpacity(0.001), + ], + stops: const [0.0, 0.8, 1.0], + ), + ), + ), + // The media player. + SizedBox( + width: MediaQuery.of(context).size.width, + child: MediaPlayer( + key: ValueKey(controller.currentMediaIndex), + currentIndex: controller.currentMediaIndex, + browserAxis: Axis.vertical, + mediaList: controller.mediaItems, + isMediaBrowserVisible: controller.mediaBrowserOpen, + onMediaSelected: controller.onMediaItemSelected, + onMediaBrowserToggle: controller.toggleMediaBrowser, + ), + ), + ], + ); + }, + ); + } + + /// Arranges the widgets in a row for landscape orientation. + Widget _landscapeView() { + return Consumer( + builder: + (BuildContext context, DetailsController controller, Widget? child) { + return Scrollbar( + controller: controller.screenScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: controller.screenScrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: MediaPlayer( + key: ValueKey(controller.currentMediaIndex), + browserAxis: Axis.horizontal, + currentIndex: controller.currentMediaIndex, + mediaList: controller.mediaItems, + isMediaBrowserVisible: controller.mediaBrowserOpen, + onMediaSelected: controller.onMediaItemSelected, + onMediaBrowserToggle: controller.toggleMediaBrowser, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoHeader( + details: widget.details, + compact: false, + ), + const Divider(height: 32), + SelectableText( + controller.description, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (controller.externalLinks.isNotEmpty) + MoreInfo(externalLinks: controller.externalLinks), + const Divider(height: 32), + if (controller.tags.isNotEmpty) + SizedBox( + width: MediaQuery.of(context).size.width * 0.2, + child: TagsMenu(tags: controller.tags), + ), + const SizedBox(height: 100.00), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + void initState() { + super.initState(); + _controller = DetailsController( + details: widget.details, + appState: Provider.of(context, listen: false), + ); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _controller, + builder: (BuildContext context, Widget? child) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return Scaffold( + appBar: GenericAppBar.build( + context: context, + title: _controller.appBarTitle, + actions: [ + DetailsAppBarActions( + onPreviousPressed: () => + _controller.onPreviousPressed(context), + onNextPressed: () => _controller.onNextPressed(context), + ), + ], + ), + body: FrostedContainer( + padding: EdgeInsets.zero, + borderRadiusAmount: 0, + child: orientation == Orientation.portrait + ? _portraitView() + : _landscapeView(), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/details/widgets/app_bar_actions.dart b/lib/pages/details/widgets/app_bar_actions.dart new file mode 100644 index 0000000..c41646e --- /dev/null +++ b/lib/pages/details/widgets/app_bar_actions.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; + +/// Actions to navigate to the previous and next details screens. +class DetailsAppBarActions extends StatelessWidget { + const DetailsAppBarActions({ + super.key, + this.onPreviousPressed, + this.onNextPressed, + }); + + /// Callback when the previous button is pressed. + final Function()? onPreviousPressed; + + /// Callback when the next button is pressed. + final Function()? onNextPressed; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + OutlinedButton( + onPressed: onPreviousPressed, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.chevron_left, + ), + Text( + Strings.prev.toUpperCase(), + ), + const SizedBox(width: 8.0), + ], + ), + ), + const SizedBox(width: 8.0), + OutlinedButton( + onPressed: onNextPressed, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 8.0), + Text( + Strings.next.toUpperCase(), + ), + const Icon( + Icons.chevron_right, + ), + ], + ), + ), + const SizedBox(width: 20.0), + ], + ); + } +} diff --git a/lib/pages/details/widgets/info_header.dart b/lib/pages/details/widgets/info_header.dart new file mode 100644 index 0000000..c388dfa --- /dev/null +++ b/lib/pages/details/widgets/info_header.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../common/theming/color_schemes.dart'; +import '../details.model.dart'; + +/// Builds the header with the logo, title, subtitle, and date range. +class InfoHeader extends StatelessWidget { + const InfoHeader({ + super.key, + required this.details, + required this.compact, + }); + + final Details details; + final bool compact; + + /// The date range text to display. + String get dateRangeText { + String startDateText = ''; + if (details.startDate != null) { + startDateText = details.startDate!; + } + + if (details.endDate == null) { + return '$startDateText - ${Strings.present}'; + } else if (details.endDate != details.startDate) { + return '$startDateText - ${details.endDate}'; + } + + return startDateText; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (details.logoPath != null) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: PortfolioColorSchemes.light.surface, + ), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Image.asset( + details.logoPath!, + fit: BoxFit.contain, + height: 40, + width: 40, + ), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + details.title, + style: compact + ? Theme.of(context).textTheme.titleMedium + : Theme.of(context).textTheme.titleLarge, + ), + if (details.subtitle.isNotEmpty) + Text( + details.subtitle, + style: compact + ? Theme.of(context).textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.normal, + ) + : Theme.of(context).textTheme.titleMedium, + ), + Text( + dateRangeText, + style: compact + ? Theme.of(context).textTheme.bodySmall + : Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/media_browser.dart b/lib/pages/details/widgets/media_player/media_browser.dart similarity index 97% rename from lib/widgets/media_browser.dart rename to lib/pages/details/widgets/media_player/media_browser.dart index 758d169..edf5fdf 100644 --- a/lib/widgets/media_browser.dart +++ b/lib/pages/details/widgets/media_player/media_browser.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:image_network/image_network.dart'; -import '../common/enums.dart'; -import '../models/media_item.dart'; -import 'hover_scale_handler.dart'; +import '../../../../common/enums.dart'; +import '../../../../models/media_item.dart'; +import '../../../../widgets/hover_scale_handler.dart'; /// Displays a gallery of YouTube videos, local images, and local videos. class MediaBrowser extends StatelessWidget { diff --git a/lib/widgets/media_player.dart b/lib/pages/details/widgets/media_player/media_player.dart similarity index 94% rename from lib/widgets/media_player.dart rename to lib/pages/details/widgets/media_player/media_player.dart index d69238f..86b1d58 100644 --- a/lib/widgets/media_player.dart +++ b/lib/pages/details/widgets/media_player/media_player.dart @@ -6,11 +6,11 @@ import 'package:video_player/video_player.dart'; import 'package:youtube_player_iframe/youtube_player_iframe.dart'; import 'package:zoom_pinch_overlay/zoom_pinch_overlay.dart'; -import '../common/color_schemes.dart'; -import '../common/enums.dart'; -import '../models/media_item.dart'; -import 'frosted_container.dart'; -import 'gallery_controls.dart'; +import '../../../../common/enums.dart'; +import '../../../../common/theming/color_schemes.dart'; +import '../../../../models/media_item.dart'; +import '../../../../widgets/frosted_container.dart'; +import '../../../../widgets/gallery_controls.dart'; import 'media_browser.dart'; /// Displays different types of media (images, videos, youTubeVideo videos) @@ -160,14 +160,14 @@ class MediaPlayerState extends State { child: Container( width: double.infinity, decoration: BoxDecoration( - border: - widget.browserAxis == Axis.horizontal && _isMediaBrowserVisible - ? Border( - right: BorderSide( - color: darkColorScheme.surface.withOpacity(0.5), - ), - ) - : null, + border: widget.browserAxis == Axis.horizontal && + _isMediaBrowserVisible + ? Border( + right: BorderSide( + color: PortfolioColorSchemes.dark.surface.withOpacity(0.5), + ), + ) + : null, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -366,7 +366,7 @@ class MediaPlayerState extends State { mainAxisSize: MainAxisSize.min, children: [ ColoredBox( - color: darkColorScheme.surface.withOpacity(0.9), + color: PortfolioColorSchemes.dark.surface.withOpacity(0.9), child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { diff --git a/lib/pages/details/widgets/more_info.dart b/lib/pages/details/widgets/more_info.dart new file mode 100644 index 0000000..fb0581b --- /dev/null +++ b/lib/pages/details/widgets/more_info.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../services/redirect_handler.dart'; + +/// Builds the more info section. +class MoreInfo extends StatelessWidget { + const MoreInfo({ + super.key, + required this.externalLinks, + }); + + final List> externalLinks; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8.0), + const Divider(height: 32), + Text( + Strings.moreInfo, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8.0), + for (final Map link in externalLinks) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${link['title']}:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 8.0), + TextButton( + child: Text( + link['url']!, + ), + onPressed: () { + RedirectHandler.openUrl(link['url']!); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pages/details/widgets/tags_menu.dart b/lib/pages/details/widgets/tags_menu.dart new file mode 100644 index 0000000..c1dd828 --- /dev/null +++ b/lib/pages/details/widgets/tags_menu.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import '../../../services/redirect_handler.dart'; +import '../../../widgets/hover_scale_handler.dart'; + +/// Builds the tags menu. +class TagsMenu extends StatelessWidget { + const TagsMenu({super.key, required this.tags}); + + final List tags; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + Strings.tags, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8.0), + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + for (final String tag in tags) + HoverScaleHandler( + onTap: () { + RedirectHandler.openUrl('${Urls.googleSearchBase}$tag'); + }, + child: Chip( + label: Text(tag), + backgroundColor: + Theme.of(context).colorScheme.surface.withOpacity(0.9)), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pages/error.dart b/lib/pages/error.dart new file mode 100644 index 0000000..478c765 --- /dev/null +++ b/lib/pages/error.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../common/routing/routes.dart'; +import '../common/strings.dart'; + +/// The error screen. +class ErrorScreen extends StatelessWidget { + const ErrorScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + Strings.uhOh, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + Strings.looksLikeSomethingWentWrong, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () { + context.go(Routes.home); + }, + child: Text( + Strings.goToTheHomePage.toUpperCase(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/home.controller.dart b/lib/pages/home/home.controller.dart new file mode 100644 index 0000000..819f654 --- /dev/null +++ b/lib/pages/home/home.controller.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../../models/app_state.dart'; +import '../../models/education.dart'; +import '../../models/professional_experience.dart'; +import '../../models/project.dart'; +import 'widgets/time_line_entry.dart'; + +class HomeController extends ChangeNotifier { + HomeController({required this.appState}); + + /// The application state. + final AppState appState; + + /// Whether the timeline should take up more space. + bool _timelineExpanded = false; + bool get timelineExpanded => _timelineExpanded; + + /// Whether the professional experiences are visible in the timeline. + bool _showProfessionalExperiences = true; + bool get showProfessionalExperiences => _showProfessionalExperiences; + + /// Whether the projects are visible in the timeline. + bool _showProjects = false; + bool get showProjects => _showProjects; + + /// Whether the education is visible in the timeline. + bool _showEducation = true; + bool get showEducation => _showEducation; + + /// Whether the timeline entries have been loaded. + bool get timelineEntriesLoaded => + appState.professionalExperiencesLoaded && + appState.educationLoaded && + appState.projectsLoaded; + + /// Loads the timeline entries. + Future> loadTimelineEntries() async { + final List entries = []; + + // Load the data if it hasn't been loaded yet. + if (!appState.professionalExperiencesLoaded) { + await appState.loadProfessionalExperiences(); + } + if (!appState.educationLoaded) { + await appState.loadEducation(); + } + if (!appState.projectsLoaded) { + await appState.loadProjects(); + } + + // Add the entries to the list. + if (_showProfessionalExperiences) { + entries.addAll(appState.professionalExperiences.map( + (ProfessionalExperience professionalExperience) => + professionalExperience.timelineEntry)); + } + if (_showEducation) { + entries.addAll(appState.education + .map((Education education) => education.timelineEntry)); + } + if (_showProjects) { + entries.addAll( + appState.projects.map((Project project) => project.timelineEntry)); + } + + // Sort the entries by date. + entries.sort((TimelineEntry a, TimelineEntry b) { + return b.startDate.compareTo(a.startDate); + }); + + return entries; + } + + /// A function that toggles the timeline's expansion. + void toggleTimelineExpanded() { + _timelineExpanded = !_timelineExpanded; + notifyListeners(); + } + + /// Toggles the visibility of the professional experiences. + void toggleProfessionalExperienceVisibility() { + _showProfessionalExperiences = !_showProfessionalExperiences; + notifyListeners(); + } + + /// Toggles the visibility of the projects. + void toggleProjectsVisibility() { + _showProjects = !_showProjects; + notifyListeners(); + } + + /// Toggles the visibility of the education. + void toggleEducationVisibility() { + _showEducation = !_showEducation; + notifyListeners(); + } +} diff --git a/lib/pages/home/home.screen.dart b/lib/pages/home/home.screen.dart new file mode 100644 index 0000000..3e03124 --- /dev/null +++ b/lib/pages/home/home.screen.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/app_state.dart'; +import 'home.controller.dart'; +import 'widgets/action_menu.dart'; +import 'widgets/app_bar.dart'; +import 'widgets/drawer.dart'; +import 'widgets/header.dart'; +import 'widgets/professional_vs_personal_menu.dart'; +import 'widgets/timeline.dart'; + +/// A screen that displays the home page. +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + /// The controller for the home screen. + late final HomeController _controller; + + /// Arranges the widgets in a column for portrait orientation. + Widget _portraitView() { + final double collapsedHeight = MediaQuery.of(context).size.height * 0.001; + final double expandedHeight = MediaQuery.of(context).size.height * 0.26; + + return Consumer( + builder: + (BuildContext context, HomeController controller, Widget? child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Header(compact: true), + AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: controller.timelineExpanded + ? collapsedHeight + : expandedHeight, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: controller.timelineExpanded ? 0 : 1, + child: const Column( + children: [ + SizedBox(height: 8, width: 8), + Expanded( + child: ProfessionalVsPersonalMenu(), + ), + ], + ), + )), + const SizedBox(height: 8, width: 8), + Expanded( + child: Timeline( + timelineExpanded: controller.timelineExpanded, + onToggleExpanded: controller.toggleTimelineExpanded, + ), + ), + ], + ); + }, + ); + } + + /// Arranges the widgets in a row for landscape orientation. + Widget _landscapeView() { + final double collapsedWidth = MediaQuery.of(context).size.width * 0.001; + final double expandedWidth = MediaQuery.of(context).size.width * 0.5; + + return Consumer( + builder: + (BuildContext context, HomeController controller, Widget? child) { + return Column( + children: [ + const Header(compact: false), + const SizedBox(height: 8), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: Timeline( + timelineExpanded: controller.timelineExpanded, + onToggleExpanded: controller.toggleTimelineExpanded, + ), + ), + if (!controller.timelineExpanded) const SizedBox(width: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 250), + transformAlignment: Alignment.centerRight, + width: controller.timelineExpanded + ? collapsedWidth + : expandedWidth, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: controller.timelineExpanded ? 0.01 : 1, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ProfessionalVsPersonalMenu(), + ), + SizedBox(height: 8), + Expanded(child: ActionMenu(compact: false)), + ], + ), + ), + ), + ], + ), + ), + ], + ); + }, + ); + } + + @override + void initState() { + super.initState(); + + _controller = HomeController( + appState: Provider.of(context, listen: false), + ); + } + + @override + Widget build(BuildContext context) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return Scaffold( + appBar: HomeAppBar.build( + context: context, + compact: orientation == Orientation.portrait, + ), + drawer: + orientation == Orientation.landscape ? null : const HomeDrawer(), + body: ChangeNotifierProvider.value( + value: _controller, + builder: (BuildContext context, Widget? child) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: orientation == Orientation.portrait + ? _portraitView() + : _landscapeView(), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/pages/home/widgets/action_menu.dart b/lib/pages/home/widgets/action_menu.dart new file mode 100644 index 0000000..22da676 --- /dev/null +++ b/lib/pages/home/widgets/action_menu.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import '../../../services/redirect_handler.dart'; +import '../../../widgets/frosted_container.dart'; +import 'custom_icon_button.dart'; + +/// A menu that provides various action buttons. +class ActionMenu extends StatelessWidget { + const ActionMenu({ + super.key, + required this.compact, + }); + + /// Whether the menu should be compact. + final bool compact; + + Widget _icon({ + required BuildContext context, + required IconData iconData, + }) { + return Icon( + iconData, + size: 46, + color: Theme.of(context).colorScheme.onSurface, + ); + } + + @override + Widget build(BuildContext context) { + return FrostedContainer( + borderRadiusAmount: compact ? 0 : 16.0, + child: GridView.custom( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: compact ? 2 : 4, + childAspectRatio: 0.9, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + childrenDelegate: SliverChildListDelegate( + [ + FrostedActionButton( + icon: _icon( + context: context, + iconData: Icons.quick_contacts_mail_rounded, + ), + title: Strings.contactMe, + onTap: () => RedirectHandler.openUrl(Urls.contactEmail), + ), + FrostedActionButton( + icon: _icon( + context: context, + iconData: Icons.bug_report_rounded, + ), + title: Strings.reportAnIssue, + onTap: () => RedirectHandler.openUrl(Urls.projectIssues), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/custom_app_bars.dart b/lib/pages/home/widgets/app_bar.dart similarity index 51% rename from lib/widgets/custom_app_bars.dart rename to lib/pages/home/widgets/app_bar.dart index d5d52b7..55105d8 100644 --- a/lib/widgets/custom_app_bars.dart +++ b/lib/pages/home/widgets/app_bar.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; -import '../common/strings.dart'; -import '../services/redirect_handler.dart'; +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import '../../../services/redirect_handler.dart'; +import 'powered_by_flutter_button.dart'; import 'theme_mode_button.dart'; -/// A collection of custom app bars. -class CustomAppBars { - CustomAppBars._(); +/// The app bar for the home page. +class HomeAppBar { + HomeAppBar._(); - /// The app bar to display on the home page. - static PreferredSizeWidget homeAppBar({ + /// The app bar for the home page. + static PreferredSizeWidget build({ required BuildContext context, - required Widget poweredByFlutterButton, - required String lastUpdated, - bool compact = false, + required bool compact, }) { return AppBar( title: Row( @@ -22,7 +22,7 @@ class CustomAppBars { padding: EdgeInsets.only(right: 8.0), child: Text(Strings.appName), ), - if (!compact) poweredByFlutterButton, + if (!compact) const PoweredByFlutterButton(), ], ), scrolledUnderElevation: 0, @@ -30,7 +30,7 @@ class CustomAppBars { actions: [ if (!compact) Text( - lastUpdated, + Strings.lastUpdated, style: Theme.of(context).textTheme.labelSmall!.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), @@ -39,7 +39,7 @@ class CustomAppBars { const SizedBox(width: 12), if (!compact) IconButton( - onPressed: () => RedirectHandler.openUrl(Strings.sourceCodeUrl), + onPressed: () => RedirectHandler.openUrl(Urls.projectSourceCode), icon: const Icon(Icons.code), tooltip: Strings.viewSourceCode, ), @@ -47,19 +47,4 @@ class CustomAppBars { ], ); } - - /// A generic app bar used throughout the app. - static PreferredSizeWidget genericAppBar({ - required BuildContext context, - required String title, - List? actions, - }) { - return AppBar( - title: Text(title), - titleSpacing: 0, - centerTitle: false, - scrolledUnderElevation: 0, - actions: actions, - ); - } } diff --git a/lib/pages/home/widgets/custom_icon_button.dart b/lib/pages/home/widgets/custom_icon_button.dart new file mode 100644 index 0000000..78dd33b --- /dev/null +++ b/lib/pages/home/widgets/custom_icon_button.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../../widgets/frosted_container.dart'; +import '../../../widgets/hover_scale_handler.dart'; + +/// A button that displays an icon and a title on a frosted glass background. +/// The button can be tapped to perform an action. +class FrostedActionButton extends StatelessWidget { + const FrostedActionButton({ + super.key, + required this.icon, + required this.title, + required this.onTap, + }); + + /// The icon to display in the button. + final Widget icon; + + /// The title to display in the button. + final String title; + + /// The function to call when the button is tapped. + final Function()? onTap; + + @override + Widget build(BuildContext context) { + return HoverScaleHandler( + onTap: onTap, + child: FrostedContainer( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 4), + icon, + const SizedBox(height: 8), + Text( + title.toUpperCase(), + style: Theme.of(context).textTheme.titleSmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/drawer.dart b/lib/pages/home/widgets/drawer.dart new file mode 100644 index 0000000..0154750 --- /dev/null +++ b/lib/pages/home/widgets/drawer.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import '../../../services/redirect_handler.dart'; +import 'action_menu.dart'; +import 'powered_by_flutter_button.dart'; +import 'social_media_buttons.dart'; + +/// A drawer that provides various action buttons. +class HomeDrawer extends StatelessWidget { + const HomeDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return Drawer( + backgroundColor: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + DrawerHeader( + margin: EdgeInsets.zero, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () => + RedirectHandler.openUrl(Urls.projectSourceCode), + icon: const Icon(Icons.code), + tooltip: Strings.viewSourceCode, + ), + const SocialMediaButtons(), + ], + ), + const PoweredByFlutterButton(), + Text( + Strings.lastUpdated, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.5), + ), + ), + ], + ), + ), + const Flexible( + child: ActionMenu(compact: true), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/widgets/header.dart b/lib/pages/home/widgets/header.dart new file mode 100644 index 0000000..200abe8 --- /dev/null +++ b/lib/pages/home/widgets/header.dart @@ -0,0 +1,132 @@ +import 'package:animated_text_kit/animated_text_kit.dart'; +import 'package:flutter/material.dart'; + +import '../../../common/asset_paths.dart'; +import '../../../common/strings.dart'; +import '../../../widgets/frosted_container.dart'; +import 'social_media_buttons.dart'; + +/// A header banner with a profile photo, name, subtitle, and social media +/// buttons. +class Header extends StatelessWidget { + const Header({ + super.key, + required this.compact, + }); + + /// Whether the header should be compact. + final bool compact; + + /// My profile picture. + CircleAvatar _profilePicture(BuildContext context) { + return CircleAvatar( + radius: compact ? 38 : 55, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundImage: const AssetImage(AssetPaths.profilePhoto), + ); + } + + /// The title of the header. + Row _title(BuildContext context) { + return Row( + crossAxisAlignment: + compact ? CrossAxisAlignment.center : CrossAxisAlignment.end, + children: [ + Text( + Strings.name, + style: compact + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.headlineLarge, + ), + const Spacer(), + if (!compact) const SocialMediaButtons(), + const SizedBox(width: 8.0), + if (compact) _location(context), + ], + ); + } + + /// The subtitle of the header. + Row _subtitle(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DefaultTextStyle( + style: compact + ? Theme.of(context).textTheme.labelSmall! + : Theme.of(context).textTheme.titleMedium!, + textAlign: TextAlign.left, + child: AnimatedTextKit( + repeatForever: true, + pause: const Duration(milliseconds: 250), + animatedTexts: [ + RotateAnimatedText( + Strings.subtitle, + duration: const Duration(seconds: 7), + ), + RotateAnimatedText( + Strings.motto, + duration: const Duration(seconds: 7), + ), + ], + ), + ), + SizedBox( + height: compact ? 40 : 55, + ), + if (!compact) _location(context), + ], + ); + } + + /// My current location. + Widget _location(BuildContext context) { + return Row( + children: [ + Icon( + Icons.pin_drop, + size: compact ? 14 : 18, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(width: 4.0), + Text( + Strings.currentLocation, + style: compact + ? Theme.of(context).textTheme.labelSmall + : Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(width: 18.0), + ], + ); + } + + @override + Widget build(BuildContext context) { + return FrostedContainer( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Row( + children: [ + _profilePicture(context), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: _title(context), + ), + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: _subtitle(context), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/widgets/powered_by_flutter_button.dart b/lib/pages/home/widgets/powered_by_flutter_button.dart new file mode 100644 index 0000000..47dcc35 --- /dev/null +++ b/lib/pages/home/widgets/powered_by_flutter_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import '../../../services/redirect_handler.dart'; + +/// A button that redirects to the Flutter website. +class PoweredByFlutterButton extends StatelessWidget { + const PoweredByFlutterButton({super.key}); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () => RedirectHandler.openUrl(Urls.flutter), + child: Text( + Strings.poweredByFlutter.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/professional_vs_personal_menu.dart b/lib/pages/home/widgets/professional_vs_personal_menu.dart new file mode 100644 index 0000000..a6f51b7 --- /dev/null +++ b/lib/pages/home/widgets/professional_vs_personal_menu.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../common/asset_paths.dart'; +import '../../../common/routing/routes.dart'; +import '../../../common/strings.dart'; +import '../../../widgets/floating_thumbnail.dart'; +import '../../../widgets/frosted_container.dart'; + +/// A menu that allows the user to navigate to the professional experience +/// and personal projects screens. +class ProfessionalVsPersonalMenu extends StatelessWidget { + const ProfessionalVsPersonalMenu({super.key}); + + @override + Widget build(BuildContext context) { + return FrostedContainer( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + Strings.explore.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context).hintColor.withOpacity(0.75), + ), + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: FloatingThumbnail( + title: Strings.professional, + image: AssetPaths.professionalExperiencePhoto, + shimmer: true, + onTap: () => context.go(Routes.professionalExperiences), + ), + ), + Column( + children: [ + const Expanded( + child: VerticalDivider( + indent: 8, + endIndent: 8, + ), + ), + Text( + Strings.or.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context).hintColor.withOpacity(0.5), + ), + ), + const Expanded( + child: VerticalDivider( + indent: 8, + endIndent: 8, + ), + ), + ], + ), + Expanded( + child: FloatingThumbnail( + title: Strings.personal, + image: AssetPaths.personalExperiencePhoto, + shimmer: true, + onTap: () => context.go(Routes.personalProjects), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/widgets/social_icon_button.dart b/lib/pages/home/widgets/social_icon_button.dart new file mode 100644 index 0000000..d540f7b --- /dev/null +++ b/lib/pages/home/widgets/social_icon_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../../services/redirect_handler.dart'; + +/// A social icon button widget containing an icon that redirects to the +/// specified URL. +class SocialIconButton extends StatelessWidget { + const SocialIconButton({ + super.key, + required this.title, + required this.socialAssetBasePath, + required this.urlString, + }); + + /// The title of the social media platform. + final String title; + + /// The base path to the social media assets. + final String socialAssetBasePath; + + /// The URL string to redirect to. + final String urlString; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Image.asset( + '$socialAssetBasePath/${title.toLowerCase()}.webp', + height: 24.0, + color: Theme.of(context).colorScheme.onSurface, + ), + tooltip: title, + onPressed: () => RedirectHandler.openUrl(urlString), + ); + } +} diff --git a/lib/pages/home/widgets/social_media_buttons.dart b/lib/pages/home/widgets/social_media_buttons.dart new file mode 100644 index 0000000..f09dcd6 --- /dev/null +++ b/lib/pages/home/widgets/social_media_buttons.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../../common/asset_paths.dart'; +import '../../../common/strings.dart'; +import '../../../common/urls.dart'; +import 'social_icon_button.dart'; + +/// A list of social media buttons that redirect to the respective URLs. +class SocialMediaButtons extends StatelessWidget { + const SocialMediaButtons({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisSize: MainAxisSize.min, + children: [ + SocialIconButton( + title: Strings.github, + socialAssetBasePath: AssetPaths.socialIconsBase, + urlString: Urls.github, + ), + SocialIconButton( + title: Strings.linkedin, + socialAssetBasePath: AssetPaths.socialIconsBase, + urlString: Urls.linkedin, + ), + SocialIconButton( + title: Strings.thingiverse, + socialAssetBasePath: AssetPaths.socialIconsBase, + urlString: Urls.thingiverse, + ), + ], + ); + } +} diff --git a/lib/pages/home/widgets/theme_mode_button.dart b/lib/pages/home/widgets/theme_mode_button.dart new file mode 100644 index 0000000..f0fe67e --- /dev/null +++ b/lib/pages/home/widgets/theme_mode_button.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../common/strings.dart'; +import '../../../common/theming/theme_notifier.dart'; + +/// An icon button that toggles the theme mode. +class ThemeModeButton extends StatelessWidget { + const ThemeModeButton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Consumer( + builder: (BuildContext context, ThemeNotifier themeNotifier, + Widget? child) => + IconButton( + tooltip: Strings.toggleBrightness, + icon: Icon( + themeNotifier.isDarkMode ? Icons.light_mode : Icons.dark_mode, + ), + onPressed: themeNotifier.toggleThemeMode, + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/time_line_entry.dart b/lib/pages/home/widgets/time_line_entry.dart new file mode 100644 index 0000000..ca26573 --- /dev/null +++ b/lib/pages/home/widgets/time_line_entry.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../common/theming/color_schemes.dart'; +import '../../../services/redirect_handler.dart'; + +/// A timeline entry widget containing an icon, title, description, and time +/// frame. The title is a clickable link that redirects to the specified URL. +class TimelineEntry extends StatelessWidget { + const TimelineEntry({ + super.key, + required this.logoPath, + required this.title, + required this.label, + required this.description, + required this.startDate, + required this.finalDateString, + required this.urlString, + required this.labelColor, + this.coverImage = true, + }); + + /// The path to the logo. + final String logoPath; + + /// The title of the entry. + final String title; + + /// The label of the entry. + final String label; + + /// The color of the label. + final Color labelColor; + + /// The description of the entry. + final String description; + + /// The start date of the entry. + final DateTime startDate; + + /// The final date of the entry. + final String finalDateString; + + /// The URL string to redirect to. + final String urlString; + + /// Whether the image should cover the entire space. + final bool coverImage; + + /// A string representation of the start date. + String get startDateString => + DateFormat('MMM yyyy').format(startDate).toUpperCase(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: ListTile( + contentPadding: EdgeInsets.zero, + horizontalTitleGap: 8, + dense: false, + visualDensity: VisualDensity.standard, + leading: ClipOval( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: PortfolioColorSchemes.light.surface, + ), + child: Padding( + padding: coverImage ? EdgeInsets.zero : const EdgeInsets.all(4.0), + child: Image.asset( + logoPath, + fit: coverImage ? BoxFit.cover : BoxFit.contain, + height: coverImage ? 50 : 38, + width: coverImage ? 50 : 38, + ), + ), + ), + ), + title: Wrap( + children: [ + TextButton( + onPressed: () => RedirectHandler.openUrl(urlString), + child: Text(title.toUpperCase()), + ), + const SizedBox(width: 2), + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Container( + decoration: BoxDecoration( + color: labelColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: labelColor.withOpacity(0.8), + ), + ), + ), + ) + ], + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$startDateString - $finalDateString', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/timeline.dart b/lib/pages/home/widgets/timeline.dart new file mode 100644 index 0000000..95acdca --- /dev/null +++ b/lib/pages/home/widgets/timeline.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../common/strings.dart'; +import '../../../widgets/custom_filter_chip.dart'; +import '../../../widgets/frosted_container.dart'; +import '../home.controller.dart'; +import 'time_line_entry.dart'; + +/// A list of notable events, projects, and experiences. +class Timeline extends StatelessWidget { + const Timeline({ + super.key, + required this.timelineExpanded, + required this.onToggleExpanded, + }); + + /// Whether the timeline should take up more space. + final bool timelineExpanded; + + /// A function that toggles the timeline's expansion. + final Function() onToggleExpanded; + + /// A controller for the scroll view. + static final ScrollController _scrollController = ScrollController(); + + Widget _filterChipMenu({ + required BuildContext context, + required HomeController homeController, + }) { + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + 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), + CustomFilterChip( + label: Strings.education, + selected: homeController.showEducation, + onSelected: (bool value) { + homeController.toggleEducationVisibility(); + }, + ), + CustomFilterChip( + label: Strings.professional, + selected: homeController.showProfessionalExperiences, + onSelected: (bool value) { + homeController.toggleProfessionalExperienceVisibility(); + }, + ), + CustomFilterChip( + label: Strings.projects, + selected: homeController.showProjects, + onSelected: (bool value) { + homeController.toggleProjectsVisibility(); + }, + ), + const SizedBox(width: 52), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: + (BuildContext context, HomeController homeController, Widget? child) { + return FrostedContainer( + padding: EdgeInsets.zero, + child: FutureBuilder>( + future: homeController.loadTimelineEntries(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + List entries = []; + if (snapshot.connectionState == ConnectionState.done) { + entries = snapshot.data as List; + } + + return AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: homeController.timelineEntriesLoaded ? 1 : 0, + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: ListView.custom( + controller: _scrollController, + padding: const EdgeInsets.all(16), + childrenDelegate: SliverChildListDelegate( + [ + Stack( + children: [ + _filterChipMenu( + context: context, + homeController: homeController, + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: timelineExpanded + ? Strings.collapse + : Strings.expand, + visualDensity: VisualDensity.compact, + color: Theme.of(context).colorScheme.onSurface, + onPressed: onToggleExpanded, + icon: Icon( + timelineExpanded + ? Icons.zoom_in_map + : Icons.zoom_out_map, + color: Theme.of(context) + .hintColor + .withOpacity(0.5), + ), + ), + ) + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + '${entries.length} ${Strings.entries}', + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.5), + ), + ), + ), + if (entries.isEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + Strings.selectACategoryToViewTheEntries, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.5), + ), + ), + ), + ...entries.map( + (TimelineEntry entry) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + entry, + const Divider( + indent: 8, + endIndent: 8, + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/screens/loading.dart b/lib/pages/loading.dart similarity index 69% rename from lib/screens/loading.dart rename to lib/pages/loading.dart index 9501244..8a7cfca 100644 --- a/lib/screens/loading.dart +++ b/lib/pages/loading.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../common/strings.dart'; +import '../common/routing/routes.dart'; /// A screen that displays a loading indicator while loading data. class LoadingScreen extends StatefulWidget { const LoadingScreen({ super.key, - required this.fromRoute, - required this.nextRoute, + required this.from, + required this.to, required this.loadFunction, }); - final String fromRoute; + final String from; /// The route to navigate to after loading. - final String nextRoute; + final String to; /// The function to load data. final Future Function(BuildContext context) loadFunction; @@ -25,19 +25,23 @@ class LoadingScreen extends StatefulWidget { } class LoadingScreenState extends State { - /// Loads the data and navigates to the next route when done. - Future _loadDataAndNavigate() async { + /// Loads the data and then redirects to the next screen. + Future _loadThenRedirect() async { await widget.loadFunction(context); if (context.mounted) { context.go( - '${Strings.homeRoute}/${widget.fromRoute}/${Strings.detailsSubRoute}/${widget.nextRoute}'); + Routes.loadingRedirect( + from: widget.from, + to: widget.to, + ), + ); } } @override void initState() { super.initState(); - _loadDataAndNavigate(); + _loadThenRedirect(); } @override diff --git a/lib/pages/personal_projects/projects_menu.screen.dart b/lib/pages/personal_projects/projects_menu.screen.dart new file mode 100644 index 0000000..45bf698 --- /dev/null +++ b/lib/pages/personal_projects/projects_menu.screen.dart @@ -0,0 +1,32 @@ +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/projects_menu.dart b/lib/pages/personal_projects/widgets/projects_menu.dart new file mode 100644 index 0000000..a4faa5d --- /dev/null +++ b/lib/pages/personal_projects/widgets/projects_menu.dart @@ -0,0 +1,104 @@ +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 'thumbnail_item.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) { + return FrostedContainer( + padding: EdgeInsets.zero, + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + padding: orientation == Orientation.portrait + ? const EdgeInsets.only(right: 8) + : EdgeInsets.zero, + controller: _scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FutureBuilder( + future: appState.loadProjects(), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.connectionState == + ConnectionState.done) { + return _gridView( + appState: appState, + orientation: orientation, + ); + } else { + return const Spinner(); + } + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/personal_projects/widgets/thumbnail_item.dart b/lib/pages/personal_projects/widgets/thumbnail_item.dart new file mode 100644 index 0000000..2ba4d52 --- /dev/null +++ b/lib/pages/personal_projects/widgets/thumbnail_item.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import '../../../widgets/hover_scale_handler.dart'; + +/// A project thumbnail widget containing an image, title, and subtitle. Can be +/// tapped to perform an action. +class ProjectThumbnail extends StatefulWidget { + const ProjectThumbnail({ + super.key, + required this.title, + required this.subtitle, + required this.imagePath, + required this.onTap, + this.compact = false, + }); + + /// 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; + + @override + State createState() => _ProjectThumbnailState(); +} + +class _ProjectThumbnailState extends State { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: HoverScaleHandler( + onTap: widget.onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1.5, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + widget.imagePath, + fit: BoxFit.cover, + errorBuilder: (BuildContext context, Object error, + StackTrace? stackTrace) { + return Icon( + Icons.broken_image_outlined, + size: 50, + color: Theme.of(context) + .colorScheme + .surfaceVariant + .withOpacity(0.5), + ); + }, + ), + ), + ), + const SizedBox(height: 8.0), + Text( + widget.title, + style: widget.compact + ? Theme.of(context).textTheme.bodyMedium + : 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, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/professional_experiences/prof_exp_menu.screen.dart b/lib/pages/professional_experiences/prof_exp_menu.screen.dart new file mode 100644 index 0000000..9123db4 --- /dev/null +++ b/lib/pages/professional_experiences/prof_exp_menu.screen.dart @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..5813506 --- /dev/null +++ b/lib/pages/professional_experiences/widgets/prof_exp_menu.dart @@ -0,0 +1,85 @@ +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 Consumer( + builder: (BuildContext context, AppState appState, Widget? child) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return FrostedContainer( + padding: EdgeInsets.zero, + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + padding: orientation == Orientation.portrait + ? const EdgeInsets.only(right: 8) + : EdgeInsets.zero, + controller: _scrollController, + child: FutureBuilder( + future: appState.loadProfessionalExperiences(), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return _gridView(orientation, appState); + } else { + return const Spinner(); + } + }, + ), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart deleted file mode 100644 index e57adc3..0000000 --- a/lib/routes/app_router.dart +++ /dev/null @@ -1,213 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../common/strings.dart'; -import '../models/app_state.dart'; -import '../models/professional_experience.dart'; -import '../models/project.dart'; -import '../screens/details.dart'; -import '../screens/home.dart'; -import '../screens/loading.dart'; -import '../screens/personal.dart'; -import '../screens/professional.dart'; - -class AppRouter { - AppRouter( - this.appState, - ); - - AppState appState; - - /// Returns a [GoRouter] instance with the app's routing configuration. - GoRouter configureRouter() { - return GoRouter( - navigatorKey: appState.navigatorKey, - initialLocation: appState.currentRoute, - refreshListenable: appState, - routes: [ - GoRoute( - path: '${Strings.loadingRoute}/:from/:goto', - builder: (BuildContext context, GoRouterState state) => LoadingScreen( - fromRoute: state.pathParameters['from']!, - nextRoute: state.pathParameters['goto']!, - loadFunction: (BuildContext context) async { - if (state.pathParameters['from'] == - Strings.professionalSubRoute && - !appState.professionalExperiencesLoaded) { - await appState.loadProfessionalExperiences(); - } - if (state.pathParameters['from'] == Strings.personalSubRoute && - !appState.projectsLoaded && - context.mounted) { - await appState.loadProjects(); - } - }, - ), - ), - GoRoute( - path: Strings.homeRoute, - pageBuilder: (BuildContext context, GoRouterState state) => - CustomTransitionPage( - child: const HomeScreen(), - transitionsBuilder: (BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child) => - FadeTransition(opacity: animation, child: child), - ), - routes: [ - GoRoute( - path: Strings.professionalSubRoute, - builder: (BuildContext context, GoRouterState state) => - const ProfessionalScreen(), - routes: [ - GoRoute( - path: '${Strings.detailsSubRoute}/:title', - builder: (BuildContext context, GoRouterState state) { - final ProfessionalExperience professionalExperience = - appState.getProfessionalExperienceByTitlePath( - state.pathParameters['title']!); - return DetailsScreen( - appBarTitle: Strings.professionalExperiences, - subtitle: professionalExperience.role, - logoPath: professionalExperience.logoPath, - title: professionalExperience.company, - description: professionalExperience.description, - externalLinks: professionalExperience.externalLinks, - startDate: professionalExperience.startDateString, - finalDate: professionalExperience.finalDateString, - mediaItems: professionalExperience.mediaItems, - tags: const [], - onPreviousPressed: () { - context.go( - '${Strings.homeRoute}/${Strings.professionalSubRoute}/${Strings.detailsSubRoute}/' - '${appState.previousProfessionalExperience(professionalExperience.titleAsPath)}', - ); - }, - onNextPressed: () { - context.go( - '${Strings.homeRoute}/${Strings.professionalSubRoute}/${Strings.detailsSubRoute}/' - '${appState.nextProfessionalExperience(professionalExperience.titleAsPath)}', - ); - }, - ); - }), - ], - ), - GoRoute( - path: Strings.personalSubRoute, - builder: (BuildContext context, GoRouterState state) => - const PersonalScreen(), - routes: [ - GoRoute( - path: '${Strings.detailsSubRoute}/:title', - builder: (BuildContext context, GoRouterState state) { - final Project project = appState - .getProjectByTitlePath(state.pathParameters['title']!); - return DetailsScreen( - appBarTitle: Strings.personalProjects, - title: project.title, - subtitle: project.subtitle, - description: project.description, - externalLinks: project.externalLinks, - startDate: project.startDateString, - finalDate: project.finalDateString, - mediaItems: project.mediaItems, - tags: project.tags, - onPreviousPressed: () { - context.go( - '${Strings.homeRoute}/${Strings.personalSubRoute}/${Strings.detailsSubRoute}/' - '${appState.previousProject(project.titleAsPath)}', - ); - }, - onNextPressed: () { - context.go( - '${Strings.homeRoute}/${Strings.personalSubRoute}/${Strings.detailsSubRoute}/' - '${appState.nextProject(project.titleAsPath)}', - ); - }, - ); - }, - ), - ], - ), - ], - ), - ], - redirect: (BuildContext context, GoRouterState state) { - final String location = state.uri.toString(); - final String goto = location.split('/').last; - - final bool isAtProfessionalDetails = - location.contains(Strings.professionalSubRoute) && - location.contains(Strings.detailsSubRoute); - // Redirect to loading screen if the user is trying to access the - // professional experience details page and the professional experiences - // haven't been loaded yet. - if (isAtProfessionalDetails && - !appState.professionalExperiencesLoaded) { - return '${Strings.loadingRoute}/${Strings.professionalSubRoute}/$goto'; - } - - // Redirect to professional experience menu if the professional - // experience route doesn't exist. - if (isAtProfessionalDetails && - appState.professionalExperiencesLoaded && - !appState.isValidProfessionalExperience(goto)) { - return '${Strings.homeRoute}/${Strings.professionalSubRoute}'; - } - - final bool isAtPersonalDetails = - location.contains(Strings.personalSubRoute) && - location.contains(Strings.detailsSubRoute); - // Redirect to loading screen if the user is trying to access the - // personal project details page and the projects haven't been - // loaded yet. - if (isAtPersonalDetails && !appState.projectsLoaded) { - return '${Strings.loadingRoute}/${Strings.personalSubRoute}/$goto'; - } - - // Redirect to personal projects menu if the personal projects route - // doesn't exist. - // print(goto); - if (location.contains(Strings.personalSubRoute) && - !appState.isValidProject(goto) && - appState.projectsLoaded) { - return '${Strings.homeRoute}/${Strings.personalSubRoute}'; - } - - return null; - }, - errorBuilder: (BuildContext context, GoRouterState state) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - Strings.uhOh, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - Strings.looksLikeSomethingWentWrong, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () { - context.go(Strings.homeRoute); - }, - child: Text( - Strings.goToTheHomePage.toUpperCase(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ) - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/screens/details.dart b/lib/screens/details.dart deleted file mode 100644 index 74a77a8..0000000 --- a/lib/screens/details.dart +++ /dev/null @@ -1,460 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../common/color_schemes.dart'; -import '../common/strings.dart'; -import '../models/app_state.dart'; -import '../models/media_item.dart'; -import '../services/redirect_handler.dart'; -import '../widgets/custom_app_bars.dart'; -import '../widgets/frosted_container.dart'; -import '../widgets/hover_scale_handler.dart'; -import '../widgets/media_player.dart'; - -/// A screen that displays details about a project/experience. -class DetailsScreen extends StatefulWidget { - const DetailsScreen({ - super.key, - required this.title, - required this.subtitle, - required this.appBarTitle, - required this.description, - required this.startDate, - required this.finalDate, - required this.tags, - required this.externalLinks, - required this.onPreviousPressed, - required this.onNextPressed, - required this.mediaItems, - this.logoPath, - }); - - /// The title of the app bar. - final String appBarTitle; - - /// The path to the logo image to display. - final String? logoPath; - - /// The title of the project/experience. - final String title; - - /// The subtitle of the project/experience. - final String subtitle; - - /// The start date of the project/experience. - final String? startDate; - - /// The final date of the project/experience. - final String? finalDate; - - /// The description of the project/experience. - final String description; - - /// The list of media items to display. - final List mediaItems; - - /// The tags to display. - final List tags; - - /// The external links to display. - final List> externalLinks; - - /// The callback to call when the app bar's previous button is pressed. - final Function()? onPreviousPressed; - - /// The callback to call when the app bar's next button is pressed. - final Function()? onNextPressed; - - @override - DetailsScreenState createState() => DetailsScreenState(); -} - -class DetailsScreenState extends State { - /// The controller for the scroll view. - final ScrollController _scrollController = ScrollController(); - - /// The list of media items. - List get mediaItems => widget.mediaItems; - - /// The index of the current media item. - int _currentMediaIndex = 0; - - /// The date range text to display. - String get dateRangeText { - String dateRangeText = ''; - if (widget.startDate != null) { - dateRangeText += '${widget.startDate} - '; - } - if (widget.finalDate != null) { - dateRangeText += widget.finalDate!; - } else { - dateRangeText += Strings.present; - } - return dateRangeText; - } - - /// Builds the tag chips. - Widget _tagChips(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - Strings.tags, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8.0), - Wrap( - spacing: 8.0, - runSpacing: 8.0, - children: [ - for (final String tag in widget.tags) - HoverScaleHandler( - onTap: () { - RedirectHandler.openUrl( - 'https://www.google.com/search?q=$tag'); - }, - child: Chip( - label: Text(tag), - backgroundColor: - Theme.of(context).colorScheme.surface.withOpacity(0.9)), - ), - ], - ), - ], - ); - } - - /// Builds the more info section. - Widget _moreInfo(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 8.0), - const Divider(height: 32), - Text( - Strings.moreInfo, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8.0), - for (final Map link in widget.externalLinks) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - '${link['title']}:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(width: 8.0), - TextButton( - child: Text( - link['url']!, - ), - onPressed: () { - RedirectHandler.openUrl(link['url']!); - }, - ), - ], - ), - ), - ], - ); - } - - /// Builds the header with the logo, title, subtitle, and date range. - Widget _infoHeader({required BuildContext context, required bool compact}) { - return Row( - children: [ - if (widget.logoPath != null) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: lightColorScheme.surface, - ), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Image.asset( - widget.logoPath!, - fit: BoxFit.contain, - height: 40, - width: 40, - ), - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: compact - ? Theme.of(context).textTheme.titleMedium - : Theme.of(context).textTheme.titleLarge, - ), - if (widget.subtitle.isNotEmpty) - Text( - widget.subtitle, - style: compact - ? Theme.of(context).textTheme.titleSmall!.copyWith( - fontWeight: FontWeight.normal, - ) - : Theme.of(context).textTheme.titleMedium, - ), - Text( - dateRangeText, - style: compact - ? Theme.of(context).textTheme.bodySmall - : Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ], - ); - } - - /// Arranges the widgets in a column for portrait orientation. - Widget _portraitView( - {required BuildContext context, required AppState appState}) { - return Stack( - children: [ - AnimatedOpacity( - opacity: appState.mediaBrowserVisible ? 0.0 : 1.0, - duration: const Duration(milliseconds: 250), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: SizedBox( - width: MediaQuery.of(context).size.width, - ), - ), - const SizedBox( - height: 64, - ), - Expanded( - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - clipBehavior: Clip.none, - controller: _scrollController, - child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - top: 8.0, - right: 16.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _infoHeader(context: context, compact: true), - const Divider(height: 32), - SelectableText( - widget.description, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.justify, - ), - if (widget.externalLinks.isNotEmpty) - _moreInfo(context), - const Divider(height: 32), - if (widget.tags.isNotEmpty) - SizedBox( - width: MediaQuery.of(context).size.width * 0.2, - child: _tagChips(context), - ), - const SizedBox(height: 100.00), - ], - ), - ), - ), - ), - ), - ], - ), - ), - // A gradient overlay to fade out the text under the media player. - Container( - height: MediaQuery.of(context).size.width * 0.69 + 24, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(16.0), - bottomRight: Radius.circular(16.0), - ), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surface.withOpacity(0.95), - Theme.of(context).colorScheme.surface.withOpacity(0.001), - ], - stops: const [0.0, 0.8, 1.0], - ), - ), - ), - // The media player. - SizedBox( - width: MediaQuery.of(context).size.width, - child: MediaPlayer( - key: ValueKey(_currentMediaIndex), - currentIndex: _currentMediaIndex, - browserAxis: Axis.vertical, - mediaList: mediaItems, - isMediaBrowserVisible: appState.mediaBrowserVisible, - onMediaSelected: (int index) { - setState(() { - _currentMediaIndex = index; - }); - }, - onMediaBrowserToggle: appState.toggleMediaBrowserVisibility, - ), - ), - ], - ); - } - - /// Arranges the widgets in a row for landscape orientation. - Widget _landscapeView( - {required BuildContext context, required AppState appState}) { - return Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _scrollController, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: MediaPlayer( - key: ValueKey(_currentMediaIndex), - browserAxis: Axis.horizontal, - currentIndex: _currentMediaIndex, - mediaList: mediaItems, - isMediaBrowserVisible: appState.mediaBrowserVisible, - onMediaSelected: (int index) { - setState(() { - _currentMediaIndex = index; - }); - }, - onMediaBrowserToggle: appState.toggleMediaBrowserVisibility, - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _infoHeader(context: context, compact: false), - const Divider(height: 32), - SelectableText( - widget.description, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (widget.externalLinks.isNotEmpty) _moreInfo(context), - const Divider(height: 32), - if (widget.tags.isNotEmpty) - SizedBox( - width: MediaQuery.of(context).size.width * 0.2, - child: _tagChips(context), - ), - const SizedBox(height: 100.00), - ], - ), - ), - ], - ), - ), - ); - } - - /// Actions to navigate to the previous and next details screens. - List _appBarActions() { - return [ - OutlinedButton( - onPressed: () { - setState(() { - _currentMediaIndex = 0; - }); - widget.onPreviousPressed?.call(); - }, - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.chevron_left, - ), - Text( - Strings.prev.toUpperCase(), - ), - const SizedBox(width: 8.0), - ], - ), - ), - const SizedBox(width: 8.0), - OutlinedButton( - onPressed: () { - _currentMediaIndex = 0; - widget.onNextPressed?.call(); - }, - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(width: 8.0), - Text( - Strings.next.toUpperCase(), - ), - const Icon( - Icons.chevron_right, - ), - ], - ), - ), - const SizedBox(width: 20.0), - ]; - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Scaffold( - key: ValueKey(widget.title), - appBar: CustomAppBars.genericAppBar( - context: context, - title: widget.appBarTitle, - actions: _appBarActions(), - ), - body: FrostedContainer( - padding: EdgeInsets.zero, - borderRadiusAmount: 0, - child: orientation == Orientation.portrait - ? _portraitView(context: context, appState: appState) - : _landscapeView(context: context, appState: appState), - ), - ); - }); - }); - } -} diff --git a/lib/screens/home.dart b/lib/screens/home.dart deleted file mode 100644 index cb5db76..0000000 --- a/lib/screens/home.dart +++ /dev/null @@ -1,546 +0,0 @@ -import 'package:animated_text_kit/animated_text_kit.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../common/strings.dart'; -import '../models/app_state.dart'; -import '../services/redirect_handler.dart'; -import '../widgets/custom_app_bars.dart'; -import '../widgets/custom_filter_chip.dart'; -import '../widgets/custom_icon_button.dart'; -import '../widgets/floating_thumbnail.dart'; -import '../widgets/frosted_container.dart'; -import '../widgets/header_banner.dart'; -import '../widgets/social_icon_button.dart'; -import '../widgets/time_line_entry.dart'; - -/// A screen that displays the home page. -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - /// A boolean that determines whether the timeline should take up more space. - bool _timelineExpanded = false; - - /// A button that redirects to the Flutter website. - Widget _poweredByFlutterButton(BuildContext context) { - return TextButton( - onPressed: () async { - if (await canLaunchUrl(Uri.parse(Strings.flutterUrl))) { - launchUrl( - Uri.parse(Strings.flutterUrl), - ); - } - }, - child: Text(Strings.poweredByFlutter.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: - Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - ))); - } - - /// A header banner with a profile photo, name, subtitle, and social media - /// buttons. - Widget _header(BuildContext context, {bool compact = false}) { - Widget location() { - return Row( - children: [ - Icon( - Icons.pin_drop, - size: compact ? 14 : 18, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - ), - const SizedBox(width: 4.0), - Text( - Strings.currentLocation, - style: compact - ? Theme.of(context).textTheme.labelSmall - : Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(width: 18.0), - ], - ); - } - - return HeaderBanner( - leading: CircleAvatar( - radius: compact ? 38 : 55, - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - backgroundImage: AssetImage( - Strings.profilePhotoPath(Theme.of(context).brightness), - ), - ), - title: Row( - crossAxisAlignment: - compact ? CrossAxisAlignment.center : CrossAxisAlignment.end, - children: [ - Text( - Strings.name, - style: compact - ? Theme.of(context).textTheme.titleLarge - : Theme.of(context).textTheme.headlineLarge, - ), - const Spacer(), - if (!compact) _socialMediaButtons(context), - const SizedBox(width: 8.0), - if (compact) location(), - ], - ), - subtitle: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - DefaultTextStyle( - style: compact - ? Theme.of(context).textTheme.labelSmall! - : Theme.of(context).textTheme.titleMedium!, - textAlign: TextAlign.left, - child: AnimatedTextKit( - repeatForever: true, - pause: const Duration(milliseconds: 250), - animatedTexts: [ - RotateAnimatedText( - Strings.subtitle, - duration: const Duration(seconds: 7), - ), - RotateAnimatedText( - Strings.motto, - duration: const Duration(seconds: 7), - ), - ], - ), - ), - SizedBox( - height: compact ? 40 : 55, - ), - if (!compact) location(), - ], - ), - ); - } - - /// A list of social media buttons that redirect to the respective URLs. - Widget _socialMediaButtons(BuildContext context) { - return const Row( - mainAxisSize: MainAxisSize.min, - children: [ - SocialIconButton( - title: Strings.github, - socialAssetBasePath: Strings.socialAssetsBasePath, - urlString: Strings.githubUrl, - ), - SocialIconButton( - title: Strings.linkedin, - socialAssetBasePath: Strings.socialAssetsBasePath, - urlString: Strings.linkedinUrl, - ), - SocialIconButton( - title: Strings.thingiverse, - socialAssetBasePath: Strings.socialAssetsBasePath, - urlString: Strings.thingiverseUrl, - ), - ], - ); - } - - /// A list of notable events, projects, and experiences. - Widget _timeline(BuildContext context) { - final ScrollController scrollController = ScrollController(); - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - return FrostedContainer( - padding: EdgeInsets.zero, - child: FutureBuilder>( - future: appState.loadTimelineEntries(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - List entries = []; - if (snapshot.connectionState == ConnectionState.done) { - entries = snapshot.data as List; - } - - return AnimatedOpacity( - duration: const Duration(milliseconds: 500), - opacity: entries.isEmpty ? 0.001 : 1, - child: Scrollbar( - controller: scrollController, - thumbVisibility: true, - child: ListView.custom( - controller: scrollController, - padding: const EdgeInsets.all(16), - childrenDelegate: SliverChildListDelegate([ - Stack( - children: [ - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - 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), - CustomFilterChip( - label: Strings.education, - selected: appState.educationVisible, - onSelected: (bool value) { - appState.toggleEducationVisibility(); - }, - ), - CustomFilterChip( - label: Strings.professional, - selected: - appState.professionalExperiencesVisible, - onSelected: (bool value) { - appState - .toggleProfessionalExperienceVisibility(); - }, - ), - CustomFilterChip( - label: Strings.projects, - selected: appState.projectsVisible, - onSelected: (bool value) { - appState.toggleProjectsVisibility(); - }, - ), - const SizedBox(width: 52), - ], - ), - ), - Align( - alignment: Alignment.centerRight, - child: IconButton( - tooltip: _timelineExpanded - ? Strings.collapse - : Strings.expand, - visualDensity: VisualDensity.compact, - color: Theme.of(context).colorScheme.onSurface, - onPressed: () { - setState(() { - _timelineExpanded = !_timelineExpanded; - }); - }, - icon: Icon( - _timelineExpanded - ? Icons.zoom_in_map - : Icons.zoom_out_map, - color: Theme.of(context) - .hintColor - .withOpacity(0.5), - ), - ), - ) - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - '${entries.length} ${Strings.entries}', - style: Theme.of(context) - .textTheme - .labelSmall! - .copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.5), - ), - ), - ), - if (entries.isEmpty) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - Strings.selectACategoryToViewTheEntries, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.5), - ), - ), - ), - ...entries.map( - (TimelineEntry entry) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - entry, - const Divider( - indent: 8, - endIndent: 8, - ), - ], - ), - ), - ]), - ), - ), - ); - }), - ); - }, - ); - } - - /// A menu that allows the user to navigate to the professional experience - /// and personal projects screens. - Widget _professionalVsPersonalMenu(BuildContext context) { - return FrostedContainer( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Text( - Strings.explore.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: Theme.of(context).hintColor.withOpacity(0.75), - ), - ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: FloatingThumbnail( - title: Strings.professional, - image: Strings.professionalExperiencePhotoPath, - shimmer: true, - onTap: () { - context.go( - '${Strings.homeRoute}/${Strings.professionalSubRoute}'); - }, - ), - ), - Column( - children: [ - const Expanded( - child: VerticalDivider( - indent: 8, - endIndent: 8, - ), - ), - Text( - Strings.or.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: Theme.of(context).hintColor.withOpacity(0.5), - ), - ), - const Expanded( - child: VerticalDivider( - indent: 8, - endIndent: 8, - ), - ), - ], - ), - Expanded( - child: FloatingThumbnail( - title: Strings.personal, - image: Strings.personalExperiencePhotoPath, - shimmer: true, - onTap: () { - context.go( - '${Strings.homeRoute}/${Strings.personalSubRoute}'); - }, - ), - ), - ], - ), - ), - ], - ), - ); - } - - /// A menu that provides various action buttons. - Widget _actionMenu(BuildContext context, {bool compact = false}) { - Widget icon(IconData iconData) { - return Icon( - iconData, - size: 46, - color: Theme.of(context).colorScheme.onSurface, - ); - } - - return FrostedContainer( - borderRadiusAmount: compact ? 0 : 16.0, - child: GridView.custom( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: compact ? 2 : 4, - childAspectRatio: 0.9, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - childrenDelegate: SliverChildListDelegate( - [ - FrostedActionButton( - icon: icon(Icons.quick_contacts_mail_rounded), - title: Strings.contactMe, - onTap: () => RedirectHandler.openUrl(Strings.contactEmailUrl), - ), - FrostedActionButton( - icon: icon(Icons.bug_report_rounded), - title: Strings.reportAnIssue, - onTap: () => RedirectHandler.openUrl(Strings.issuesUrl), - ), - ], - ), - ), - ); - } - - /// Arranges the widgets in a column for portrait orientation. - Widget _portraitView(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _header(context, compact: true), - AnimatedContainer( - duration: const Duration(milliseconds: 250), - height: _timelineExpanded - ? MediaQuery.of(context).size.height * 0.001 - : MediaQuery.of(context).size.height * 0.26, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _timelineExpanded ? 0.001 : 1, - child: Column( - children: [ - const SizedBox(height: 8, width: 8), - Expanded(child: _professionalVsPersonalMenu(context)), - ], - ), - )), - const SizedBox(height: 8, width: 8), - Expanded(child: _timeline(context)), - ], - ); - } - - /// Arranges the widgets in a row for landscape orientation. - Widget _landscapeView(BuildContext context) { - return Column( - children: [ - _header(context), - const SizedBox(height: 8), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: _timeline(context), - ), - if (!_timelineExpanded) const SizedBox(width: 8), - AnimatedContainer( - duration: const Duration(milliseconds: 150), - transformAlignment: Alignment.centerRight, - width: _timelineExpanded - ? MediaQuery.of(context).size.width * 0.001 - : MediaQuery.of(context).size.width * 0.5, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: _timelineExpanded ? 0.001 : 1, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _professionalVsPersonalMenu(context), - ), - const SizedBox(height: 8), - Expanded(child: _actionMenu(context)), - ], - ), - ), - ), - ], - ), - ), - ], - ); - } - - /// A drawer that provides various action buttons. - Widget _drawer(BuildContext context) { - return Drawer( - backgroundColor: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - DrawerHeader( - margin: EdgeInsets.zero, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () => - RedirectHandler.openUrl(Strings.sourceCodeUrl), - icon: const Icon(Icons.code), - tooltip: Strings.viewSourceCode, - ), - _socialMediaButtons(context), - ], - ), - _poweredByFlutterButton(context), - Text( - Strings.lastUpdated, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.5), - ), - ), - ], - ), - ), - Flexible( - child: _actionMenu( - context, - compact: true, - ), - ), - ], - )); - } - - @override - Widget build(BuildContext context) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Scaffold( - appBar: CustomAppBars.homeAppBar( - context: context, - poweredByFlutterButton: _poweredByFlutterButton(context), - lastUpdated: Strings.lastUpdated, - compact: orientation == Orientation.portrait, - ), - drawer: - orientation == Orientation.landscape ? null : _drawer(context), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: orientation == Orientation.portrait - ? _portraitView(context) - : _landscapeView(context), - ), - ); - }, - ); - } -} diff --git a/lib/screens/personal.dart b/lib/screens/personal.dart deleted file mode 100644 index 1e10b9a..0000000 --- a/lib/screens/personal.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; - -import '../common/strings.dart'; -import '../models/app_state.dart'; -import '../widgets/custom_app_bars.dart'; -import '../widgets/frosted_container.dart'; -import '../widgets/thumbnail_item.dart'; - -/// A screen that displays a collection of personal projects. -class PersonalScreen extends StatefulWidget { - const PersonalScreen({super.key}); - - @override - State createState() => _PersonalScreenState(); -} - -class _PersonalScreenState extends State { - /// The controller for the scroll view. - final ScrollController _scrollController = ScrollController(); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Scaffold( - appBar: CustomAppBars.genericAppBar( - context: context, title: Strings.personalProjects), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: FrostedContainer( - padding: EdgeInsets.zero, - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - padding: orientation == Orientation.portrait - ? const EdgeInsets.only(right: 8) - : EdgeInsets.zero, - controller: _scrollController, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FutureBuilder( - future: appState.loadProjects(), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - if (snapshot.connectionState == - ConnectionState.done) { - return GridView.custom( - padding: const EdgeInsets.all(8), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - orientation == Orientation.portrait - ? 1 - : 5, - crossAxisSpacing: 8, - childAspectRatio: - orientation == Orientation.portrait - ? 1.175 - : 1.0, - ), - 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( - '${Strings.homeRoute}/${Strings.personalSubRoute}/${Strings.detailsSubRoute}/${appState.projects[index].titleAsPath}', - ), - ); - }, - childCount: appState.projects.length, - ), - ); - } else { - return Padding( - padding: const EdgeInsets.all(24), - child: SizedBox( - height: - MediaQuery.of(context).size.height - - 150, - child: const Center( - child: CircularProgressIndicator - .adaptive(), - ), - ), - ); - } - }, - ), - ], - ), - ), - ), - ), - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/screens/professional.dart b/lib/screens/professional.dart deleted file mode 100644 index c36b779..0000000 --- a/lib/screens/professional.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; - -import '../common/strings.dart'; -import '../models/app_state.dart'; -import '../widgets/custom_app_bars.dart'; -import '../widgets/floating_thumbnail.dart'; -import '../widgets/frosted_container.dart'; - -/// A screen that displays a collection of professional experiences. -class ProfessionalScreen extends StatefulWidget { - const ProfessionalScreen({super.key}); - - @override - State createState() => _ProfessionalScreenState(); -} - -class _ProfessionalScreenState extends State { - /// The controller for the scroll view. - final ScrollController _scrollController = ScrollController(); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, AppState appState, Widget? child) { - return OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Scaffold( - appBar: CustomAppBars.genericAppBar( - context: context, title: Strings.professionalExperiences), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: FrostedContainer( - padding: EdgeInsets.zero, - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - padding: orientation == Orientation.portrait - ? const EdgeInsets.only(right: 8) - : EdgeInsets.zero, - controller: _scrollController, - child: FutureBuilder( - future: appState.loadProfessionalExperiences(), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - if (snapshot.connectionState == - ConnectionState.done) { - 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( - '${Strings.homeRoute}/${Strings.professionalSubRoute}/${Strings.detailsSubRoute}/${appState.professionalExperiences[index].titleAsPath}', - ), - ); - }, - childCount: - appState.professionalExperiences.length, - )); - } else { - return Padding( - padding: const EdgeInsets.all(24), - child: SizedBox( - height: - MediaQuery.of(context).size.height - 150, - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ); - } - }, - ), - ), - ), - ), - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/widgets/custom_filter_chip.dart b/lib/widgets/custom_filter_chip.dart index c64761f..416d68c 100644 --- a/lib/widgets/custom_filter_chip.dart +++ b/lib/widgets/custom_filter_chip.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +/// A custom filter chip used for filtering content. class CustomFilterChip extends StatelessWidget { const CustomFilterChip({ super.key, diff --git a/lib/widgets/floating_thumbnail.dart b/lib/widgets/floating_thumbnail.dart index 25a79c5..8dda6c5 100644 --- a/lib/widgets/floating_thumbnail.dart +++ b/lib/widgets/floating_thumbnail.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; -import '../common/color_schemes.dart'; +import '../common/theming/color_schemes.dart'; import 'frosted_container.dart'; import 'hover_scale_handler.dart'; @@ -57,7 +57,7 @@ class _FloatingThumbnailState extends State { child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - color: lightColorScheme.surface, + color: PortfolioColorSchemes.light.surface, ), child: Padding( padding: const EdgeInsets.all(8.0), diff --git a/lib/widgets/generic_app_bar.dart b/lib/widgets/generic_app_bar.dart new file mode 100644 index 0000000..453daf2 --- /dev/null +++ b/lib/widgets/generic_app_bar.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// A generic app bar used throughout the app. +class GenericAppBar { + GenericAppBar._(); + + static PreferredSizeWidget build({ + required BuildContext context, + required String title, + List? actions, + }) { + return AppBar( + title: Text(title), + titleSpacing: 0, + centerTitle: false, + scrolledUnderElevation: 0, + actions: actions, + ); + } +} diff --git a/lib/widgets/spinner.dart b/lib/widgets/spinner.dart new file mode 100644 index 0000000..1642711 --- /dev/null +++ b/lib/widgets/spinner.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +/// A widget that displays a loading spinner. +class Spinner extends StatelessWidget { + const Spinner({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: SizedBox( + height: MediaQuery.of(context).size.height - 150, + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 95a27a1..5722e30 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.4.0+45 +version: 2.4.0+46 environment: sdk: ">=3.1.1 <4.0.0" From 9a1590a1fa9f2e207bb1084dc8d3d2ab05846a40 Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Wed, 7 Aug 2024 14:16:45 -0400 Subject: [PATCH 2/3] Removed Duplicate Widget Files --- lib/widgets/custom_icon_button.dart | 48 ----------- lib/widgets/header_banner.dart | 53 ------------ lib/widgets/social_icon_button.dart | 36 -------- lib/widgets/theme_mode_button.dart | 29 ------- lib/widgets/thumbnail_item.dart | 89 ------------------- lib/widgets/time_line_entry.dart | 127 ---------------------------- 6 files changed, 382 deletions(-) delete mode 100644 lib/widgets/custom_icon_button.dart delete mode 100644 lib/widgets/header_banner.dart delete mode 100644 lib/widgets/social_icon_button.dart delete mode 100644 lib/widgets/theme_mode_button.dart delete mode 100644 lib/widgets/thumbnail_item.dart delete mode 100644 lib/widgets/time_line_entry.dart diff --git a/lib/widgets/custom_icon_button.dart b/lib/widgets/custom_icon_button.dart deleted file mode 100644 index a789cea..0000000 --- a/lib/widgets/custom_icon_button.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'frosted_container.dart'; -import 'hover_scale_handler.dart'; - -/// A button that displays an icon and a title on a frosted glass background. -/// The button can be tapped to perform an action. -class FrostedActionButton extends StatelessWidget { - const FrostedActionButton({ - super.key, - required this.icon, - required this.title, - required this.onTap, - }); - - /// The icon to display in the button. - final Widget icon; - - /// The title to display in the button. - final String title; - - /// The function to call when the button is tapped. - final Function()? onTap; - - @override - Widget build(BuildContext context) { - return HoverScaleHandler( - onTap: onTap, - child: FrostedContainer( - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 4), - icon, - const SizedBox(height: 8), - Text( - title.toUpperCase(), - style: Theme.of(context).textTheme.titleSmall, - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/header_banner.dart b/lib/widgets/header_banner.dart deleted file mode 100644 index bdbc20b..0000000 --- a/lib/widgets/header_banner.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'frosted_container.dart'; - -/// A banner that displays a leading widget, a title, and a subtitle. -class HeaderBanner extends StatelessWidget { - const HeaderBanner({ - super.key, - required this.title, - required this.subtitle, - this.leading, - }); - - /// The widget to display on the left side of the banner. - final Widget? leading; - - /// The widget to display as the title of the banner. - final Widget title; - - /// The widget to display as the subtitle of the banner. - final Widget subtitle; - - @override - Widget build(BuildContext context) { - return FrostedContainer( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: Row( - children: [ - if (leading != null) leading!, - if (leading != null) const SizedBox(width: 12.0), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 10.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: title, - ), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: subtitle, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/social_icon_button.dart b/lib/widgets/social_icon_button.dart deleted file mode 100644 index 9a7ad72..0000000 --- a/lib/widgets/social_icon_button.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../services/redirect_handler.dart'; - -/// A social icon button widget containing an icon that redirects to the -/// specified URL. -class SocialIconButton extends StatelessWidget { - const SocialIconButton({ - super.key, - required this.title, - required this.socialAssetBasePath, - required this.urlString, - }); - - /// The title of the social media platform. - final String title; - - /// The base path to the social media assets. - final String socialAssetBasePath; - - /// The URL string to redirect to. - final String urlString; - - @override - Widget build(BuildContext context) { - return IconButton( - icon: Image.asset( - '$socialAssetBasePath/${title.toLowerCase()}.webp', - height: 24.0, - color: Theme.of(context).colorScheme.onSurface, - ), - tooltip: title, - onPressed: () => RedirectHandler.openUrl(urlString), - ); - } -} diff --git a/lib/widgets/theme_mode_button.dart b/lib/widgets/theme_mode_button.dart deleted file mode 100644 index f85b7f1..0000000 --- a/lib/widgets/theme_mode_button.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../common/strings.dart'; -import '../common/theme.dart'; - -/// An icon button that toggles the theme mode. -class ThemeModeButton extends StatelessWidget { - const ThemeModeButton({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Consumer( - builder: (BuildContext context, ThemeNotifier themeNotifier, - Widget? child) => - IconButton( - tooltip: Strings.toggleBrightness, - icon: Icon( - themeNotifier.isDarkMode ? Icons.light_mode : Icons.dark_mode), - onPressed: () { - themeNotifier.setDarkTheme(!themeNotifier.isDarkMode); - }, - ), - ), - ); - } -} diff --git a/lib/widgets/thumbnail_item.dart b/lib/widgets/thumbnail_item.dart deleted file mode 100644 index f8360d1..0000000 --- a/lib/widgets/thumbnail_item.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'hover_scale_handler.dart'; - -/// A project thumbnail widget containing an image, title, and subtitle. Can be -/// tapped to perform an action. -class ProjectThumbnail extends StatefulWidget { - const ProjectThumbnail({ - super.key, - required this.title, - required this.subtitle, - required this.imagePath, - required this.onTap, - this.compact = false, - }); - - /// 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; - - @override - State createState() => _ProjectThumbnailState(); -} - -class _ProjectThumbnailState extends State { - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: HoverScaleHandler( - onTap: widget.onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1.5, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.asset( - widget.imagePath, - fit: BoxFit.cover, - errorBuilder: (BuildContext context, Object error, - StackTrace? stackTrace) { - return Icon( - Icons.broken_image_outlined, - size: 50, - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withOpacity(0.5), - ); - }, - ), - ), - ), - const SizedBox(height: 8.0), - Text( - widget.title, - style: widget.compact - ? Theme.of(context).textTheme.bodyMedium - : 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, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/time_line_entry.dart b/lib/widgets/time_line_entry.dart deleted file mode 100644 index e7514b1..0000000 --- a/lib/widgets/time_line_entry.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../common/color_schemes.dart'; -import '../services/redirect_handler.dart'; - -/// A timeline entry widget containing an icon, title, description, and time -/// frame. The title is a clickable link that redirects to the specified URL. -class TimelineEntry extends StatelessWidget { - const TimelineEntry({ - super.key, - required this.logoPath, - required this.title, - required this.label, - required this.description, - required this.startDate, - required this.finalDateString, - required this.urlString, - required this.labelColor, - this.coverImage = true, - }); - - /// The path to the logo. - final String logoPath; - - /// The title of the entry. - final String title; - - /// The label of the entry. - final String label; - - /// The color of the label. - final Color labelColor; - - /// The description of the entry. - final String description; - - /// The start date of the entry. - final DateTime startDate; - - /// The final date of the entry. - final String finalDateString; - - /// The URL string to redirect to. - final String urlString; - - /// Whether the image should cover the entire space. - final bool coverImage; - - /// A string representation of the start date. - String get startDateString => - DateFormat('MMM yyyy').format(startDate).toUpperCase(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ListTile( - contentPadding: EdgeInsets.zero, - horizontalTitleGap: 8, - dense: false, - visualDensity: VisualDensity.standard, - leading: ClipOval( - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: lightColorScheme.surface, - ), - child: Padding( - padding: coverImage ? EdgeInsets.zero : const EdgeInsets.all(4.0), - child: Image.asset( - logoPath, - fit: coverImage ? BoxFit.cover : BoxFit.contain, - height: coverImage ? 50 : 38, - width: coverImage ? 50 : 38, - ), - ), - ), - ), - title: Wrap( - children: [ - TextButton( - onPressed: () => RedirectHandler.openUrl(urlString), - child: Text(title.toUpperCase()), - ), - const SizedBox(width: 2), - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Container( - decoration: BoxDecoration( - color: labelColor.withOpacity(0.05), - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: labelColor.withOpacity(0.8), - ), - ), - ), - ) - ], - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$startDateString - $finalDateString', - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), - ), - ); - } -} From 4922516457f843545e449022df34263af46689cd Mon Sep 17 00:00:00 2001 From: Pablo Guerra Date: Wed, 7 Aug 2024 23:10:10 -0400 Subject: [PATCH 3/3] MultiMediaPlayer Refactoring --- assets/json/professional_experience.json | 118 +++--- assets/json/projects.json | 336 +++++++-------- lib/common/urls.dart | 3 + lib/models/media_item.dart | 6 +- lib/models/professional_experience.dart | 4 +- lib/models/project.dart | 6 +- lib/pages/details/details.controller.dart | 23 +- lib/pages/details/details.screen.dart | 144 +++---- .../media_browser/local_image_thumbnail.dart | 23 + .../media_browser/local_video_thumbnail.dart | 34 ++ .../widgets/media_browser/media_browser.dart | 109 +++++ .../network_image_thumbnail.dart | 47 +++ .../media_browser/youtube_thumbnail.dart | 38 ++ .../media_player/gallery_controls.dart | 73 ++++ .../widgets/media_player/image_viewer.dart | 25 ++ .../media_player/local_video_player.dart | 38 ++ .../widgets/media_player/media_browser.dart | 176 -------- .../widgets/media_player/media_player.dart | 392 ------------------ .../widgets/media_player/media_viewer.dart | 102 +++++ .../multi_media_player.controller.dart | 112 +++++ .../media_player/multi_media_player.dart | 122 ++++++ .../widgets/media_player/player_banner.dart | 65 +++ .../widgets/media_player/youtube_player.dart | 27 ++ pubspec.yaml | 2 +- 24 files changed, 1128 insertions(+), 897 deletions(-) create mode 100644 lib/pages/details/widgets/media_browser/local_image_thumbnail.dart create mode 100644 lib/pages/details/widgets/media_browser/local_video_thumbnail.dart create mode 100644 lib/pages/details/widgets/media_browser/media_browser.dart create mode 100644 lib/pages/details/widgets/media_browser/network_image_thumbnail.dart create mode 100644 lib/pages/details/widgets/media_browser/youtube_thumbnail.dart create mode 100644 lib/pages/details/widgets/media_player/gallery_controls.dart create mode 100644 lib/pages/details/widgets/media_player/image_viewer.dart create mode 100644 lib/pages/details/widgets/media_player/local_video_player.dart delete mode 100644 lib/pages/details/widgets/media_player/media_browser.dart delete mode 100644 lib/pages/details/widgets/media_player/media_player.dart create mode 100644 lib/pages/details/widgets/media_player/media_viewer.dart create mode 100644 lib/pages/details/widgets/media_player/multi_media_player.controller.dart create mode 100644 lib/pages/details/widgets/media_player/multi_media_player.dart create mode 100644 lib/pages/details/widgets/media_player/player_banner.dart create mode 100644 lib/pages/details/widgets/media_player/youtube_player.dart diff --git a/assets/json/professional_experience.json b/assets/json/professional_experience.json index 5c95b58..336b7cb 100644 --- a/assets/json/professional_experience.json +++ b/assets/json/professional_experience.json @@ -11,27 +11,27 @@ { "type": "youTubeVideo", "caption": "", - "path": "Mz4JteT4jLw" + "source": "Mz4JteT4jLw" }, { "type": "youTubeVideo", "caption": "", - "path": "TcdKbZmju5I" + "source": "TcdKbZmju5I" }, { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Store Screenshots", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "UI Design", - "path": "image_2.webp" + "source": "image_2.webp" } ], "externalLinks": [ @@ -65,172 +65,172 @@ { "type": "youTubeVideo", "caption": "The Henry Ford's Innovation Nation [CBS]", - "path": "uTrtARdG20E" + "source": "uTrtARdG20E" }, { "type": "youTubeVideo", "caption": "Swarm Manufacturing of Mini-EV", - "path": "iimghhKjQlY" + "source": "iimghhKjQlY" }, { "type": "youTubeVideo", "caption": "3-chunk dachshund wall-art printed by the AMBOTS swarm 3D printing system.", - "path": "WLdU_nB4Tmg" + "source": "WLdU_nB4Tmg" }, { "type": "localImage", "caption": "3-chunk dachshund wall-art printed by the AMBOTS swarm 3D printing system.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "youTubeVideo", "caption": "A New Generation of Swarm 3D Printing Robots", - "path": "xMMauLmHcwk" + "source": "xMMauLmHcwk" }, { "type": "youTubeVideo", "caption": "UARK Hog Concrete Mold printed by the AMBOTS swarm 3D printing system.", - "path": "kyzpE83BQIM" + "source": "kyzpE83BQIM" }, { "type": "localImage", "caption": "Concrete UARK Hog finished product.", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "youTubeVideo", "caption": "Printer movement to next chunk.", - "path": "Bo7Qsp6Bg3w" + "source": "Bo7Qsp6Bg3w" }, { "type": "localImage", "caption": "Concrete wall facade mold in progress.", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Concrete wall facade mold in progress.", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "youTubeVideo", "caption": "4-chunk AMBOTS logo printed by the AMBOTS swarm 3D printing system.", - "path": "CZnIDNd1iqs" + "source": "CZnIDNd1iqs" }, { "type": "localImage", "caption": "4-chunk AMBOTS logo finished product (post-processed).", - "path": "image_16.webp" + "source": "image_16.webp" }, { "type": "youTubeVideo", "caption": "Transporter", - "path": "p09xkrQKyMs" + "source": "p09xkrQKyMs" }, { "type": "youTubeVideo", "caption": "Transporter: Return to Dock", - "path": "AyI313TPQuE" + "source": "AyI313TPQuE" }, { "type": "youTubeVideo", "caption": "Transporter misalignment recovery test.", - "path": "bu5smH7ooAE" + "source": "bu5smH7ooAE" }, { "type": "youTubeVideo", "caption": "Demonstration of Transporter onboard UI for direct control.", - "path": "3SWOADS7OOk" + "source": "3SWOADS7OOk" }, { "type": "youTubeVideo", "caption": "Transporter Onboard UI Demonstration", - "path": "bJGc8ChOAcs" + "source": "bJGc8ChOAcs" }, { "type": "localImage", "caption": "7th-Generation Transporter - Front Left View", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "7th-Generation Transporter - Front Right View", - "path": "image_8.webp" + "source": "image_8.webp" }, { "type": "localImage", "caption": "7th-Generation Transporter - Above View", - "path": "image_9.webp" + "source": "image_9.webp" }, { "type": "localImage", "caption": "7th-Generation AMBOTS Swarm 3D Printing System", - "path": "image_10.webp" + "source": "image_10.webp" }, { "type": "localImage", "caption": "Preparing for UARK's 150th anniversary demo.", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "AMBOTS swarm platform demo for students at the UofA campus.", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Rendering of the AMBOTS Swarm 3D-Printing Development Platform.", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Rendering of an Extended AMBOTS Development Kit", - "path": "image_12.webp" + "source": "image_12.webp" }, { "type": "localImage", "caption": "Extended AMBOTS Development Kit", - "path": "image_13.webp" + "source": "image_13.webp" }, { "type": "youTubeVideo", "caption": "Transporter Demo", - "path": "BbSe9UXH_A8" + "source": "BbSe9UXH_A8" }, { "type": "localImage", "caption": "Me with the 7th-Generation AMBOTS Swarm 3D Printing System", - "path": "image_11.webp" + "source": "image_11.webp" }, { "type": "localImage", "caption": "United States Patent Plaque", - "path": "image_18.webp" + "source": "image_18.webp" }, { "type": "localImage", "caption": "Founders of AMBOTS Inc.", - "path": "image_17.webp" + "source": "image_17.webp" }, { "type": "localImage", "caption": "Team Photo", - "path": "image_14.webp" + "source": "image_14.webp" }, { "type": "localImage", "caption": "Team Photo", - "path": "image_15.webp" + "source": "image_15.webp" }, { "type": "localVideo", "caption": "System Conceptualization Animation", - "path": "video_10.mp4" + "source": "video_10.mp4" }, { "type": "youTubeVideo", "caption": "Technology Centers Spotlight Series - Swarm Manufacturing", - "path": "zSt3IIy00O4" + "source": "zSt3IIy00O4" } ], "externalLinks": [ @@ -272,97 +272,97 @@ { "type": "youTubeVideo", "caption": "Implementing bed leveling on the 5th-Gen AMBOTS Printer. (My first task)", - "path": "C9XJQf-Xsi8" + "source": "C9XJQf-Xsi8" }, { "type": "youTubeVideo", "caption": "Printing a vase on the 5th-Gen AMBOTS Printer.", - "path": "iyC6uOwBRJ8" + "source": "iyC6uOwBRJ8" }, { "type": "youTubeVideo", "caption": "6th-Gen System Demo: Printer printing a vase and Transporter Movement.", - "path": "OKf5JHyCjuU" + "source": "OKf5JHyCjuU" }, { "type": "youTubeVideo", - "caption": "6th-Gen System Demo: Transporter Path Planning and Printer Movement.", - "path": "yrxG1dslxHE" + "caption": "6th-Gen System Demo: Transporter source Planning and Printer Movement.", + "source": "yrxG1dslxHE" }, { "type": "youTubeVideo", "caption": "Early 7th-Gen Transporter active alignment (spring align) test.", - "path": "SEu59ochxd4" + "source": "SEu59ochxd4" }, { "type": "youTubeVideo", "caption": "Early 7th-Gen Transporter", - "path": "-GJoTirxo74" + "source": "-GJoTirxo74" }, { "type": "localImage", "caption": "Testing Transporter Image Processing under different lighting conditions", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "youTubeVideo", "caption": "Testing Transporter Image Processing under low lighting conditions", - "path": "RiuoP2lwnu4" + "source": "RiuoP2lwnu4" }, { "type": "youTubeVideo", "caption": "Transporter Battery Life & Movement Reliability Test", - "path": "P9ySf_Wu0Zc" + "source": "P9ySf_Wu0Zc" }, { "type": "youTubeVideo", "caption": "Transporter Docking & Movement Reliability Test", - "path": "F-MT72wZfUQ" + "source": "F-MT72wZfUQ" }, { "type": "youTubeVideo", "caption": "Transporter FPV Cam", - "path": "ijfGwYGG6SU" + "source": "ijfGwYGG6SU" }, { "type": "youTubeVideo", "caption": "Early 7th-Gen AMBOTS Printer printing a vase.", - "path": "JhLCmqpONOc" + "source": "JhLCmqpONOc" }, { "type": "localImage", "caption": "Chunk Angle and Bond Test with off-the-shelf nozzles", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Vase Printed by the 5th-Gen AMBOTS Printer", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Early 7th-Gen Printer Starting a Large 3D Phil Print", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Small and Large 3D Phil Prints by the early 7th-Gen AMBOTS Printer", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Small and Large 3D Phil Prints by the early 7th-Gen AMBOTS Printer", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Some notable prints made by the various custom AM³ Lab 3D Printers", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Rendering of the 7th-Gen Transporter", - "path": "thumbnail.webp" + "source": "thumbnail.webp" } ], "externalLinks": [ diff --git a/assets/json/projects.json b/assets/json/projects.json index 5710ab4..45d30fd 100644 --- a/assets/json/projects.json +++ b/assets/json/projects.json @@ -9,82 +9,82 @@ { "type": "youTubeVideo", "caption": "AMG GT3 Inspired Mobile Sim Wheel: CAD Development Process", - "path": "u2Hcy3uadDQ" + "source": "u2Hcy3uadDQ" }, { "type": "youTubeVideo", "caption": "Demo Video - Real Racing 3", - "path": "YmphJtfp86E" + "source": "YmphJtfp86E" }, { "type": "localImage", "caption": "V2 Wheel CAD Renderings", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Initial Prototype using paracord and screws for assembly.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "1: Original AMG GT3 Wheel Kit,\n2: Shrunken Wheel Plate Prototype,\n3: Final Product with built-in handles", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "networkImage", "caption": "V1 CAD Rendering Left-Front View", - "path": "https://cdn.thingiverse.com/assets/07/e6/89/5b/bc/large_display_014c469a-e014-4ad5-bdf3-da2e75f129b8.jpeg" + "source": "https://cdn.thingiverse.com/assets/07/e6/89/5b/bc/large_display_014c469a-e014-4ad5-bdf3-da2e75f129b8.jpeg" }, { "type": "networkImage", "caption": "V1 Final Assembly Left-Front View", - "path": "https://cdn.thingiverse.com/assets/61/fb/45/5a/3b/large_display_7993f07e-1718-4088-8bc4-bbcd39a565f7.jpeg" + "source": "https://cdn.thingiverse.com/assets/61/fb/45/5a/3b/large_display_7993f07e-1718-4088-8bc4-bbcd39a565f7.jpeg" }, { "type": "networkImage", "caption": "V1 Final Assembly Front-Right View", - "path": "https://cdn.thingiverse.com/assets/22/cd/c0/bb/80/large_display_8e7ff666-9c87-4011-a4a6-cc84536fed04.jpeg" + "source": "https://cdn.thingiverse.com/assets/22/cd/c0/bb/80/large_display_8e7ff666-9c87-4011-a4a6-cc84536fed04.jpeg" }, { "type": "networkImage", "caption": "V1 Final Assembly Rear View", - "path": "https://cdn.thingiverse.com/assets/53/53/b4/eb/5b/large_display_e883ea50-e25d-45ff-9f6d-cf7d13653c5c.jpeg" + "source": "https://cdn.thingiverse.com/assets/53/53/b4/eb/5b/large_display_e883ea50-e25d-45ff-9f6d-cf7d13653c5c.jpeg" }, { "type": "networkImage", "caption": "V1 Assembly Diagram", - "path": "https://cdn.thingiverse.com/assets/d2/97/e6/70/c7/large_display_4f5e265d-58c6-429c-8c98-63f70b6025c8.png" + "source": "https://cdn.thingiverse.com/assets/d2/97/e6/70/c7/large_display_4f5e265d-58c6-429c-8c98-63f70b6025c8.png" }, { "type": "localImage", "caption": "1: V1 Original Design,\n2: V1.1 (Updated handles and Phone Clamp),\n3: V2 Rendering (Added 'Fidget' Buttons)", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "V1 and V2 Wheel Comparison", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "V2 Wheel Exploded View - Front", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "V2 Wheel Exploded View - Rear", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Button Section Analysis - PLA based spring mechanism", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Button Section Analysis - TPU based spring mechanism", - "path": "image_8.webp" + "source": "image_8.webp" } ], "externalLinks": [ @@ -119,72 +119,72 @@ { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Presentation Slide 1/7", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Presentation Slide 2/7", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Presentation Slide 3/7", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Presentation Slide 4/7", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Presentation Slide 5/7", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Presentation Slide 6/7", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Presentation Slide 7/7", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "networkImage", "caption": "Spin the Wheel", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/spin_the_wheel.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/spin_the_wheel.gif" }, { "type": "networkImage", "caption": "Shuffle Prizes", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/shuffle_prizes.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/shuffle_prizes.gif" }, { "type": "networkImage", "caption": "Scratch the Card", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/scratch_the_card.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/scratch_the_card.gif" }, { "type": "networkImage", "caption": "Swipe to Dismiss Card", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/swipe_to_dismiss_card.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/swipe_to_dismiss_card.gif" }, { "type": "networkImage", "caption": "Pay to Unlock Reward", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/pay_to_unlock_reward.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/pay_to_unlock_reward.gif" }, { "type": "networkImage", "caption": "Toggle Theme Mode", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/toggle_theme_mode.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/swe_632_lottery/main/showcase/toggle_theme_mode.gif" } ], "externalLinks": [ @@ -232,37 +232,37 @@ { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "networkImage", "caption": "Overview", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/overview.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/overview.gif" }, { "type": "networkImage", "caption": "Edit Contact Information", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/edit_contact.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/edit_contact.gif" }, { "type": "networkImage", "caption": "Edit Experiences", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/edit_experiences.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/edit_experiences.gif" }, { "type": "networkImage", "caption": "Toggle Visibility", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/visibility.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/visibility.gif" }, { "type": "networkImage", "caption": "Download Resume", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/download.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/download.gif" }, { "type": "networkImage", "caption": "Import Resume", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/import.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/flutter_resume_builder/main/showcase/import.gif" } ], "externalLinks": [ @@ -296,47 +296,47 @@ { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "networkImage", "caption": "Splash Screen", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_play.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_play.gif" }, { "type": "networkImage", "caption": "Login Screen", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/login_screen.png" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/login_screen.png" }, { "type": "networkImage", "caption": "Lobby", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/lobby.png" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/lobby.png" }, { "type": "networkImage", "caption": "Lobby Room", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/lobby_room.png" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/lobby_room.png" }, { "type": "networkImage", "caption": "Game Screen", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game.png" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game.png" }, { "type": "networkImage", "caption": "Playing a Piece", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/play_piece.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/play_piece.gif" }, { "type": "networkImage", "caption": "Game Play", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_play.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_play.gif" }, { "type": "networkImage", "caption": "Game Over", - "path": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_over.gif" + "source": "https://raw.githubusercontent.com/PLGuerraDesigns/isa681_blokus/main/product_showcase/game_over.gif" } ], "externalLinks": [ @@ -366,162 +366,162 @@ { "type": "youTubeVideo", "caption": "Wheel in Action on a Thrustmaster Racing Rig", - "path": "nsCTZuELh-k" + "source": "nsCTZuELh-k" }, { "type": "youTubeVideo", "caption": "Full view of the wheel.", - "path": "S6_5_JhzCXQ" + "source": "S6_5_JhzCXQ" }, { "type": "youTubeVideo", "caption": "Community Video: Build Photos and Race Gameplay", - "path": "R-0pL1AsorE" + "source": "R-0pL1AsorE" }, { "type": "localImage", "caption": "Wheel installed on a Thrustmaster Servo Base.", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Initial Photoshop Concept.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Fusion 360 CAD Rendering.", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Fusion 360 CAD Rendering.", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Wheel Layout and Button Mapping.", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "8-PIN DIN and Paddle Shifter Wiring.", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Material Strength and Feel Testing.", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Application of Vinyl Wrap and Decals.", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Wheel Fully Assembled.", - "path": "image_8.webp" + "source": "image_8.webp" }, { "type": "localImage", "caption": "Improved Handle Grip.", - "path": "image_9.webp" + "source": "image_9.webp" }, { "type": "localImage", "caption": "Finished wheel mounted on a custom display stand.", - "path": "image_10.webp" + "source": "image_10.webp" }, { "type": "localImage", "caption": "The ProtoBoard Circuit design in Progress.", - "path": "image_11.webp" + "source": "image_11.webp" }, { "type": "localImage", "caption": "ProtoBoard Design vs. Final Custom Manufactured PCB Board.", - "path": "image_12.webp" + "source": "image_12.webp" }, { "type": "localImage", "caption": "PCB Assembled and Installed.", - "path": "image_13.webp" + "source": "image_13.webp" }, { "type": "localImage", "caption": "My Sim Racing Rig (at the time) with the AMG GT3 Wheel.", - "path": "image_14.webp" + "source": "image_14.webp" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/a9/d5/6e/59/76/IMG_20191001_172428.jpg" + "source": "https://cdn.thingiverse.com/assets/a9/d5/6e/59/76/IMG_20191001_172428.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://i.redd.it/ib6m4vsja3k21.jpg" + "source": "https://i.redd.it/ib6m4vsja3k21.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/d4/ad/d6/24/2a/IMG_20201129_163233_HDR.jpg" + "source": "https://cdn.thingiverse.com/assets/d4/ad/d6/24/2a/IMG_20201129_163233_HDR.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/89/87/88/95/19/1.jpg" + "source": "https://cdn.thingiverse.com/assets/89/87/88/95/19/1.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/ea/15/cb/eb/60/AMG_Steering_Wheel.jpg" + "source": "https://cdn.thingiverse.com/assets/ea/15/cb/eb/60/AMG_Steering_Wheel.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/55/1b/f3/7a/3f/volant_face.jpg" + "source": "https://cdn.thingiverse.com/assets/55/1b/f3/7a/3f/volant_face.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/fe/7a/98/09/29/FDE0EFB6-C784-498E-B386-7E9A9C3C0BA0.jpeg" + "source": "https://cdn.thingiverse.com/assets/fe/7a/98/09/29/FDE0EFB6-C784-498E-B386-7E9A9C3C0BA0.jpeg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/7e/e1/4a/69/8a/87052509_854879888290366_2111462423008378880_n.jpg" + "source": "https://cdn.thingiverse.com/assets/7e/e1/4a/69/8a/87052509_854879888290366_2111462423008378880_n.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/renders/88/e5/e8/5b/a0/6ea030e2dbcc1b8c82ef0ef56de93fc9_display_large.jpg" + "source": "https://cdn.thingiverse.com/renders/88/e5/e8/5b/a0/6ea030e2dbcc1b8c82ef0ef56de93fc9_display_large.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/renders/7e/be/d2/09/ac/86dc5ba0f800cf421ee795145cbdea15_display_large.JPG" + "source": "https://cdn.thingiverse.com/renders/7e/be/d2/09/ac/86dc5ba0f800cf421ee795145cbdea15_display_large.JPG" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/renders/df/17/0f/d5/a6/2723476733c115f2167e5e15e1f090ad_display_large.jpg" + "source": "https://cdn.thingiverse.com/renders/df/17/0f/d5/a6/2723476733c115f2167e5e15e1f090ad_display_large.jpg" }, { "type": "networkImage", "caption": "Community Remake", - "path": "https://cdn.thingiverse.com/assets/7f/8e/b0/a6/97/dc90e110-15da-4dca-8109-b9910a4b4e12.jpg" + "source": "https://cdn.thingiverse.com/assets/7f/8e/b0/a6/97/dc90e110-15da-4dca-8109-b9910a4b4e12.jpg" }, { "type": "youTubeVideo", "caption": "Community Video: Build Tutorial Pt. 1 (Spanish)", - "path": "qrQnisocpDg" + "source": "qrQnisocpDg" }, { "type": "youTubeVideo", "caption": "Community Video: Build Tutorial Pt. 2 (Spanish)", - "path": "KhZN_uf8_Q4" + "source": "KhZN_uf8_Q4" } ], "externalLinks": [ @@ -563,42 +563,42 @@ { "type": "localImage", "caption": "Apple Charging Station", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Original 2016 Design Concept in SketchUp.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Original 2016 Design Concept in SketchUp.", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Redesigned in 2019 and 3D Printed.", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Base with Swappable Charging Module Inserts.", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Application of Wood Stain.", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "After Stain Application.", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Charging Station (minus apple watch puck)", - "path": "image_7.webp" + "source": "image_7.webp" } ], "externalLinks": [], @@ -625,12 +625,12 @@ { "type": "localImage", "caption": "Companion App for Codemaster's F1 2019 Game", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localVideo", "caption": "F1 Sim Engineer Demo", - "path": "video_1.mp4" + "source": "video_1.mp4" } ], "externalLinks": [ @@ -664,37 +664,37 @@ { "type": "localImage", "caption": "Nintendo Switch Speaker Attachment", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "CAD Design vs. Final Product", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "CAD Design vs. Final Product", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Casing Prototypes", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Speaker connected to Nintendo Switch (Front View)", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Speaker connected to Nintendo Switch (Left Side View)", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Speaker connected to Nintendo Switch (Right Side View)", - "path": "image_6.webp" + "source": "image_6.webp" } ], "externalLinks": [ @@ -739,142 +739,142 @@ { "type": "localImage", "caption": "Homemade Sim Racing Rig", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localVideo", "caption": "Project Demo", - "path": "video_1.mp4" + "source": "video_1.mp4" }, { "type": "localImage", "caption": "Pedals carved from foam, reinforced in the rear with popsicle sticks", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Applying fiberglass to foam pedals, wrapped in foil and masking tape", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Pedal finished with body filler, primed and painted; door hinge used for pedal attachment.", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Foam pedal base reinforced with wood and wrapped in foil and masking tape.", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Applying fiberglass to pedal base", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Spray painting pedal base", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Fitment test with pedal springs", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Potentiometers added to the pedals for determining their position.", - "path": "image_8.webp" + "source": "image_8.webp" }, { "type": "localImage", "caption": "Steering wheel shape outlined on foam", - "path": "image_9.webp" + "source": "image_9.webp" }, { "type": "localImage", "caption": "Wheel carved from foam", - "path": "image_10.webp" + "source": "image_10.webp" }, { "type": "localImage", "caption": "Foam wheel reinforced with wood and wrapped in foil and masking tape.", - "path": "image_11.webp" + "source": "image_11.webp" }, { "type": "localImage", "caption": "Applying fiberglass to the steering wheel", - "path": "image_12.webp" + "source": "image_12.webp" }, { "type": "localImage", "caption": "Fiberglass wheel finished with body filler and vinyl spackling.", - "path": "image_13.webp" + "source": "image_13.webp" }, { "type": "localImage", "caption": "Wheel sanded smooth, and the buttonholes drilled.", - "path": "image_14.webp" + "source": "image_14.webp" }, { "type": "localImage", "caption": "Wheel primed and mounted to wheelbase using PCB pipes.", - "path": "image_15.webp" + "source": "image_15.webp" }, { "type": "localImage", "caption": "A potentiometer attached to the wheel shaft mechanism was added to determine wheel position.", - "path": "image_16.webp" + "source": "image_16.webp" }, { "type": "localImage", "caption": "Wheelbase painted by a friend", - "path": "image_17.webp" + "source": "image_17.webp" }, { "type": "localImage", "caption": "Steering wheel painted", - "path": "image_18.webp" + "source": "image_18.webp" }, { "type": "localImage", "caption": "Paddle shifters cut from pieces of acrylic and attached to cabinet door hinges", - "path": "image_19.webp" + "source": "image_19.webp" }, { "type": "localImage", "caption": "Paddle shifters mounted to the wheel", - "path": "image_20.webp" + "source": "image_20.webp" }, { "type": "localImage", "caption": "Endstop switches added for paddle shifter input", - "path": "image_21.webp" + "source": "image_21.webp" }, { "type": "localImage", "caption": "Finished wheel", - "path": "image_22.webp" + "source": "image_22.webp" }, { "type": "localImage", "caption": "Custom rig built for the steering wheel and pedal base", - "path": "image_23.webp" + "source": "image_23.webp" }, { "type": "localImage", "caption": "Bungee cord added to the steering wheel shaft for auto-centering", - "path": "image_24.webp" + "source": "image_24.webp" }, { "type": "localImage", "caption": "Finished pedal base", - "path": "image_25.webp" + "source": "image_25.webp" }, { "type": "localImage", "caption": "Rear side view of the rig", - "path": "image_26.webp" + "source": "image_26.webp" } ], "externalLinks": [], @@ -897,67 +897,67 @@ { "type": "localImage", "caption": "Concept Self Destructing Storage Device", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localVideo", "caption": "Initial Prototype Testing", - "path": "video_2.mp4" + "source": "video_2.mp4" }, { "type": "localVideo", "caption": "Successful Connection to Storage", - "path": "video_3.mp4" + "source": "video_3.mp4" }, { "type": "localVideo", "caption": "Fingerprint invalid attempts state recovery after power cycle.", - "path": "video_4.mp4" + "source": "video_4.mp4" }, { "type": "localImage", "caption": "Initial breadboard prototype", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Fusion 360 CAD Rendering", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Fusion 360 CAD Rendering", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "3D-Printed Case", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Wiring and Assembly", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Unfinished SafeDrive MK1", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Case Sanded, Filled, Primed, and Painted", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Warning Labels Added", - "path": "image_8.webp" + "source": "image_8.webp" }, { "type": "youTubeVideo", "caption": "Project Demo", - "path": "XBJTbQ3lhxo" + "source": "XBJTbQ3lhxo" } ], "externalLinks": [ @@ -997,47 +997,47 @@ { "type": "youTubeVideo", "caption": "Project Demo", - "path": "XxF-zlooXNg" + "source": "XxF-zlooXNg" }, { "type": "youTubeVideo", "caption": "Testing Gyroscope and Accelerometer.", - "path": "_smU7T_qflA" + "source": "_smU7T_qflA" }, { "type": "localVideo", "caption": "Using Digital Potentiometers to Control Thumbstick Input.", - "path": "video_3.mp4" + "source": "video_3.mp4" }, { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Prototype Mounted on a Hat.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Modded after-market Xbox controller.", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Wires soldered inline to thumbstick input (maintaining original functionality).", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Initial Head Tracking Circuit.", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Top View of the Circuit.", - "path": "image_5.webp" + "source": "image_5.webp" } ], "videoCount": 3, @@ -1068,17 +1068,17 @@ { "type": "localImage", "caption": "", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localVideo", "caption": "Calculator App Demo", - "path": "video_1.mp4" + "source": "video_1.mp4" }, { "type": "localImage", "caption": "Development and Testing using an Emulated Android Device.", - "path": "image_1.webp" + "source": "image_1.webp" } ], "externalLinks": [ @@ -1105,17 +1105,17 @@ { "type": "localImage", "caption": "Programmable Macro Pad (HID)", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Macro Pad - Front view", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Desk setup with Macro Pad.", - "path": "image_2.webp" + "source": "image_2.webp" } ], "externalLinks": [ @@ -1143,17 +1143,17 @@ { "type": "localImage", "caption": "Smart LED Wall Decor", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localImage", "caption": "Interchangeable cover plate using magnets.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "USB Micro Port for Continuous Power", - "path": "image_2.webp" + "source": "image_2.webp" } ], "externalLinks": [ @@ -1183,72 +1183,72 @@ { "type": "localImage", "caption": "Preparing For the Science Fair Presentation", - "path": "thumbnail.webp" + "source": "thumbnail.webp" }, { "type": "localVideo", "caption": "3 Years Later... Glove melted in Belize's hot climate.", - "path": "video_1.mp4" + "source": "video_1.mp4" }, { "type": "localImage", "caption": "Hand made with foam, rubber tubing, and some fishing line.", - "path": "image_1.webp" + "source": "image_1.webp" }, { "type": "localImage", "caption": "Forearm skeleton made using foam and wood.", - "path": "image_2.webp" + "source": "image_2.webp" }, { "type": "localImage", "caption": "Hand attached to the forearm", - "path": "image_3.webp" + "source": "image_3.webp" }, { "type": "localImage", "caption": "Securing servo motors", - "path": "image_4.webp" + "source": "image_4.webp" }, { "type": "localImage", "caption": "Forearm wrapped with bristol board", - "path": "image_5.webp" + "source": "image_5.webp" }, { "type": "localImage", "caption": "Cut-out made to access servo motors", - "path": "image_6.webp" + "source": "image_6.webp" }, { "type": "localImage", "caption": "Applying fiberglass to the entire arm", - "path": "image_7.webp" + "source": "image_7.webp" }, { "type": "localImage", "caption": "Input glove with flex sensors on fingers", - "path": "image_8.webp" + "source": "image_8.webp" }, { "type": "localImage", "caption": "Finished hand", - "path": "image_9.webp" + "source": "image_9.webp" }, { "type": "localImage", "caption": "Finished hand with Arduino and input glove", - "path": "image_10.webp" + "source": "image_10.webp" }, { "type": "localImage", "caption": "School science fair display", - "path": "image_11.webp" + "source": "image_11.webp" }, { "type": "localImage", "caption": "Demonstration for local news", - "path": "image_12.webp" + "source": "image_12.webp" } ], "externalLinks": [], diff --git a/lib/common/urls.dart b/lib/common/urls.dart index 07dbd00..1708472 100644 --- a/lib/common/urls.dart +++ b/lib/common/urls.dart @@ -18,4 +18,7 @@ class Urls { static const String contactEmail = 'mailto:plguerra@outlook.com'; static const String googleSearchBase = 'https://www.google.com/search?q='; + + static String youTubeThumbnail(String videoId) => + 'https://img.youtube.com/vi/$videoId/hqdefault.jpg'; } diff --git a/lib/models/media_item.dart b/lib/models/media_item.dart index 9af8574..d4edce7 100644 --- a/lib/models/media_item.dart +++ b/lib/models/media_item.dart @@ -4,7 +4,7 @@ import '../common/enums.dart'; class MediaItem { MediaItem({ required this.type, - required this.path, + required this.source, required this.caption, }); @@ -12,7 +12,7 @@ class MediaItem { factory MediaItem.fromJson(Map json) { return MediaItem( type: mediaTypeFromString(json['type'].toString()), - path: json['path'].toString(), + source: json['source'].toString(), caption: json['caption'].toString(), ); } @@ -21,7 +21,7 @@ class MediaItem { final MediaType type; /// The path to the media. - String path; + String source; /// The caption to be displayed with the media. final String caption; diff --git a/lib/models/professional_experience.dart b/lib/models/professional_experience.dart index de0c2fd..f665f53 100644 --- a/lib/models/professional_experience.dart +++ b/lib/models/professional_experience.dart @@ -35,8 +35,8 @@ class ProfessionalExperience { final MediaItem mediaItem = mediaItems[i]; if (mediaItem.type == MediaType.localImage || mediaItem.type == MediaType.localVideo) { - mediaItems[i].path = - 'assets/images/professional/${json['folderName'].toString().toLowerCase().replaceAll(' ', '_')}/${mediaItem.path}'; + mediaItems[i].source = + 'assets/images/professional/${json['folderName'].toString().toLowerCase().replaceAll(' ', '_')}/${mediaItem.source}'; } } diff --git a/lib/models/project.dart b/lib/models/project.dart index fea689e..74e0f6d 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -33,8 +33,8 @@ class Project { final MediaItem mediaItem = mediaItems[i]; if (mediaItem.type == MediaType.localImage || mediaItem.type == MediaType.localVideo) { - mediaItems[i].path = - 'assets/images/personal/${json['title'].toString().toLowerCase().replaceAll(' ', '_')}/${mediaItem.path}'; + mediaItems[i].source = + 'assets/images/personal/${json['title'].toString().toLowerCase().replaceAll(' ', '_')}/${mediaItem.source}'; } } @@ -119,7 +119,7 @@ class Project { logoPath: null, title: title, titleAsPath: titleAsPath, - appBarTitle: Strings.projects, + appBarTitle: Strings.personalProjects, subtitle: subtitle, description: description, externalLinks: externalLinks, diff --git a/lib/pages/details/details.controller.dart b/lib/pages/details/details.controller.dart index 5ea28d4..7dc6316 100644 --- a/lib/pages/details/details.controller.dart +++ b/lib/pages/details/details.controller.dart @@ -6,6 +6,7 @@ import '../../common/strings.dart'; import '../../models/app_state.dart'; import '../../models/media_item.dart'; import 'details.model.dart'; +import 'widgets/media_player/multi_media_player.controller.dart'; class DetailsController extends ChangeNotifier { DetailsController({ @@ -14,6 +15,11 @@ class DetailsController extends ChangeNotifier { }) { _details = details; _appState = appState; + mediaController = MultiMediaPlayerController( + mediaItems: _details.mediaItems, + isMediaBrowserOpen: _appState.mediaBrowserOpen, + updateMediaBrowserVisibilityState: _appState.toggleMediaBrowserVisibility, + ); } /// The application state. @@ -25,9 +31,8 @@ class DetailsController extends ChangeNotifier { /// The controller for the scroll view. final ScrollController screenScrollController = ScrollController(); - /// The index of the current media item. - int _currentMediaIndex = 0; - int get currentMediaIndex => _currentMediaIndex; + /// The controller for the media player. + late final MultiMediaPlayerController mediaController; /// The title of the app bar. String get appBarTitle => _details.appBarTitle; @@ -50,12 +55,6 @@ class DetailsController extends ChangeNotifier { /// Whether the media browser is open. bool get mediaBrowserOpen => _appState.mediaBrowserOpen; - /// Callback to toggle the media browser. - void toggleMediaBrowser() { - _appState.toggleMediaBrowserVisibility(); - notifyListeners(); - } - /// The callback for navigating to the previous project/experience. void onPreviousPressed(BuildContext context) { String route = ''; @@ -88,10 +87,4 @@ class DetailsController extends ChangeNotifier { context.go(route); } - - /// The callback for selecting a media item. - void onMediaItemSelected(int index) { - _currentMediaIndex = index; - notifyListeners(); - } } diff --git a/lib/pages/details/details.screen.dart b/lib/pages/details/details.screen.dart index bbbb45d..4a1dd72 100644 --- a/lib/pages/details/details.screen.dart +++ b/lib/pages/details/details.screen.dart @@ -8,7 +8,8 @@ import 'details.controller.dart'; import 'details.model.dart'; import 'widgets/app_bar_actions.dart'; import 'widgets/info_header.dart'; -import 'widgets/media_player/media_player.dart'; +import 'widgets/media_player/multi_media_player.controller.dart'; +import 'widgets/media_player/multi_media_player.dart'; import 'widgets/more_info.dart'; import 'widgets/tags_menu.dart'; @@ -35,68 +36,62 @@ class DetailsScreenState extends State { (BuildContext context, DetailsController controller, Widget? child) { return Stack( children: [ - AnimatedOpacity( - opacity: controller.mediaBrowserOpen ? 0.0 : 1.0, - duration: const Duration(milliseconds: 250), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: SizedBox( - width: MediaQuery.of(context).size.width, - ), - ), - const SizedBox( - height: 64, + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: SizedBox( + width: MediaQuery.of(context).size.width, ), - Expanded( - child: Scrollbar( + ), + const SizedBox( + height: 64, + ), + Expanded( + child: Scrollbar( + controller: controller.screenScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + clipBehavior: Clip.none, controller: controller.screenScrollController, - thumbVisibility: true, - child: SingleChildScrollView( - clipBehavior: Clip.none, - controller: controller.screenScrollController, - child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - top: 8.0, - right: 16.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - InfoHeader( - details: widget.details, - compact: true, - ), - const Divider(height: 32), - SelectableText( - controller.description, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.justify, + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + top: 8.0, + right: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoHeader( + details: widget.details, + compact: true, + ), + const Divider(height: 32), + SelectableText( + controller.description, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.justify, + ), + if (controller.externalLinks.isNotEmpty) + MoreInfo(externalLinks: controller.externalLinks), + const Divider(height: 32), + if (controller.tags.isNotEmpty) + SizedBox( + width: MediaQuery.of(context).size.width * 0.2, + child: TagsMenu(tags: controller.tags), ), - if (controller.externalLinks.isNotEmpty) - MoreInfo( - externalLinks: controller.externalLinks), - const Divider(height: 32), - if (controller.tags.isNotEmpty) - SizedBox( - width: - MediaQuery.of(context).size.width * 0.2, - child: TagsMenu(tags: controller.tags), - ), - const SizedBox(height: 100.00), - ], - ), + const SizedBox(height: 100.00), + ], ), ), ), ), - ], - ), + ), + ], ), // A gradient overlay to fade out the text under the media player. Container( @@ -121,15 +116,7 @@ class DetailsScreenState extends State { // The media player. SizedBox( width: MediaQuery.of(context).size.width, - child: MediaPlayer( - key: ValueKey(controller.currentMediaIndex), - currentIndex: controller.currentMediaIndex, - browserAxis: Axis.vertical, - mediaList: controller.mediaItems, - isMediaBrowserVisible: controller.mediaBrowserOpen, - onMediaSelected: controller.onMediaItemSelected, - onMediaBrowserToggle: controller.toggleMediaBrowser, - ), + child: const MultiMediaPlayer(), ), ], ); @@ -151,17 +138,9 @@ class DetailsScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: MediaPlayer( - key: ValueKey(controller.currentMediaIndex), - browserAxis: Axis.horizontal, - currentIndex: controller.currentMediaIndex, - mediaList: controller.mediaItems, - isMediaBrowserVisible: controller.mediaBrowserOpen, - onMediaSelected: controller.onMediaItemSelected, - onMediaBrowserToggle: controller.toggleMediaBrowser, - ), + const Padding( + padding: EdgeInsets.only(right: 8.0), + child: MultiMediaPlayer(), ), Padding( padding: const EdgeInsets.symmetric( @@ -209,11 +188,20 @@ class DetailsScreenState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: _controller, + return MultiProvider( + providers: >[ + ChangeNotifierProvider.value(value: _controller), + ChangeNotifierProvider.value( + value: _controller.mediaController, + ), + ], builder: (BuildContext context, Widget? child) { return OrientationBuilder( builder: (BuildContext context, Orientation orientation) { + _controller.mediaController.browserAxis = + orientation == Orientation.portrait + ? Axis.vertical + : Axis.horizontal; return Scaffold( appBar: GenericAppBar.build( context: context, diff --git a/lib/pages/details/widgets/media_browser/local_image_thumbnail.dart b/lib/pages/details/widgets/media_browser/local_image_thumbnail.dart new file mode 100644 index 0000000..ce1a8a3 --- /dev/null +++ b/lib/pages/details/widgets/media_browser/local_image_thumbnail.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +/// Thumbnail for a local image. +class LocalImageThumbnail extends StatelessWidget { + const LocalImageThumbnail({ + super.key, + required this.source, + }); + + /// The path to the image. + final String source; + + @override + Widget build(BuildContext context) { + return Image.asset( + source, + cacheHeight: 120, + width: 120, + height: 120, + fit: BoxFit.cover, + ); + } +} diff --git a/lib/pages/details/widgets/media_browser/local_video_thumbnail.dart b/lib/pages/details/widgets/media_browser/local_video_thumbnail.dart new file mode 100644 index 0000000..8ecfa40 --- /dev/null +++ b/lib/pages/details/widgets/media_browser/local_video_thumbnail.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'local_image_thumbnail.dart'; + +/// Thumbnail for a local video. +class LocalVideoThumbnail extends StatelessWidget { + const LocalVideoThumbnail({ + super.key, + required this.source, + }); + + /// The path to the video. + final String source; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + LocalImageThumbnail(source: source), + ColoredBox( + color: Colors.transparent, + child: Center( + child: Icon( + Icons.play_circle_outline, + color: Colors.white.withOpacity(0.95), + size: 48.0, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/details/widgets/media_browser/media_browser.dart b/lib/pages/details/widgets/media_browser/media_browser.dart new file mode 100644 index 0000000..081a04c --- /dev/null +++ b/lib/pages/details/widgets/media_browser/media_browser.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/enums.dart'; +import '../../../../models/media_item.dart'; +import '../../../../widgets/hover_scale_handler.dart'; +import 'local_image_thumbnail.dart'; +import 'local_video_thumbnail.dart'; +import 'network_image_thumbnail.dart'; +import 'youtube_thumbnail.dart'; + +/// Displays a gallery of YouTube videos, local images, and local videos. +class MediaBrowser extends StatelessWidget { + MediaBrowser({ + super.key, + required this.mediaItems, + required this.onTapped, + }); + + /// The media items to display. + final List mediaItems; + + /// The function to call when an image, video, or YouTube video is tapped. + final Function(int)? onTapped; + + /// The controller for the scroll view. + final ScrollController _scrollController = ScrollController(); + + /// The thumbnail for the specified video. + String _localVideoThumbnailPath(String source) { + final String thumbnailPath = + source.split('_').sublist(0, source.split('_').length - 1).join('_'); + final int index = int.parse( + source.split('_').last.split('.').first, + ); + return '${thumbnailPath}_thumbnail_$index.webp'; + } + + /// Returns the thumbnail for the specified media item. + Widget _thumbnail(MediaItem mediaItem) { + Widget thumbnailImage; + + if (mediaItem.type == MediaType.localImage) { + thumbnailImage = LocalImageThumbnail( + source: mediaItem.source, + ); + } else if (mediaItem.type == MediaType.networkImage) { + thumbnailImage = NetworkImageThumbnail( + url: mediaItem.source, + ); + } else if (mediaItem.type == MediaType.localVideo) { + thumbnailImage = LocalVideoThumbnail( + source: _localVideoThumbnailPath(mediaItem.source), + ); + } else { + thumbnailImage = YouTubeThumbnail( + videoId: mediaItem.source, + ); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: ColoredBox( + color: Colors.black12, + child: thumbnailImage, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: CustomScrollView( + controller: _scrollController, + shrinkWrap: true, + slivers: [ + SliverPadding( + padding: const EdgeInsets.only( + right: 14.0, + top: 2.0, + bottom: 2.0, + left: 2.0, + ), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 4.0, + crossAxisSpacing: 4.0, + ), + delegate: SliverChildBuilderDelegate( + childCount: mediaItems.length, + (BuildContext context, int index) => HoverScaleHandler( + tooltip: mediaItems[index].type.stringValue, + onTap: () { + if (onTapped == null) { + return; + } + onTapped!(index); + }, + child: _thumbnail(mediaItems[index]), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/details/widgets/media_browser/network_image_thumbnail.dart b/lib/pages/details/widgets/media_browser/network_image_thumbnail.dart new file mode 100644 index 0000000..cb71870 --- /dev/null +++ b/lib/pages/details/widgets/media_browser/network_image_thumbnail.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:image_network/image_network.dart'; + +/// Thumbnail for a network image. +class NetworkImageThumbnail extends StatelessWidget { + const NetworkImageThumbnail({ + super.key, + required this.url, + }); + + /// The url of the image. + final String url; + + @override + Widget build(BuildContext context) { + return FittedBox( + fit: BoxFit.cover, + child: Stack( + children: [ + ImageNetwork( + image: url, + height: 120, + width: 120, + onLoading: const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.white70, + ), + ), + onError: const Center( + child: Icon( + Icons.error_outline, + color: Colors.white70, + size: 48.0, + ), + ), + ), + // Container to block the ImageNetwork onTap event from being reached. + Container( + width: 120, + height: 120, + color: Colors.transparent, + ), + ], + ), + ); + } +} diff --git a/lib/pages/details/widgets/media_browser/youtube_thumbnail.dart b/lib/pages/details/widgets/media_browser/youtube_thumbnail.dart new file mode 100644 index 0000000..4677370 --- /dev/null +++ b/lib/pages/details/widgets/media_browser/youtube_thumbnail.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/urls.dart'; +import 'network_image_thumbnail.dart'; + +/// Thumbnail for a YouTube video. +class YouTubeThumbnail extends StatelessWidget { + const YouTubeThumbnail({ + super.key, + required this.videoId, + }); + + /// The id of the YouTube video. + final String videoId; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + NetworkImageThumbnail( + url: Urls.youTubeThumbnail(videoId), + ), + ColoredBox( + color: Colors.transparent, + child: Center( + child: Icon( + Icons.play_circle, + color: Colors.red.withOpacity(0.95), + size: 48.0, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/details/widgets/media_player/gallery_controls.dart b/lib/pages/details/widgets/media_player/gallery_controls.dart new file mode 100644 index 0000000..bd969cd --- /dev/null +++ b/lib/pages/details/widgets/media_player/gallery_controls.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/strings.dart'; + +/// The controls for the media gallery. +class GalleryControls extends StatelessWidget { + const GalleryControls({ + super.key, + required this.currentIndex, + required this.totalMediaCount, + required this.onPrevious, + required this.onNext, + required this.onMediaBrowser, + }); + + /// The index of the current media. + final int currentIndex; + + /// The total number of media. + final int totalMediaCount; + + /// The function to call when the previous button is pressed. + final Function()? onPrevious; + + /// The function to call when the next button is pressed. + final Function()? onNext; + + /// The function to call when the media browser button is pressed. + final Function()? onMediaBrowser; + + Widget _iconButton({ + required IconData iconData, + required String tooltip, + required Function()? onPressed, + }) { + return IconButton( + padding: EdgeInsets.zero, + icon: Icon(iconData), + tooltip: tooltip, + onPressed: onPressed, + ); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _iconButton( + iconData: Icons.chevron_left, + tooltip: Strings.previous, + onPressed: onPrevious, + ), + Text( + '${currentIndex + 1} / $totalMediaCount', + style: Theme.of(context).textTheme.bodySmall, + ), + _iconButton( + iconData: Icons.chevron_right, + tooltip: Strings.next, + onPressed: onNext, + ), + const SizedBox(width: 8.0), + _iconButton( + iconData: Icons.grid_view, + tooltip: Strings.viewAllMedia, + onPressed: onMediaBrowser, + ), + ], + ); + } +} diff --git a/lib/pages/details/widgets/media_player/image_viewer.dart b/lib/pages/details/widgets/media_player/image_viewer.dart new file mode 100644 index 0000000..846b729 --- /dev/null +++ b/lib/pages/details/widgets/media_player/image_viewer.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// A simple image viewer that displays an image. +class ImageViewer extends StatelessWidget { + const ImageViewer({ + super.key, + required this.imageProvider, + }); + + /// The image provider for the image to display. + final ImageProvider imageProvider; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Image( + image: imageProvider, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), + ); + } +} diff --git a/lib/pages/details/widgets/media_player/local_video_player.dart b/lib/pages/details/widgets/media_player/local_video_player.dart new file mode 100644 index 0000000..ffd4ed8 --- /dev/null +++ b/lib/pages/details/widgets/media_player/local_video_player.dart @@ -0,0 +1,38 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// A video player that plays a local video. +class LocalVideoPlayer extends StatelessWidget { + const LocalVideoPlayer({ + super.key, + required this.videoPlayerController, + }); + + /// The controller for the video player. + final VideoPlayerController videoPlayerController; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: videoPlayerController.initialize(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return Chewie( + controller: ChewieController( + autoPlay: true, + aspectRatio: 16 / 9, + autoInitialize: true, + videoPlayerController: videoPlayerController, + ), + ); + } + return Center( + child: CircularProgressIndicator( + color: Theme.of(context).hintColor, + ), + ); + }, + ); + } +} diff --git a/lib/pages/details/widgets/media_player/media_browser.dart b/lib/pages/details/widgets/media_player/media_browser.dart deleted file mode 100644 index edf5fdf..0000000 --- a/lib/pages/details/widgets/media_player/media_browser.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:image_network/image_network.dart'; - -import '../../../../common/enums.dart'; -import '../../../../models/media_item.dart'; -import '../../../../widgets/hover_scale_handler.dart'; - -/// Displays a gallery of YouTube videos, local images, and local videos. -class MediaBrowser extends StatelessWidget { - MediaBrowser({ - super.key, - required this.mediaItems, - required this.onTapped, - }); - - /// The media items to display. - final List mediaItems; - - /// The function to call when an image, video, or YouTube video is tapped. - final Function(int)? onTapped; - - /// The controller for the scroll view. - final ScrollController _scrollController = ScrollController(); - - /// Thumbnail for a YouTube video. - Widget _youTubeVideoThumbnail( - String path, - ) { - return Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - _networkImageThumbnail('https://i3.ytimg.com/vi/$path/sddefault.jpg'), - ColoredBox( - color: Colors.transparent, - child: Center( - child: Icon( - Icons.play_circle, - color: Colors.red.withOpacity(0.95), - size: 48.0, - ), - ), - ), - ], - ); - } - - /// Thumbnail for a local video. - Widget _videoThumbnail(String path) { - return Stack( - children: [ - _imageThumbnail(path), - ColoredBox( - color: Colors.transparent, - child: Center( - child: Icon( - Icons.play_circle_outline, - color: Colors.white.withOpacity(0.95), - size: 48.0, - ), - ), - ), - ], - ); - } - - /// Thumbnail for a local image. - Widget _imageThumbnail(String path) { - return Image.asset( - path, - cacheHeight: 250, - width: 250, - height: 250, - fit: BoxFit.cover, - ); - } - - /// Thumbnail for a network image. - Widget _networkImageThumbnail(String path) { - return FittedBox( - fit: BoxFit.cover, - child: ImageNetwork( - onTap: () { - if (onTapped == null) { - return; - } - onTapped!(mediaItems.indexWhere( - (MediaItem mediaItem) => mediaItem.path == path, - )); - }, - image: path, - height: 250, - width: 250, - onLoading: const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Colors.white70, - ), - ), - onError: const Center( - child: Icon( - Icons.error_outline, - color: Colors.white70, - size: 48.0, - ), - ), - ), - ); - } - - /// The thumbnail for the specified video. - String getVideoThumbnailPath(String path) { - final String thumbnailPath = - path.split('_').sublist(0, path.split('_').length - 1).join('_'); - final int index = int.parse( - path.split('_').last.split('.').first, - ); - return '${thumbnailPath}_thumbnail_$index.webp'; - } - - @override - Widget build(BuildContext context) { - return Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: CustomScrollView( - controller: _scrollController, - shrinkWrap: true, - slivers: [ - SliverPadding( - padding: const EdgeInsets.only( - right: 14.0, - top: 2.0, - bottom: 2.0, - left: 2.0, - ), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 4.0, - crossAxisSpacing: 4.0, - ), - delegate: SliverChildBuilderDelegate( - childCount: mediaItems.length, - (BuildContext context, int index) => HoverScaleHandler( - tooltip: mediaItems[index].type.stringValue, - onTap: () { - if (onTapped == null) { - return; - } - onTapped!(index); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: ColoredBox( - color: Colors.black12, - child: mediaItems[index].type == MediaType.localImage - ? _imageThumbnail(mediaItems[index].path) - : mediaItems[index].type == MediaType.networkImage - ? _networkImageThumbnail(mediaItems[index].path) - : mediaItems[index].type == MediaType.localVideo - ? _videoThumbnail(getVideoThumbnailPath( - mediaItems[index].path, - )) - : _youTubeVideoThumbnail( - mediaItems[index].path), - ), - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/details/widgets/media_player/media_player.dart b/lib/pages/details/widgets/media_player/media_player.dart deleted file mode 100644 index 86b1d58..0000000 --- a/lib/pages/details/widgets/media_player/media_player.dart +++ /dev/null @@ -1,392 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:photo_view/photo_view_gallery.dart'; -import 'package:video_player/video_player.dart'; -import 'package:youtube_player_iframe/youtube_player_iframe.dart'; -import 'package:zoom_pinch_overlay/zoom_pinch_overlay.dart'; - -import '../../../../common/enums.dart'; -import '../../../../common/theming/color_schemes.dart'; -import '../../../../models/media_item.dart'; -import '../../../../widgets/frosted_container.dart'; -import '../../../../widgets/gallery_controls.dart'; -import 'media_browser.dart'; - -/// Displays different types of media (images, videos, youTubeVideo videos) -/// with controls for navigating between them. -class MediaPlayer extends StatefulWidget { - const MediaPlayer({ - super.key, - required this.mediaList, - required this.browserAxis, - required this.currentIndex, - required this.onMediaSelected, - required this.onMediaBrowserToggle, - required this.isMediaBrowserVisible, - }); - - /// The index of the current media item. - final int currentIndex; - - /// The list of media items to display. - final List mediaList; - - /// The function to call when the media browser is toggled. - final Function() onMediaBrowserToggle; - - /// The function to call when a media item is selected. - final Function(int)? onMediaSelected; - - /// Whether the media browser is visible. - final bool isMediaBrowserVisible; - - /// The axis of the media player and browser. - final Axis browserAxis; - - @override - State createState() => MediaPlayerState(); -} - -class MediaPlayerState extends State { - /// Whether the media browser is visible. - late bool _isMediaBrowserVisible; - - /// The index of the current media item. - late int _currentIndex; - - /// The controller for the image gallery. - late PageController _imagePageController; - - /// The controller for the video player. - VideoPlayerController? _videoPlayerController; - - int get totalMediaCount => widget.mediaList.length; - - List get mediaItems => widget.mediaList; - - /// The viewer for the media gallery. - Widget _viewer(BoxConstraints constraints) { - return SizedBox( - width: constraints.maxWidth, - height: widget.browserAxis == Axis.vertical - ? null - : MediaQuery.of(context).size.height * 0.75, - child: AspectRatio( - aspectRatio: 16 / 9, - child: PhotoViewGallery.builder( - scrollPhysics: const NeverScrollableScrollPhysics(), - builder: (BuildContext context, int index) { - return PhotoViewGalleryPageOptions.customChild( - disableGestures: true, - heroAttributes: PhotoViewHeroAttributes( - tag: index.toString(), - ), - child: ZoomOverlay( - twoTouchOnly: true, - modalBarrierColor: Colors.black87, - minScale: 0.5, - maxScale: 3.0, - animationDuration: const Duration(milliseconds: 300), - child: SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: _media(index), - ), - ), - ); - }, - enableRotation: true, - itemCount: widget.mediaList.length, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - pageController: _imagePageController, - onPageChanged: (_) {}, - ), - ), - ); - } - - /// Builds the media browser. - Widget _mediaBrowser({required BuildContext context}) { - return Padding( - padding: !widget.isMediaBrowserVisible - ? EdgeInsets.zero - : widget.browserAxis == Axis.vertical - ? const EdgeInsets.only(bottom: 8.0) - : const EdgeInsets.only(left: 8.0), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - child: SizedBox( - height: _isMediaBrowserVisible - ? widget.browserAxis == Axis.vertical - ? double.infinity - : MediaQuery.of(context).size.height * 0.8 - : 0, - width: _isMediaBrowserVisible - ? widget.browserAxis == Axis.vertical - ? double.infinity - : MediaQuery.of(context).size.width * 0.25 - : 0, - child: Flex( - direction: widget.browserAxis, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(), - Flexible( - child: Padding( - padding: widget.browserAxis == Axis.vertical - ? const EdgeInsets.only(left: 8.0, bottom: 4.0) - : const EdgeInsets.only(top: 8.0), - child: MediaBrowser( - mediaItems: mediaItems, - onTapped: widget.onMediaSelected, - ), - ), - ), - ], - ), - ), - ), - ); - } - - /// A banner that displays captions and controls for the player. - Widget _playerControlBanner(BuildContext context) { - return Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - decoration: BoxDecoration( - border: widget.browserAxis == Axis.horizontal && - _isMediaBrowserVisible - ? Border( - right: BorderSide( - color: PortfolioColorSchemes.dark.surface.withOpacity(0.5), - ), - ) - : null, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - mediaItems[_currentIndex].caption, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: GalleryControls( - currentIndex: _currentIndex, - totalMediaCount: totalMediaCount, - onPrevious: _onPrevious, - onNext: _onNext, - onMediaBrowser: () { - widget.onMediaBrowserToggle(); - setState(() { - _isMediaBrowserVisible = !_isMediaBrowserVisible; - }); - }, - ), - ), - ], - ), - ), - ); - } - - /// The current media item. - Widget _media(int index) { - final MediaType mediaType = mediaItems[index].type; - switch (mediaType) { - case MediaType.youTubeVideo: - return _youTubePlayer(mediaItems[_currentIndex].path); - case MediaType.localVideo: - if (_videoPlayerController != null) { - _videoPlayerController!.dispose(); - } - _videoPlayerController = VideoPlayerController.asset( - mediaItems[index].path, - ); - return _localVideoPlayer(_videoPlayerController!); - case MediaType.localImage: - return _imageViewer(AssetImage(mediaItems[index].path)); - case MediaType.networkImage: - return _imageViewer(NetworkImage(mediaItems[index].path)); - } - } - - /// A YouTube video player. - Widget _youTubePlayer(String videoId) { - return YoutubePlayer( - controller: YoutubePlayerController.fromVideoId( - videoId: videoId, - autoPlay: true, - params: const YoutubePlayerParams( - strictRelatedVideos: true, - loop: true, - ), - ), - ); - } - - /// A local video player. - Widget _localVideoPlayer(VideoPlayerController videoPlayerController) { - return FutureBuilder( - future: videoPlayerController.initialize(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Chewie( - controller: ChewieController( - autoPlay: true, - aspectRatio: 16 / 9, - autoInitialize: true, - videoPlayerController: videoPlayerController, - ), - ); - } - return Center( - child: CircularProgressIndicator( - color: Theme.of(context).hintColor, - ), - ); - }, - ); - } - - /// An image viewer. - Widget _imageViewer(ImageProvider imageProvider) { - return SizedBox( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - child: Image( - image: imageProvider, - fit: BoxFit.contain, - filterQuality: FilterQuality.medium, - ), - ); - } - - /// Navigate to the previous media item. - Future _onPrevious() async { - int previousIndex = _currentIndex - 1; - if (previousIndex < 0) { - previousIndex = totalMediaCount - 1; - _imagePageController.jumpToPage(previousIndex); - setState(() { - _currentIndex = previousIndex; - }); - } else { - setState(() { - _currentIndex = previousIndex; - }); - if (mediaItems[_currentIndex].type == MediaType.youTubeVideo) { - _imagePageController.jumpToPage(previousIndex); - return; - } - await _imagePageController.animateToPage( - _currentIndex = previousIndex, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } - } - - /// Navigate to the next media item. - Future _onNext() async { - int nextIndex = _currentIndex + 1; - if (nextIndex == totalMediaCount) { - nextIndex = 0; - _imagePageController.jumpToPage(nextIndex); - setState(() { - _currentIndex = nextIndex; - }); - } else { - setState(() { - _currentIndex = nextIndex; - }); - if (mediaItems[_currentIndex].type == MediaType.youTubeVideo) { - _imagePageController.jumpToPage(nextIndex); - return; - } - await _imagePageController.animateToPage( - _currentIndex = nextIndex, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } - } - - @override - void initState() { - super.initState(); - _isMediaBrowserVisible = widget.isMediaBrowserVisible; - _currentIndex = widget.currentIndex; - _imagePageController = PageController( - initialPage: _currentIndex, - ); - _imagePageController.addListener(() { - if (_imagePageController.page == null || - _imagePageController.page!.round() == _currentIndex) { - return; - } - _currentIndex = _imagePageController.page!.round(); - }); - } - - @override - void dispose() { - _imagePageController.dispose(); - if (_videoPlayerController != null) { - _videoPlayerController!.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(4.0), - child: FrostedContainer( - padding: EdgeInsets.zero, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ColoredBox( - color: PortfolioColorSchemes.dark.surface.withOpacity(0.9), - child: LayoutBuilder( - builder: - (BuildContext context, BoxConstraints constraints) { - return _viewer(constraints); - }, - ), - ), - _playerControlBanner(context), - if (widget.browserAxis == Axis.vertical) - Flexible( - child: _mediaBrowser(context: context), - ), - ], - ), - ), - if (widget.browserAxis == Axis.horizontal) - _mediaBrowser(context: context), - ], - ), - ), - ); - } -} diff --git a/lib/pages/details/widgets/media_player/media_viewer.dart b/lib/pages/details/widgets/media_player/media_viewer.dart new file mode 100644 index 0000000..229003d --- /dev/null +++ b/lib/pages/details/widgets/media_player/media_viewer.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:video_player/video_player.dart'; +import 'package:zoom_pinch_overlay/zoom_pinch_overlay.dart'; + +import '../../../../common/enums.dart'; +import '../../../../models/media_item.dart'; +import 'image_viewer.dart'; +import 'local_video_player.dart'; +import 'youtube_player.dart'; + +/// The viewer for the media player. +class MediaViewer extends StatelessWidget { + const MediaViewer({ + super.key, + required this.axis, + required this.mediaItems, + required this.totalMediaCount, + required this.imagePageController, + }); + + /// The axis of the media player and browser. + final Axis axis; + + /// The media to display. + final List mediaItems; + + /// The total number of media items. + final int totalMediaCount; + + /// The controller for the image gallery. + final PageController imagePageController; + + /// Returns the media widget based on the media type. + Widget _media(MediaItem mediaItem) { + final MediaType mediaType = mediaItem.type; + switch (mediaType) { + case MediaType.youTubeVideo: + return CustomYouTubePlayer(videoId: mediaItem.source); + case MediaType.localVideo: + return LocalVideoPlayer( + videoPlayerController: VideoPlayerController.asset(mediaItem.source), + ); + case MediaType.localImage: + return ImageViewer( + imageProvider: AssetImage(mediaItem.source), + ); + case MediaType.networkImage: + return ImageViewer( + imageProvider: NetworkImage(mediaItem.source), + ); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SizedBox( + width: constraints.maxWidth, + height: axis == Axis.vertical + ? null + : MediaQuery.of(context).size.height * 0.75, + child: AspectRatio( + aspectRatio: 16 / 9, + child: PhotoViewGallery.builder( + enableRotation: true, + itemCount: totalMediaCount, + pageController: imagePageController, + scrollPhysics: const NeverScrollableScrollPhysics(), + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + builder: (BuildContext context, int index) { + return PhotoViewGalleryPageOptions.customChild( + disableGestures: true, + heroAttributes: PhotoViewHeroAttributes( + tag: index.toString(), + ), + child: ZoomOverlay( + twoTouchOnly: true, + modalBarrierColor: Colors.black87, + minScale: 0.5, + maxScale: 3.0, + animationDuration: const Duration(milliseconds: 300), + child: SizedBox( + key: ValueKey(mediaItems[index].source), + width: constraints.maxWidth, + height: constraints.maxHeight, + child: _media(mediaItems[index]), + ), + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/details/widgets/media_player/multi_media_player.controller.dart b/lib/pages/details/widgets/media_player/multi_media_player.controller.dart new file mode 100644 index 0000000..4cac4e0 --- /dev/null +++ b/lib/pages/details/widgets/media_player/multi_media_player.controller.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../../common/enums.dart'; +import '../../../../models/media_item.dart'; + +class MultiMediaPlayerController extends ChangeNotifier { + MultiMediaPlayerController({ + required this.mediaItems, + required bool isMediaBrowserOpen, + required Function() updateMediaBrowserVisibilityState, + }) { + _isMediaBrowserOpen = isMediaBrowserOpen; + _updateMediaBrowserVisibilityState = updateMediaBrowserVisibilityState; + imagePageController = PageController( + initialPage: currentIndex, + ); + } + + /// The index of the current media item. + int currentIndex = 0; + + /// The list of media items to display. + final List mediaItems; + + /// The current media item. + MediaItem get currentMediaItem => mediaItems[currentIndex]; + + /// The total number of media items. + int get totalMediaCount => mediaItems.length; + + /// Whether the media browser is visible. + bool _isMediaBrowserOpen = false; + bool get isMediaBrowserOpen => _isMediaBrowserOpen; + + /// The axis of the media player and browser. + Axis browserAxis = Axis.vertical; + + /// The controller for the image gallery. + late PageController imagePageController; + + /// The controller for the video player. + VideoPlayerController? videoPlayerController; + + /// The function to call to toggle the media browser. + late Function() _updateMediaBrowserVisibilityState; + + /// Toggle the media browser. + void toggleMediaBrowser() { + _updateMediaBrowserVisibilityState(); + _isMediaBrowserOpen = !_isMediaBrowserOpen; + notifyListeners(); + } + + /// Navigate to the previous media item. + Future onPrevious() async { + int previousIndex = currentIndex - 1; + if (previousIndex < 0) { + previousIndex = totalMediaCount - 1; + imagePageController.jumpToPage(previousIndex); + } else { + if (mediaItems[currentIndex].type == MediaType.youTubeVideo) { + imagePageController.jumpToPage(previousIndex); + } else { + await imagePageController.animateToPage( + previousIndex, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } + currentIndex = previousIndex; + notifyListeners(); + } + + /// Navigate to the next media item. + Future onNext() async { + int nextIndex = currentIndex + 1; + if (nextIndex == totalMediaCount) { + nextIndex = 0; + imagePageController.jumpToPage(nextIndex); + } else { + if (mediaItems[currentIndex].type == MediaType.youTubeVideo) { + imagePageController.jumpToPage(nextIndex); + } else { + await imagePageController.animateToPage( + nextIndex, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } + currentIndex = nextIndex; + notifyListeners(); + } + + /// The function to call when a media item is selected. + void onMediaSelected(int index) { + imagePageController.jumpToPage(index); + currentIndex = index; + notifyListeners(); + } + + @override + void dispose() { + imagePageController.dispose(); + if (videoPlayerController != null) { + videoPlayerController!.dispose(); + } + super.dispose(); + } +} diff --git a/lib/pages/details/widgets/media_player/multi_media_player.dart b/lib/pages/details/widgets/media_player/multi_media_player.dart new file mode 100644 index 0000000..ec20650 --- /dev/null +++ b/lib/pages/details/widgets/media_player/multi_media_player.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../common/theming/color_schemes.dart'; +import '../../../../widgets/frosted_container.dart'; +import '../media_browser/media_browser.dart'; +import 'media_viewer.dart'; +import 'multi_media_player.controller.dart'; +import 'player_banner.dart'; + +/// Displays different types of media (images, videos, youTubeVideo videos) +/// with controls for navigating between them. +class MultiMediaPlayer extends StatefulWidget { + const MultiMediaPlayer({ + super.key, + }); + + @override + State createState() => MultiMediaPlayerState(); +} + +class MultiMediaPlayerState extends State { + /// Builds the media browser. + Widget _browser({ + required BuildContext context, + required MultiMediaPlayerController controller, + }) { + return Padding( + padding: !controller.isMediaBrowserOpen + ? EdgeInsets.zero + : controller.browserAxis == Axis.vertical + ? const EdgeInsets.only(bottom: 8.0) + : const EdgeInsets.only(left: 8.0), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: SizedBox( + height: controller.isMediaBrowserOpen + ? controller.browserAxis == Axis.vertical + ? double.infinity + : MediaQuery.of(context).size.height * 0.8 + : 0, + width: controller.isMediaBrowserOpen + ? controller.browserAxis == Axis.vertical + ? double.infinity + : MediaQuery.of(context).size.width * 0.25 + : 0, + child: Flex( + direction: controller.browserAxis, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + Flexible( + child: Padding( + padding: controller.browserAxis == Axis.vertical + ? const EdgeInsets.only(left: 8.0, bottom: 4.0) + : const EdgeInsets.only(top: 8.0), + child: MediaBrowser( + mediaItems: controller.mediaItems, + onTapped: controller.onMediaSelected, + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (BuildContext context, + MultiMediaPlayerController controller, Widget? child) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: FrostedContainer( + padding: EdgeInsets.zero, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ColoredBox( + color: + PortfolioColorSchemes.dark.surface.withOpacity(0.9), + child: MediaViewer( + axis: controller.browserAxis, + mediaItems: controller.mediaItems, + totalMediaCount: controller.totalMediaCount, + imagePageController: controller.imagePageController, + ), + ), + PlayerBanner( + controller: controller, + browserAxis: controller.browserAxis, + ), + if (controller.browserAxis == Axis.vertical) + Flexible( + child: _browser( + context: context, + controller: controller, + ), + ), + ], + ), + ), + if (controller.browserAxis == Axis.horizontal) + _browser( + context: context, + controller: controller, + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/pages/details/widgets/media_player/player_banner.dart b/lib/pages/details/widgets/media_player/player_banner.dart new file mode 100644 index 0000000..6e400ac --- /dev/null +++ b/lib/pages/details/widgets/media_player/player_banner.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/theming/color_schemes.dart'; +import 'gallery_controls.dart'; +import 'multi_media_player.controller.dart'; + +/// A banner that displays captions and controls for the player. +class PlayerBanner extends StatelessWidget { + const PlayerBanner({ + super.key, + required this.browserAxis, + required this.controller, + }); + + /// The axis of the media browser. + final Axis browserAxis; + + /// The controller for the media player. + final MultiMediaPlayerController controller; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: browserAxis == Axis.horizontal && + controller.isMediaBrowserOpen + ? Border( + right: BorderSide( + color: PortfolioColorSchemes.dark.surface.withOpacity(0.5), + ), + ) + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + controller.currentMediaItem.caption, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: GalleryControls( + currentIndex: controller.currentIndex, + totalMediaCount: controller.totalMediaCount, + onPrevious: controller.onPrevious, + onNext: controller.onNext, + onMediaBrowser: controller.toggleMediaBrowser, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/details/widgets/media_player/youtube_player.dart b/lib/pages/details/widgets/media_player/youtube_player.dart new file mode 100644 index 0000000..f82b5cc --- /dev/null +++ b/lib/pages/details/widgets/media_player/youtube_player.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:youtube_player_iframe/youtube_player_iframe.dart'; + +/// A custom YouTube player that automatically plays the video. +class CustomYouTubePlayer extends StatelessWidget { + const CustomYouTubePlayer({ + super.key, + required this.videoId, + }); + + /// The ID of the YouTube video to display. + final String videoId; + + @override + Widget build(BuildContext context) { + return YoutubePlayer( + controller: YoutubePlayerController.fromVideoId( + videoId: videoId, + autoPlay: true, + params: const YoutubePlayerParams( + strictRelatedVideos: true, + loop: true, + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 5722e30..6be121d 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.4.0+46 +version: 2.4.1+47 environment: sdk: ">=3.1.1 <4.0.0"