diff --git a/.vscode/launch.json b/.vscode/launch.json index 0be6f8c..d60d9d1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,12 @@ "type": "dart", "request": "launch", }, + { + "name": "Scantron", + "program": "example/scantron_answer_sheet.dart", + "type": "dart", + "request": "launch", + }, { "name": "Basic", "program": "example/basic.dart", diff --git a/CHANGELOG.md b/CHANGELOG.md index 54688de..4c685f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [0.11.4] +* Improve performance of periodic table example + +## [0.11.3] +* Update Scrabble screenshot to follow game rules (middle square must be + occupied) + +## [0.11.2] +* Add hashCode to TrackSize subclasses + +## [0.11.1] +* Fix screenshots for pub.dev + ## [0.11.0] * Tons of bug fixes in track sizing * Documentation overhaul diff --git a/README.md b/README.md index 5d60d39..30570e5 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,21 @@ design. Piet painting recreated using Flutter Layout Grid   Periodic table rendered using Flutter Layout Grid   Scrabble board rendered using Flutter Layout Grid @@ -85,7 +85,7 @@ dependencies: Desktop app layout rendered using Flutter Layout Grid @@ -164,7 +164,7 @@ There are currently three way to size rows and columns: | Class Name | Description | Usage | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | | `FixedTrackSize` | Occupies a specific number of pixels on an axis | `FixedTrackSize(64)`
`fixed(64)`
`64.px` | -| `FlexibleSizeTrack` | Fills remaining space after the initial layout has completed | `FlexibleTrackSize(1.5)`
`flexible(1.5)`
`1.5.fr` | +| `FlexibleSizeTrack` | Fills remaining space after the initial layout has completed | `FlexibleTrackSize(1.5)`
`flex(1.5)`
`1.5.fr` | | `IntrinsicContentTrackSize` | Sized to contain its itemsʼ contents. Will also expand to fill available space, once `FlexibleTrackSize` tracks have been given the opportunity. | `IntrinsicContentTrackSize()`
`intrinsic()`
`auto` | Technically, you can also define your own, but probably shouldnʼt as the API diff --git a/doc/images/scrabble.png b/doc/images/scrabble.png index 604aa11..cfb4eb4 100644 Binary files a/doc/images/scrabble.png and b/doc/images/scrabble.png differ diff --git a/example/example_helpers.dart b/example/example_helpers.dart new file mode 100644 index 0000000..9bd9de3 --- /dev/null +++ b/example/example_helpers.dart @@ -0,0 +1,33 @@ +import 'dart:ui'; + +// Responsiveness + +Size viewportSize = Size.zero; + +extension ViewportUnits on num { + double get vw => viewportSize.width * (this / 100.0); +} + +// Formatting + +extension DoubleFormatting on double { + /// Formats a double with a maximum precision of [maxFractionDigits]. Any + /// trailing zeroes will be trimmed from the returned string. + String toStringAsMaxFixed([int maxFractionDigits = 2]) { + return this + .toStringAsFixed(maxFractionDigits) + .replaceAll(RegExp(r'\.?0+$'), ''); + } +} + +// Iterables + +extension ListExt on List { + List operator *(int times) => generate(times).expand((e) => this).toList(); +} + +Iterable generate(int times) sync* { + for (int i = 0; i < times; i++) { + yield 0; + } +} diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 5c30a0b..9b901f6 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -2,20 +2,26 @@ PODS: - FlutterMacOS (1.0.0) - path_provider_macos (0.0.1): - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral path_provider_macos: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 path_provider_macos: a0a3fd666cb7cd0448e936fb4abad4052961002b + url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 93c495f..a2e9f40 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -321,10 +321,12 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", "${BUILT_PRODUCTS_DIR}/path_provider_macos/path_provider_macos.framework", + "${BUILT_PRODUCTS_DIR}/url_launcher_macos/url_launcher_macos.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_macos.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_macos.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/example/periodic_table.dart b/example/periodic_table.dart index e8c13f6..7caee2a 100644 --- a/example/periodic_table.dart +++ b/example/periodic_table.dart @@ -11,6 +11,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_layout_grid/flutter_layout_grid.dart'; +import 'example_helpers.dart'; + void main() { runApp(PeriodicTableApp()); } @@ -28,7 +30,7 @@ class PeriodicTableApp extends StatelessWidget { debugShowCheckedModeBanner: false, builder: (_, __) { return LayoutBuilder(builder: (_, constraints) { - _viewportSize = constraints.biggest; + viewportSize = constraints.biggest; return SingleChildScrollView(child: PeriodicTableWidget()); }); }, @@ -37,6 +39,7 @@ class PeriodicTableApp extends StatelessWidget { } } +/// Renders a periodic table. class PeriodicTableWidget extends StatefulWidget { @override _PeriodicTableWidgetState createState() => _PeriodicTableWidgetState(); @@ -65,6 +68,10 @@ class _PeriodicTableWidgetState extends State { } Widget _buildGrid(PeriodicTable table) { + // !!! This is the grid behind the periodic table! !!! + // + // The rest of the code is just details (what goes where, how things should + // look, etc). return LayoutGrid( gridFit: GridFit.loose, columnSizes: repeat(table.numColumns, [1.fr]), @@ -73,15 +80,19 @@ class _PeriodicTableWidgetState extends State { rowGap: 0.4.vw, children: [ for (final e in table.elements) - AtomicElementWidget( - key: ValueKey(e.symbol), - element: e, + AspectRatio( + aspectRatio: 40.1 / 42.4, + child: AtomicElementWidget( + key: ValueKey(e.symbol), + element: e, + ), ).withGridPlacement(columnStart: e.x, rowStart: e.y), ], ); } } +// Mappings between atomic categories and their associated colors. const categoryColorMapping = { AtomicElementCategory.actinide: Color(0xffc686cc), AtomicElementCategory.alkaliMetal: Color(0xffecbe59), @@ -95,6 +106,7 @@ const categoryColorMapping = { AtomicElementCategory.unknown: Color(0xffcccccc), }; +/// A widget representing an element's square on the periodic table. class AtomicElementWidget extends StatelessWidget { AtomicElementWidget({Key key, this.element}) : super(key: key); final AtomicElement element; @@ -115,39 +127,54 @@ class AtomicElementWidget extends StatelessWidget { color: elementColor, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + // Some viewport sizes give us slight overflows, which can be attributed + // to rounding errors. So we use a stack and allow overflow on the bottom + // edge. + child: Stack( + clipBehavior: Clip.hardEdge, children: [ - Padding( - padding: EdgeInsets.fromLTRB(0.3.vw, 0.15.vw, 0, 0), - child: Text( - element.number.toString(), - style: elementTextStyle.copyWith(fontSize: 0.5.vw), - textAlign: TextAlign.left, - ), + Positioned.fill( + bottom: null, + child: _buildElementDetails(elementTextStyle), ), - Text( - element.symbol, - style: elementTextStyle.copyWith(fontSize: 1.9.vw), - textAlign: TextAlign.center, - softWrap: false, + ], + ), + ); + } + + Column _buildElementDetails(TextStyle elementTextStyle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(0.3.vw, 0.15.vw, 0, 0), + child: Text( + element.number.toString(), + style: elementTextStyle.copyWith(fontSize: 0.5.vw), + textAlign: TextAlign.left, ), - Text( - element.name, - style: elementTextStyle.copyWith(fontSize: 0.65.vw), + ), + Text( + element.symbol, + style: elementTextStyle.copyWith(fontSize: 1.9.vw), + textAlign: TextAlign.center, + softWrap: false, + ), + Text( + element.name, + style: elementTextStyle.copyWith(fontSize: 0.65.vw), + textAlign: TextAlign.center, + softWrap: false, + ), + Padding( + padding: EdgeInsets.fromLTRB(0.0, 0.2.vw, 0.0, 0.3.vw), + child: Text( + element.formattedMass, + style: elementTextStyle.copyWith(fontSize: 0.5.vw), textAlign: TextAlign.center, - softWrap: false, - ), - Padding( - padding: EdgeInsets.fromLTRB(0.0, 0.2.vw, 0.0, 0.3.vw), - child: Text( - element.formattedMass, - style: elementTextStyle.copyWith(fontSize: 0.5.vw), - textAlign: TextAlign.center, - ), ), - ], - ), + ), + ], ); } } @@ -163,6 +190,7 @@ Future loadPeriodicTable() async { .toList()); } +/// The elements and structure of the periodic table. class PeriodicTable { PeriodicTable(this.elements) { for (final e in elements) { @@ -176,6 +204,8 @@ class PeriodicTable { int numRows = 0; } +/// Describes an atomic element, with a few view helpers and deserialization +/// logic. class AtomicElement { AtomicElement({ @required this.name, @@ -219,6 +249,7 @@ class AtomicElement { } } +/// Categories of atomic element, as dictacted by...physics! enum AtomicElementCategory { actinide, alkaliMetal, @@ -259,29 +290,3 @@ AtomicElementCategory _parseAtomicElementCategory(String category) { assert(category.startsWith('unknown')); return AtomicElementCategory.unknown; } - -extension ListExt on List { - List operator *(int times) => generate(times).expand((e) => this).toList(); -} - -Size _viewportSize = Size.zero; - -extension on num { - double get vw => _viewportSize.width * (this / 100.0); -} - -extension on double { - /// Formats a double with a maximum precision of [maxFractionDigits]. Any - /// trailing zeroes will be trimmed from the returned string. - String toStringAsMaxFixed([int maxFractionDigits = 2]) { - return this - .toStringAsFixed(maxFractionDigits) - .replaceAll(RegExp(r'\.?0+$'), ''); - } -} - -Iterable generate(int times) sync* { - for (int i = 0; i < times; i++) { - yield 0; - } -} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 926255e..e87c1ae 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter_layout_grid: path: ../ google_fonts: ^1.0.0 + quiver: ^2.1.5 + url_launcher: ^5.7.0 flutter: assets: diff --git a/example/scantron_answer_sheet.dart b/example/scantron_answer_sheet.dart new file mode 100644 index 0000000..9aadac8 --- /dev/null +++ b/example/scantron_answer_sheet.dart @@ -0,0 +1,1236 @@ +// Inspired by the excellent work of Jon Kantner: +// https://codepen.io/jkantner/pen/MGMMVo + +import 'package:flutter/material.dart'; +import 'package:flutter_layout_grid/flutter_layout_grid.dart'; + +import 'support/decoration.dart'; +import 'support/link.dart'; + +void main() { + runApp(ScantronAnswerSheetApp()); +} + +const questionCount = 50; + +/// Used by a couple of extensions on num, defined below +const remUnit = 16.0; +const scantronGreen = Color(0xff20b090); +final choiceLabelScaleFactor = 1.9; +final questionGroupingBorderRadius = 0.375.rem; +final scoreGridRadius = Radius.circular(0.5.rem); + +/// Column/row sizes, used by a couple of widgets +const questionLabels = ['A', 'B', 'C', 'D', 'E']; +final questionGridColumnSizes = [ + fixed(2.rem), // Question number + // A, B, C, D, E, (blank) + ...repeat(questionLabels.length + 1, [fixed(2.5.rem)]), +]; +final questionGridRowHeight = 1.25.rem; + +final rootTextStyle = TextStyle( + fontFamily: 'Helvetica Neue', + fontFamilyFallback: ['Helvetica'], + fontSize: 1.rem, + color: scantronGreen, +); +final bubbleHeaderTextStyle = rootTextStyle.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, +); +final instructionsTextStyle = rootTextStyle.copyWith( + fontSize: 0.625.rem, + fontWeight: FontWeight.bold, + height: 1.4, +); +final tagSeparatedTextStyle = TextStyle( + fontSize: 3.rem, + height: 0, +); +final questionHeaderTextStyle = TextStyle( + fontSize: 0.8.rem, + letterSpacing: 0.05.rem, +); + +/// Top-level widget, composing the components of the answer sheet. +class ScantronAnswerSheet extends StatelessWidget { + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: rootTextStyle, + child: Builder( + builder: (context) => SizedBox( + height: 56 * questionGridRowHeight + 76, + child: LayoutGrid( + gridFit: GridFit.loose, + areas: ''' + . . score . . part + margin barcode score . . part + margin barcode questions tags reorder metadata + ''', + columnSizes: [ + fixed(3.rem), // help and copyright (margin) + fixed(2.625.rem), // barcode + auto, // questions + auto, // test tags + fixed(2.6.rem), // call to reorder + auto, // metadata and legend + ], + rowSizes: [ + fixed(3.5.rem), + auto, + auto, + ], + children: [ + _buildMargin(context).inGridArea('margin'), + _buildBarcode().inGridArea('barcode'), + _buildScore().inGridArea('score'), + _buildQuestions().inGridArea('questions'), + _buildTestTags().inGridArea('tags'), + _buildReorder().inGridArea('reorder'), + _buildPartLabel().inGridArea('part'), + _buildMetadataAndInstructions().inGridArea('metadata'), + ], + ), + ), + ), + ); + } + + Widget _buildMargin(BuildContext context) { + return RotatedBox( + quarterTurns: 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + '© SCANTRON CORPORATION 2007\nALL RIGHTS RESERVED', + style: TextStyle(fontSize: 0.6.rem, letterSpacing: -0.03.rem), + ), + Row( + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Customer Service\n', + style: TextStyle( + fontSize: .9.rem, + letterSpacing: 0.04.rem, + ), + ), + WidgetSpan( + child: UrlLink( + 'tel:1-800-SCANTRON', + style: TextStyle( + fontSize: .9.rem, + letterSpacing: 0.04.rem, + ), + ), + ), + ], + ), + textAlign: TextAlign.center, + ), + SizedBox(width: 1.8.rem), + Container( + decoration: ArrowBoxDecoration( + headLength: 4.75.rem, + tailLength: 4.75.rem, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 0.4.rem), + child: Text( + 'FEED THIS DIRECTION', + style: TextStyle( + fontSize: .75.rem, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ], + ), + RichText( + text: TextSpan( + style: DefaultTextStyle.of(context) + .style + .copyWith(fontSize: 0.75.rem), + children: [ + TextSpan( + text: '8012 4207 599', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: ' 16'), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBarcode() { + return SizedBox( + width: double.infinity, + height: (questionCount + 4 + 3) * questionGridRowHeight, + child: Padding( + padding: EdgeInsets.only(right: 0.6.rem), + child: QuestionBarcodes( + pattern: [ + for (int i = 0; i < 4; i++) BarcodeStripeType.short, + BarcodeStripeType.blank, + BarcodeStripeType.long, + for (int i = 0; i < questionCount - 1; i++) BarcodeStripeType.short, + BarcodeStripeType.long, + ], + ), + ), + ); + } + + Widget _buildScore() { + return ScoreGrid(); + } + + Widget _buildQuestions() { + return QuestionGrid( + questionCount: questionCount, + ); + } + + Widget _buildTestTags() { + return Padding( + padding: EdgeInsets.only(top: questionGridRowHeight), + child: RotatedBox( + quarterTurns: 1, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: 'SHORT ESSAY '), + WidgetSpan(child: Text('•', style: tagSeparatedTextStyle)), + TextSpan(text: ' COMPLETION '), + WidgetSpan(child: Text('•', style: tagSeparatedTextStyle)), + TextSpan(text: ' MULTIPLE CHOICE '), + WidgetSpan(child: Text('•', style: tagSeparatedTextStyle)), + TextSpan(text: ' MATCHING'), + ], + ), + style: TextStyle( + fontSize: 1.8.rem, + letterSpacing: 0.08.rem, + color: scantronGreen, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + Widget _buildReorder() { + return RotatedBox( + quarterTurns: 1, + // Transform squishes the text a bit + child: Transform( + transform: Matrix4.diagonal3Values(0.85, 1.25, 1.0), + child: SizedBox.expand( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('REORDER ONLINE'), + SizedBox(width: 1.rem), + UrlLink('https://www.scantronforms.com'), + ], + ), + ), + ), + ); + } + + Widget _buildPartLabel() { + return Padding( + padding: EdgeInsets.only(bottom: 3.rem), + child: Center( + child: Text( + 'PART 1', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _buildMetadataAndInstructions() { + return RotatedBox( + quarterTurns: 1, + child: ScantronMetadataContainer(), + ); + } +} + +enum BarcodeStripeType { + blank, + short, + long, +} + +class QuestionBarcodes extends StatelessWidget { + const QuestionBarcodes({ + Key key, + @required this.pattern, + }) : super(key: key); + final List pattern; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: BarcodePainter(pattern: pattern), + ); + } +} + +class BarcodePainter extends CustomPainter { + BarcodePainter({@required this.pattern}); + final List pattern; + + Rect rectForStripe(BarcodeStripeType type) { + switch (type) { + case BarcodeStripeType.long: + return Offset(0, -2) & Size(double.infinity, 0.8.rem); + + case BarcodeStripeType.short: + return Offset.zero & Size(double.infinity, 0.18.rem); + + default: + return Rect.zero; + } + } + + @override + void paint(Canvas canvas, Size size) { + final fill = Paint() + ..style = PaintingStyle.fill + ..color = Colors.black; + + for (int i = 0; i < pattern.length; i++) { + if (pattern[i] != BarcodeStripeType.blank) { + final cellRect = rectForStripe(pattern[i]); + final adjustedCellRect = cellRect + .shift(Offset(0, i * questionGridRowHeight)) + .copyWith(width: size.width); + + canvas.drawRect(adjustedCellRect, fill); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class ScoreGrid extends StatelessWidget { + const ScoreGrid({ + Key key, + this.includeHeader = true, + this.includeEmptyLeadingColumn = true, + this.finalScore, + this.filledScoreCells = const {}, + }) : super(key: key); + + final bool includeHeader; + final bool includeEmptyLeadingColumn; + final String finalScore; + final Set filledScoreCells; + + int get firstScoreRow => includeHeader ? 1 : 0; + int get firstScoreColumn => includeEmptyLeadingColumn ? 1 : 0; + + @override + Widget build(BuildContext context) { + return LayoutGrid( + gridFit: GridFit.expand, + columnSizes: questionGridColumnSizes, + rowSizes: [ + if (includeHeader) auto, + ...repeat(4, [questionGridRowHeight.px]) + ], + children: [ + ..._buildScoreCells(), + if (includeHeader) _buildHeader(), + ..._buildOutlines(), + if (finalScore != null) _buildFinalScore(), + ], + ); + } + + Widget _buildHeader() { + return SizedBox.expand( + child: Heading('SUBJECTIVE SCORE\nINSTRUCTOR USE ONLY'), + ).withGridPlacement(columnStart: 1, columnSpan: 6, rowStart: 0); + } + + List _buildScoreCells() { + final rows = [ + [100, 90, 80, 70, 60], + [50, 40, 30, 20, 10], + [9, 8, 7, 6, 5], + [4, 3, 2, 1, 0], + ]; + final colStart = firstScoreColumn; + final rowStart = firstScoreRow; + + return [ + for (int i = 0; i < rows.length; i++) + for (int j = 0; j < 5; j++) + QuestionChoice( + label: rows[i][j].toString(), + filled: filledScoreCells.contains(rows[i][j]), + ).withGridPlacement( + columnStart: colStart + j, + rowStart: rowStart + i, + ), + ]; + } + + List _buildOutlines() { + return [ + // Outline around entire grid + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(scoreGridRadius), + border: Border.all(color: scantronGreen), + ), + ).withGridPlacement( + columnStart: firstScoreColumn, + columnSpan: 6, + rowStart: 0, + rowSpan: includeHeader ? 5 : 4, + ), + // Single line on right edge of choices + Container( + decoration: BoxDecoration( + border: Border(right: BorderSide(color: scantronGreen)), + ), + ).withGridPlacement( + columnStart: firstScoreColumn, + columnSpan: 5, + rowStart: firstScoreRow, + rowSpan: 4, + ) + ]; + } + + Widget _buildFinalScore() { + return SizedBox(); + } +} + +class QuestionGrid extends StatelessWidget { + QuestionGrid({ + @required this.questionCount, + }); + + final int questionCount; + + @override + Widget build(BuildContext context) { + // TODO(shyndman): This would be better with a template + return LayoutGrid( + // ordinals + questions + blank + columnSizes: questionGridColumnSizes, + // header + pre-question + questions + rowSizes: repeat(1 + 1 + questionCount, [fixed(questionGridRowHeight)]), + children: [ + ..._buildHeaderRow(), + ..._buildPreQuestionRow(), + ..._buildQuestions(), + ..._buildQuestionGroupings(), + ], + ); + } + + List _buildHeaderRow() { + return [ + QuestionHeader(label: '(T)') + .withGridPlacement(columnStart: 1, rowStart: 0), + QuestionHeader(label: '(F)') + .withGridPlacement(columnStart: 2, rowStart: 0), + QuestionHeader(label: 'KEY', bold: true) + .withGridPlacement(columnStart: 5, rowStart: 0), + ]; + } + + List _buildPreQuestionRow() { + return [ + QuestionChoice(label: '%', italic: true) + .withGridPlacement(columnStart: 1, rowStart: 1), + QuestionChoice(label: '2', italic: true) + .withGridPlacement(columnStart: 2, rowStart: 1), + QuestionChoice(label: '3', italic: true) + .withGridPlacement(columnStart: 3, rowStart: 1), + QuestionChoice(label: '5', italic: true) + .withGridPlacement(columnStart: 5, rowStart: 1), + ]; + } + + List _buildQuestions() { + final firstRow = 2; + return [ + for (int i = 0; i < questionCount; i++) + QuestionNumber(label: i + 1) + .withGridPlacement(columnStart: 0, rowStart: firstRow + i), + for (int i = 0; i < questionCount; i++) + for (int j = 0; j < questionLabels.length; j++) + QuestionChoice(label: questionLabels[j]).withGridPlacement( + columnStart: 1 + j, + rowStart: firstRow + i, + ), + ]; + } + + /// Lined brackets around groups of questions + List _buildQuestionGroupings() { + final firstRow = 2; + return [ + if (questionCount > 20) + QuestionGrouping().withGridPlacement( + columnStart: 0, + columnSpan: 6, + rowStart: firstRow + 10, + rowSpan: 10, + ), + if (questionCount > 40) + QuestionGrouping().withGridPlacement( + columnStart: 0, + columnSpan: 6, + rowStart: firstRow + 30, + rowSpan: 10, + ), + ]; + } +} + +class QuestionHeader extends StatelessWidget { + const QuestionHeader({ + Key key, + this.label, + this.bold = false, + }) : super(key: key); + + final String label; + final bool bold; + + @override + Widget build(BuildContext context) { + final style = questionHeaderTextStyle; + return SizedBox.expand( + child: Center( + child: Text( + label, + style: bold ? style.copyWith(fontWeight: FontWeight.bold) : style, + ), + ), + ); + } +} + +/// The number next to a question +class QuestionNumber extends StatelessWidget { + const QuestionNumber({Key key, this.label}) : super(key: key); + final int label; + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Padding( + padding: EdgeInsets.only(right: 0.25.rem), + child: Text( + label.toString(), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 1.rem, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } +} + +/// One of a question's choices (ie, A, B, C, etc) +class QuestionChoice extends StatelessWidget { + const QuestionChoice({ + Key key, + this.label, + this.margin, + this.filled = false, + this.italic = false, + }) : super(key: key); + + final String label; + final bool filled; + final bool italic; + final EdgeInsets margin; + + double get fontSize => label.length == 3 + ? 0.74.rem + : label.length == 2 + ? 0.85.rem + : 0.95.rem; + + EdgeInsets get labelPadding => label.length == 3 + ? EdgeInsets.symmetric(vertical: 0.075.rem) + : EdgeInsets.all(0.05.rem); + + EdgeInsets get effectiveBoxMargin => + margin ?? + EdgeInsets.symmetric( + horizontal: 0.45.rem, + vertical: 0.37.rem, + ); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + // Green edges + Container( + margin: effectiveBoxMargin, + decoration: BoxDecoration( + border: Border.all(color: scantronGreen), + ), + ), + Transform.scale( + scale: choiceLabelScaleFactor, + child: Container( + color: Colors.white, + padding: labelPadding, + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: fontSize / choiceLabelScaleFactor, + fontWeight: FontWeight.bold, + fontStyle: italic ? FontStyle.italic : FontStyle.normal, + ), + ), + ), + ), + if (filled) + Container( + margin: effectiveBoxMargin, + color: scantronGreen, + ), + ], + ); + } +} + +class QuestionGrouping extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: CustomPaint( + painter: QuestionGroupingPainter(), + ), + ); + } +} + +class ScantronMetadataContainer extends StatelessWidget { + @override + Widget build(BuildContext context) { + return LayoutGrid( + areas: ''' + instructions sheet_meta score_tally + instructions student_meta score_tally + ''', + columnSizes: [1.5.fr, 1.5.fr, 0.75.fr], + rowSizes: [auto, auto], + columnGap: 1.rem, + children: [ + ScantronInstructions().inGridArea('instructions'), + ScantronLogoAndSheetMetadata().inGridArea('sheet_meta'), + ScantronStudentMetadata().inGridArea('student_meta'), + ScantronScoreTally().inGridArea('score_tally'), + ], + ); + } +} + +class ScantronInstructions extends StatelessWidget { + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: instructionsTextStyle, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: scantronGreen), + borderRadius: BorderRadius.all(scoreGridRadius), + ), + clipBehavior: Clip.hardEdge, + child: _buildGrid(), + ), + ); + } + + LayoutGrid _buildGrid() { + return LayoutGrid( + gridFit: GridFit.loose, + areas: ''' + header header + student teacher + ''', + columnSizes: [1.fr, 1.fr], + rowSizes: [ + auto, + auto, + ], + children: [ + _buildHeader().inGridArea('header'), + _buildStudentInstructions().inGridArea('student'), + _buildTeacherInstructions().inGridArea('teacher'), + ], + ); + } + + Widget _buildHeader() { + return Container( + color: scantronGreen, + child: Heading('IMPORTANT'), + ); + } + + Widget _buildStudentInstructions() { + return Container( + decoration: BoxDecoration( + border: Border( + right: BorderSide(color: scantronGreen), + ), + ), + padding: EdgeInsets.symmetric( + horizontal: 0.25.rem, + vertical: 0.125.rem, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: PencilBoxDecoration( + headLength: 1.3.rem, + tailLength: 0.9.rem, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 0.4.rem), + child: Text( + 'USE NO. 2 PENCIL ONLY', + style: TextStyle( + fontFamily: 'Times New Roman', + fontWeight: FontWeight.normal, + height: 1.1, + ), + ), + ), + ), + Bullet(child: Text('MAKE DARK MARKS')), + Bullet( + child: Text('ERASE COMPLETELY\nTO CHANGE'), + ), + Bullet( + child: Row( + children: [ + Expanded(child: Text('EXAMPLE:')), + ..._buildExampleChoices(), + ], + ), + ), + ], + ), + ); + } + + List _buildExampleChoices() { + return [ + for (int i = 0; i < questionLabels.length; i++) + SizedBox( + width: 26, + height: 13, + child: Transform.scale( + scale: 0.6, + child: QuestionChoice( + margin: EdgeInsets.symmetric(vertical: 0.15.rem), + label: questionLabels[i], + filled: i == 1, // fill in B + ), + ), + ), + ]; + } + + Widget _buildTeacherInstructions() { + return Padding( + padding: EdgeInsets.only( + left: 0.4.rem, + right: 0.25.rem, + top: 0.125.rem, + bottom: 0.3125.rem, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + // crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'TO USE SUBJECTIVE\nSCORE FEATURE:', + textAlign: TextAlign.center, + style: TextStyle( + height: 1.5, + ), + ), + Column( + children: [ + Bullet(child: Text('Make total possible subjective points')), + Bullet(child: Text('Only one mark per line on key')), + Bullet(child: Text('163 points maximum')), + ], + ), + _buildTeacherExample(), + ], + ), + ); + } + + Widget _buildTeacherExample() { + return Row( + children: [ + Flexible(child: Text('EXAMPLE OF STUDENT SCORE:')), + Transform.scale( + scale: 0.5, + origin: Offset(0, 0), + child: ScoreGrid( + includeEmptyLeadingColumn: false, + includeHeader: false, + filledScoreCells: {100, 20, 5}, + finalScore: '125', + ), + ), + ], + ); + } +} + +class ScantronLogoAndSheetMetadata extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ + Column( + children: [ + Transform( + transform: Matrix4.diagonal3Values(1, 1.5, 1), + child: Text( + 'SCANTRON®', + style: TextStyle( + fontWeight: FontWeight.w300, + letterSpacing: 0.825.rem, + ), + ), + ), + Text('FORM NO. 883-E'), + ], + ), + Text( + 'FOR USE ON\nTEST SCORING\nMACHINE ONLY', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 0.9.rem, + fontStyle: FontStyle.italic, + ), + ) + ]); + } +} + +class ScantronStudentMetadata extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: scantronGreen), + borderRadius: BorderRadius.all(scoreGridRadius), + ), + clipBehavior: Clip.hardEdge, + child: LayoutGrid( + areas: ''' + nameLabel name name name + subjectLabel subject testNoLabel testLabel + dateLabel date periodLabel period + ''', + columnGap: 10, + columnSizes: repeat(4, [auto]), + rowSizes: repeat(3, [auto]), + children: [ + // Headings + Heading('NAME').inGridArea('nameLabel'), + Heading('SUBJECT').inGridArea('subjectLabel'), + Heading('DATE').inGridArea('dateLabel'), + Heading('TEST NO.').inGridArea('testNoLabel'), + Heading('PERIOD').inGridArea('periodLabel'), + + // Lines + ..._buildRowLines(), + ], + ), + ); + } + + List _buildRowLines() { + return [ + Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: scantronGreen), + ), + ), + ).withGridPlacement(columnStart: 0, columnSpan: 4, rowStart: 1), + Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: scantronGreen), + ), + ), + ).withGridPlacement(columnStart: 0, columnSpan: 4, rowStart: 2), + ]; + } +} + +class ScantronScoreTally extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: scantronGreen), + borderRadius: BorderRadius.all(scoreGridRadius), + ), + clipBehavior: Clip.hardEdge, + child: LayoutGrid( + areas: ''' + header header + part1 score1 + part2 score2 + part3 score3 + total total_score + ''', + columnSizes: repeat(2, [auto]), + rowSizes: repeat(5, [auto]), + children: [ + _buildHeader().inGridArea('header'), + for (int i = 1; i <= 3; i++) ...[ + SizedBox.expand(child: Text('PART $i', textAlign: TextAlign.center)) + .inGridArea('part$i'), + Container().inGridArea('score$i'), + ], + _buildTotal().inGridArea('total'), + ], + ), + ); + } + + Widget _buildHeader() { + return SizedBox.expand( + child: Container( + color: scantronGreen, + child: Heading('TEST RECORD'), + ), + ); + } + + Widget _buildTotal() { + return SizedBox.expand( + child: Text( + 'TOTAL', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ); + } +} + +class Heading extends StatelessWidget { + const Heading( + this.label, { + Key key, + this.topLeft = false, + this.topRight = false, + this.bottomRight = false, + this.bottomLeft = false, + }) : super(key: key); + + final String label; + final bool topLeft; + final bool topRight; + final bool bottomRight; + final bool bottomLeft; + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Container( + decoration: BoxDecoration( + color: scantronGreen, + borderRadius: BorderRadius.only( + topLeft: topLeft ? scoreGridRadius : Radius.zero, + topRight: topRight ? scoreGridRadius : Radius.zero, + bottomRight: bottomRight ? scoreGridRadius : Radius.zero, + bottomLeft: bottomLeft ? scoreGridRadius : Radius.zero, + ), + ), + padding: EdgeInsets.symmetric(horizontal: 0.75.rem, vertical: 0.25.rem), + child: Text( + label, + textAlign: TextAlign.center, + style: bubbleHeaderTextStyle, + ), + ), + ); + } +} + +class Bullet extends StatelessWidget { + const Bullet({Key key, this.child}) : super(key: key); + final Widget child; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('•'), + SizedBox(width: 4), + Flexible(child: child), + ], + ); + } +} + +class QuestionGroupingPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final radius = questionGroupingBorderRadius; + + final path = Path() + ..moveTo(size.width, 0) + ..lineTo(radius, 0) + ..arcToPoint(Offset(0, radius), + radius: Radius.circular(radius), clockwise: false) + ..lineTo(0, size.height - radius) + ..arcToPoint(Offset(radius, size.height), + radius: Radius.circular(radius), clockwise: false) + ..lineTo(size.width, size.height); + + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..color = scantronGreen + ..strokeWidth = 0.125.rem, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} + +/// Draws a fancy arrow around the [DecoratedBox]'s child. +class ArrowBoxDecoration extends SimpleDecoration { + ArrowBoxDecoration({ + @required this.headLength, + @required this.tailLength, + }); + + final double headLength; + final double tailLength; + + @override + EdgeInsetsGeometry get padding => + EdgeInsets.fromLTRB(headLength, 0, tailLength, 0); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final paint = Paint() + ..style = PaintingStyle.fill + ..color = scantronGreen + ..isAntiAlias = true; + final size = configuration.size; + + final pointLength = 1.2 * headLength / 3; + final featherLength = 3 * tailLength / 5; + final featherStraightLength = 1.3 * tailLength / 3; + final featherInsetLength = tailLength / 5; + + final shaftWidth = size.height / 4; + final shaftTop = (size.height / 2) - shaftWidth / 2; + final shaftBottom = shaftTop + shaftWidth; + + final headPath = Path() + // The point of the arrow + ..moveTo(0, size.height / 2) + ..lineTo(pointLength, 0) + ..lineTo(pointLength, shaftTop) + ..lineTo(headLength, shaftTop) + ..lineTo(headLength, shaftBottom) + ..lineTo(pointLength, shaftBottom) + ..lineTo(pointLength, size.height); + + final tailBox = + Rect.fromLTWH(size.width - tailLength, 0, tailLength, size.height); + final tailPath = Path() + // Top of the + ..moveTo(tailBox.left, shaftTop) + ..lineTo(tailBox.right - featherLength, shaftTop) + ..lineTo(tailBox.right - featherStraightLength, 0) + ..lineTo(tailBox.right, 0) + ..lineTo(tailBox.right - featherInsetLength, size.height / 2) + ..lineTo(tailBox.right, size.height) + ..lineTo(tailBox.right - featherStraightLength, size.height) + ..lineTo(tailBox.right - featherLength, shaftBottom) + ..lineTo(tailBox.left, shaftBottom); + + canvas.save(); + canvas.translate(offset.dx, offset.dy); + canvas.drawPath(headPath, paint); + canvas.drawPath(tailPath, paint); + canvas.restore(); + } +} + +/// Draws a pencil around the [DecoratedBox]'s child. +class PencilBoxDecoration extends SimpleDecoration { + PencilBoxDecoration({ + @required this.headLength, + @required this.tailLength, + }); + + final double headLength; + final double tailLength; + + @override + EdgeInsetsGeometry get padding => + EdgeInsets.fromLTRB(headLength, 0, tailLength, 0); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0 + ..color = scantronGreen; + + final fill = Paint() + ..style = PaintingStyle.fill + ..color = scantronGreen; + + final size = configuration.size; + final leadLength = 0.4 * headLength; + final pointLength = 3 * headLength / 4; + final eraserLength = 4 * tailLength / 7; + final eraserHolderLength = 4 * tailLength / 7; + + final pointPath = Path() + ..moveTo(0, size.height / 2) + ..lineTo(pointLength, 0) + ..lineTo(pointLength, size.height) + ..lineTo(0, size.height / 2); + canvas.drawPath(pointPath.shift(offset), stroke); + + canvas.save(); + canvas.clipPath(pointPath.shift(offset)); + canvas.drawRect( + Rect.fromLTWH(0, 0, leadLength, size.height).shift(offset), fill); + canvas.restore(); + + final outlineRect = Rect.fromLTWH( + pointLength, + 0, + size.width - pointLength, + size.height, + ); + canvas.drawRect( + outlineRect.shift(offset), + stroke, + ); + + final eraserRect = + Rect.fromLTWH(size.width - eraserLength, 0, eraserLength, size.height); + canvas.drawRect( + eraserRect.shift(offset), + fill, + ); + + final eraserHolderRect = Rect.fromLTWH(eraserRect.left - eraserHolderLength, + 0, eraserHolderLength, size.height); + canvas.drawRect( + eraserHolderRect.shift(offset), + stroke, + ); + } +} + +/// Little extension to support scaling +extension on num { + double get rem => this.toDouble() * remUnit; +} + +/// Little extension to support simple copies +extension on Rect { + Rect copyWith({ + double left, + double top, + double width, + double height, + }) { + return Rect.fromLTWH( + left ?? this.left, + top ?? this.top, + width ?? this.width, + height ?? this.height, + ); + } +} + +/// Boilerplate +class ScantronAnswerSheetApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + ), + child: WidgetsApp( + title: 'Scantron Answer Sheet', + color: Colors.white, + debugShowCheckedModeBanner: false, + builder: (_, __) { + return Center( + child: SizedBox( + width: 416, + child: ScantronInstructions(), + ), + ); + + // return SingleChildScrollView( + // child: Center( + // child: RotatedBox( + // quarterTurns: 3, + // child: ScantronAnswerSheet(), + // ), + // ), + // ); + }, + ), + ); + } +} diff --git a/example/support/decoration.dart b/example/support/decoration.dart new file mode 100644 index 0000000..5dbf5d1 --- /dev/null +++ b/example/support/decoration.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +typedef _BoxPainterDelegate = void Function( + Canvas canvas, + Offset offset, + ImageConfiguration configuration, +); + +/// Simplifies decoration by allowing all functionality through a single class. +abstract class SimpleDecoration extends Decoration { + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration); + + @override + @nonVirtual + BoxPainter createBoxPainter([onChanged]) { + return _GenericBoxPainter(this.paint); + } +} + +class _GenericBoxPainter extends BoxPainter { + _GenericBoxPainter(this.delegate); + final _BoxPainterDelegate delegate; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + delegate(canvas, offset, configuration); + } +} diff --git a/example/support/link.dart b/example/support/link.dart new file mode 100644 index 0000000..9162679 --- /dev/null +++ b/example/support/link.dart @@ -0,0 +1,36 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class UrlLink extends StatelessWidget { + UrlLink( + this.url, { + Key key, + this.style, + this.stripScheme = true, + }) : assert(url.contains(':')), + super(key: key); + + final String url; + final TextStyle style; + final bool stripScheme; + + String get label { + if (!stripScheme) return url; + + return url.contains('://') + ? url.substring(url.indexOf('://') + 3) + : url.substring(url.indexOf(':') + 1); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => launch(url), + child: Text(label, style: style), + ), + ); + } +} diff --git a/lib/src/foundation/collections.dart b/lib/src/foundation/collections.dart index 92ddf73..845c0e1 100644 --- a/lib/src/foundation/collections.dart +++ b/lib/src/foundation/collections.dart @@ -20,7 +20,7 @@ T sum(Iterable numbers) { return numbers.fold(zeroForType(), (acc, number) => (acc + number) as T); } -/// Returns an iterable of [number]'s cumulative sums. +/// Returns an iterable of [label]'s cumulative sums. /// /// ``` /// cumulativeSum([1, 2, 3]) // 0, 1, 3, 6 diff --git a/lib/src/rendering/track_size.dart b/lib/src/rendering/track_size.dart index aee31f6..94cae94 100644 --- a/lib/src/rendering/track_size.dart +++ b/lib/src/rendering/track_size.dart @@ -172,6 +172,9 @@ class FixedTrackSize extends TrackSize { return sizeInPx; } + @override + int get hashCode => hashValues(sizeInPx, debugLabel); + @override bool operator ==(dynamic other) { if (identical(this, other)) return true; @@ -242,6 +245,9 @@ class FlexibleTrackSize extends TrackSize { properties.add(DoubleProperty('flex', flex)); } + @override + int get hashCode => hashValues(flex, debugLabel); + @override bool operator ==(dynamic other) { if (identical(this, other)) return true; @@ -298,6 +304,9 @@ class IntrinsicContentTrackSize extends TrackSize { return max(maxContentContributions); } + @override + int get hashCode => debugLabel.hashCode; + @override bool operator ==(dynamic other) { if (identical(this, other)) return true; diff --git a/pubspec.yaml b/pubspec.yaml index d4ee9f4..6933978 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,16 @@ name: flutter_layout_grid description: A powerful grid layout system for Flutter, optimized for complex user interface design. -version: 0.11.0 +version: 0.11.4 homepage: https://github.com/madewithfelt/flutter_layout_grid environment: - flutter: '>=1.14.0 <2.0.0' + flutter: '>=1.14.0' sdk: ">=2.6.0 <3.0.0" dependencies: - collection: ^1.0.0 flutter: sdk: flutter + collection: ^1.0.0 meta: ^1.0.0 quiver: ^2.0.0