950 lines
28 KiB
Dart
950 lines
28 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
|
import 'package:relationship_saver/features/local/local_models.dart';
|
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
|
|
|
class DashboardView extends ConsumerWidget {
|
|
const DashboardView({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final AsyncValue<LocalDataState> localData = ref.watch(
|
|
localRepositoryProvider,
|
|
);
|
|
|
|
return localData.when(
|
|
data: (LocalDataState data) {
|
|
final DashboardSummary summary = data.summary();
|
|
final List<DashboardTask> tasks = data.tasks;
|
|
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
final bool compact = constraints.maxWidth < 720;
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 16 : 28,
|
|
compact ? 16 : 24,
|
|
compact ? 16 : 28,
|
|
compact ? 24 : 36,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Today',
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'See the relationship graph first, then execute the highest-leverage next moves.',
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
_RelationshipGraphCard(people: data.people, compact: compact),
|
|
const SizedBox(height: 20),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: <Widget>[
|
|
_StatCard(
|
|
label: 'Active People',
|
|
value: '${summary.activePeople}',
|
|
accent: const Color(0xFF16B8CA),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Upcoming Plans',
|
|
value: '${summary.upcomingPlans}',
|
|
accent: const Color(0xFF2B7FFF),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Pending Ideas',
|
|
value: '${summary.pendingIdeas}',
|
|
accent: const Color(0xFFFFA447),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Weekly Rhythm',
|
|
value: '${summary.weeklyConsistency}%',
|
|
accent: const Color(0xFF30B66A),
|
|
compact: compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Next Moves',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (tasks.isEmpty)
|
|
Text(
|
|
'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.',
|
|
style: Theme.of(context).textTheme.bodyLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
)
|
|
else
|
|
...tasks.map(
|
|
(DashboardTask task) => _TaskRow(
|
|
task: task,
|
|
compact: compact,
|
|
onToggleDone: () {
|
|
ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.toggleTaskDone(task.id);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (Object error, StackTrace stackTrace) {
|
|
return Center(
|
|
child: Text(
|
|
'Could not load local data.',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipGraphCard extends StatelessWidget {
|
|
const _RelationshipGraphCard({required this.people, required this.compact});
|
|
|
|
final List<PersonProfile> people;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Text(
|
|
'Relationship Graph',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEAF7FB),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Text(
|
|
'${people.length} nodes',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.primary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
height: compact ? 330 : 410,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
const Color(0xFFEAF3F8),
|
|
const Color(0xFFF7FAFC),
|
|
Colors.white.withValues(alpha: 0.9),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
if (people.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
'No people yet. Add your first relationship to start the graph.',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final _GraphLayout layout = _GraphLayout.build(
|
|
people: people,
|
|
size: constraints.biggest,
|
|
compact: compact,
|
|
);
|
|
|
|
return Stack(
|
|
children: <Widget>[
|
|
Positioned.fill(
|
|
child: CustomPaint(
|
|
painter: _RelationshipEdgePainter(
|
|
center: layout.center,
|
|
edges: layout.edges,
|
|
),
|
|
),
|
|
),
|
|
...layout.nodes.map(
|
|
(_GraphNode node) =>
|
|
_GraphBubble(node: node, compact: compact),
|
|
),
|
|
_CenterNode(center: layout.center, compact: compact),
|
|
if (layout.hiddenCount > 0)
|
|
Positioned(
|
|
right: 12,
|
|
top: 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Text(
|
|
'+${layout.hiddenCount} more',
|
|
style: Theme.of(context).textTheme.labelLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _collectLegend(people)
|
|
.map(
|
|
(_RelationshipVisual visual) =>
|
|
_RelationshipLegendPill(visual: visual),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_RelationshipVisual> _collectLegend(List<PersonProfile> source) {
|
|
final Set<String> seen = <String>{};
|
|
final List<_RelationshipVisual> result = <_RelationshipVisual>[];
|
|
|
|
for (final PersonProfile person in source) {
|
|
final _RelationshipVisual visual = _relationshipVisualFor(
|
|
person.relationship,
|
|
);
|
|
if (seen.add(visual.label)) {
|
|
result.add(visual);
|
|
}
|
|
if (result.length >= 7) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (result.isEmpty) {
|
|
result.add(_otherVisual);
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
class _GraphLayout {
|
|
const _GraphLayout({
|
|
required this.center,
|
|
required this.nodes,
|
|
required this.edges,
|
|
required this.hiddenCount,
|
|
});
|
|
|
|
final Offset center;
|
|
final List<_GraphNode> nodes;
|
|
final List<_GraphEdge> edges;
|
|
final int hiddenCount;
|
|
|
|
static _GraphLayout build({
|
|
required List<PersonProfile> people,
|
|
required Size size,
|
|
required bool compact,
|
|
}) {
|
|
final int maxNodes = compact ? 10 : 16;
|
|
final List<PersonProfile> visiblePeople = people
|
|
.take(maxNodes)
|
|
.toList(growable: false);
|
|
final int hiddenCount = people.length - visiblePeople.length;
|
|
|
|
final Offset center = Offset(size.width / 2, size.height / 2);
|
|
if (visiblePeople.isEmpty) {
|
|
return _GraphLayout(
|
|
center: center,
|
|
nodes: const <_GraphNode>[],
|
|
edges: const <_GraphEdge>[],
|
|
hiddenCount: hiddenCount,
|
|
);
|
|
}
|
|
|
|
final double minSide = math.min(size.width, size.height);
|
|
final int total = visiblePeople.length;
|
|
final int innerCount = total <= 8 ? total : 8;
|
|
final int outerCount = total - innerCount;
|
|
final double innerRadius = minSide * (compact ? 0.28 : 0.31);
|
|
final double outerRadius = minSide * (compact ? 0.39 : 0.43);
|
|
|
|
final List<_GraphNode> nodes = <_GraphNode>[];
|
|
for (int i = 0; i < total; i += 1) {
|
|
final bool inOuterRing = i >= innerCount;
|
|
final int ringCount = inOuterRing ? outerCount : innerCount;
|
|
final int ringIndex = inOuterRing ? i - innerCount : i;
|
|
final double radius = inOuterRing ? outerRadius : innerRadius;
|
|
final double phaseOffset = inOuterRing ? math.pi / ringCount : 0;
|
|
final double angle =
|
|
(-math.pi / 2) +
|
|
phaseOffset +
|
|
((2 * math.pi * ringIndex) / ringCount);
|
|
|
|
final Offset position = Offset(
|
|
center.dx + radius * math.cos(angle),
|
|
center.dy + radius * math.sin(angle),
|
|
);
|
|
final PersonProfile person = visiblePeople[i];
|
|
final _RelationshipVisual visual = _relationshipVisualFor(
|
|
person.relationship,
|
|
);
|
|
nodes.add(_GraphNode(person: person, visual: visual, position: position));
|
|
}
|
|
|
|
final List<_GraphEdge> edges = <_GraphEdge>[];
|
|
for (final _GraphNode node in nodes) {
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: center,
|
|
end: node.position,
|
|
color: node.visual.color.withValues(alpha: 0.46),
|
|
width: compact ? 1.7 : 2.0,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (nodes.length > 2) {
|
|
for (int i = 0; i < nodes.length; i += 1) {
|
|
final _GraphNode current = nodes[i];
|
|
final _GraphNode next = nodes[(i + 1) % nodes.length];
|
|
final Color color =
|
|
Color.lerp(current.visual.color, next.visual.color, 0.5) ??
|
|
AppTheme.accent;
|
|
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: current.position,
|
|
end: next.position,
|
|
color: color.withValues(alpha: 0.2),
|
|
width: 1.1,
|
|
),
|
|
);
|
|
|
|
if (nodes.length >= 7 && i.isEven) {
|
|
final _GraphNode jump = nodes[(i + 3) % nodes.length];
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: current.position,
|
|
end: jump.position,
|
|
color: current.visual.color.withValues(alpha: 0.1),
|
|
width: 1.0,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return _GraphLayout(
|
|
center: center,
|
|
nodes: nodes,
|
|
edges: edges,
|
|
hiddenCount: hiddenCount,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GraphNode {
|
|
const _GraphNode({
|
|
required this.person,
|
|
required this.visual,
|
|
required this.position,
|
|
});
|
|
|
|
final PersonProfile person;
|
|
final _RelationshipVisual visual;
|
|
final Offset position;
|
|
}
|
|
|
|
class _GraphEdge {
|
|
const _GraphEdge({
|
|
required this.start,
|
|
required this.end,
|
|
required this.color,
|
|
required this.width,
|
|
});
|
|
|
|
final Offset start;
|
|
final Offset end;
|
|
final Color color;
|
|
final double width;
|
|
}
|
|
|
|
class _RelationshipEdgePainter extends CustomPainter {
|
|
const _RelationshipEdgePainter({required this.center, required this.edges});
|
|
|
|
final Offset center;
|
|
final List<_GraphEdge> edges;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Paint centerGlow = Paint()
|
|
..shader =
|
|
RadialGradient(
|
|
colors: <Color>[
|
|
AppTheme.accent.withValues(alpha: 0.15),
|
|
Colors.transparent,
|
|
],
|
|
).createShader(
|
|
Rect.fromCircle(
|
|
center: center,
|
|
radius: math.min(size.width, size.height) * 0.36,
|
|
),
|
|
);
|
|
|
|
canvas.drawCircle(
|
|
center,
|
|
math.min(size.width, size.height) * 0.36,
|
|
centerGlow,
|
|
);
|
|
|
|
for (final _GraphEdge edge in edges) {
|
|
final Paint paint = Paint()
|
|
..color = edge.color
|
|
..strokeWidth = edge.width
|
|
..strokeCap = StrokeCap.round
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawLine(edge.start, edge.end, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _RelationshipEdgePainter oldDelegate) {
|
|
return oldDelegate.edges != edges || oldDelegate.center != center;
|
|
}
|
|
}
|
|
|
|
class _GraphBubble extends StatelessWidget {
|
|
const _GraphBubble({required this.node, required this.compact});
|
|
|
|
final _GraphNode node;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double diameter = compact ? 52 : 62;
|
|
final double labelWidth = compact ? 66 : 86;
|
|
|
|
return Positioned(
|
|
left: node.position.dx - (diameter / 2),
|
|
top: node.position.dy - (diameter / 2),
|
|
child: Tooltip(
|
|
message: '${node.person.name} • ${node.visual.label}',
|
|
child: SizedBox(
|
|
width: labelWidth,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: <Widget>[
|
|
Container(
|
|
width: diameter,
|
|
height: diameter,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
_shiftLightness(node.visual.color, 0.16),
|
|
_shiftLightness(node.visual.color, -0.12),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: node.visual.color.withValues(alpha: 0.24),
|
|
blurRadius: 14,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
_initials(node.person.name),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: -2,
|
|
top: -2,
|
|
child: Container(
|
|
width: compact ? 20 : 22,
|
|
height: compact ? 20 : 22,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white,
|
|
border: Border.all(
|
|
color: node.visual.color.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
child: Icon(
|
|
node.visual.icon,
|
|
size: compact ? 12 : 13,
|
|
color: node.visual.color,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (!compact) ...<Widget>[
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
_shortName(node.person.name),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CenterNode extends StatelessWidget {
|
|
const _CenterNode({required this.center, required this.compact});
|
|
|
|
final Offset center;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double diameter = compact ? 74 : 86;
|
|
|
|
return Positioned(
|
|
left: center.dx - (diameter / 2),
|
|
top: center.dy - (diameter / 2),
|
|
child: Container(
|
|
width: diameter,
|
|
height: diameter,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: const LinearGradient(
|
|
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: const Color(0xFF1AB6C8).withValues(alpha: 0.26),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
const Icon(Icons.favorite_rounded, color: Colors.white, size: 18),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'YOU',
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipLegendPill extends StatelessWidget {
|
|
const _RelationshipLegendPill({required this.visual});
|
|
|
|
final _RelationshipVisual visual;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: visual.color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(99),
|
|
border: Border.all(color: visual.color.withValues(alpha: 0.18)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(visual.icon, size: 14, color: visual.color),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
visual.label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatCard extends StatelessWidget {
|
|
const _StatCard({
|
|
required this.label,
|
|
required this.value,
|
|
required this.accent,
|
|
required this.compact,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final Color accent;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FrostedCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: SizedBox(
|
|
width: compact ? double.infinity : 188,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Container(
|
|
height: 8,
|
|
width: 40,
|
|
decoration: BoxDecoration(
|
|
color: accent,
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
value,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TaskRow extends StatelessWidget {
|
|
const _TaskRow({
|
|
required this.task,
|
|
required this.onToggleDone,
|
|
required this.compact,
|
|
});
|
|
|
|
final DashboardTask task;
|
|
final VoidCallback onToggleDone;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5FAFB),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
InkWell(
|
|
onTap: onToggleDone,
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Icon(
|
|
task.done
|
|
? Icons.check_circle_rounded
|
|
: Icons.radio_button_unchecked_rounded,
|
|
color: task.done
|
|
? const Color(0xFF1D9C66)
|
|
: const Color(0xFF1AB6C8),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
task.title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
decoration: task.done ? TextDecoration.lineThrough : null,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
task.description,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!compact) ...<Widget>[
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
_shortDate(task.when),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipVisual {
|
|
const _RelationshipVisual({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final Color color;
|
|
}
|
|
|
|
const _RelationshipVisual _spouseVisual = _RelationshipVisual(
|
|
label: 'Spouse',
|
|
icon: Icons.favorite_rounded,
|
|
color: Color(0xFFE0557B),
|
|
);
|
|
|
|
const _RelationshipVisual _fianceVisual = _RelationshipVisual(
|
|
label: 'Fiance',
|
|
icon: Icons.diamond_rounded,
|
|
color: Color(0xFFCB6CF2),
|
|
);
|
|
|
|
const _RelationshipVisual _partnerVisual = _RelationshipVisual(
|
|
label: 'Partner',
|
|
icon: Icons.favorite_border_rounded,
|
|
color: Color(0xFFE67A5A),
|
|
);
|
|
|
|
const _RelationshipVisual _bestieVisual = _RelationshipVisual(
|
|
label: 'Bestie',
|
|
icon: Icons.emoji_emotions_rounded,
|
|
color: Color(0xFF8E6BFF),
|
|
);
|
|
|
|
const _RelationshipVisual _friendVisual = _RelationshipVisual(
|
|
label: 'Friend',
|
|
icon: Icons.groups_rounded,
|
|
color: Color(0xFF3F8CFF),
|
|
);
|
|
|
|
const _RelationshipVisual _familyVisual = _RelationshipVisual(
|
|
label: 'Family',
|
|
icon: Icons.family_restroom_rounded,
|
|
color: Color(0xFF2FB67D),
|
|
);
|
|
|
|
const _RelationshipVisual _sisterVisual = _RelationshipVisual(
|
|
label: 'Sister',
|
|
icon: Icons.female_rounded,
|
|
color: Color(0xFFFF6EA9),
|
|
);
|
|
|
|
const _RelationshipVisual _brotherVisual = _RelationshipVisual(
|
|
label: 'Brother',
|
|
icon: Icons.male_rounded,
|
|
color: Color(0xFF4A7DFF),
|
|
);
|
|
|
|
const _RelationshipVisual _otherVisual = _RelationshipVisual(
|
|
label: 'Connection',
|
|
icon: Icons.hub_rounded,
|
|
color: Color(0xFF5F7280),
|
|
);
|
|
|
|
_RelationshipVisual _relationshipVisualFor(String relationship) {
|
|
final String value = relationship.toLowerCase();
|
|
|
|
if (_containsAny(value, <String>['spouse', 'wife', 'husband'])) {
|
|
return _spouseVisual;
|
|
}
|
|
if (_containsAny(value, <String>['fiance', 'engaged'])) {
|
|
return _fianceVisual;
|
|
}
|
|
if (_containsAny(value, <String>[
|
|
'girlfriend',
|
|
'boyfriend',
|
|
'partner',
|
|
'gf',
|
|
'bf',
|
|
])) {
|
|
return _partnerVisual;
|
|
}
|
|
if (_containsAny(value, <String>['bestie', 'best friend', 'bff'])) {
|
|
return _bestieVisual;
|
|
}
|
|
if (_containsAny(value, <String>['sister', 'sis'])) {
|
|
return _sisterVisual;
|
|
}
|
|
if (_containsAny(value, <String>['brother', 'bro'])) {
|
|
return _brotherVisual;
|
|
}
|
|
if (_containsAny(value, <String>[
|
|
'family',
|
|
'mother',
|
|
'father',
|
|
'mom',
|
|
'dad',
|
|
'parent',
|
|
'aunt',
|
|
'uncle',
|
|
'cousin',
|
|
'grand',
|
|
])) {
|
|
return _familyVisual;
|
|
}
|
|
if (_containsAny(value, <String>['friend', 'pal', 'buddy', 'mate'])) {
|
|
return _friendVisual;
|
|
}
|
|
return _otherVisual;
|
|
}
|
|
|
|
bool _containsAny(String value, List<String> needles) {
|
|
for (final String needle in needles) {
|
|
if (value.contains(needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Color _shiftLightness(Color color, double delta) {
|
|
final HSLColor hsl = HSLColor.fromColor(color);
|
|
final double next = (hsl.lightness + delta).clamp(0.0, 1.0);
|
|
return hsl.withLightness(next).toColor();
|
|
}
|
|
|
|
String _initials(String name) {
|
|
final List<String> parts = name
|
|
.split(' ')
|
|
.where((String part) => part.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (parts.isEmpty) {
|
|
return '?';
|
|
}
|
|
|
|
if (parts.length == 1) {
|
|
return parts.first
|
|
.substring(0, math.min(2, parts.first.length))
|
|
.toUpperCase();
|
|
}
|
|
|
|
return '${parts.first[0]}${parts[1][0]}'.toUpperCase();
|
|
}
|
|
|
|
String _shortName(String name) {
|
|
final List<String> parts = name
|
|
.split(' ')
|
|
.where((String part) => part.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (parts.isEmpty) {
|
|
return 'Unknown';
|
|
}
|
|
return parts.first;
|
|
}
|
|
|
|
String _shortDate(DateTime value) {
|
|
const List<String> weekdays = <String>[
|
|
'Mon',
|
|
'Tue',
|
|
'Wed',
|
|
'Thu',
|
|
'Fri',
|
|
'Sat',
|
|
'Sun',
|
|
];
|
|
final String weekday = weekdays[value.weekday - 1];
|
|
final String day = value.day.toString().padLeft(2, '0');
|
|
return '$weekday $day';
|
|
}
|