Refactor: Implement multi-item inventory for Pad Tracker and dynamic navigation

This commit is contained in:
2026-01-02 18:10:50 -06:00
parent 56683f5407
commit 8772b56f36
44 changed files with 3515 additions and 781 deletions

View File

@@ -1,159 +1,105 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:open_filex/open_filex.dart';
import 'package:universal_html/html.dart' as html;
import '../models/user_profile.dart';
import '../models/cycle_entry.dart';
import '../theme/app_theme.dart';
class PdfService {
static Future<void> generateCycleReport(UserProfile user, List<CycleEntry> entries) async {
static Future<void> generateCycleReport(UserProfile? user, List<CycleEntry> entries) async {
final pdf = pw.Document();
final font = await PdfGoogleFonts.outfitRegular();
final boldFont = await PdfGoogleFonts.outfitBold();
final primaryColor = PdfColor.fromInt(AppColors.sageGreen.value);
final accentColor = PdfColor.fromInt(AppColors.rose.value);
final textColor = PdfColor.fromInt(AppColors.charcoal.value);
// Sort entries by date for the report
entries.sort((a, b) => a.date.compareTo(b.date));
// Group entries by month
final entriesByMonth = <String, List<CycleEntry>>{};
for (var entry in entries) {
final month = DateFormat('MMMM yyyy').format(entry.date);
if (!entriesByMonth.containsKey(month)) {
entriesByMonth[month] = [];
}
entriesByMonth[month]!.add(entry);
}
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (pw.Context context) => [
_buildHeader(user, primaryColor, accentColor, textColor),
pw.SizedBox(height: 20),
_buildCycleSummary(user, textColor),
pw.SizedBox(height: 20),
_buildEntriesTable(entries, primaryColor, textColor),
],
),
);
final String fileName = 'cycle_report_${DateFormat('yyyyMMdd').format(DateTime.now())}.pdf';
if (kIsWeb) {
// Web platform
final bytes = await pdf.save();
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', fileName)
..click();
html.Url.revokeObjectUrl(url);
} else {
// Mobile platform
final directory = await getApplicationDocumentsDirectory();
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(await pdf.save());
await OpenFilex.open(filePath);
}
}
static pw.Widget _buildHeader(UserProfile user, PdfColor primaryColor, PdfColor accentColor, PdfColor textColor) {
return pw.Container(
padding: pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: primaryColor.lighter(30),
borderRadius: pw.BorderRadius.circular(8),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'Cycle Report for ${user.name}',
style: pw.TextStyle(
fontSize: 24,
fontWeight: pw.FontWeight.bold,
color: primaryColor.isLight ? primaryColor.darker(50) : PdfColors.white,
),
),
pw.SizedBox(height: 5),
pw.Text(
'Generated on: ${DateFormat('MMMM d, yyyy').format(DateTime.now())}',
style: pw.TextStyle(fontSize: 12, color: primaryColor.isLight ? primaryColor.darker(30) : PdfColors.white.darker(10)),
),
],
),
);
}
static pw.Widget _buildCycleSummary(UserProfile user, PdfColor textColor) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'Summary',
style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold, color: textColor),
theme: pw.ThemeData.withFont(
base: font,
bold: boldFont,
),
pw.SizedBox(height: 10),
pw.Text('Average Cycle Length: ${user.averageCycleLength} days', style: pw.TextStyle(color: textColor)),
pw.Text('Average Period Length: ${user.averagePeriodLength} days', style: pw.TextStyle(color: textColor)),
if (user.lastPeriodStartDate != null)
pw.Text('Last Period Start: ${DateFormat.yMMMMd().format(user.lastPeriodStartDate!)}', style: pw.TextStyle(color: textColor)),
pw.Text('Irregular Cycle: ${user.isIrregularCycle ? 'Yes' : 'No'}', style: pw.TextStyle(color: textColor)),
],
);
}
build: (pw.Context context) {
return [
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Cycle Report', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.Text(DateFormat.yMMMd().format(DateTime.now()), style: const pw.TextStyle(color: PdfColors.grey)),
],
),
),
if (user != null)
pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 20),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Name: ${user.name}'),
pw.Text('Average Cycle Length: ${user.averageCycleLength} days'),
pw.Text('Average Period Length: ${user.averagePeriodLength} days'),
],
),
),
...entriesByMonth.entries.map((entry) {
final month = entry.key;
final monthEntries = entry.value;
// Sort by date
monthEntries.sort((a, b) => a.date.compareTo(b.date));
static pw.Widget _buildEntriesTable(List<CycleEntry> entries, PdfColor primaryColor, PdfColor textColor) {
final headers = ['Date', 'Period', 'Mood', 'Symptoms', 'Notes'];
return pw.Table.fromTextArray(
headers: headers,
data: entries.map((entry) {
return [
DateFormat.yMMMd().format(entry.date),
entry.isPeriodDay ? 'Yes' : 'No',
entry.mood != null ? entry.mood!.label : 'N/A',
entry.hasSymptoms ? entry.symptomCount.toString() : 'No',
entry.notes != null && entry.notes!.isNotEmpty ? entry.notes! : 'N/A',
];
}).toList(),
border: pw.TableBorder.all(color: primaryColor.lighter(10)),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: primaryColor),
cellStyle: pw.TextStyle(color: textColor),
cellAlignment: pw.Alignment.centerLeft,
headerDecoration: pw.BoxDecoration(color: primaryColor.lighter(20)),
rowDecoration: pw.BoxDecoration(color: PdfColors.grey100),
tableWidth: pw.TableWidth.min,
);
}
}
// Extension to determine if a color is light or dark for text contrast
extension on PdfColor {
bool get isLight {
// Calculate luminance (Y from YIQ)
// Formula: Y = (R*299 + G*587 + B*114) / 1000
final r = red * 255;
final g = green * 255;
final b = blue * 255;
final luminance = (r * 299 + g * 587 + b * 114) / 1000;
return luminance > 128; // Using 128 as a threshold
}
PdfColor lighter(int amount) {
double factor = 1 + (amount / 100.0);
return PdfColor(
red * factor > 1.0 ? 1.0 : red * factor,
green * factor > 1.0 ? 1.0 : green * factor,
blue * factor > 1.0 ? 1.0 : blue * factor,
);
}
PdfColor darker(int amount) {
double factor = 1 - (amount / 100.0);
return PdfColor(
red * factor < 0.0 ? 0.0 : red * factor,
green * factor < 0.0 ? 0.0 : green * factor,
blue * factor < 0.0 ? 0.0 : blue * factor,
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(height: 10),
pw.Text(month, style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold, color: PdfColors.blueGrey800)),
pw.SizedBox(height: 5),
pw.Table.fromTextArray(
context: context,
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headers: ['Date', 'Phase', 'Details', 'Notes'],
data: monthEntries.map((e) {
final details = <String>[];
if (e.isPeriodDay) details.add('Period');
if (e.mood != null) details.add('Mood: ${e.mood!.label}');
if (e.symptomCount > 0) details.add('${e.symptomCount} symptoms');
return [
DateFormat('d, E').format(e.date),
'${e.isPeriodDay ? "Menstrual" : "-"}', // Simplified for report
details.join(', '),
e.notes ?? '',
];
}).toList(),
columnWidths: {
0: const pw.FlexColumnWidth(1),
1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(2),
3: const pw.FlexColumnWidth(2),
},
),
pw.SizedBox(height: 15),
],
);
}),
];
},
),
);
await Printing.sharePdf(bytes: await pdf.save(), filename: 'cycle_report.pdf');
}
}