Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f7a3343
build(app): add Flutter application manifest
sanskarIN Aug 19, 2026
b61a571
chore(app): enable correctness-focused Dart analysis
sanskarIN Aug 19, 2026
4463d21
feat(app): add unit domain model
sanskarIN Aug 19, 2026
d580499
feat(app): add offline unit catalog
sanskarIN Aug 19, 2026
71ad6c5
feat(app): add conversion and formatting service
sanskarIN Aug 19, 2026
537bdb0
feat(app): add conversion application state
sanskarIN Aug 19, 2026
2332cbd
feat(app): add searchable unit picker
sanskarIN Aug 19, 2026
1ed107c
feat(app): add batch conversion dialog
sanskarIN Aug 19, 2026
71e129f
feat(app): add display settings sheet
sanskarIN Aug 19, 2026
8b025b7
feat(app): add recent conversion history
sanskarIN Aug 19, 2026
c012078
feat(app): add primary converter interface
sanskarIN Aug 19, 2026
934f788
feat(app): build responsive UnitFlow home screen
sanskarIN Aug 19, 2026
90d5234
feat(app): add UnitFlow application entrypoint
sanskarIN Aug 19, 2026
54a6f44
test(app): add conversion adapter tests
sanskarIN Aug 19, 2026
5d55028
test(app): add primary UI smoke test
sanskarIN Aug 19, 2026
6ff68ed
build: add Unix Flutter platform bootstrap
sanskarIN Aug 19, 2026
ac44a62
build: add Windows Flutter platform bootstrap
sanskarIN Aug 19, 2026
a4e497e
ci: add Rust and Flutter validation workflow
sanskarIN Aug 19, 2026
12857ce
chore: add bug report template
sanskarIN Aug 19, 2026
704fe93
chore: add feature request template
sanskarIN Aug 19, 2026
deac352
chore: add pull request template
sanskarIN Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions app/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions app/lib/core/converter.dart
Original file line number Diff line number Diff line change
@@ -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<double> batchConvert({
required Iterable<double> 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'),
};
}
}
93 changes: 93 additions & 0 deletions app/lib/core/unit_catalog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import 'unit_model.dart';

const List<ConversionUnit> unitCatalog = <ConversionUnit>[
ConversionUnit(id: 'meter', category: UnitCategory.length, name: 'Meter', symbol: 'm', factorToBase: 1, aliases: <String>['metre'], description: 'SI base unit of length.'),
ConversionUnit(id: 'kilometer', category: UnitCategory.length, name: 'Kilometer', symbol: 'km', factorToBase: 1000, aliases: <String>['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: <String>['inches'], description: 'Exactly 2.54 centimeters.'),
ConversionUnit(id: 'foot', category: UnitCategory.length, name: 'Foot', symbol: 'ft', factorToBase: 0.3048, aliases: <String>['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: <String>['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: <String>['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: <String>['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: <String>['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: <String>['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: <String>['litre'], description: 'Metric unit of volume.'),
ConversionUnit(id: 'milliliter', category: UnitCategory.volume, name: 'Milliliter', symbol: 'mL', factorToBase: 0.001, aliases: <String>['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: <String>['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: <String>['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: <String>['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: <String>['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: <String>['deg'], description: 'One 360th of a full turn.'),
ConversionUnit(id: 'gradian', category: UnitCategory.angle, name: 'Gradian', symbol: 'gon', factorToBase: 0.015707963267948967, aliases: <String>['grad'], description: 'One 400th of a full turn.'),
ConversionUnit(id: 'turn', category: UnitCategory.angle, name: 'Turn', symbol: 'turn', factorToBase: 6.283185307179586, aliases: <String>['revolution', 'rev'], description: 'One full revolution.'),
];

List<ConversionUnit> unitsForCategory(UnitCategory category) {
return unitCatalog
.where((ConversionUnit unit) => unit.category == category)
.toList(growable: false);
}

List<ConversionUnit> searchCatalog(String query) {
return unitCatalog
.where((ConversionUnit unit) => unit.matches(query))
.toList(growable: false);
}
Loading