New
This commit is contained in:
75
lib/services/health_service.dart
Normal file
75
lib/services/health_service.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:health/health.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import '../models/cycle_entry.dart';
|
||||
|
||||
class HealthService {
|
||||
static final HealthService _instance = HealthService._internal();
|
||||
factory HealthService() => _instance;
|
||||
HealthService._internal();
|
||||
|
||||
final Health _health = Health();
|
||||
List<HealthDataType> _requestedTypes = [];
|
||||
|
||||
// Define data types for menstruation
|
||||
static const List<HealthDataType> _menstruationDataTypes = [
|
||||
HealthDataType.menstruation,
|
||||
];
|
||||
|
||||
Future<bool> requestAuthorization(List<HealthDataType> types) async {
|
||||
_requestedTypes = types;
|
||||
try {
|
||||
final bool authorized = await _health.requestAuthorization(types);
|
||||
return authorized;
|
||||
} catch (e) {
|
||||
debugPrint("Error requesting authorization: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasPermissions(List<HealthDataType> types) async {
|
||||
return await _health.hasPermissions(types);
|
||||
}
|
||||
|
||||
Future<bool> writeMenstruationData(List<CycleEntry> entries) async {
|
||||
// Filter for period days
|
||||
final periodEntries = entries.where((entry) => entry.isPeriodDay).toList();
|
||||
|
||||
if (periodEntries.isEmpty) {
|
||||
debugPrint("No period entries to write.");
|
||||
return true; // Nothing to write is not an error
|
||||
}
|
||||
|
||||
// Check if authorized for menstruation data
|
||||
final hasAuth = await hasPermissions([HealthDataType.menstruation]);
|
||||
if (!hasAuth) {
|
||||
debugPrint("Authorization not granted for menstruation data.");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool allWrittenSuccessfully = true;
|
||||
for (var entry in periodEntries) {
|
||||
try {
|
||||
final success = await _health.writeHealthData(
|
||||
entry.date, // Start date
|
||||
entry.date.add(const Duration(days: 1)), // End date (inclusive of start, so +1 day for all-day event)
|
||||
HealthDataType.menstruation,
|
||||
// HealthKit menstruation type often doesn't need a value,
|
||||
// it's the presence of the event that matters.
|
||||
// For other types, a value would be required.
|
||||
Platform.isIOS ? 0.0 : 0.0, // Value depends on platform and data type
|
||||
);
|
||||
if (!success) {
|
||||
allWrittenSuccessfully = false;
|
||||
debugPrint("Failed to write menstruation data for ${entry.date}");
|
||||
}
|
||||
} catch (e) {
|
||||
allWrittenSuccessfully = false;
|
||||
debugPrint("Error writing menstruation data for ${entry.date}: $e");
|
||||
}
|
||||
}
|
||||
return allWrittenSuccessfully;
|
||||
}
|
||||
|
||||
List<HealthDataType> get mensturationDataTypes => _menstruationDataTypes;
|
||||
}
|
||||
69
lib/services/ics_service.dart
Normal file
69
lib/services/ics_service.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:icalendar_parser/icalendar_parser.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/cycle_entry.dart';
|
||||
|
||||
class IcsService {
|
||||
static Future<void> generateCycleCalendar(List<CycleEntry> entries) async {
|
||||
final iCalendar = ICalendar(
|
||||
properties: {
|
||||
'prodid': '-//Christian Period Tracker//NONSGML v1.0//EN',
|
||||
'version': '2.0',
|
||||
'calscale': 'GREGORIAN',
|
||||
'x-wr-calname': 'Cycle Tracking',
|
||||
'x-wr-timezone': DateTime.now().timeZoneName,
|
||||
},
|
||||
components: [],
|
||||
);
|
||||
|
||||
// Sort entries by date to ensure proper calendar order
|
||||
entries.sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
for (var entry in entries) {
|
||||
if (entry.isPeriodDay) {
|
||||
final date = entry.date;
|
||||
final formattedDate = DateFormat('yyyyMMdd').format(date);
|
||||
final uid = '${date.year}${date.month}${date.day}-${entry.id}@christianperiodtracker.app';
|
||||
|
||||
iCalendar.components.add(
|
||||
CalendarEvent(
|
||||
properties: {
|
||||
'uid': uid,
|
||||
'dtstamp': IcsDateTime(dt: DateTime.now()),
|
||||
'dtstart': IcsDateTime(dt: date, isUtc: false, date: true), // All-day event
|
||||
'dtend': IcsDateTime(dt: date.add(const Duration(days: 1)), isUtc: false, date: true), // End on next day for all-day
|
||||
'summary': 'Period Day',
|
||||
'description': 'Period tracking for ${DateFormat.yMMMd().format(date)}',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final String icsContent = iCalendar.serialize();
|
||||
final String fileName = 'cycle_calendar_${DateFormat('yyyyMMdd').format(DateTime.now())}.ics';
|
||||
|
||||
if (kIsWeb) {
|
||||
// Web platform
|
||||
final bytes = icsContent.codeUnits;
|
||||
final blob = html.Blob([bytes], 'text/calendar');
|
||||
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.writeAsString(icsContent);
|
||||
await OpenFilex.open(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
159
lib/services/pdf_service.dart
Normal file
159
lib/services/pdf_service.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
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 {
|
||||
final pdf = pw.Document();
|
||||
|
||||
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));
|
||||
|
||||
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),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user