diff --git a/lib/common/sample_resume.dart b/lib/common/sample_resume.dart new file mode 100644 index 0000000..75573ee --- /dev/null +++ b/lib/common/sample_resume.dart @@ -0,0 +1,114 @@ +import 'package:flutter/cupertino.dart'; + +import '../models/contact.dart'; +import '../models/education.dart'; +import '../models/experience.dart'; +import '../models/generic.dart'; +import '../models/resume.dart'; + +class SampleResume extends Resume { + SampleResume() + : super( + name: 'John Doe', + location: 'San Francisco, CA', + skills: [ + 'Flutter', + 'Dart', + 'Python', + 'Java', + 'C++', + ], + contactList: [ + Contact( + value: 'johndoe@email.com', + iconData: CupertinoIcons.mail, + ), + Contact( + value: 'linkedin.com/in/jdoe', + iconData: CupertinoIcons.link, + ), + Contact( + value: '123-456-7890', + iconData: CupertinoIcons.phone, + ), + Contact( + value: 'example.com/portfolio/jdoe', + iconData: CupertinoIcons.globe, + ), + ], + experiences: [ + Experience( + company: 'Example Holdings Inc.', + position: 'Software Engineer', + startDate: '01/2020', + endDate: 'Present', + location: 'San Francisco, CA', + description: + '• Installed and configured software applications and tested solutions for effectiveness.\n• Worked with project managers, developers, quality assurance and customers to resolve\n technical issues.\n• Interfaced with cross-functional team of business analysts, developers and technical\n support professionals to determine comprehensive list of requirement specifications for\n new applications.', + ), + Experience( + company: 'Example Technologies', + position: 'Software Development Associate', + startDate: '01/2019', + endDate: '12/2019', + location: 'San Francisco, CA', + description: + '• Administered government-supported community development programs and promoted\n department programs and services.\n• Worked closely with clients to establish problem specifications and system designs.\n• Developed next generation integration platform for internal applications.', + ), + Experience( + position: 'Web Development Intern', + company: 'Example Appraisal Services', + startDate: '06/2018', + endDate: '08/2018', + location: 'San Francisco, CA', + description: + '• Participated with preparation of design documents for trackwork, including alignments,\n specifications, criteria details and estimates.\n• Collaborated with senior engineers on projects and offered insight.\n• Engaged in software development utilizing wide range of technological tools and\n industrial Ethernet-based protocols.', + ) + ], + educationHistory: [ + Education( + institution: 'Example University', + degree: 'MS, Software Engineering', + startDate: '08/2018', + endDate: '12/2019', + location: 'San Francisco, CA', + ), + Education( + institution: 'Example State University', + degree: 'BS, Computer Science', + startDate: '08/2014', + endDate: '05/2018', + location: 'San Francisco, CA', + ), + ], + customSections: >[ + { + 'Summary': GenericEntry( + title: '', + description: + 'Technology-driven Software Engineer with 4 years of experience in translating business requirements and functional specification into code modules and software solutions. Deep understanding of system integration testing (SIT) and user acceptance testing (UAT). Engages in the software development lifecycle to support the development, configuration, modification, and testing of integrated business and enterprise application solutions. Drives the adoption of new technologies by researching innovative technology trends and developments.', + ), + }, + { + 'Projects': GenericEntry( + title: 'Inventory Management System', + description: + '• Developed a mobile application for a local business to manage their inventory and sales.\n• Utilized Flutter and Firebase to create a cross-platform application for Android and iOS.\n• Implemented a barcode scanner to scan products and update inventory in real-time.\n• Designed a user interface to display sales and inventory data in a visually appealing manner.'), + }, + { + 'Projects': GenericEntry( + title: 'Project Management System', + description: + '• Built a web application to manage and track the progress of projects.\n• Utilized Flutter and GitHub Pages to create a web application for desktop and mobile.\n') + } + ], + hiddenSections: ['Skills'], + sectionOrder: [ + 'Summary', + 'Skills', + 'Experience', + 'Projects', + 'Education' + ], + ); +} diff --git a/lib/constants/strings.dart b/lib/common/strings.dart similarity index 59% rename from lib/constants/strings.dart rename to lib/common/strings.dart index ccf5b57..bf87f0e 100644 --- a/lib/constants/strings.dart +++ b/lib/common/strings.dart @@ -2,6 +2,8 @@ class Strings { Strings._(); // URLs + static const String siteUrl = + 'https://plguerradesigns.github.io/flutter_resume_builder/'; static const String flutterUrl = 'https://flutter.dev/'; static const String sponsorUrl = 'https://www.buymeacoffee.com/plguerra'; static const String sourceCodeUrl = @@ -10,18 +12,20 @@ class Strings { 'https://plguerradesigns.github.io/portfolio/'; // Asset Paths - static const String iconPath = 'images/icon.png'; + static const String iconPath = 'assets/images/icon.png'; // General static const String resumeBuilder = 'Resume Builder'; static const String poweredByFlutter = 'Powered by Flutter'; static const String flutterResumeBuilder = 'Flutter Resume Builder'; static const String projectInfo = - 'This project is still under development!\n\nThe Flutter Resume Builder is an open source project created by Pablo L. Guerra to provide users with a web-based resume builder that is free and easy to use while still providing professional resumes.'; - static const String aboutThisProject = 'About this project'; - static const String moreOptions = 'More options'; - static const String contributeToThisProject = 'Contribute to this project'; - static const String projectDonation = 'Project Donation'; + 'The Flutter Resume Builder, crafted by Pablo L. Guerra, is an ' + 'open-source initiative aimed at offering users\na user-friendly, ' + 'web-based resume builder that is both free and capable of producing polished resumes.'; + static const String aboutThisProject = 'About this Project'; + static const String moreOptions = 'More Options'; + static const String contributeCode = 'Contribute Code'; + static const String donate = 'Donate'; static const String licenses = 'Licenses'; static const String moreProjects = 'More Projects'; static String copyRight(String year) => '© $year Pablo L. Guerra'; @@ -51,26 +55,38 @@ class Strings { 'Failed to load network image.'; static const String addImageBeforeEnabling = 'Add an image before enabling.'; static const String removeSection = 'Remove Section'; - static const String hideAllEntries = 'Hide All Entries'; - static const String showAllEntries = 'Show All Entries'; + static const String hideSection = 'Hide Section'; + static const String showSection = 'Show Section'; static const String newEntry = 'New Entry'; static const String moveSectionUp = 'Move Section Up'; static const String moveSectionDown = 'Move Section Down'; static String deleteSection(String sectionName) => 'Delete $sectionName Section?'; - static const String addNewSection = 'Add New Section'; + static const String addCustomSection = 'Add Custom Section'; static const String startDate = 'Start Date'; static const String endDate = 'End Date'; static const String title = 'Title'; static const String subtitle = 'Subtitle'; static const String description = 'Description'; + static const String removeSectionWarning = + 'Are you sure you want to remove this section?\nThis action cannot be undone.'; - static const String deleteSectionWarning = - 'Are you sure you want to delete this section?\nThis action cannot be undone.'; - - // Split view - static const String recompile = 'RECOMPILE'; - static const String downloadPDF = 'DOWNLOAD PDF'; + // Split Screen + static const String recompile = 'Recompile'; + static const String downloadPdfAndJson = 'Download PDF and JSON File'; + static const String noValidJsonFile = 'No valid JSON file selected.'; + static const String importJson = 'Import JSON File'; static const String form = 'Form'; static const String preview = 'Preview'; + static const String printPDF = 'Print PDF'; + static const String clear = 'Clear'; + static const String clearResume = 'Clear Resume'; + static const String clearResumeWarning = + 'Are you sure you want to clear the resume?\nThis action cannot be undone.'; + static const String removeEntry = 'Remove Entry'; + static const String removeEntryWarning = + 'Are you sure you want to remove this entry?\nThis action cannot be undone.'; + static const String remove = 'Remove'; + static const String hideEntry = 'Hide Entry'; + static const String showEntry = 'Show Entry'; } diff --git a/lib/main.dart b/lib/main.dart index e2a4691..10dd423 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'constants/strings.dart'; -import 'pages/split_view.dart'; +import 'common/strings.dart'; +import 'screens/split_screen.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -18,7 +18,7 @@ class FlutterResumeBuilder extends StatelessWidget { brightness: Brightness.dark, useMaterial3: true, ), - home: const SplitView(), + home: const SplitScreen(), ); } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 3c6489c..46c88ac 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -3,15 +3,34 @@ import 'package:flutter/cupertino.dart'; /// A contact entry. class Contact { Contact({ - String value = '', - this.iconData = CupertinoIcons.phone, + String? value, + IconData? iconData, }) { - textController.text = value; + textController.text = value ?? ''; + this.iconData = iconData ?? CupertinoIcons.minus; + } + + /// Return a contact from a map. + factory Contact.fromMap(Map map) { + return Contact( + value: map['value'] as String, + iconData: IconData(map['iconData'] as int, + fontFamily: CupertinoIcons.iconFont, + fontPackage: CupertinoIcons.iconFontPackage), + ); } /// The controller for the text field. TextEditingController textController = TextEditingController(); /// The icon to display for this contact. - IconData iconData = CupertinoIcons.phone; + late IconData iconData; + + /// Return a map of the contact. + Map toMap() { + return { + 'value': textController.text, + 'iconData': iconData.codePoint, + }; + } } diff --git a/lib/models/education.dart b/lib/models/education.dart index 9c083ad..961fac1 100644 --- a/lib/models/education.dart +++ b/lib/models/education.dart @@ -1,19 +1,30 @@ import 'package:flutter/cupertino.dart'; /// A education history entry. -class Education extends ChangeNotifier { +class Education { Education({ - String institution = '', - String degree = '', - String startDate = '', - String endDate = '', - String location = '', + String? institution, + String? degree, + String? startDate, + String? endDate, + String? location, }) { - institutionController.text = institution; - degreeController.text = degree; - startDateController.text = startDate; - endDateController.text = endDate; - locationController.text = location; + institutionController.text = institution ?? ''; + degreeController.text = degree ?? ''; + startDateController.text = startDate ?? ''; + endDateController.text = endDate ?? ''; + locationController.text = location ?? ''; + } + + /// Return an education instance from a map. + factory Education.fromMap(Map map) { + return Education( + institution: map['institution'] as String, + degree: map['degree'] as String, + startDate: map['startDate'] as String, + endDate: map['endDate'] as String, + location: map['location'] as String, + ); } /// The controller for the institution field. @@ -30,4 +41,26 @@ class Education extends ChangeNotifier { /// The controller for the location field. TextEditingController locationController = TextEditingController(); + + /// Whether the entry is visible. + bool _visible = true; + + /// Whether the entry is visible. + bool get visible => _visible; + + /// Toggle the visibility of the entry. + void toggleVisibility() { + _visible = !_visible; + } + + /// Return a map of the education history entry. + Map toMap() { + return { + 'institution': institutionController.text, + 'degree': degreeController.text, + 'startDate': startDateController.text, + 'endDate': endDateController.text, + 'location': locationController.text, + }; + } } diff --git a/lib/models/experience.dart b/lib/models/experience.dart index 196234b..a009b96 100644 --- a/lib/models/experience.dart +++ b/lib/models/experience.dart @@ -1,21 +1,33 @@ import 'package:flutter/cupertino.dart'; /// A professional experience entry. -class Experience extends ChangeNotifier { +class Experience { Experience({ - String company = '', - String position = '', - String startDate = '', - String endDate = '', - String location = '', - String description = '', + String? company, + String? position, + String? startDate, + String? endDate, + String? location, + String? description, }) { - companyController.text = company; - positionController.text = position; - startDateController.text = startDate; - endDateController.text = endDate; - locationController.text = location; - descriptionController.text = description; + companyController.text = company ?? ''; + positionController.text = position ?? ''; + startDateController.text = startDate ?? ''; + endDateController.text = endDate ?? ''; + locationController.text = location ?? ''; + descriptionController.text = description ?? ''; + } + + /// Return an experience instance from a map. + factory Experience.fromMap(Map map) { + return Experience( + company: map['company'] as String, + position: map['position'] as String, + startDate: map['startDate'] as String, + endDate: map['endDate'] as String, + location: map['location'] as String, + description: map['description'] as String, + ); } /// The controller for the company field. @@ -35,4 +47,27 @@ class Experience extends ChangeNotifier { /// The controller for the description field. TextEditingController descriptionController = TextEditingController(); + + /// Whether the entry is visible. + bool _visible = true; + + /// Whether the entry is visible. + bool get visible => _visible; + + /// Toggle the visibility of the entry. + void toggleVisibility() { + _visible = !_visible; + } + + /// Return a map of the experience. + Map toMap() { + return { + 'company': companyController.text, + 'position': positionController.text, + 'startDate': startDateController.text, + 'endDate': endDateController.text, + 'location': locationController.text, + 'description': descriptionController.text, + }; + } } diff --git a/lib/models/generic.dart b/lib/models/generic.dart index 0dab484..caa6d88 100644 --- a/lib/models/generic.dart +++ b/lib/models/generic.dart @@ -1,21 +1,33 @@ import 'package:flutter/cupertino.dart'; /// A generic entry that can be used for custom sections. -class GenericEntry extends ChangeNotifier { +class GenericEntry { GenericEntry({ - String title = '', - String subtitle = '', - String startDate = '', - String endDate = '', - String location = '', - String description = '', + String? title, + String? subtitle, + String? startDate, + String? endDate, + String? location, + String? description, }) { - titleController.text = title; - subtitleController.text = subtitle; - startDateController.text = startDate; - endDateController.text = endDate; - locationController.text = location; - descriptionController.text = description; + titleController.text = title ?? ''; + subtitleController.text = subtitle ?? ''; + startDateController.text = startDate ?? ''; + endDateController.text = endDate ?? ''; + locationController.text = location ?? ''; + descriptionController.text = description ?? ''; + } + + /// Return a generic entry from a map. + factory GenericEntry.fromMap(Map map) { + return GenericEntry( + title: map['title'] as String, + subtitle: map['subtitle'] as String, + startDate: map['startDate'] as String, + endDate: map['endDate'] as String, + location: map['location'] as String, + description: map['description'] as String, + ); } /// The controller for the title field. @@ -35,4 +47,27 @@ class GenericEntry extends ChangeNotifier { /// The controller for the location field. TextEditingController locationController = TextEditingController(); + + /// Whether the entry is visible. + bool _visible = true; + + /// Whether the entry is visible. + bool get visible => _visible; + + /// Toggle the visibility of the entry. + void toggleVisibility() { + _visible = !_visible; + } + + /// Return a map of the generic entry. + Map toMap() { + return { + 'title': titleController.text, + 'subtitle': subtitleController.text, + 'startDate': startDateController.text, + 'endDate': endDateController.text, + 'location': locationController.text, + 'description': descriptionController.text, + }; + } } diff --git a/lib/models/resume.dart b/lib/models/resume.dart index 03f624a..54bc046 100644 --- a/lib/models/resume.dart +++ b/lib/models/resume.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import '../constants/strings.dart'; import 'contact.dart'; import 'education.dart'; import 'experience.dart'; @@ -10,31 +9,88 @@ import 'generic.dart'; /// The resume being edited. class Resume extends ChangeNotifier { - Resume() { - nameController.text = ''; - locationController.text = ''; - contactList = [Contact(), Contact(), Contact(), Contact()]; - experiences = [ - Experience(), - Experience(), - ]; - educationHistory = [ - Education(), - Education(), - ]; - customSections = >[ - { - 'Projects': GenericEntry(), - }, - ]; - sectionOrder = [ - Strings.skills, - Strings.experience, - 'Projects', - Strings.education, - ]; + Resume({ + DateTime? creationDate, + DateTime? lastModified, + String? name, + String? location, + List? contactList, + List? experiences, + List? educationHistory, + List? skills, + List>? customSections, + List? sectionOrder, + List? hiddenSections, + this.logoAsBytes, + }) { + this.creationDate = creationDate ?? DateTime.now(); + this.lastModified = lastModified ?? DateTime.now(); + nameController.text = name ?? ''; + locationController.text = location ?? ''; + this.contactList = + contactList ?? [Contact(), Contact(), Contact(), Contact()]; + this.experiences = experiences ?? [Experience()]; + this.educationHistory = educationHistory ?? [Education()]; + skillTextControllers = skills != null + ? skills.map((String e) => TextEditingController(text: e)).toList() + : [TextEditingController()]; + this.customSections = customSections ?? >[]; + this.sectionOrder = sectionOrder ?? + [ + 'Skills', + 'Experience', + 'Education', + ]; + _hiddenSections = hiddenSections ?? []; } + factory Resume.fromMap(Map map) { + return Resume( + creationDate: DateTime.parse(map['creationDate'] as String), + lastModified: DateTime.parse(map['lastModified'] as String), + name: map['name'] as String, + location: map['location'] as String, + contactList: (map['contact'] as List) + .map((dynamic e) => Contact.fromMap(e as Map)) + .toList(), + experiences: (map['experience'] as List) + .map((dynamic e) => Experience.fromMap(e as Map)) + .toList(), + educationHistory: (map['education'] as List) + .map((dynamic e) => Education.fromMap(e as Map)) + .toList(), + skills: (map['skills'] as List) + .map((dynamic e) => e as String) + .toList(), + customSections: (map['customSections'] as List) + .cast>() + .map( + (Map e) => { + e.keys.first: GenericEntry( + title: + (e.values.first as Map)['title'] as String, + description: (e.values.first + as Map)['description'] as String, + ), + }, + ) + .toList(), + sectionOrder: (map['sectionOrder'] as List) + .map((dynamic e) => e as String) + .toList(), + logoAsBytes: map['logoAsBytes'] != null + ? Uint8List.fromList( + (map['logoAsBytes'] as List).cast()) + : null, + ); + } + + /// The creation date of the resume. + DateTime creationDate = DateTime.now(); + + /// The last modified date of the resume. + DateTime lastModified = DateTime.now(); + /// The form key for the resume. final GlobalKey formKey = GlobalKey(); @@ -50,9 +106,17 @@ class Resume extends ChangeNotifier { /// The list of professional experiences. List experiences = []; + /// The list of visible experiences. + List get visibleExperiences => + experiences.where((Experience e) => e.visible).toList(); + /// The educational history. List educationHistory = []; + /// The list of visible education entries. + List get visibleEducation => + educationHistory.where((Education e) => e.visible).toList(); + /// The list of skills. List skillTextControllers = []; @@ -60,6 +124,11 @@ class Resume extends ChangeNotifier { List> customSections = >[]; + /// The list of visible custom sections. + List> get visibleCustomSections => customSections + .where((Map e) => e.values.first.visible) + .toList(); + /// The order of the sections. List sectionOrder = []; @@ -67,7 +136,7 @@ class Resume extends ChangeNotifier { Uint8List? logoAsBytes; /// The list of hidden sections. - final List _hiddenSections = []; + List _hiddenSections = []; /// Whether the section is visible. bool sectionVisible(String sectionName) { @@ -220,8 +289,58 @@ class Resume extends ChangeNotifier { notifyListeners(); } + /// Delete a custom section entry. + void onDeleteCustomSectionEntry(GenericEntry entry, String sectionName) { + customSections + .where((Map element) => + element.containsKey(sectionName)) + .first + .removeWhere((String key, GenericEntry value) => value == entry); + notifyListeners(); + } + + /// Delete a contact. + void onDeleteExperience(Experience experience) { + experiences.remove(experience); + notifyListeners(); + } + + /// Delete an education entry. + void onDeleteEducation(Education education) { + educationHistory.remove(education); + notifyListeners(); + } + /// Rebuild the resume/UI. void rebuild() { notifyListeners(); } + + /// Return a map of the resume. + Map toMap() { + final Map map = { + 'creationDate': creationDate.toString(), + 'lastModified': lastModified.toString(), + 'name': nameController.text, + 'location': locationController.text, + 'contact': contactList.map((Contact e) => e.toMap()).toList(), + 'experience': experiences.map((Experience e) => e.toMap()).toList(), + 'education': educationHistory.map((Education e) => e.toMap()).toList(), + 'skills': skillTextControllers + .map((TextEditingController e) => e.text) + .toList(), + 'customSections': customSections + .map((Map entry) => + >>{ + entry.keys.first: >[ + for (final GenericEntry entry in entry.values) entry.toMap() + ], + }) + .toList(), + 'sectionOrder': sectionOrder, + 'hiddenSections': _hiddenSections, + 'logoAsBytes': logoAsBytes + }; + return map; + } } diff --git a/lib/pages/split_view.dart b/lib/pages/split_view.dart deleted file mode 100644 index e8165e7..0000000 --- a/lib/pages/split_view.dart +++ /dev/null @@ -1,456 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import '../constants/strings.dart'; -import '../models/contact.dart'; -import '../models/education.dart'; -import '../models/experience.dart'; -import '../models/generic.dart'; -import '../models/resume.dart'; -import '../services/file_handler.dart'; -import '../services/pdf_generator.dart'; -import '../services/project_info.dart'; -import '../services/redirect_handler.dart'; -import '../widgets/portrait_drawer.dart'; -import 'input_form.dart'; -import 'pdf_viewer.dart'; - -/// Split view of the resume builder (input form and PDF viewer). -class SplitView extends StatefulWidget { - const SplitView({super.key}); - @override - State createState() => SplitViewState(); -} - -class SplitViewState extends State with TickerProviderStateMixin { - /// The resume to use. - final Resume _resume = Resume(); - - /// The project info handler. - ProjectVersionInfoHandler projectInfoHandler = ProjectVersionInfoHandler(); - - /// The PDF generator. - late PDFGenerator pdfGenerator; - - /// The tab controller. - TabController? _tabController; - - /// Populates the resume with sample data. - void _populateSampleResume() { - _resume.nameController.text = 'John Doe'; - _resume.locationController.text = 'San Francisco, CA'; - _resume.skillTextControllers = [ - TextEditingController(text: 'Flutter'), - TextEditingController(text: 'Dart'), - TextEditingController(text: 'Python'), - TextEditingController(text: 'Java'), - TextEditingController(text: 'C++'), - ]; - _resume.contactList = [ - Contact(value: 'johndoe@email.com', iconData: CupertinoIcons.mail), - Contact(value: 'linkedin.com/in/jdoe', iconData: CupertinoIcons.link), - Contact(value: '123-456-7890'), - Contact( - value: 'example.com/portfolio/jdoe', iconData: CupertinoIcons.globe), - ]; - _resume.experiences = [ - Experience( - company: 'Example Holdings Inc.', - position: 'Software Engineer', - startDate: '01/2020', - endDate: 'Present', - location: 'San Francisco, CA', - description: - '• Installed and configured software applications and tested solutions for effectiveness.\n• Worked with project managers, developers, quality assurance and customers to resolve\n technical issues.\n• Interfaced with cross-functional team of business analysts, developers and technical\n support professionals to determine comprehensive list of requirement specifications for\n new applications.', - ), - Experience( - company: 'Example Technologies', - position: 'Software Development Associate', - startDate: '01/2019', - endDate: '12/2019', - location: 'San Francisco, CA', - description: - '• Administered government-supported community development programs and promoted\n department programs and services.\n• Worked closely with clients to establish problem specifications and system designs.\n• Developed next generation integration platform for internal applications.', - ), - Experience( - position: 'Web Development Intern', - company: 'Example Appraisal Services', - startDate: '06/2018', - endDate: '08/2018', - location: 'San Francisco, CA', - description: - '• Participated with preparation of design documents for trackwork, including alignments,\n specifications, criteria details and estimates.\n• Collaborated with senior engineers on projects and offered insight.\n• Engaged in software development utilizing wide range of technological tools and\n industrial Ethernet-based protocols.', - ) - ]; - _resume.educationHistory = [ - Education( - institution: 'Example University', - degree: 'MS, Software Engineering', - startDate: '08/2018', - endDate: '12/2019', - location: 'San Francisco, CA', - ), - Education( - institution: 'Example State University', - degree: 'BS, Computer Science', - startDate: '08/2014', - endDate: '05/2018', - location: 'San Francisco, CA', - ), - ]; - _resume.customSections = >[ - { - 'Projects': GenericEntry( - title: 'Inventory Management System', - description: - '• Developed a mobile application for a local business to manage their inventory and sales.\n• Utilized Flutter and Firebase to create a cross-platform application for Android and iOS.\n• Implemented a barcode scanner to scan products and update inventory in real-time.\n• Designed a user interface to display sales and inventory data in a visually appealing manner.'), - }, - { - 'Projects': GenericEntry( - title: 'Project Management System', - description: - '• Built a web application to manage and track the progress of projects.\n• Utilized Flutter and GitHub Pages to create a web application for desktop and mobile.\n') - } - ]; - } - - /// The portrait layout of the split view. - Widget _portraitLayout() { - return TabBarView( - controller: _tabController, - children: [ - const InputForm( - portrait: true, - ), - PDFViewer(pdfGenerator: pdfGenerator), - ], - ); - } - - /// The landscape layout of the split view. - Widget _landscapeLayout() { - return Row( - children: [ - const Expanded(child: InputForm()), - Expanded( - child: Stack( - children: [ - PDFViewer(pdfGenerator: pdfGenerator), - Positioned( - top: 10, - right: 10, - child: _recompileButton(), - ), - ], - ), - ), - ], - ); - } - - /// A tab bar for switching between the input form and the PDF viewer. - Widget _tabBar() { - return TabBar( - indicatorSize: TabBarIndicatorSize.tab, - controller: _tabController, - tabs: const [ - Tab( - text: Strings.form, - ), - Tab( - text: Strings.preview, - ), - ], - ); - } - - /// A button to save and recompile the resume. - Widget _recompileButton() { - return MaterialButton( - color: Colors.green[700], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - onPressed: () { - _resume.formKey.currentState!.save(); - }, - minWidth: 25, - padding: const EdgeInsets.all(15), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.sync, - color: Colors.white, - size: 20, - ), - SizedBox(width: 5), - Text( - Strings.recompile, - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ) - ], - ), - ); - } - - /// A list tile option for the drawer and popup menu. - Widget _listOption({ - required BuildContext context, - required String title, - required IconData iconData, - required Function() onTap, - }) { - return ListTile( - leading: Icon( - iconData, - color: Theme.of(context).colorScheme.onSurface, - ), - title: Text( - title.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall, - ), - onTap: onTap, - ); - } - - /// Displays an about dialog containing information about the project and - /// more options. - void _showAboutDialog({required bool portraitMode}) { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - insetPadding: portraitMode - ? const EdgeInsets.symmetric(horizontal: 30.0, vertical: 24.0) - : const EdgeInsets.symmetric(horizontal: 150, vertical: 100), - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - Strings.iconPath, - height: 50, - width: 50, - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - Strings.flutterResumeBuilder, - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${projectInfoHandler.version} (${projectInfoHandler.buildNumber})', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ], - ), - content: const Text( - Strings.projectInfo, - textAlign: TextAlign.center, - ), - actions: [ - TextButton( - child: Text(Strings.moreProjects.toUpperCase()), - onPressed: () { - Navigator.pop(context); - RedirectHandler.openUrl(Strings.portfolioUrl); - }, - ), - TextButton( - child: Text(Strings.licenses.toUpperCase()), - onPressed: () { - Navigator.pop(context); - showLicensePage( - context: context, - applicationName: Strings.flutterResumeBuilder, - applicationVersion: projectInfoHandler.version, - applicationLegalese: Strings.copyRight('2021'), - ); - }, - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text(Strings.ok), - ), - ], - ); - }); - } - - /// The action items to display in the drawer or popup menu. - List _actionItems({required bool portraitMode}) { - if (portraitMode) { - return [ - _listOption( - context: context, - title: Strings.downloadPDF, - iconData: Icons.download, - onTap: () { - Navigator.pop(context); - FileHandler().savePDF(pdfGenerator); - }, - ), - _listOption( - context: context, - title: Strings.aboutThisProject.toUpperCase(), - iconData: Icons.info, - onTap: () { - Navigator.pop(context); - _showAboutDialog(portraitMode: portraitMode); - }, - ), - _listOption( - context: context, - title: Strings.contributeToThisProject.toUpperCase(), - iconData: Icons.code, - onTap: () { - Navigator.pop(context); - RedirectHandler.openUrl(Strings.sourceCodeUrl); - }, - ), - _listOption( - context: context, - title: Strings.projectDonation.toUpperCase(), - iconData: Icons.attach_money, - onTap: () { - Navigator.pop(context); - RedirectHandler.openUrl(Strings.sponsorUrl); - }, - ), - ]; - } - return [ - IconButton( - icon: const Icon(Icons.download), - tooltip: Strings.downloadPDF, - onPressed: () => FileHandler().savePDF(pdfGenerator), - ), - IconButton( - icon: const Icon(Icons.info), - tooltip: Strings.aboutThisProject.toUpperCase(), - onPressed: () => _showAboutDialog(portraitMode: portraitMode), - ), - PopupMenuButton( - tooltip: Strings.moreOptions.toUpperCase(), - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - padding: EdgeInsets.zero, - child: _listOption( - context: context, - title: Strings.contributeToThisProject.toUpperCase(), - iconData: Icons.code, - onTap: () { - Navigator.pop(context); - RedirectHandler.openUrl(Strings.sourceCodeUrl); - }), - onTap: () {}, - ), - PopupMenuItem( - padding: EdgeInsets.zero, - child: _listOption( - context: context, - title: Strings.projectDonation.toUpperCase(), - iconData: Icons.attach_money, - onTap: () { - Navigator.pop(context); - RedirectHandler.openUrl(Strings.sponsorUrl); - }, - ), - onTap: () {}, - ), - ], - ), - const SizedBox(width: 10), - ]; - } - - @override - void initState() { - super.initState(); - _populateSampleResume(); - _tabController = TabController(length: 2, vsync: this); - pdfGenerator = PDFGenerator(resume: _resume); - } - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: _resume, - child: OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Scaffold( - drawer: orientation == Orientation.landscape - ? null - : PortraitDrawer( - pdfGenerator: pdfGenerator, - actionItems: _actionItems(portraitMode: true), - ), - appBar: AppBar( - bottom: orientation == Orientation.landscape - ? null - : PreferredSize( - preferredSize: const Size.fromHeight(48), - child: _tabBar(), - ), - title: Row( - children: [ - const Text(Strings.resumeBuilder), - if (orientation == Orientation.landscape) - TextButton( - onPressed: () => - RedirectHandler.openUrl(Strings.flutterUrl), - child: Text( - Strings.poweredByFlutter.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.5), - ), - ), - ), - ], - ), - actions: orientation == Orientation.landscape - ? _actionItems(portraitMode: false) - : null, - centerTitle: false, - ), - body: Consumer( - builder: (BuildContext context, Resume resume, Widget? child) { - return Shortcuts( - shortcuts: { - LogicalKeySet( - LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): - const RecompileIntent(), - }, - child: Actions( - actions: >{ - RecompileIntent: CallbackAction( - onInvoke: (RecompileIntent intent) => setState(() { - _resume.formKey.currentState!.saveAndValidate(); - }), - ), - }, - child: orientation == Orientation.portrait - ? _portraitLayout() - : _landscapeLayout(), - ), - ); - }, - ), - ); - }, - ), - ); - } -} - -/// The intent to recompile the resume. -class RecompileIntent extends Intent { - const RecompileIntent(); -} diff --git a/lib/pages/input_form.dart b/lib/screens/input_form.dart similarity index 86% rename from lib/pages/input_form.dart rename to lib/screens/input_form.dart index 132630c..b796c52 100644 --- a/lib/pages/input_form.dart +++ b/lib/screens/input_form.dart @@ -8,11 +8,12 @@ import 'package:flutter_reorderable_grid_view/entities/order_update_entity.dart' import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; import 'package:provider/provider.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/education.dart'; import '../models/experience.dart'; import '../models/generic.dart'; import '../models/resume.dart'; +import '../widgets/confirmation_dialog.dart'; import '../widgets/contact_entry.dart'; import '../widgets/custom_entry.dart'; import '../widgets/education_entry.dart'; @@ -22,8 +23,9 @@ import '../widgets/generic_text_field.dart'; import '../widgets/image_file_picker.dart'; /// The input form for the resume. -class InputForm extends StatefulWidget { - const InputForm({ +class ResumeInputForm extends StatefulWidget { + const ResumeInputForm({ + required this.scrollController, super.key, this.portrait = false, }); @@ -31,11 +33,13 @@ class InputForm extends StatefulWidget { /// Whether the layout is portrait or not. final bool portrait; + final ScrollController scrollController; + @override - State createState() => _InputFormState(); + State createState() => _ResumeInputFormState(); } -class _InputFormState extends State { +class _ResumeInputFormState extends State { /// Form fields for requesting the user's name, location, and a logo. Widget _header(Resume resume) { return Row( @@ -132,11 +136,14 @@ class _InputFormState extends State { onPressed: () { showDialog( context: context, - builder: (BuildContext context) => - deleteSectionConfirmationDialog( - context: context, - resume: resume, - sectionName: title, + builder: (BuildContext context) => ConfirmationDialog( + title: Strings.removeSection, + content: Strings.removeSectionWarning, + confirmText: Strings.remove, + onConfirm: () { + resume.onDeleteCustomSection(title); + Navigator.of(context).pop(); + }, ), ); }, @@ -150,8 +157,8 @@ class _InputFormState extends State { IconButton( onPressed: () => resume.toggleSectionVisibility(title), tooltip: resume.sectionVisible(title) - ? Strings.hideAllEntries - : Strings.showAllEntries, + ? Strings.hideSection + : Strings.showSection, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, iconSize: 18, @@ -216,7 +223,9 @@ class _InputFormState extends State { return GridView.custom( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - childrenDelegate: SliverChildListDelegate(children), + childrenDelegate: SliverChildListDelegate( + children, + ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( mainAxisExtent: 74, crossAxisCount: widget.portrait ? 1 : 2, @@ -304,6 +313,7 @@ class _InputFormState extends State { key: UniqueKey(), child: TextFormField( controller: resume.skillTextControllers[index], + enabled: resume.sectionVisible(Strings.skills), decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), @@ -349,9 +359,9 @@ class _InputFormState extends State { child: ExperienceEntry( portrait: widget.portrait, experience: resume.experiences[index], - onSubmitted: (_) { - resume.rebuild(); - }, + onSubmitted: (_) => resume.rebuild, + onRemove: () => + resume.onDeleteExperience(resume.experiences[index]), ), ); }, @@ -388,9 +398,9 @@ class _InputFormState extends State { child: EducationEntry( portrait: widget.portrait, education: resume.educationHistory[index], - onSubmitted: (_) { - resume.rebuild(); - }, + onSubmitted: (_) => resume.rebuild(), + onRemove: () => + resume.onDeleteEducation(resume.educationHistory[index]), ), ); }, @@ -442,6 +452,11 @@ class _InputFormState extends State { child: CustomEntry( portrait: widget.portrait, genericSection: genericSection[index], + onRemove: () { + resume.onDeleteCustomSectionEntry( + genericSection[index], title); + }, + enableEditing: resume.sectionVisible(title), onSubmitted: (_) { resume.rebuild(); }, @@ -478,39 +493,6 @@ class _InputFormState extends State { ); } - /// A confirmation dialog for deleting a custom section. - Widget deleteSectionConfirmationDialog( - {required BuildContext context, - required Resume resume, - required String sectionName}) { - return AlertDialog( - title: Text(Strings.deleteSection(sectionName)), - content: const Text( - Strings.deleteSectionWarning, - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text(Strings.cancel), - ), - TextButton( - onPressed: () { - resume.onDeleteCustomSection(sectionName); - Navigator.of(context).pop(); - }, - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), - child: const Text( - Strings.delete, - ), - ), - ], - ); - } - /// Returns a list of sections in the order they should be displayed. List _orderedSections(Resume resume) { final List sections = []; @@ -518,13 +500,10 @@ class _InputFormState extends State { switch (sectionTitle) { case Strings.skills: sections.add(_skillsSection(resume)); - break; case Strings.experience: sections.add(_experienceSection(resume)); - break; case Strings.education: sections.add(_educationSection(resume)); - break; default: sections.add(_customSection(title: sectionTitle, resume: resume)); } @@ -534,31 +513,37 @@ class _InputFormState extends State { @override Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, Resume resume, Widget? child) { - return SingleChildScrollView( + return Scrollbar( + controller: widget.scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: widget.scrollController, child: Padding( padding: const EdgeInsets.all(20), - child: FormBuilder( - key: resume.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _header(resume), - const SizedBox(height: 10), - _contactSection(resume), - ..._orderedSections(resume), - const SizedBox(height: 20), - OutlinedButton( - onPressed: resume.addCustomSection, - child: Text( - Strings.addNewSection.toUpperCase(), + child: Consumer( + builder: (BuildContext context, Resume resume, Widget? child) { + return FormBuilder( + key: resume.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(resume), + const SizedBox(height: 10), + _contactSection(resume), + ..._orderedSections(resume), + const SizedBox(height: 10), + OutlinedButton( + onPressed: resume.addCustomSection, + child: Text( + Strings.addCustomSection.toUpperCase(), + ), ), - ), - ], - )), + const SizedBox(height: 50), + ], + )); + }), ), - ); - }); + ), + ); } } diff --git a/lib/pages/pdf_viewer.dart b/lib/screens/pdf_viewer.dart similarity index 100% rename from lib/pages/pdf_viewer.dart rename to lib/screens/pdf_viewer.dart diff --git a/lib/screens/split_screen.dart b/lib/screens/split_screen.dart new file mode 100644 index 0000000..cb0815b --- /dev/null +++ b/lib/screens/split_screen.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pdf/pdf.dart'; +import 'package:printing/printing.dart'; +import 'package:provider/provider.dart'; + +import '../common/sample_resume.dart'; +import '../common/strings.dart'; +import '../models/resume.dart'; +import '../services/file_handler.dart'; +import '../services/pdf_generator.dart'; +import '../services/project_info.dart'; +import '../services/redirect_handler.dart'; +import '../widgets/about_dialog.dart'; +import '../widgets/confirmation_dialog.dart'; +import '../widgets/portrait_drawer.dart'; +import 'input_form.dart'; +import 'pdf_viewer.dart'; + +/// Split view of the resume builder (input form and PDF viewer). +class SplitScreen extends StatefulWidget { + const SplitScreen({super.key}); + @override + State createState() => SplitScreenState(); +} + +class SplitScreenState extends State + with TickerProviderStateMixin { + /// The resume to use. + Resume _resume = SampleResume(); + + /// The project info handler. + ProjectVersionInfoHandler projectInfoHandler = ProjectVersionInfoHandler(); + + /// The PDF generator. + late PDFGenerator pdfGenerator; + + /// The tab controller. + TabController? _tabController; + + /// The form scroll controller. + final ScrollController _formScrollController = ScrollController(); + + /// The portrait layout of the split view. + Widget _portraitLayout() { + return TabBarView( + controller: _tabController, + children: [ + ResumeInputForm( + scrollController: _formScrollController, + portrait: true, + ), + PDFViewer(pdfGenerator: pdfGenerator), + ], + ); + } + + /// The landscape layout of the split view. + Widget _landscapeLayout() { + String firstWordOnly(String text) => text.split(' ').first.toUpperCase(); + + void _printPDF() async { + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) => + pdfGenerator.generateResumeAsPDF()); + } + + return Row( + children: [ + NavigationRail( + selectedIndex: 0, + elevation: 12, + useIndicator: false, + labelType: NavigationRailLabelType.all, + backgroundColor: Theme.of(context).colorScheme.surface, + onDestinationSelected: (int index) { + switch (index) { + case 0: + { + _resume = Resume(); + pdfGenerator = PDFGenerator(resume: _resume); + _formScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + setState(() {}); + } + case 1: + _printPDF(); + case 2: + FileHandler().downloadFiles( + pdfGenerator: pdfGenerator, + projectVersionInfoHandler: projectInfoHandler); + case 3: + FileHandler().importResume().then((dynamic result) { + if (result != null) { + setState(() { + _resume = Resume.fromMap( + (result as Map)['resume'] + as Map); + pdfGenerator = PDFGenerator(resume: _resume); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(Strings.noValidJsonFile, + style: Theme.of(context).textTheme.bodyMedium), + duration: const Duration(seconds: 2), + backgroundColor: Colors.red[700], + ), + ); + } + }); + case 4: + showDialog( + context: context, + builder: (BuildContext context) { + return CustomAboutDialog( + projectInfoHandler: projectInfoHandler, + ); + }); + case 5: + RedirectHandler.openUrl(Strings.sourceCodeUrl); + case 6: + RedirectHandler.openUrl(Strings.sponsorUrl); + } + }, + destinations: [ + NavigationRailDestination( + icon: const Tooltip( + message: Strings.clearResume, child: Icon(Icons.restart_alt)), + label: Text( + firstWordOnly(Strings.clearResume), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.printPDF, + child: Icon(Icons.print), + ), + label: Text( + firstWordOnly(Strings.printPDF), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.downloadPdfAndJson, + child: Icon(Icons.download), + ), + label: Text( + firstWordOnly(Strings.downloadPdfAndJson), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.importJson, + child: Icon(Icons.upload_file), + ), + label: Text( + firstWordOnly(Strings.importJson), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.aboutThisProject, + child: Icon(Icons.info), + ), + label: Text( + firstWordOnly(Strings.aboutThisProject), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.contributeCode, + child: Icon(Icons.code), + ), + label: Text( + firstWordOnly(Strings.contributeCode), + ), + ), + NavigationRailDestination( + icon: const Tooltip( + message: Strings.donate, + child: Icon(Icons.attach_money), + ), + label: Text( + firstWordOnly(Strings.donate), + ), + ), + ], + ), + Expanded( + child: ResumeInputForm( + scrollController: _formScrollController, + ), + ), + Expanded( + child: Stack( + children: [ + PDFViewer(pdfGenerator: pdfGenerator), + Positioned( + top: 10, + right: 10, + child: _recompileButton(), + ), + ], + ), + ), + ], + ); + } + + /// A tab bar for switching between the input form and the PDF viewer. + Widget _tabBar() { + return TabBar( + indicatorSize: TabBarIndicatorSize.tab, + controller: _tabController, + tabs: const [ + Tab( + text: Strings.form, + ), + Tab( + text: Strings.preview, + ), + ], + ); + } + + /// A button to save and recompile the resume. + Widget _recompileButton() { + return MaterialButton( + color: Colors.green[700], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + onPressed: () { + _resume.formKey.currentState!.save(); + }, + minWidth: 25, + padding: const EdgeInsets.all(15), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sync, + color: Colors.white, + size: 20, + ), + SizedBox(width: 5), + Text( + Strings.recompile, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ) + ], + ), + ); + } + + /// A list tile option for the drawer and popup menu. + Widget _listOption({ + required BuildContext context, + required String title, + required IconData iconData, + required Function() onTap, + }) { + return ListTile( + leading: Icon( + iconData, + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text( + title.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall, + ), + onTap: onTap, + ); + } + + /// The action items to display in the drawer + List _drawerActions() { + return [ + _listOption( + context: context, + title: Strings.printPDF, + iconData: Icons.print, + onTap: () async { + Navigator.pop(context); + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) => + pdfGenerator.generateResumeAsPDF()); + }, + ), + _listOption( + context: context, + title: Strings.aboutThisProject.toUpperCase(), + iconData: Icons.info, + onTap: () { + Navigator.pop(context); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomAboutDialog( + projectInfoHandler: projectInfoHandler, + ); + }); + }, + ), + _listOption( + context: context, + title: Strings.contributeCode.toUpperCase(), + iconData: Icons.code, + onTap: () { + Navigator.pop(context); + RedirectHandler.openUrl(Strings.sourceCodeUrl); + }, + ), + _listOption( + context: context, + title: Strings.donate.toUpperCase(), + iconData: Icons.attach_money, + onTap: () { + Navigator.pop(context); + RedirectHandler.openUrl(Strings.sponsorUrl); + }, + ), + ]; + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + pdfGenerator = PDFGenerator(resume: _resume); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _resume, + child: OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + return Scaffold( + drawer: orientation == Orientation.landscape + ? null + : PortraitDrawer( + pdfGenerator: pdfGenerator, + actionItems: _drawerActions(), + projectVersionInfoHandler: projectInfoHandler, + ), + appBar: AppBar( + bottom: orientation == Orientation.landscape + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(48), + child: _tabBar(), + ), + title: Row( + children: [ + const Text(Strings.resumeBuilder), + if (orientation == Orientation.landscape) + TextButton( + onPressed: () => + RedirectHandler.openUrl(Strings.flutterUrl), + child: Text( + Strings.poweredByFlutter.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.5), + ), + ), + ), + const Spacer(), + if (orientation == Orientation.portrait) + IconButton( + icon: const Icon(Icons.restart_alt), + tooltip: Strings.clearResume, + onPressed: () async { + showDialog( + context: context, + builder: (BuildContext context) => ConfirmationDialog( + title: Strings.clearResume, + content: Strings.clearResumeWarning, + confirmText: Strings.clear, + onConfirm: () { + _resume = Resume(); + pdfGenerator = PDFGenerator(resume: _resume); + _formScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + setState(() {}); + }, + ), + ); + }, + ), + if (orientation == Orientation.portrait) + IconButton( + icon: const Icon(Icons.upload_file), + tooltip: Strings.importJson, + onPressed: () { + FileHandler().importResume().then((dynamic result) { + if (result != null) { + setState(() { + _resume = Resume.fromMap( + (result as Map)['resume'] + as Map); + pdfGenerator = PDFGenerator(resume: _resume); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(Strings.noValidJsonFile, + style: + Theme.of(context).textTheme.bodyMedium), + duration: const Duration(seconds: 2), + backgroundColor: Colors.red[700], + ), + ); + } + }); + }, + ), + if (orientation == Orientation.portrait) + IconButton( + icon: const Icon(Icons.download), + tooltip: Strings.downloadPdfAndJson, + onPressed: () { + FileHandler().downloadFiles( + pdfGenerator: pdfGenerator, + projectVersionInfoHandler: projectInfoHandler); + }, + ), + ], + ), + centerTitle: false, + ), + body: Consumer( + builder: (BuildContext context, Resume resume, Widget? child) { + return Shortcuts( + shortcuts: { + LogicalKeySet( + LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): + const RecompileIntent(), + }, + child: Actions( + actions: >{ + RecompileIntent: CallbackAction( + onInvoke: (RecompileIntent intent) => setState(() { + _resume.formKey.currentState!.saveAndValidate(); + }), + ), + }, + child: orientation == Orientation.portrait + ? _portraitLayout() + : _landscapeLayout(), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +/// The intent to recompile the resume. +class RecompileIntent extends Intent { + const RecompileIntent(); +} diff --git a/lib/services/file_handler.dart b/lib/services/file_handler.dart index 33b0fe8..17dffd9 100644 --- a/lib/services/file_handler.dart +++ b/lib/services/file_handler.dart @@ -1,12 +1,16 @@ import 'dart:convert'; import 'dart:html' as html; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + import 'pdf_generator.dart'; +import 'project_info.dart'; /// A class that handles file operations. class FileHandler { /// Saves the PDF to the user's device. - Future savePDF(PDFGenerator pdfGenerator) async { + Future _savePDF(PDFGenerator pdfGenerator) async { final String docID = '${DateTime.now().month}${DateTime.now().day}${DateTime.now().year.toString().substring(2)}-${DateTime.now().hour}${DateTime.now().minute}${DateTime.now().second}'; final String content = @@ -14,7 +18,56 @@ class FileHandler { html.AnchorElement( href: 'data:application/octet-stream;charset=utf-16le;base64,$content') ..setAttribute('download', - '${pdfGenerator.resume.nameController.text} Resume $docID.pdf') + '${pdfGenerator.resume.nameController.text.replaceAll(' ', '_').toLowerCase()}_resume_$docID.pdf') + ..click(); + } + + /// Saves the resume as a JSON file. + Future _saveJSONData( + {required PDFGenerator pdfGenerator, + required ProjectVersionInfoHandler projectVersionInfoHandler}) async { + final String docID = + '${DateTime.now().month}${DateTime.now().day}${DateTime.now().year.toString().substring(2)}-${DateTime.now().hour}${DateTime.now().minute}${DateTime.now().second}'; + + final String content = base64Encode(utf8.encode( + jsonEncode({ + 'resume': pdfGenerator.resume.toMap(), + 'projectVersionInfo': projectVersionInfoHandler.toMap(), + }), + )); + + html.AnchorElement( + href: 'data:application/octet-stream;charset=utf-16le;base64,$content') + ..setAttribute('download', + '${pdfGenerator.resume.nameController.text.replaceAll(' ', '_').toLowerCase()}_resume_data_$docID.plgrb.json') ..click(); } + + /// Download all the files. + Future downloadFiles( + {required PDFGenerator pdfGenerator, + required ProjectVersionInfoHandler projectVersionInfoHandler}) async { + _saveJSONData( + pdfGenerator: pdfGenerator, + projectVersionInfoHandler: projectVersionInfoHandler); + _savePDF(pdfGenerator); + } + + /// Import the resume JSON file. + Future importResume() async { + try { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result != null) { + final String content = utf8.decode(result.files.first.bytes!); + return jsonDecode(content); + } + } catch (e) { + debugPrint(e.toString()); + } + return null; + } } diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 156400f..05f2f57 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -6,7 +6,7 @@ import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart'; import 'package:printing/printing.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/education.dart'; import '../models/experience.dart'; import '../models/generic.dart'; @@ -162,7 +162,7 @@ class PDFGenerator { children: [ _sectionLabel(sectionName), for (final Map genericSection - in resume.customSections) + in resume.visibleCustomSections) if (genericSection.keys.first == sectionName) _genericEntryDetails(genericSection.values.first) ], @@ -231,7 +231,7 @@ class PDFGenerator { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _sectionLabel(Strings.experience), - for (final Experience experience in resume.experiences) + for (final Experience experience in resume.visibleExperiences) _experienceEntryDetails(experience) ], ); @@ -303,7 +303,7 @@ class PDFGenerator { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _sectionLabel(Strings.education), - for (final Education education in resume.educationHistory) + for (final Education education in resume.visibleEducation) _educationEntryDetails(education) ], ); @@ -399,16 +399,12 @@ class PDFGenerator { switch (sectionName) { case Strings.skills: sections.add(_skillsList()); - break; case Strings.experience: sections.add(_experienceSection()); - break; case Strings.education: sections.add(_educationSection()); - break; default: sections.add(_customSection(sectionIndex, sectionName: sectionName)); - break; } sectionIndex++; } diff --git a/lib/services/project_info.dart b/lib/services/project_info.dart index b20cbfd..f1bb7f5 100644 --- a/lib/services/project_info.dart +++ b/lib/services/project_info.dart @@ -1,5 +1,7 @@ import 'package:package_info_plus/package_info_plus.dart'; +import '../common/strings.dart'; + /// Contains information about the project version. class ProjectVersionInfoHandler { ProjectVersionInfoHandler() { @@ -14,9 +16,27 @@ class ProjectVersionInfoHandler { packageInfo = await PackageInfo.fromPlatform(); } + /// The project name. + String get appName => packageInfo.appName; + + /// The project site URL. + String get siteUrl => Strings.siteUrl; + /// The project version number. String get version => packageInfo.version; /// The project build number. String get buildNumber => packageInfo.buildNumber; + + String get fullVersion => '$version ($buildNumber)'; + + /// Return a map of the project version info. + Map toMap() { + return { + 'appName': appName, + 'siteUrl': siteUrl, + 'version': version, + 'buildNumber': buildNumber, + }; + } } diff --git a/lib/widgets/about_dialog.dart b/lib/widgets/about_dialog.dart new file mode 100644 index 0000000..f19b5b1 --- /dev/null +++ b/lib/widgets/about_dialog.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../common/strings.dart'; +import '../services/project_info.dart'; +import '../services/redirect_handler.dart'; + +/// Displays information about the project with actions. +class CustomAboutDialog extends StatelessWidget { + const CustomAboutDialog({ + super.key, + required this.projectInfoHandler, + }); + + /// The project version info handler. + final ProjectVersionInfoHandler projectInfoHandler; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + Strings.iconPath, + height: 50, + width: 50, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Strings.flutterResumeBuilder, + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + projectInfoHandler.fullVersion, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ], + ), + content: Text( + Strings.projectInfo, + style: Theme.of(context).textTheme.bodyMedium, + ), + actions: [ + TextButton( + child: Text(Strings.moreProjects.toUpperCase()), + onPressed: () { + Navigator.pop(context); + RedirectHandler.openUrl(Strings.portfolioUrl); + }, + ), + TextButton( + child: Text(Strings.licenses.toUpperCase()), + onPressed: () { + Navigator.pop(context); + showLicensePage( + context: context, + applicationName: Strings.flutterResumeBuilder, + applicationVersion: projectInfoHandler.version, + applicationLegalese: Strings.copyRight('2023'), + ); + }, + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text(Strings.ok), + ), + ], + ); + } +} diff --git a/lib/widgets/confirmation_dialog.dart b/lib/widgets/confirmation_dialog.dart new file mode 100644 index 0000000..ee0c0ef --- /dev/null +++ b/lib/widgets/confirmation_dialog.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../common/strings.dart'; + +/// A confirmation dialog that prompts the user to confirm an action. +class ConfirmationDialog extends StatelessWidget { + const ConfirmationDialog({ + super.key, + required this.title, + required this.content, + required this.confirmText, + required this.onConfirm, + }); + + /// The title of the dialog. + final String title; + + /// The content of the dialog. + final String content; + + /// The text for the confirm button. + final String confirmText; + + /// The callback when the user confirms. + final Function()? onConfirm; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(Strings.cancel.toUpperCase()), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onConfirm?.call(); + }, + child: Text( + confirmText.toUpperCase(), + style: + const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/contact_entry.dart b/lib/widgets/contact_entry.dart index b370223..08b06c7 100644 --- a/lib/widgets/contact_entry.dart +++ b/lib/widgets/contact_entry.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/contact.dart'; import 'frosted_container.dart'; import 'generic_text_field.dart'; diff --git a/lib/widgets/custom_entry.dart b/lib/widgets/custom_entry.dart index 236c3c6..9f9eb0c 100644 --- a/lib/widgets/custom_entry.dart +++ b/lib/widgets/custom_entry.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/generic.dart'; import 'date_range_entry.dart'; +import 'edit_entry_menu.dart'; import 'frosted_container.dart'; import 'generic_text_field.dart'; @@ -11,19 +12,26 @@ class CustomEntry extends StatefulWidget { const CustomEntry({ super.key, required this.genericSection, + required this.onRemove, required this.onSubmitted, required this.portrait, + required this.enableEditing, }); /// The generic section to use. final GenericEntry genericSection; + final Function()? onRemove; + /// The callback when the user submits the text field. final Function(String?)? onSubmitted; /// Whether the layout is portrait or not. final bool portrait; + /// Whether the text fields are enabled. + final bool enableEditing; + @override State createState() => CustomEntryState(); } @@ -46,62 +54,83 @@ class CustomEntryState extends State { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: FrostedContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - _responsiveLayout( - children: [ - Flexible( - child: GenericTextField( - label: Strings.title, - onSubmitted: onSubmitted, - controller: widget.genericSection.titleController, + return AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: widget.genericSection.visible ? 1 : 0.75, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: FrostedContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EditEntryMenu( + visible: widget.genericSection.visible, + onRemove: widget.enableEditing ? widget.onRemove : null, + onToggleVisibility: widget.enableEditing + ? () => setState(widget.genericSection.toggleVisibility) + : null, + ), + const SizedBox(height: 4), + _responsiveLayout( + children: [ + Flexible( + child: GenericTextField( + label: Strings.title, + onSubmitted: onSubmitted, + controller: widget.genericSection.titleController, + enabled: + widget.enableEditing && widget.genericSection.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: DateRangeEntry( - startDateController: - widget.genericSection.startDateController, - endDateController: widget.genericSection.endDateController, - onSubmitted: onSubmitted, + const SizedBox(width: 10, height: 10), + Flexible( + child: DateRangeEntry( + startDateController: + widget.genericSection.startDateController, + endDateController: + widget.genericSection.endDateController, + onSubmitted: onSubmitted, + enableEditing: + widget.enableEditing && widget.genericSection.visible, + ), ), - ), - ], - ), - const SizedBox(height: 10), - _responsiveLayout( - children: [ - Flexible( - flex: 2, - child: GenericTextField( - label: Strings.subtitle, - onSubmitted: onSubmitted, - controller: widget.genericSection.subtitleController, + ], + ), + const SizedBox(height: 10), + _responsiveLayout( + children: [ + Flexible( + flex: 2, + child: GenericTextField( + label: Strings.subtitle, + onSubmitted: onSubmitted, + controller: widget.genericSection.subtitleController, + enabled: + widget.enableEditing && widget.genericSection.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: GenericTextField( - label: Strings.location, - controller: widget.genericSection.locationController, - onSubmitted: onSubmitted, + const SizedBox(width: 10, height: 10), + Flexible( + child: GenericTextField( + label: Strings.location, + controller: widget.genericSection.locationController, + onSubmitted: onSubmitted, + enabled: + widget.enableEditing && widget.genericSection.visible, + ), ), - ), - ], - ), - const SizedBox(height: 10), - GenericTextField( - label: Strings.description, - controller: widget.genericSection.descriptionController, - multiLine: true, - onSubmitted: onSubmitted, - ), - ], + ], + ), + const SizedBox(height: 10), + GenericTextField( + label: Strings.description, + controller: widget.genericSection.descriptionController, + multiLine: true, + onSubmitted: onSubmitted, + enabled: widget.enableEditing && widget.genericSection.visible, + ), + ], + ), ), ), ); diff --git a/lib/widgets/date_range_entry.dart b/lib/widgets/date_range_entry.dart index be00204..eaf4497 100644 --- a/lib/widgets/date_range_entry.dart +++ b/lib/widgets/date_range_entry.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import 'generic_text_field.dart'; /// A form field for a date range. @@ -9,6 +9,7 @@ class DateRangeEntry extends StatelessWidget { required this.startDateController, required this.endDateController, required this.onSubmitted, + required this.enableEditing, }); /// The controller for the start date. @@ -20,6 +21,9 @@ class DateRangeEntry extends StatelessWidget { /// The callback when the user submits the text field. final Function(String?)? onSubmitted; + /// Whether the text fields are enabled. + final bool enableEditing; + @override Widget build(BuildContext context) { return Row( @@ -29,17 +33,27 @@ class DateRangeEntry extends StatelessWidget { controller: startDateController, label: Strings.startDate, onSubmitted: onSubmitted, + enabled: enableEditing, ), ), - const Padding( - padding: EdgeInsets.all(4.0), - child: Text('-'), + Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + '-', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .secondary + .withOpacity(enableEditing ? 0.5 : 0.25), + ), + ), ), Expanded( child: GenericTextField( controller: endDateController, label: Strings.endDate, onSubmitted: onSubmitted, + enabled: enableEditing, ), ), ], diff --git a/lib/widgets/edit_entry_menu.dart b/lib/widgets/edit_entry_menu.dart new file mode 100644 index 0000000..1d1b22a --- /dev/null +++ b/lib/widgets/edit_entry_menu.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../common/strings.dart'; +import 'confirmation_dialog.dart'; + +/// A menu for removing or hiding an entry. +class EditEntryMenu extends StatelessWidget { + const EditEntryMenu({ + super.key, + required this.onRemove, + required this.onToggleVisibility, + required this.visible, + }); + + /// Whether the entry is visible. + final bool visible; + + /// The callback when the user removes the entry. + final Function()? onRemove; + + /// The callback when the user toggles the visibility of the entry. + final Function()? onToggleVisibility; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const SizedBox(width: 4), + Icon( + Icons.drag_indicator_outlined, + size: 18, + color: Theme.of(context).colorScheme.secondary.withOpacity(0.15), + ), + const Spacer(), + IconButton( + onPressed: () async { + showDialog( + context: context, + builder: (BuildContext context) => ConfirmationDialog( + title: Strings.removeEntry, + content: Strings.removeEntryWarning, + confirmText: Strings.remove, + onConfirm: onRemove, + ), + ); + }, + tooltip: Strings.removeEntry, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + iconSize: 18, + icon: const Icon(Icons.delete_outline), + ), + const SizedBox(width: 4), + IconButton( + onPressed: onToggleVisibility, + tooltip: visible ? Strings.hideEntry : Strings.showEntry, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + iconSize: 18, + icon: Icon(visible ? Icons.visibility : Icons.visibility_off), + ), + ], + ); + } +} diff --git a/lib/widgets/education_entry.dart b/lib/widgets/education_entry.dart index 1223581..9ae0cfb 100644 --- a/lib/widgets/education_entry.dart +++ b/lib/widgets/education_entry.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/education.dart'; import 'date_range_entry.dart'; +import 'edit_entry_menu.dart'; import 'frosted_container.dart'; import 'generic_text_field.dart'; @@ -13,6 +14,7 @@ class EducationEntry extends StatefulWidget { required this.education, required this.onSubmitted, required this.portrait, + required this.onRemove, }); /// The callback when the user submits the text field. @@ -24,6 +26,8 @@ class EducationEntry extends StatefulWidget { /// Whether the layout is portrait or not. final bool portrait; + final Function()? onRemove; + @override State createState() => EducationEntryState(); } @@ -46,53 +50,66 @@ class EducationEntryState extends State { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: FrostedContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - _responsiveLayout( - children: [ - Flexible( - child: GenericTextField( - label: Strings.institution, - controller: widget.education.institutionController, - onSubmitted: onSubmitted, + return Opacity( + opacity: widget.education.visible ? 1 : 0.75, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: FrostedContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EditEntryMenu( + onRemove: widget.onRemove, + onToggleVisibility: () => + setState(widget.education.toggleVisibility), + visible: widget.education.visible, + ), + const SizedBox(height: 4), + _responsiveLayout( + children: [ + Flexible( + child: GenericTextField( + label: Strings.institution, + controller: widget.education.institutionController, + onSubmitted: onSubmitted, + enabled: widget.education.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: DateRangeEntry( - startDateController: widget.education.startDateController, - endDateController: widget.education.endDateController, - onSubmitted: onSubmitted, + const SizedBox(width: 10, height: 10), + Flexible( + child: DateRangeEntry( + startDateController: widget.education.startDateController, + endDateController: widget.education.endDateController, + onSubmitted: onSubmitted, + enableEditing: widget.education.visible, + ), ), - ), - ], - ), - const SizedBox(height: 10), - _responsiveLayout( - children: [ - Flexible( - child: GenericTextField( - label: Strings.degree, - controller: widget.education.degreeController, - onSubmitted: onSubmitted, + ], + ), + const SizedBox(height: 10), + _responsiveLayout( + children: [ + Flexible( + child: GenericTextField( + label: Strings.degree, + controller: widget.education.degreeController, + onSubmitted: onSubmitted, + enabled: widget.education.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: GenericTextField( - label: Strings.location, - controller: widget.education.locationController, - onSubmitted: onSubmitted, + const SizedBox(width: 10, height: 10), + Flexible( + child: GenericTextField( + label: Strings.location, + controller: widget.education.locationController, + onSubmitted: onSubmitted, + enabled: widget.education.visible, + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); diff --git a/lib/widgets/experience_entry.dart b/lib/widgets/experience_entry.dart index b6a9aab..31d0c9d 100644 --- a/lib/widgets/experience_entry.dart +++ b/lib/widgets/experience_entry.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../models/experience.dart'; import 'date_range_entry.dart'; +import 'edit_entry_menu.dart'; import 'frosted_container.dart'; import 'generic_text_field.dart'; @@ -12,6 +13,7 @@ class ExperienceEntry extends StatefulWidget { super.key, required this.experience, required this.onSubmitted, + required this.onRemove, required this.portrait, }); @@ -21,6 +23,8 @@ class ExperienceEntry extends StatefulWidget { /// The callback when the user submits the text field. final Function(String?)? onSubmitted; + final Function()? onRemove; + /// Whether the layout is portrait or not. final bool portrait; @@ -46,61 +50,77 @@ class ExperienceEntryState extends State { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: FrostedContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - _responsiveLayout( - children: [ - Flexible( - child: GenericTextField( - label: Strings.position, - onSubmitted: onSubmitted, - controller: widget.experience.positionController, + return AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: widget.experience.visible ? 1 : 0.75, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: FrostedContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EditEntryMenu( + visible: widget.experience.visible, + onRemove: widget.onRemove, + onToggleVisibility: () => + setState(widget.experience.toggleVisibility), + ), + const SizedBox(height: 4), + _responsiveLayout( + children: [ + Flexible( + child: GenericTextField( + label: Strings.position, + onSubmitted: onSubmitted, + controller: widget.experience.positionController, + enabled: widget.experience.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: DateRangeEntry( - startDateController: widget.experience.startDateController, - endDateController: widget.experience.endDateController, - onSubmitted: onSubmitted, + const SizedBox(width: 10, height: 10), + Flexible( + child: DateRangeEntry( + startDateController: + widget.experience.startDateController, + endDateController: widget.experience.endDateController, + onSubmitted: onSubmitted, + enableEditing: widget.experience.visible, + ), ), - ), - ], - ), - const SizedBox(height: 10), - _responsiveLayout( - children: [ - Flexible( - flex: 2, - child: GenericTextField( - label: Strings.company, - onSubmitted: onSubmitted, - controller: widget.experience.companyController, + ], + ), + const SizedBox(height: 10), + _responsiveLayout( + children: [ + Flexible( + flex: 2, + child: GenericTextField( + label: Strings.company, + onSubmitted: onSubmitted, + controller: widget.experience.companyController, + enabled: widget.experience.visible, + ), ), - ), - const SizedBox(width: 10, height: 10), - Flexible( - child: GenericTextField( - label: Strings.location, - onSubmitted: onSubmitted, - controller: widget.experience.locationController, + const SizedBox(width: 10, height: 10), + Flexible( + child: GenericTextField( + label: Strings.location, + onSubmitted: onSubmitted, + controller: widget.experience.locationController, + enabled: widget.experience.visible, + ), ), - ), - ], - ), - const SizedBox(height: 10), - GenericTextField( - label: Strings.jobDescription, - controller: widget.experience.descriptionController, - multiLine: true, - onSubmitted: onSubmitted, - ), - ], + ], + ), + const SizedBox(height: 10), + GenericTextField( + label: Strings.jobDescription, + controller: widget.experience.descriptionController, + multiLine: true, + onSubmitted: onSubmitted, + enabled: widget.experience.visible, + ), + ], + ), ), ), ); diff --git a/lib/widgets/generic_text_field.dart b/lib/widgets/generic_text_field.dart index ef25d4e..bea849d 100644 --- a/lib/widgets/generic_text_field.dart +++ b/lib/widgets/generic_text_field.dart @@ -8,6 +8,7 @@ class GenericTextField extends StatefulWidget { required this.label, required this.controller, required this.onSubmitted, + this.enabled = true, this.multiLine = false, this.roundedStyling = true, }); @@ -27,6 +28,9 @@ class GenericTextField extends StatefulWidget { /// The callback when the user submits the text field. final Function(String?)? onSubmitted; + /// Whether the text field is enabled. + final bool enabled; + @override State createState() => _GenericTextFieldState(); } @@ -39,6 +43,7 @@ class _GenericTextFieldState extends State { minLines: widget.multiLine ? 2 : 1, maxLines: widget.multiLine || widget.multiLine ? 15 : 1, controller: widget.controller, + enabled: widget.enabled, style: !widget.roundedStyling ? const TextStyle( fontSize: 20, diff --git a/lib/widgets/portrait_drawer.dart b/lib/widgets/portrait_drawer.dart index fead57e..131d8df 100644 --- a/lib/widgets/portrait_drawer.dart +++ b/lib/widgets/portrait_drawer.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../constants/strings.dart'; +import '../common/strings.dart'; import '../services/pdf_generator.dart'; +import '../services/project_info.dart'; /// A drawer that is displayed in portrait mode. class PortraitDrawer extends StatelessWidget { @@ -10,6 +11,7 @@ class PortraitDrawer extends StatelessWidget { super.key, required this.pdfGenerator, required this.actionItems, + required this.projectVersionInfoHandler, }); /// The PDF generator. @@ -18,6 +20,9 @@ class PortraitDrawer extends StatelessWidget { /// The action items to display in the drawer. final List actionItems; + /// The project version info handler. + final ProjectVersionInfoHandler projectVersionInfoHandler; + @override Widget build(BuildContext context) { return Drawer( @@ -34,6 +39,13 @@ class PortraitDrawer extends StatelessWidget { Strings.iconPath, height: 50, ), + const SizedBox(height: 4), + Text( + projectVersionInfoHandler.fullVersion, + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), TextButton( onPressed: () async { if (await canLaunchUrl(Uri.parse(Strings.flutterUrl))) { diff --git a/pubspec.lock b/pubspec.lock index ddab167..f9e5906 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2,7 +2,7 @@ # See https://dart.dev/tools/pub/glossary#lockfile packages: archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + csv: + dependency: "direct main" + description: + name: csv + sha256: "016b31a51a913744a0a1655c74ff13c9379e1200e246a03d96c81c5d9ed297b5" + url: "https://pub.dev" + source: hosted + version: "5.0.2" cupertino_icons: dependency: "direct main" description: @@ -300,10 +308,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "0351aaba3b267c4962ed73058a5f62a84de7e39670a20e2916a6baff2ffcfbe5" + sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "4.1.0" package_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a8562af..cf3c177 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,10 +3,10 @@ description: A resume builder app made with flutter. publish_to: "none" -version: 0.3.0+4 +version: 0.4.0+7 environment: - sdk: ">=2.17.1 <3.0.0" + sdk: ">=3.0.6 <4.0.0" dependencies: flutter: @@ -25,7 +25,9 @@ dependencies: flutter_reorderable_grid_view: ^4.0.0 file_picker: ^5.5.0 url_launcher: ^6.1.14 - package_info_plus: ^5.0.0 + package_info_plus: ^4.1.0 + csv: ^5.0.2 + archive: ^3.4.5 dev_dependencies: flutter_test: @@ -36,5 +38,5 @@ dev_dependencies: flutter: uses-material-design: true -assets: - - assets/images/ + assets: + - assets/images/