259 lines
9.9 KiB
Dart
259 lines
9.9 KiB
Dart
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:island/models/activity.dart';
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
|
|
/// A widget that displays a graph of fortune levels over time
|
|
/// This can be used alongside the EventCalendarWidget to provide a different visualization
|
|
class FortuneGraphWidget extends HookConsumerWidget {
|
|
/// The list of calendar entries to display
|
|
final AsyncValue<List<SnEventCalendarEntry>> events;
|
|
|
|
/// Whether to constrain the width of the graph
|
|
final bool constrainWidth;
|
|
|
|
/// Maximum width constraint when constrainWidth is true
|
|
final double maxWidth;
|
|
|
|
/// Height of the graph
|
|
final double height;
|
|
|
|
/// Callback when a point is selected
|
|
final void Function(DateTime)? onPointSelected;
|
|
|
|
const FortuneGraphWidget({
|
|
super.key,
|
|
required this.events,
|
|
this.constrainWidth = false,
|
|
this.maxWidth = double.infinity,
|
|
this.height = 180,
|
|
this.onPointSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// Filter events to only include those with check-in results
|
|
final filteredEvents = events.whenData(
|
|
(data) =>
|
|
data
|
|
.where((event) => event.checkInResult != null)
|
|
.toList()
|
|
.cast<SnEventCalendarEntry>()
|
|
// Sort by date
|
|
..sort((a, b) => a.date.compareTo(b.date)),
|
|
);
|
|
|
|
final content = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'fortuneGraph',
|
|
).tr().fontSize(18).bold().padding(all: 16, bottom: 24),
|
|
SizedBox(
|
|
height: height,
|
|
child: filteredEvents.when(
|
|
data: (data) {
|
|
if (data.isEmpty) {
|
|
return Center(child: Text('noFortuneData').tr());
|
|
}
|
|
|
|
// Create spots for the line chart
|
|
final spots =
|
|
data
|
|
.map(
|
|
(e) => FlSpot(
|
|
e.date.millisecondsSinceEpoch.toDouble(),
|
|
e.checkInResult!.level.toDouble(),
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
// Get min and max dates for the x-axis
|
|
final minDate = data.first.date;
|
|
final maxDate = data.last.date;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
child: LineChart(
|
|
LineChartData(
|
|
gridData: FlGridData(
|
|
show: true,
|
|
horizontalInterval: 1,
|
|
drawVerticalLine: false,
|
|
),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 30,
|
|
interval: _calculateDateInterval(minDate, maxDate),
|
|
getTitlesWidget: (value, meta) {
|
|
final date = DateTime.fromMillisecondsSinceEpoch(
|
|
value.toInt(),
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
DateFormat.MMMd().format(date),
|
|
style: TextStyle(fontSize: 10),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: 1,
|
|
reservedSize: 40,
|
|
getTitlesWidget: (value, meta) {
|
|
final level = value.toInt();
|
|
if (level < 0 || level > 4) return const SizedBox();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: Text(
|
|
'checkInResultT$level'.tr(),
|
|
style: TextStyle(fontSize: 10),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
topTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
rightTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
),
|
|
borderData: FlBorderData(
|
|
show: true,
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: Theme.of(context).dividerColor,
|
|
),
|
|
left: BorderSide(color: Theme.of(context).dividerColor),
|
|
),
|
|
),
|
|
minX: minDate.millisecondsSinceEpoch.toDouble(),
|
|
maxX: maxDate.millisecondsSinceEpoch.toDouble(),
|
|
minY: 0,
|
|
maxY: 4,
|
|
lineTouchData: LineTouchData(
|
|
touchTooltipData: LineTouchTooltipData(
|
|
getTooltipItems: (touchedSpots) {
|
|
return touchedSpots.map((spot) {
|
|
final date = DateTime.fromMillisecondsSinceEpoch(
|
|
spot.x.toInt(),
|
|
);
|
|
final level = spot.y.toInt();
|
|
return LineTooltipItem(
|
|
'${DateFormat.yMMMd().format(date)}\n',
|
|
TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
children: [
|
|
TextSpan(
|
|
text: 'checkInResultLevel$level'.tr(),
|
|
style: TextStyle(
|
|
color:
|
|
Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}).toList();
|
|
},
|
|
),
|
|
touchCallback: (
|
|
FlTouchEvent event,
|
|
LineTouchResponse? response,
|
|
) {
|
|
if (event is FlTapUpEvent &&
|
|
response != null &&
|
|
response.lineBarSpots != null &&
|
|
response.lineBarSpots!.isNotEmpty) {
|
|
final spot = response.lineBarSpots!.first;
|
|
final date = DateTime.fromMillisecondsSinceEpoch(
|
|
spot.x.toInt(),
|
|
);
|
|
onPointSelected?.call(date);
|
|
}
|
|
},
|
|
),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: spots,
|
|
isCurved: true,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: FlDotData(
|
|
show: true,
|
|
getDotPainter: (spot, percent, barData, index) {
|
|
return FlDotCirclePainter(
|
|
radius: 4,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
strokeWidth: 2,
|
|
strokeColor:
|
|
Theme.of(context).colorScheme.surface,
|
|
);
|
|
},
|
|
),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.primary.withOpacity(0.2),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (constrainWidth) {
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
child: Card(margin: EdgeInsets.all(16), child: content),
|
|
).center();
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
/// Calculate an appropriate interval for date labels based on the date range
|
|
double _calculateDateInterval(DateTime minDate, DateTime maxDate) {
|
|
final difference = maxDate.difference(minDate).inDays;
|
|
|
|
// If less than 7 days, show all days
|
|
if (difference <= 7) {
|
|
return 24 * 60 * 60 * 1000; // One day in milliseconds
|
|
}
|
|
|
|
// If less than a month, show every 3 days
|
|
if (difference <= 30) {
|
|
return 3 * 24 * 60 * 60 * 1000; // Three days in milliseconds
|
|
}
|
|
|
|
// If less than 3 months, show weekly
|
|
if (difference <= 90) {
|
|
return 7 * 24 * 60 * 60 * 1000; // One week in milliseconds
|
|
}
|
|
|
|
// Otherwise show every 2 weeks
|
|
return 14 * 24 * 60 * 60 * 1000; // Two weeks in milliseconds
|
|
}
|
|
}
|