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.
@@ -85,7 +85,7 @@ dependencies:
@@ -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