✨ Fortune graph
This commit is contained in:
258
lib/widgets/account/fortune_graph.dart
Normal file
258
lib/widgets/account/fortune_graph.dart
Normal file
@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user