mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 19:07:06 +01:00
billsplit was a standalone Flutter app in ~/Downloads with one commit and no git remote — that directory was the only copy of its history. Three things the move needed beyond copying the tree: - The root .gitignore silently swallowed 32 of its files (*.png, *.ico, *.manifest). Ignored files do not error on `git add`, they just vanish, so this would have committed an app that cannot build. Added a trailing block of ! overrides plus .binary-allowlist (which did not exist yet) — the documented in-repo exception for binaries a build genuinely needs. The ../testsAndMisc_binaries/ symlink scheme does not apply: those directories are gone, and a symlinked PNG breaks a fresh clone. - billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads workflows at the repository root. Relocated with a working-directory and a billsplit/** path filter. - The app's coverage gate moved to python_pkg/billsplit_coverage/ so every Python file stays under python_pkg/, and gained a test suite at 100% branch coverage as that location requires. billsplit/ now holds only Dart. Also replaces the stock Flutter icon with a family icon from the shared generator. The glyph is a filled silhouette because the family's 72px stroke cannot carry a fine torn edge, and the split is an evenodd-cut perforation so the holes stay transparent on the foreground and monochrome layers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
6.4 KiB
Dart
216 lines
6.4 KiB
Dart
/// Exports a receipt + computed split to an .xlsx workbook whose layout
|
|
/// mirrors the hand-made "podzial_rachunku" sheet: one row per item, a 1/0
|
|
/// person matrix, per-item cost-per-person, and a per-person summary.
|
|
///
|
|
/// Values are written as computed numbers (grosz-exact, matching the in-app
|
|
/// engine), plus live SUM formulas for the column totals so the sheet stays
|
|
/// consistent when opened in Excel/LibreOffice.
|
|
library;
|
|
|
|
import 'package:billsplit/domain/models.dart';
|
|
import 'package:billsplit/domain/split_engine.dart';
|
|
import 'package:excel/excel.dart';
|
|
|
|
/// Renders [receipt] with its current split into xlsx bytes.
|
|
List<int> exportXlsx({
|
|
required Receipt receipt,
|
|
required List<Person> roster,
|
|
required List<Group> groups,
|
|
}) {
|
|
final excel = Excel.createExcel()..rename('Sheet1', 'Split');
|
|
final sheet = excel['Split'];
|
|
|
|
final people = receiptParticipants(receipt, roster)
|
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
final split = computeSplit(receipt, roster, groups);
|
|
|
|
final bold = CellStyle(bold: true);
|
|
final boldGrey = CellStyle(
|
|
bold: true,
|
|
backgroundColorHex: ExcelColor.fromHexString('#D9D9D9'),
|
|
);
|
|
final alcoholStyle =
|
|
CellStyle(backgroundColorHex: ExcelColor.fromHexString('#FCE4D6'));
|
|
final warnStyle = CellStyle(
|
|
bold: true,
|
|
backgroundColorHex: ExcelColor.fromHexString('#FFC7CE'),
|
|
);
|
|
|
|
void put(int col, int row, CellValue value, [CellStyle? style]) {
|
|
final index = CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row);
|
|
final cell = sheet.cell(index)..value = value;
|
|
if (style != null) cell.cellStyle = style;
|
|
}
|
|
|
|
double zl(int gr) => gr / 100.0;
|
|
|
|
var r = 0;
|
|
put(0, r, TextCellValue(receipt.title), bold);
|
|
r++;
|
|
put(
|
|
0,
|
|
r,
|
|
TextCellValue(
|
|
[
|
|
if (receipt.store != null) receipt.store!,
|
|
receipt.date.toIso8601String().substring(0, 10),
|
|
if (receipt.paidTotalGr != null)
|
|
'paid ${formatPln(receipt.paidTotalGr!)}',
|
|
].join(' · '),
|
|
),
|
|
);
|
|
r += 2;
|
|
|
|
// Header row.
|
|
const fixed = ['Lp', 'Item', 'Category', 'Qty', 'Unit', 'Discount', 'Total'];
|
|
for (var c = 0; c < fixed.length; c++) {
|
|
put(c, r, TextCellValue(fixed[c]), boldGrey);
|
|
}
|
|
const p0 = 7;
|
|
for (var i = 0; i < people.length; i++) {
|
|
put(p0 + i, r, TextCellValue(people[i].name), boldGrey);
|
|
}
|
|
put(p0 + people.length, r, TextCellValue('People on item'), boldGrey);
|
|
put(p0 + people.length + 1, r, TextCellValue('Cost / person'), boldGrey);
|
|
r++;
|
|
|
|
final firstDataRow = r;
|
|
var lp = 1;
|
|
for (final item in receipt.items) {
|
|
final shares = split.itemShares[item.id] ?? const <String, int>{};
|
|
final isAlcohol = item.category == Categories.alcohol;
|
|
final rowStyle = shares.isEmpty
|
|
? warnStyle
|
|
: isAlcohol
|
|
? alcoholStyle
|
|
: null;
|
|
put(0, r, IntCellValue(lp++), rowStyle);
|
|
put(1, r, TextCellValue(item.name), rowStyle);
|
|
put(2, r, TextCellValue(item.category), rowStyle);
|
|
put(3, r, DoubleCellValue(item.qty), rowStyle);
|
|
put(4, r, DoubleCellValue(zl(item.unitPriceGr)), rowStyle);
|
|
put(5, r, DoubleCellValue(zl(item.discountGr)), rowStyle);
|
|
put(6, r, DoubleCellValue(zl(item.totalGr)), rowStyle);
|
|
for (var i = 0; i < people.length; i++) {
|
|
put(
|
|
p0 + i,
|
|
r,
|
|
IntCellValue(shares.containsKey(people[i].id) ? 1 : 0),
|
|
rowStyle,
|
|
);
|
|
}
|
|
put(p0 + people.length, r, IntCellValue(shares.length), rowStyle);
|
|
if (shares.isNotEmpty) {
|
|
// Representative value; the exact grosz split may differ by ±0.01
|
|
// between people — the summary uses the exact per-person numbers.
|
|
put(
|
|
p0 + people.length + 1,
|
|
r,
|
|
DoubleCellValue(zl((item.totalGr / shares.length).round())),
|
|
rowStyle,
|
|
);
|
|
} else {
|
|
put(p0 + people.length + 1, r, TextCellValue('UNASSIGNED'), warnStyle);
|
|
}
|
|
r++;
|
|
}
|
|
final lastDataRow = r - 1;
|
|
|
|
// Totals row with a live formula over the Total column.
|
|
put(1, r, TextCellValue('TOTAL'), bold);
|
|
final totalCol = colName(6);
|
|
put(
|
|
6,
|
|
r,
|
|
FormulaCellValue(
|
|
'SUM($totalCol${firstDataRow + 1}:$totalCol${lastDataRow + 1})',
|
|
),
|
|
bold,
|
|
);
|
|
r += 2;
|
|
|
|
// Per-person summary (exact grosz numbers from the engine).
|
|
put(1, r, TextCellValue('SUMMARY'), bold);
|
|
r++;
|
|
put(1, r, TextCellValue('Person'), boldGrey);
|
|
put(2, r, TextCellValue('Owes'), boldGrey);
|
|
put(3, r, TextCellValue('of which alcohol'), boldGrey);
|
|
put(4, r, TextCellValue('rest'), boldGrey);
|
|
r++;
|
|
final firstSummaryRow = r;
|
|
for (final p in people) {
|
|
final t = split.perPerson[p.id];
|
|
put(1, r, TextCellValue(p.name + (p.drinks ? '' : ' (non-drinker)')));
|
|
put(2, r, DoubleCellValue(zl(t?.totalGr ?? 0)));
|
|
put(3, r, DoubleCellValue(zl(t?.alcoholGr ?? 0)));
|
|
put(4, r, DoubleCellValue(zl(t?.restGr ?? 0)));
|
|
r++;
|
|
}
|
|
put(1, r, TextCellValue('SUM'), bold);
|
|
for (var c = 2; c <= 4; c++) {
|
|
final col = colName(c);
|
|
put(c, r, FormulaCellValue('SUM($col$firstSummaryRow:$col$r)'), bold);
|
|
}
|
|
r += 2;
|
|
|
|
// Reconciliation note.
|
|
final assigned = split.assignedTotalGr;
|
|
final itemsTotal = receipt.itemsTotalGr;
|
|
put(
|
|
1,
|
|
r,
|
|
TextCellValue(
|
|
_checkLine(receipt, assigned, itemsTotal, split),
|
|
),
|
|
split.unassigned.isEmpty && assigned == itemsTotal ? bold : warnStyle,
|
|
);
|
|
r += 2;
|
|
put(
|
|
0,
|
|
r,
|
|
TextCellValue(
|
|
'1 = person shares the item. Matrix and summary are exported values '
|
|
'from the app (grosz-exact); edit splits in the app, not here.',
|
|
),
|
|
);
|
|
|
|
// Column widths.
|
|
sheet
|
|
..setColumnWidth(0, 5)
|
|
..setColumnWidth(1, 34)
|
|
..setColumnWidth(2, 11);
|
|
for (var c = 3; c < 7; c++) {
|
|
sheet.setColumnWidth(c, 10);
|
|
}
|
|
for (var i = 0; i < people.length + 2; i++) {
|
|
sheet.setColumnWidth(p0 + i, 12);
|
|
}
|
|
|
|
return excel.encode()!;
|
|
}
|
|
|
|
String _checkLine(
|
|
Receipt receipt,
|
|
int assigned,
|
|
int itemsTotal,
|
|
SplitResult split,
|
|
) {
|
|
final paid = receipt.paidTotalGr;
|
|
final paidPart = paid == null ? '' : ' / paid ${formatPln(paid)}';
|
|
final un = split.unassigned.length;
|
|
final unPart = un == 0 ? '' : ' — $un UNASSIGNED item(s) excluded';
|
|
return 'Check: assigned ${formatPln(assigned)} '
|
|
'/ items ${formatPln(itemsTotal)}$paidPart$unPart';
|
|
}
|
|
|
|
/// Converts a 0-based column index into its A1-style name (0 → A, 26 → AA).
|
|
String colName(int index) {
|
|
var i = index;
|
|
var s = '';
|
|
while (i >= 0) {
|
|
s = String.fromCharCode(65 + (i % 26)) + s;
|
|
i = i ~/ 26 - 1;
|
|
}
|
|
return s;
|
|
}
|