diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..9cda2b44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a reproducible UnitFlow problem +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: Thanks for helping improve UnitFlow. Remove private information before submitting logs or screenshots. + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + validations: + required: true + - type: input + id: version + attributes: + label: UnitFlow version or commit + validations: + required: true + - type: input + id: platform + attributes: + label: Platform and OS + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Select ... + 2. Enter ... + 3. Observe ... + validations: + required: true + - type: textarea + id: conversion + attributes: + label: Conversion details + description: Include source unit, destination unit, input, actual output, and expected output when relevant. + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Optional. Remove secrets and personal information. + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched existing issues for duplicates. + required: true + - label: I removed secrets and private information. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..4be21714 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Suggest an improvement for UnitFlow +title: "feat: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem or opportunity + description: What user need would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior, UI, units, or workflow you would like. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Optional alternatives or tradeoffs. + - type: dropdown + id: area + attributes: + label: Area + options: + - Rust core + - Flutter UI + - Unit catalog + - Accessibility + - Documentation + - Build and release + - Other + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched existing issues for similar requests. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..fc077559 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +Describe the problem and the focused change that solves it. + +## Validation + +- [ ] Rust checks/tests pass when Rust code changed. +- [ ] Flutter analyze/tests pass when Flutter code changed. +- [ ] Conversion constants/formulas have regression coverage when changed. +- [ ] UI changes were checked on narrow and wide layouts. +- [ ] Accessibility implications were reviewed. +- [ ] Documentation/changelog were updated when needed. +- [ ] No secrets or private information are included. + +## Screenshots + +Add screenshots for meaningful UI changes when useful. + +## Notes / limitations + +Call out migrations, compatibility concerns, follow-up work, or known limitations. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cabc01a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + rust: + name: Rust core + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo check + run: cargo check --workspace --all-targets + + - name: Cargo test + run: cargo test --workspace + + flutter: + name: Flutter app + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve packages + working-directory: app + run: flutter pub get + + - name: Analyze + working-directory: app + run: flutter analyze + + - name: Test + working-directory: app + run: flutter test diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 00000000..a59810ec --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,19 @@ +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + dead_code: error + unused_import: error + unused_local_variable: error + invalid_assignment: error + +linter: + rules: + - always_declare_return_types + - avoid_print + - avoid_relative_lib_imports + - cancel_subscriptions + - directives_ordering + - use_build_context_synchronously diff --git a/app/lib/core/converter.dart b/app/lib/core/converter.dart new file mode 100644 index 00000000..ff8f5f99 --- /dev/null +++ b/app/lib/core/converter.dart @@ -0,0 +1,92 @@ +import 'dart:math' as math; + +import 'unit_model.dart'; + +class ConversionException implements Exception { + const ConversionException(this.message); + + final String message; + + @override + String toString() => message; +} + +class Converter { + const Converter(); + + double convert({ + required double value, + required ConversionUnit from, + required ConversionUnit to, + }) { + if (from.category != to.category) { + throw const ConversionException('Units must belong to the same category.'); + } + + if (from.category == UnitCategory.temperature) { + return _convertTemperature(value, from.id, to.id); + } + + if (to.factorToBase == 0) { + throw const ConversionException('Destination unit has an invalid zero factor.'); + } + + return value * from.factorToBase / to.factorToBase; + } + + List batchConvert({ + required Iterable values, + required ConversionUnit from, + required ConversionUnit to, + }) { + return values + .map((double value) => convert(value: value, from: from, to: to)) + .toList(growable: false); + } + + String format( + double value, { + int decimalPlaces = 8, + bool scientific = false, + }) { + final int safePlaces = decimalPlaces.clamp(0, 15).toInt(); + if (value.isNaN || value.isInfinite) { + return value.toString(); + } + if (scientific) { + return value.toStringAsExponential(safePlaces); + } + + final String fixed = value.toStringAsFixed(safePlaces); + if (!fixed.contains('.')) { + return fixed; + } + return fixed.replaceFirst(RegExp(r'\.?0+$'), ''); + } + + double round(double value, int decimalPlaces) { + final int safePlaces = decimalPlaces.clamp(0, 15).toInt(); + final double scale = math.pow(10, safePlaces).toDouble(); + return (value * scale).roundToDouble() / scale; + } + + double _convertTemperature(double value, String from, String to) { + if (from == to) { + return value; + } + + final double kelvin = switch (from) { + 'kelvin' => value, + 'celsius' => value + 273.15, + 'fahrenheit' => (value + 459.67) * 5 / 9, + _ => throw ConversionException('Unknown temperature unit: $from'), + }; + + return switch (to) { + 'kelvin' => kelvin, + 'celsius' => kelvin - 273.15, + 'fahrenheit' => kelvin * 9 / 5 - 459.67, + _ => throw ConversionException('Unknown temperature unit: $to'), + }; + } +} diff --git a/app/lib/core/unit_catalog.dart b/app/lib/core/unit_catalog.dart new file mode 100644 index 00000000..3413ca6b --- /dev/null +++ b/app/lib/core/unit_catalog.dart @@ -0,0 +1,93 @@ +import 'unit_model.dart'; + +const List unitCatalog = [ + ConversionUnit(id: 'meter', category: UnitCategory.length, name: 'Meter', symbol: 'm', factorToBase: 1, aliases: ['metre'], description: 'SI base unit of length.'), + ConversionUnit(id: 'kilometer', category: UnitCategory.length, name: 'Kilometer', symbol: 'km', factorToBase: 1000, aliases: ['kilometre'], description: 'One thousand meters.'), + ConversionUnit(id: 'centimeter', category: UnitCategory.length, name: 'Centimeter', symbol: 'cm', factorToBase: 0.01, description: 'One hundredth of a meter.'), + ConversionUnit(id: 'millimeter', category: UnitCategory.length, name: 'Millimeter', symbol: 'mm', factorToBase: 0.001, description: 'One thousandth of a meter.'), + ConversionUnit(id: 'inch', category: UnitCategory.length, name: 'Inch', symbol: 'in', factorToBase: 0.0254, aliases: ['inches'], description: 'Exactly 2.54 centimeters.'), + ConversionUnit(id: 'foot', category: UnitCategory.length, name: 'Foot', symbol: 'ft', factorToBase: 0.3048, aliases: ['feet'], description: 'Twelve inches.'), + ConversionUnit(id: 'yard', category: UnitCategory.length, name: 'Yard', symbol: 'yd', factorToBase: 0.9144, description: 'Three feet.'), + ConversionUnit(id: 'mile', category: UnitCategory.length, name: 'Mile', symbol: 'mi', factorToBase: 1609.344, description: '5,280 feet.'), + ConversionUnit(id: 'nautical_mile', category: UnitCategory.length, name: 'Nautical mile', symbol: 'nmi', factorToBase: 1852, description: 'International nautical mile.'), + + ConversionUnit(id: 'kilogram', category: UnitCategory.mass, name: 'Kilogram', symbol: 'kg', factorToBase: 1, description: 'SI base unit of mass.'), + ConversionUnit(id: 'gram', category: UnitCategory.mass, name: 'Gram', symbol: 'g', factorToBase: 0.001, description: 'One thousandth of a kilogram.'), + ConversionUnit(id: 'milligram', category: UnitCategory.mass, name: 'Milligram', symbol: 'mg', factorToBase: 0.000001, description: 'One millionth of a kilogram.'), + ConversionUnit(id: 'tonne', category: UnitCategory.mass, name: 'Metric tonne', symbol: 't', factorToBase: 1000, aliases: ['metric ton'], description: 'One thousand kilograms.'), + ConversionUnit(id: 'ounce', category: UnitCategory.mass, name: 'Ounce', symbol: 'oz', factorToBase: 0.028349523125, description: 'International avoirdupois ounce.'), + ConversionUnit(id: 'pound', category: UnitCategory.mass, name: 'Pound', symbol: 'lb', factorToBase: 0.45359237, aliases: ['lbs'], description: 'International avoirdupois pound.'), + + ConversionUnit(id: 'kelvin', category: UnitCategory.temperature, name: 'Kelvin', symbol: 'K', factorToBase: 1, description: 'SI base unit of thermodynamic temperature.'), + ConversionUnit(id: 'celsius', category: UnitCategory.temperature, name: 'Celsius', symbol: '°C', factorToBase: 1, aliases: ['centigrade'], description: 'Temperature scale offset from kelvin by 273.15.'), + ConversionUnit(id: 'fahrenheit', category: UnitCategory.temperature, name: 'Fahrenheit', symbol: '°F', factorToBase: 1, description: 'Fahrenheit temperature scale.'), + + ConversionUnit(id: 'second', category: UnitCategory.time, name: 'Second', symbol: 's', factorToBase: 1, description: 'SI base unit of time.'), + ConversionUnit(id: 'millisecond', category: UnitCategory.time, name: 'Millisecond', symbol: 'ms', factorToBase: 0.001, description: 'One thousandth of a second.'), + ConversionUnit(id: 'minute', category: UnitCategory.time, name: 'Minute', symbol: 'min', factorToBase: 60, description: 'Sixty seconds.'), + ConversionUnit(id: 'hour', category: UnitCategory.time, name: 'Hour', symbol: 'h', factorToBase: 3600, description: 'Sixty minutes.'), + ConversionUnit(id: 'day', category: UnitCategory.time, name: 'Day', symbol: 'd', factorToBase: 86400, description: 'Twenty-four hours.'), + ConversionUnit(id: 'week', category: UnitCategory.time, name: 'Week', symbol: 'wk', factorToBase: 604800, description: 'Seven days.'), + + ConversionUnit(id: 'square_meter', category: UnitCategory.area, name: 'Square meter', symbol: 'm²', factorToBase: 1, aliases: ['sqm'], description: 'Area of a one-meter square.'), + ConversionUnit(id: 'square_kilometer', category: UnitCategory.area, name: 'Square kilometer', symbol: 'km²', factorToBase: 1000000, description: 'One million square meters.'), + ConversionUnit(id: 'square_foot', category: UnitCategory.area, name: 'Square foot', symbol: 'ft²', factorToBase: 0.09290304, aliases: ['sq ft'], description: 'Area of a one-foot square.'), + ConversionUnit(id: 'acre', category: UnitCategory.area, name: 'Acre', symbol: 'ac', factorToBase: 4046.8564224, description: '43,560 square feet.'), + ConversionUnit(id: 'hectare', category: UnitCategory.area, name: 'Hectare', symbol: 'ha', factorToBase: 10000, description: 'Ten thousand square meters.'), + + ConversionUnit(id: 'liter', category: UnitCategory.volume, name: 'Liter', symbol: 'L', factorToBase: 1, aliases: ['litre'], description: 'Metric unit of volume.'), + ConversionUnit(id: 'milliliter', category: UnitCategory.volume, name: 'Milliliter', symbol: 'mL', factorToBase: 0.001, aliases: ['ml'], description: 'One thousandth of a liter.'), + ConversionUnit(id: 'cubic_meter', category: UnitCategory.volume, name: 'Cubic meter', symbol: 'm³', factorToBase: 1000, description: 'One thousand liters.'), + ConversionUnit(id: 'us_teaspoon', category: UnitCategory.volume, name: 'US teaspoon', symbol: 'tsp', factorToBase: 0.00492892159375, description: 'US customary teaspoon.'), + ConversionUnit(id: 'us_tablespoon', category: UnitCategory.volume, name: 'US tablespoon', symbol: 'tbsp', factorToBase: 0.01478676478125, description: 'US customary tablespoon.'), + ConversionUnit(id: 'us_cup', category: UnitCategory.volume, name: 'US cup', symbol: 'cup', factorToBase: 0.2365882365, description: 'US customary cup.'), + ConversionUnit(id: 'us_gallon', category: UnitCategory.volume, name: 'US gallon', symbol: 'gal', factorToBase: 3.785411784, description: 'US customary liquid gallon.'), + + ConversionUnit(id: 'meter_per_second', category: UnitCategory.speed, name: 'Meter per second', symbol: 'm/s', factorToBase: 1, aliases: ['mps'], description: 'SI derived unit of speed.'), + ConversionUnit(id: 'kilometer_per_hour', category: UnitCategory.speed, name: 'Kilometer per hour', symbol: 'km/h', factorToBase: 1 / 3.6, aliases: ['kph', 'kmph'], description: 'Kilometers traveled in one hour.'), + ConversionUnit(id: 'mile_per_hour', category: UnitCategory.speed, name: 'Mile per hour', symbol: 'mph', factorToBase: 0.44704, description: 'Miles traveled in one hour.'), + ConversionUnit(id: 'knot', category: UnitCategory.speed, name: 'Knot', symbol: 'kn', factorToBase: 1852 / 3600, aliases: ['kt'], description: 'One nautical mile per hour.'), + + ConversionUnit(id: 'byte', category: UnitCategory.data, name: 'Byte', symbol: 'B', factorToBase: 1, description: 'Eight bits.'), + ConversionUnit(id: 'bit', category: UnitCategory.data, name: 'Bit', symbol: 'bit', factorToBase: 0.125, description: 'One eighth of a byte.'), + ConversionUnit(id: 'kilobyte', category: UnitCategory.data, name: 'Kilobyte', symbol: 'kB', factorToBase: 1000, description: 'One thousand bytes.'), + ConversionUnit(id: 'megabyte', category: UnitCategory.data, name: 'Megabyte', symbol: 'MB', factorToBase: 1000000, description: 'One million bytes.'), + ConversionUnit(id: 'gigabyte', category: UnitCategory.data, name: 'Gigabyte', symbol: 'GB', factorToBase: 1000000000, description: 'One billion bytes.'), + ConversionUnit(id: 'kibibyte', category: UnitCategory.data, name: 'Kibibyte', symbol: 'KiB', factorToBase: 1024, description: '1,024 bytes.'), + ConversionUnit(id: 'mebibyte', category: UnitCategory.data, name: 'Mebibyte', symbol: 'MiB', factorToBase: 1048576, description: '1,048,576 bytes.'), + + ConversionUnit(id: 'pascal', category: UnitCategory.pressure, name: 'Pascal', symbol: 'Pa', factorToBase: 1, description: 'SI derived unit of pressure.'), + ConversionUnit(id: 'kilopascal', category: UnitCategory.pressure, name: 'Kilopascal', symbol: 'kPa', factorToBase: 1000, description: 'One thousand pascals.'), + ConversionUnit(id: 'bar', category: UnitCategory.pressure, name: 'Bar', symbol: 'bar', factorToBase: 100000, description: 'One hundred kilopascals.'), + ConversionUnit(id: 'atmosphere', category: UnitCategory.pressure, name: 'Standard atmosphere', symbol: 'atm', factorToBase: 101325, description: 'Standard atmosphere.'), + ConversionUnit(id: 'psi', category: UnitCategory.pressure, name: 'Pound per square inch', symbol: 'psi', factorToBase: 6894.757293168, description: 'Pressure in pounds-force per square inch.'), + + ConversionUnit(id: 'joule', category: UnitCategory.energy, name: 'Joule', symbol: 'J', factorToBase: 1, description: 'SI derived unit of energy.'), + ConversionUnit(id: 'kilojoule', category: UnitCategory.energy, name: 'Kilojoule', symbol: 'kJ', factorToBase: 1000, description: 'One thousand joules.'), + ConversionUnit(id: 'calorie', category: UnitCategory.energy, name: 'Calorie', symbol: 'cal', factorToBase: 4.184, description: 'Thermochemical calorie.'), + ConversionUnit(id: 'kilocalorie', category: UnitCategory.energy, name: 'Kilocalorie', symbol: 'kcal', factorToBase: 4184, aliases: ['food calorie'], description: 'One thousand thermochemical calories.'), + ConversionUnit(id: 'watt_hour', category: UnitCategory.energy, name: 'Watt-hour', symbol: 'Wh', factorToBase: 3600, description: 'Energy from one watt over one hour.'), + ConversionUnit(id: 'kilowatt_hour', category: UnitCategory.energy, name: 'Kilowatt-hour', symbol: 'kWh', factorToBase: 3600000, description: 'Energy from one kilowatt over one hour.'), + + ConversionUnit(id: 'watt', category: UnitCategory.power, name: 'Watt', symbol: 'W', factorToBase: 1, description: 'SI derived unit of power.'), + ConversionUnit(id: 'kilowatt', category: UnitCategory.power, name: 'Kilowatt', symbol: 'kW', factorToBase: 1000, description: 'One thousand watts.'), + ConversionUnit(id: 'megawatt', category: UnitCategory.power, name: 'Megawatt', symbol: 'MW', factorToBase: 1000000, description: 'One million watts.'), + ConversionUnit(id: 'horsepower', category: UnitCategory.power, name: 'Mechanical horsepower', symbol: 'hp', factorToBase: 745.69987158227022, description: 'Mechanical horsepower.'), + + ConversionUnit(id: 'radian', category: UnitCategory.angle, name: 'Radian', symbol: 'rad', factorToBase: 1, description: 'SI derived unit of plane angle.'), + ConversionUnit(id: 'degree', category: UnitCategory.angle, name: 'Degree', symbol: '°', factorToBase: 0.017453292519943295, aliases: ['deg'], description: 'One 360th of a full turn.'), + ConversionUnit(id: 'gradian', category: UnitCategory.angle, name: 'Gradian', symbol: 'gon', factorToBase: 0.015707963267948967, aliases: ['grad'], description: 'One 400th of a full turn.'), + ConversionUnit(id: 'turn', category: UnitCategory.angle, name: 'Turn', symbol: 'turn', factorToBase: 6.283185307179586, aliases: ['revolution', 'rev'], description: 'One full revolution.'), +]; + +List unitsForCategory(UnitCategory category) { + return unitCatalog + .where((ConversionUnit unit) => unit.category == category) + .toList(growable: false); +} + +List searchCatalog(String query) { + return unitCatalog + .where((ConversionUnit unit) => unit.matches(query)) + .toList(growable: false); +} diff --git a/app/lib/core/unit_model.dart b/app/lib/core/unit_model.dart new file mode 100644 index 00000000..220afa7c --- /dev/null +++ b/app/lib/core/unit_model.dart @@ -0,0 +1,79 @@ +enum UnitCategory { + length, + mass, + temperature, + time, + area, + volume, + speed, + data, + pressure, + energy, + power, + angle, +} + +extension UnitCategoryLabel on UnitCategory { + String get label { + switch (this) { + case UnitCategory.length: + return 'Length'; + case UnitCategory.mass: + return 'Mass'; + case UnitCategory.temperature: + return 'Temperature'; + case UnitCategory.time: + return 'Time'; + case UnitCategory.area: + return 'Area'; + case UnitCategory.volume: + return 'Volume'; + case UnitCategory.speed: + return 'Speed'; + case UnitCategory.data: + return 'Data'; + case UnitCategory.pressure: + return 'Pressure'; + case UnitCategory.energy: + return 'Energy'; + case UnitCategory.power: + return 'Power'; + case UnitCategory.angle: + return 'Angle'; + } + } +} + +class ConversionUnit { + const ConversionUnit({ + required this.id, + required this.category, + required this.name, + required this.symbol, + required this.factorToBase, + required this.description, + this.aliases = const [], + }); + + final String id; + final UnitCategory category; + final String name; + final String symbol; + final double factorToBase; + final String description; + final List aliases; + + String get displayName => '$name ($symbol)'; + + bool matches(String query) { + final String needle = query.trim().toLowerCase(); + if (needle.isEmpty) { + return true; + } + return id.toLowerCase().contains(needle) || + name.toLowerCase().contains(needle) || + symbol.toLowerCase().contains(needle) || + description.toLowerCase().contains(needle) || + aliases.any((String value) => value.toLowerCase().contains(needle)); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 00000000..5ed2d3d5 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'screens/home_screen.dart'; + +void main() { + runApp(const UnitFlowApp()); +} + +class UnitFlowApp extends StatelessWidget { + const UnitFlowApp({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme lightScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF3657C8), + brightness: Brightness.light, + ); + final ColorScheme darkScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF9EB0FF), + brightness: Brightness.dark, + ); + + return MaterialApp( + title: 'UnitFlow', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.system, + theme: ThemeData( + useMaterial3: true, + colorScheme: lightScheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: darkScheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + ), + home: const HomeScreen(), + ); + } +} diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart new file mode 100644 index 00000000..7b8d2706 --- /dev/null +++ b/app/lib/screens/home_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +import '../core/unit_model.dart'; +import '../state/app_state.dart'; +import '../widgets/converter_card.dart'; +import '../widgets/history_section.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late final AppState _state; + late final TextEditingController _inputController; + + @override + void initState() { + super.initState(); + _state = AppState(); + _inputController = TextEditingController(text: _state.input); + } + + @override + void dispose() { + _inputController.dispose(); + _state.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _state, + builder: (BuildContext context, Widget? child) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.swap_horiz_rounded), + SizedBox(width: 10), + Text('UnitFlow'), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: Text( + 'Made by the Sanskar', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ), + ], + ), + body: SafeArea( + child: SelectionArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeroHeader(state: _state), + const SizedBox(height: 16), + _CategorySelector(state: _state), + const SizedBox(height: 16), + ConverterCard( + state: _state, + inputController: _inputController, + ), + const SizedBox(height: 16), + HistorySection(state: _state), + const SizedBox(height: 28), + const Center( + child: Text( + 'Offline-first • Open source • MIT License', + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _HeroHeader extends StatelessWidget { + const _HeroHeader({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + theme.colorScheme.primaryContainer, + theme.colorScheme.secondaryContainer, + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Convert with confidence.', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Fast offline conversions, searchable units, batch tools, favorites, recent history, and educational context.', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + const Chip( + avatar: Icon(Icons.offline_bolt_outlined, size: 18), + label: Text('Offline core'), + ), + Chip( + avatar: const Icon(Icons.category_outlined, size: 18), + label: Text('${UnitCategory.values.length} categories'), + ), + Chip( + avatar: const Icon(Icons.science_outlined, size: 18), + label: Text(state.category.label), + ), + ], + ), + ], + ), + ); + } +} + +class _CategorySelector extends StatelessWidget { + const _CategorySelector({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Category', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: UnitCategory.values.map((UnitCategory category) { + return ChoiceChip( + label: Text(category.label), + selected: state.category == category, + onSelected: (bool selected) { + if (selected) { + state.setCategory(category); + } + }, + ); + }).toList(growable: false), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/state/app_state.dart b/app/lib/state/app_state.dart new file mode 100644 index 00000000..9c16f293 --- /dev/null +++ b/app/lib/state/app_state.dart @@ -0,0 +1,211 @@ +import 'package:flutter/foundation.dart'; + +import '../core/converter.dart'; +import '../core/unit_catalog.dart'; +import '../core/unit_model.dart'; + +@immutable +class UnitPair { + const UnitPair(this.fromId, this.toId); + + final String fromId; + final String toId; + + @override + bool operator ==(Object other) { + return other is UnitPair && other.fromId == fromId && other.toId == toId; + } + + @override + int get hashCode => Object.hash(fromId, toId); +} + +@immutable +class ConversionRecord { + const ConversionRecord({ + required this.input, + required this.output, + required this.from, + required this.to, + }); + + final String input; + final String output; + final ConversionUnit from; + final ConversionUnit to; +} + +class AppState extends ChangeNotifier { + AppState({Converter converter = const Converter()}) : _converter = converter { + _selectDefaultsForCategory(); + _recalculate(addToHistory: false); + } + + final Converter _converter; + UnitCategory _category = UnitCategory.length; + late ConversionUnit _from; + late ConversionUnit _to; + String _input = '1'; + String _output = ''; + String? _error; + int _decimalPlaces = 8; + bool _scientific = false; + final Set _favorites = {}; + final List _recent = []; + + UnitCategory get category => _category; + ConversionUnit get from => _from; + ConversionUnit get to => _to; + String get input => _input; + String get output => _output; + String? get error => _error; + int get decimalPlaces => _decimalPlaces; + bool get scientific => _scientific; + List get recent => List.unmodifiable(_recent); + Set get favorites => Set.unmodifiable(_favorites); + List get availableUnits => unitsForCategory(_category); + + bool get isCurrentPairFavorite => _favorites.contains(UnitPair(_from.id, _to.id)); + + void setCategory(UnitCategory value) { + if (_category == value) { + return; + } + _category = value; + _selectDefaultsForCategory(); + _recalculate(addToHistory: false); + notifyListeners(); + } + + void setFrom(ConversionUnit value) { + if (value.category != _category || value.id == _from.id) { + return; + } + _from = value; + _recalculate(); + notifyListeners(); + } + + void setTo(ConversionUnit value) { + if (value.category != _category || value.id == _to.id) { + return; + } + _to = value; + _recalculate(); + notifyListeners(); + } + + void swapUnits() { + final ConversionUnit previousFrom = _from; + _from = _to; + _to = previousFrom; + if (_output.isNotEmpty && _error == null) { + _input = _output; + } + _recalculate(); + notifyListeners(); + } + + void setInput(String value) { + _input = value; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void commitInput() { + _recalculate(addToHistory: true); + notifyListeners(); + } + + void setDecimalPlaces(int value) { + final int clamped = value.clamp(0, 15).toInt(); + if (_decimalPlaces == clamped) { + return; + } + _decimalPlaces = clamped; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void setScientific(bool value) { + if (_scientific == value) { + return; + } + _scientific = value; + _recalculate(addToHistory: false); + notifyListeners(); + } + + void toggleCurrentFavorite() { + final UnitPair pair = UnitPair(_from.id, _to.id); + if (!_favorites.remove(pair)) { + _favorites.add(pair); + } + notifyListeners(); + } + + List batchConvert(Iterable lines) { + final List output = []; + for (final String line in lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + final double? value = double.tryParse(trimmed); + if (value == null) { + output.add('$trimmed → invalid number'); + continue; + } + final double converted = _converter.convert(value: value, from: _from, to: _to); + output.add( + '$trimmed ${_from.symbol} → ${_converter.format(converted, decimalPlaces: _decimalPlaces, scientific: _scientific)} ${_to.symbol}', + ); + } + return output; + } + + void clearHistory() { + if (_recent.isEmpty) { + return; + } + _recent.clear(); + notifyListeners(); + } + + void _selectDefaultsForCategory() { + final List units = unitsForCategory(_category); + _from = units.first; + _to = units.length > 1 ? units[1] : units.first; + } + + void _recalculate({bool addToHistory = true}) { + final double? value = double.tryParse(_input.trim()); + if (value == null) { + _output = ''; + _error = _input.trim().isEmpty ? null : 'Enter a valid number.'; + return; + } + + try { + final double converted = _converter.convert(value: value, from: _from, to: _to); + _output = _converter.format( + converted, + decimalPlaces: _decimalPlaces, + scientific: _scientific, + ); + _error = null; + if (addToHistory) { + _recent.insert( + 0, + ConversionRecord(input: _input, output: _output, from: _from, to: _to), + ); + if (_recent.length > 12) { + _recent.removeRange(12, _recent.length); + } + } + } on ConversionException catch (error) { + _output = ''; + _error = error.message; + } + } +} diff --git a/app/lib/widgets/batch_conversion_dialog.dart b/app/lib/widgets/batch_conversion_dialog.dart new file mode 100644 index 00000000..3c1d2594 --- /dev/null +++ b/app/lib/widgets/batch_conversion_dialog.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../state/app_state.dart'; + +Future showBatchConversionDialog({ + required BuildContext context, + required AppState state, +}) async { + final TextEditingController controller = TextEditingController(); + List results = const []; + + await showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return AlertDialog( + title: const Text('Batch conversion'), + content: SizedBox( + width: 620, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Convert multiple values from ${state.from.symbol} to ${state.to.symbol}. Enter one value per line.', + ), + const SizedBox(height: 12), + TextField( + controller: controller, + minLines: 6, + maxLines: 10, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: '1\n2.5\n100', + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + setState(() { + results = state.batchConvert(controller.text.split('\n')); + }); + }, + icon: const Icon(Icons.calculate_outlined), + label: const Text('Convert batch'), + ), + if (results.isNotEmpty) ...[ + const SizedBox(height: 16), + SelectableText(results.join('\n')), + ], + ], + ), + ), + ), + actions: [ + if (results.isNotEmpty) + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: results.join('\n'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Batch results copied.')), + ); + }, + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + }, + ); + + controller.dispose(); +} diff --git a/app/lib/widgets/converter_card.dart b/app/lib/widgets/converter_card.dart new file mode 100644 index 00000000..3fb5cde2 --- /dev/null +++ b/app/lib/widgets/converter_card.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/unit_model.dart'; +import '../state/app_state.dart'; +import 'batch_conversion_dialog.dart'; +import 'settings_sheet.dart'; +import 'unit_picker_dialog.dart'; + +class ConverterCard extends StatelessWidget { + const ConverterCard({ + required this.state, + required this.inputController, + super.key, + }); + + final AppState state; + final TextEditingController inputController; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final List units = state.availableUnits; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text('Converter', style: theme.textTheme.headlineSmall), + ), + IconButton( + tooltip: 'Display settings', + onPressed: () => showSettingsSheet(context: context, state: state), + icon: const Icon(Icons.tune), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: inputController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Value', + errorText: state.error, + prefixIcon: const Icon(Icons.pin_outlined), + suffixText: state.from.symbol, + ), + onChanged: state.setInput, + onSubmitted: (_) => state.commitInput(), + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool narrow = constraints.maxWidth < 620; + final Widget fromButton = _UnitButton( + label: 'From', + unit: state.from, + onPressed: () => _pickUnit(context, units, true), + ); + final Widget toButton = _UnitButton( + label: 'To', + unit: state.to, + onPressed: () => _pickUnit(context, units, false), + ); + final Widget swap = Semantics( + button: true, + label: 'Swap source and destination units', + child: IconButton.filledTonal( + tooltip: 'Swap units', + onPressed: () { + state.swapUnits(); + inputController.text = state.input; + inputController.selection = TextSelection.collapsed( + offset: inputController.text.length, + ); + }, + icon: Icon(narrow ? Icons.swap_vert : Icons.swap_horiz), + ), + ); + + if (narrow) { + return Column( + children: [ + fromButton, + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: swap, + ), + toButton, + ], + ); + } + + return Row( + children: [ + Expanded(child: fromButton), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: swap, + ), + Expanded(child: toButton), + ], + ); + }, + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: theme.colorScheme.primaryContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Result', style: theme.textTheme.labelLarge), + const SizedBox(height: 6), + SelectableText( + state.output.isEmpty ? '—' : '${state.output} ${state.to.symbol}', + style: theme.textTheme.headlineMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: state.output.isEmpty + ? null + : () => _copyResult(context), + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy'), + ), + FilledButton.tonalIcon( + onPressed: state.toggleCurrentFavorite, + icon: Icon( + state.isCurrentPairFavorite + ? Icons.star + : Icons.star_border, + ), + label: Text( + state.isCurrentPairFavorite + ? 'Favorited' + : 'Favorite pair', + ), + ), + FilledButton.tonalIcon( + onPressed: () => showBatchConversionDialog( + context: context, + state: state, + ), + icon: const Icon(Icons.table_rows_outlined), + label: const Text('Batch'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + _Explanation(state: state), + ], + ), + ), + ); + } + + Future _pickUnit( + BuildContext context, + List units, + bool pickingFrom, + ) async { + final ConversionUnit? selected = await showUnitPickerDialog( + context: context, + title: pickingFrom ? 'Choose source unit' : 'Choose destination unit', + units: units, + selected: pickingFrom ? state.from : state.to, + ); + if (selected == null) { + return; + } + if (pickingFrom) { + state.setFrom(selected); + } else { + state.setTo(selected); + } + } + + void _copyResult(BuildContext context) { + final String text = '${state.output} ${state.to.symbol}'; + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Result copied.')), + ); + } +} + +class _UnitButton extends StatelessWidget { + const _UnitButton({ + required this.label, + required this.unit, + required this.onPressed, + }); + + final String label; + final ConversionUnit unit; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '$label unit: ${unit.name}', + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + alignment: Alignment.centerLeft, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(unit.displayName, overflow: TextOverflow.ellipsis), + ], + ), + ), + const Icon(Icons.expand_more), + ], + ), + ), + ); + } +} + +class _Explanation extends StatelessWidget { + const _Explanation({required this.state}); + + final AppState state; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('How this conversion works'), + subtitle: Text('${state.from.name} → ${state.to.name}'), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(state.from.description), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text(state.to.description), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + state.category == UnitCategory.temperature + ? 'Temperature conversions use an affine scale transformation through kelvin.' + : 'Linear conversions pass through the category base unit using each unit factor.', + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/history_section.dart b/app/lib/widgets/history_section.dart new file mode 100644 index 00000000..3165a02c --- /dev/null +++ b/app/lib/widgets/history_section.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../state/app_state.dart'; + +class HistorySection extends StatelessWidget { + const HistorySection({required this.state, super.key}); + + final AppState state; + + @override + Widget build(BuildContext context) { + final List recent = state.recent; + if (recent.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + 'Recent conversions', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + TextButton.icon( + onPressed: state.clearHistory, + icon: const Icon(Icons.delete_sweep_outlined), + label: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + for (final ConversionRecord record in recent.take(6)) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.history), + title: Text( + '${record.input} ${record.from.symbol} → ${record.output} ${record.to.symbol}', + ), + subtitle: Text('${record.from.name} to ${record.to.name}'), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/settings_sheet.dart b/app/lib/widgets/settings_sheet.dart new file mode 100644 index 00000000..697c416d --- /dev/null +++ b/app/lib/widgets/settings_sheet.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../state/app_state.dart'; + +Future showSettingsSheet({ + required BuildContext context, + required AppState state, +}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (BuildContext context) { + return AnimatedBuilder( + animation: state, + builder: (BuildContext context, Widget? child) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Display settings', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 20), + Text('Decimal places: ${state.decimalPlaces}'), + Slider( + min: 0, + max: 15, + divisions: 15, + value: state.decimalPlaces.toDouble(), + label: '${state.decimalPlaces}', + onChanged: (double value) => state.setDecimalPlaces(value.round()), + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Scientific notation'), + subtitle: const Text('Show results using exponential notation.'), + value: state.scientific, + onChanged: state.setScientific, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} diff --git a/app/lib/widgets/unit_picker_dialog.dart b/app/lib/widgets/unit_picker_dialog.dart new file mode 100644 index 00000000..e05a4d82 --- /dev/null +++ b/app/lib/widgets/unit_picker_dialog.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../core/unit_model.dart'; + +Future showUnitPickerDialog({ + required BuildContext context, + required String title, + required List units, + required ConversionUnit selected, +}) { + String query = ''; + + return showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final List filtered = units + .where((ConversionUnit unit) => unit.matches(query)) + .toList(growable: false); + + return AlertDialog( + title: Text(title), + content: SizedBox( + width: 520, + height: 520, + child: Column( + children: [ + TextField( + autofocus: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + labelText: 'Search units', + hintText: 'Name, symbol, alias, or description', + ), + onChanged: (String value) { + setState(() { + query = value; + }); + }, + ), + const SizedBox(height: 12), + Expanded( + child: filtered.isEmpty + ? const Center(child: Text('No matching units.')) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (BuildContext context, int index) { + final ConversionUnit unit = filtered[index]; + return ListTile( + selected: unit.id == selected.id, + leading: CircleAvatar(child: Text(unit.symbol)), + title: Text(unit.name), + subtitle: Text(unit.description), + trailing: unit.id == selected.id + ? const Icon(Icons.check_circle) + : null, + onTap: () => Navigator.of(context).pop(unit), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 00000000..06289c39 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,20 @@ +name: unitflow +version: 0.1.0+1 +publish_to: "none" +description: Offline-first Flutter frontend for the UnitFlow converter. + +repository: https://github.com/sanskarIN/unitflow + +environment: + sdk: ">=3.8.0 <4.0.0" + +flutter: + uses-material-design: true + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/app/test/converter_test.dart b/app/test/converter_test.dart new file mode 100644 index 00000000..3a6080b9 --- /dev/null +++ b/app/test/converter_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/core/converter.dart'; +import 'package:unitflow/core/unit_catalog.dart'; +import 'package:unitflow/core/unit_model.dart'; + +ConversionUnit unit(String id) { + return unitCatalog.firstWhere((ConversionUnit value) => value.id == id); +} + +void main() { + const Converter converter = Converter(); + + test('converts kilometers to meters', () { + final double result = converter.convert( + value: 1.25, + from: unit('kilometer'), + to: unit('meter'), + ); + expect(result, closeTo(1250, 1e-12)); + }); + + test('converts Celsius to Fahrenheit', () { + final double result = converter.convert( + value: 100, + from: unit('celsius'), + to: unit('fahrenheit'), + ); + expect(result, closeTo(212, 1e-10)); + }); + + test('keeps decimal and binary data units distinct', () { + final double decimal = converter.convert( + value: 1, + from: unit('megabyte'), + to: unit('byte'), + ); + final double binary = converter.convert( + value: 1, + from: unit('mebibyte'), + to: unit('byte'), + ); + expect(decimal, 1000000); + expect(binary, 1048576); + }); + + test('rejects mismatched categories', () { + expect( + () => converter.convert( + value: 1, + from: unit('meter'), + to: unit('kilogram'), + ), + throwsA(isA()), + ); + }); + + test('formats trailing zeros cleanly', () { + expect(converter.format(12.5, decimalPlaces: 6), '12.5'); + expect(converter.format(12, decimalPlaces: 6), '12'); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 00000000..ed5574d3 --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:unitflow/main.dart'; + +void main() { + testWidgets( + 'renders UnitFlow converter and computes a default result', + (WidgetTester tester) async { + await tester.pumpWidget(const UnitFlowApp()); + await tester.pumpAndSettle(); + + expect(find.text('UnitFlow'), findsOneWidget); + expect(find.text('Converter'), findsOneWidget); + expect(find.text('Convert with confidence.'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.textContaining('km'), findsWidgets); + }, + ); +} diff --git a/tool/bootstrap_platforms.ps1 b/tool/bootstrap_platforms.ps1 new file mode 100644 index 00000000..ced52ea0 --- /dev/null +++ b/tool/bootstrap_platforms.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location (Join-Path $RootDir "app") + +flutter create . ` + --project-name unitflow ` + --org com.sanskarin ` + --platforms android,ios,web,windows,linux,macos + +flutter pub get + +Write-Host "UnitFlow Flutter platform folders are ready." diff --git a/tool/bootstrap_platforms.sh b/tool/bootstrap_platforms.sh new file mode 100644 index 00000000..fc2217ce --- /dev/null +++ b/tool/bootstrap_platforms.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR/app" + +flutter create . \ + --project-name unitflow \ + --org com.sanskarin \ + --platforms android,ios,web,windows,linux,macos + +flutter pub get + +echo "UnitFlow Flutter platform folders are ready."