1504 lines
45 KiB
Dart
1504 lines
45 KiB
Dart
part of 'main.dart';
|
|
|
|
class PageFrame extends StatelessWidget {
|
|
const PageFrame({
|
|
super.key,
|
|
required this.eyebrow,
|
|
required this.title,
|
|
required this.child,
|
|
this.action,
|
|
});
|
|
|
|
final String eyebrow;
|
|
final String title;
|
|
final Widget child;
|
|
final Widget? action;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 14,
|
|
runSpacing: 12,
|
|
crossAxisAlignment: WrapCrossAlignment.end,
|
|
alignment: WrapAlignment.spaceBetween,
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 220, maxWidth: 620),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
eyebrow.toUpperCase(),
|
|
style: const TextStyle(
|
|
color: sage,
|
|
fontSize: 12,
|
|
letterSpacing: 1.6,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 30,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
?action,
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Expanded(child: child),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CalendarPage extends StatefulWidget {
|
|
const CalendarPage({
|
|
super.key,
|
|
required this.events,
|
|
required this.people,
|
|
required this.weather,
|
|
required this.onAdd,
|
|
required this.onEdit,
|
|
this.onAssignPerson,
|
|
});
|
|
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final WeatherSnapshot? weather;
|
|
final VoidCallback onAdd;
|
|
final ValueChanged<CalendarEvent> onEdit;
|
|
final void Function(CalendarEvent event, Person person)? onAssignPerson;
|
|
|
|
@override
|
|
State<CalendarPage> createState() => _CalendarPageState();
|
|
}
|
|
|
|
class _CalendarPageState extends State<CalendarPage> {
|
|
int selectedDay = DateTime.now().weekday - 1;
|
|
DateTime weekStart = startOfIsoWeek(DateTime.now());
|
|
Timer? returnToTodayTimer;
|
|
static const days = ['H', 'K', 'Sze', 'Cs', 'P', 'Szo', 'V'];
|
|
|
|
@override
|
|
void dispose() {
|
|
returnToTodayTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scheduleReturnToToday() {
|
|
returnToTodayTimer?.cancel();
|
|
returnToTodayTimer = Timer(const Duration(seconds: 10), _returnToToday);
|
|
}
|
|
|
|
void _returnToToday() {
|
|
final now = DateTime.now();
|
|
final currentWeekStart = startOfIsoWeek(now);
|
|
final currentDay = now.weekday - 1;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
weekStart = currentWeekStart;
|
|
selectedDay = currentDay;
|
|
});
|
|
}
|
|
|
|
void _selectDay(int day) {
|
|
setState(() => selectedDay = day);
|
|
_scheduleReturnToToday();
|
|
}
|
|
|
|
void _moveWeek(int weekDelta) {
|
|
setState(() {
|
|
weekStart = weekStart.add(Duration(days: weekDelta * 7));
|
|
selectedDay = 0;
|
|
});
|
|
_scheduleReturnToToday();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selectedDate = weekStart.add(Duration(days: selectedDay));
|
|
final todayEvents =
|
|
widget.events
|
|
.where((event) => isSameDate(event.date, selectedDate))
|
|
.toList()
|
|
..sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
return PageFrame(
|
|
eyebrow:
|
|
'${formatDate(weekStart)} \u2013 ${formatDate(weekStart.add(const Duration(days: 6)))}',
|
|
title: '${isoWeekNumber(weekStart)}. h\u00e9t',
|
|
action: FilledButton.icon(
|
|
onPressed: widget.onAdd,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('\u00daj esem\u00e9ny'),
|
|
),
|
|
child: ListView(
|
|
padding: const EdgeInsets.only(bottom: 24),
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final heroPanel = CalendarHeroCard(
|
|
selectedDate: selectedDate,
|
|
eventCount: todayEvents.length,
|
|
events: todayEvents,
|
|
people: widget.people,
|
|
weather: widget.weather,
|
|
);
|
|
|
|
final peoplePanel = NextPersonalEventsTile(
|
|
events: widget.events,
|
|
people: widget.people,
|
|
onEdit: widget.onEdit,
|
|
);
|
|
|
|
if (constraints.maxWidth >= 980) {
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(flex: 5, child: heroPanel),
|
|
const SizedBox(width: 14),
|
|
Expanded(flex: 5, child: peoplePanel),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
WeekBoard(
|
|
events: widget.events,
|
|
people: widget.people,
|
|
weekStart: weekStart,
|
|
selectedDay: selectedDay,
|
|
onSelectDay: _selectDay,
|
|
onPreviousWeek: () => _moveWeek(-1),
|
|
onNextWeek: () => _moveWeek(1),
|
|
),
|
|
const SizedBox(height: 16),
|
|
DayDetailsPanel(
|
|
selectedDayLabel: days[selectedDay],
|
|
selectedDate: selectedDate,
|
|
events: todayEvents,
|
|
people: widget.people,
|
|
onEdit: widget.onEdit,
|
|
onAssignPerson: widget.onAssignPerson,
|
|
allEvents: widget.events,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
heroPanel,
|
|
const SizedBox(height: 14),
|
|
peoplePanel,
|
|
const SizedBox(height: 14),
|
|
WeekBoard(
|
|
events: widget.events,
|
|
people: widget.people,
|
|
weekStart: weekStart,
|
|
selectedDay: selectedDay,
|
|
onSelectDay: _selectDay,
|
|
onPreviousWeek: () => _moveWeek(-1),
|
|
onNextWeek: () => _moveWeek(1),
|
|
),
|
|
const SizedBox(height: 14),
|
|
DayDetailsPanel(
|
|
selectedDayLabel: days[selectedDay],
|
|
selectedDate: selectedDate,
|
|
events: todayEvents,
|
|
people: widget.people,
|
|
onEdit: widget.onEdit,
|
|
onAssignPerson: widget.onAssignPerson,
|
|
allEvents: widget.events,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
CalendarEvent? findNextEventForPerson(
|
|
String personId,
|
|
List<CalendarEvent> events, {
|
|
DateTime? now,
|
|
}) {
|
|
final personal = findNextEventsForPerson(personId, events, 1, now: now);
|
|
return personal.isEmpty ? null : personal.first;
|
|
}
|
|
|
|
List<CalendarEvent> findNextEventsForPerson(
|
|
String personId,
|
|
List<CalendarEvent> events,
|
|
int limit, {
|
|
DateTime? now,
|
|
}) {
|
|
final today = startOfDay(now ?? DateTime.now());
|
|
final personal =
|
|
events
|
|
.where(
|
|
(e) => e.personId == personId || e.personId == everyonePersonId,
|
|
)
|
|
.where((e) => !startOfDay(e.date).isBefore(today))
|
|
.toList()
|
|
..sort((a, b) {
|
|
final dateCompare = a.date.compareTo(b.date);
|
|
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
|
|
});
|
|
return personal.take(limit).toList();
|
|
}
|
|
|
|
Person? personForEvent(CalendarEvent event, List<Person> people) {
|
|
final personId = event.personId;
|
|
if (personId == null) return null;
|
|
for (final person in people) {
|
|
if (person.id == personId) return person;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool sameImportedEvent(CalendarEvent a, CalendarEvent b) {
|
|
if (a.externalId != null && b.externalId != null) {
|
|
return a.externalId == b.externalId && a.calendarId == b.calendarId;
|
|
}
|
|
return a.title == b.title &&
|
|
a.calendarId == b.calendarId &&
|
|
isSameDate(a.date, b.date) &&
|
|
a.start == b.start;
|
|
}
|
|
|
|
class CalendarHeroCard extends StatelessWidget {
|
|
const CalendarHeroCard({
|
|
super.key,
|
|
required this.selectedDate,
|
|
required this.eventCount,
|
|
required this.events,
|
|
required this.people,
|
|
required this.weather,
|
|
});
|
|
|
|
final DateTime selectedDate;
|
|
final int eventCount;
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final WeatherSnapshot? weather;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFF18332D), Color(0xFF2F6B5B), Color(0xFFE89552)],
|
|
stops: [.05, .66, 1],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(30),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: sage.withValues(alpha: .24),
|
|
blurRadius: 30,
|
|
offset: const Offset(0, 18),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Ma a csal\u00e1dban',
|
|
style: TextStyle(
|
|
color: Colors.white70,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
formatDate(selectedDate),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 25,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 7),
|
|
Text(
|
|
eventCount == 0
|
|
? 'Szell\u0151s nap. Ilyen is kell.'
|
|
: '$eventCount program v\u00e1rhat\u00f3 ezen a napon.',
|
|
style: const TextStyle(color: Colors.white, height: 1.35),
|
|
),
|
|
if (events.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
...events.take(3).map((event) {
|
|
final person = personForEvent(event, people);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
children: [
|
|
MiniAudienceAvatar(event: event, person: person),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${event.start} \u00b7 ${event.title}'
|
|
'${person == null ? '' : ' \u00b7 ${person.name}'}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.end,
|
|
children: [
|
|
const LiveClockChip(),
|
|
HeroStatChip(
|
|
icon: weather?.icon ?? Icons.thermostat_outlined,
|
|
label: weather?.temperatureLabel ?? '\u2014',
|
|
subtitle: weather == null
|
|
? 'id\u0151j\u00e1r\u00e1s'
|
|
: '${weather!.locationName} \u00b7 ${weather!.description}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DayDetailsPanel extends StatelessWidget {
|
|
const DayDetailsPanel({
|
|
super.key,
|
|
required this.selectedDayLabel,
|
|
required this.selectedDate,
|
|
required this.events,
|
|
required this.people,
|
|
required this.onEdit,
|
|
required this.onAssignPerson,
|
|
required this.allEvents,
|
|
});
|
|
|
|
final String selectedDayLabel;
|
|
final DateTime selectedDate;
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final ValueChanged<CalendarEvent> onEdit;
|
|
final void Function(CalendarEvent event, Person person)? onAssignPerson;
|
|
final List<CalendarEvent> allEvents;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: const Color(0xFFE8E5DD)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'$selectedDayLabel, ${formatDate(selectedDate)}',
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (events.isEmpty)
|
|
const SizedBox(
|
|
height: 124,
|
|
child: EmptyState(
|
|
icon: Icons.wb_sunny_outlined,
|
|
title: 'Szabad nap',
|
|
subtitle: 'Ezen a napon m\u00e9g nincs programod.',
|
|
),
|
|
)
|
|
else
|
|
...events.map(
|
|
(event) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: EventCard(
|
|
event: event,
|
|
person: personForEvent(event, people),
|
|
onTap: () => onEdit(event),
|
|
),
|
|
),
|
|
),
|
|
UncategorizedEventsTile(
|
|
events: allEvents,
|
|
people: people,
|
|
onAssignPerson: onAssignPerson,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class LiveClockChip extends StatefulWidget {
|
|
const LiveClockChip({super.key});
|
|
|
|
@override
|
|
State<LiveClockChip> createState() => _LiveClockChipState();
|
|
}
|
|
|
|
class _LiveClockChipState extends State<LiveClockChip> {
|
|
late DateTime now;
|
|
Timer? timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
now = DateTime.now();
|
|
timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (!mounted) return;
|
|
setState(() => now = DateTime.now());
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 128,
|
|
constraints: const BoxConstraints(minHeight: 114),
|
|
padding: const EdgeInsets.all(11),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: .18),
|
|
borderRadius: BorderRadius.circular(22),
|
|
border: Border.all(color: Colors.white30),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 50,
|
|
height: 50,
|
|
child: CustomPaint(painter: _ClockPainter(now)),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
formatTimeWithSeconds(now),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: .4,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
const Text(
|
|
'\u00e9l\u0151 \u00f3ra',
|
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClockPainter extends CustomPainter {
|
|
_ClockPainter(this.time);
|
|
|
|
final DateTime time;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.shortestSide / 2;
|
|
final facePaint = Paint()
|
|
..color = Colors.white.withValues(alpha: .16)
|
|
..style = PaintingStyle.fill;
|
|
final rimPaint = Paint()
|
|
..color = Colors.white.withValues(alpha: .7)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5;
|
|
|
|
canvas.drawCircle(center, radius - 1, facePaint);
|
|
canvas.drawCircle(center, radius - 1, rimPaint);
|
|
|
|
final tickPaint = Paint()
|
|
..color = Colors.white.withValues(alpha: .75)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeWidth = 1.4;
|
|
for (var i = 0; i < 12; i++) {
|
|
final angle = math.pi * 2 * i / 12 - math.pi / 2;
|
|
final outer =
|
|
center + Offset(math.cos(angle), math.sin(angle)) * (radius - 7);
|
|
final inner =
|
|
center + Offset(math.cos(angle), math.sin(angle)) * (radius - 12);
|
|
canvas.drawLine(inner, outer, tickPaint);
|
|
}
|
|
|
|
void hand(double angle, double length, double width, Color color) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = width
|
|
..strokeCap = StrokeCap.round;
|
|
final end = center + Offset(math.cos(angle), math.sin(angle)) * length;
|
|
canvas.drawLine(center, end, paint);
|
|
}
|
|
|
|
final secondAngle = math.pi * 2 * time.second / 60 - math.pi / 2;
|
|
final minuteAngle =
|
|
math.pi * 2 * (time.minute + time.second / 60) / 60 - math.pi / 2;
|
|
final hourAngle =
|
|
math.pi * 2 * ((time.hour % 12) + time.minute / 60) / 12 - math.pi / 2;
|
|
|
|
hand(hourAngle, radius * .45, 4, Colors.white);
|
|
hand(minuteAngle, radius * .62, 3, Colors.white.withValues(alpha: .9));
|
|
hand(secondAngle, radius * .68, 1.4, const Color(0xFFFFD08A));
|
|
|
|
canvas.drawCircle(center, 4, Paint()..color = const Color(0xFFFFD08A));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _ClockPainter oldDelegate) =>
|
|
oldDelegate.time.second != time.second;
|
|
}
|
|
|
|
class HeroStatChip extends StatelessWidget {
|
|
const HeroStatChip({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.subtitle,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String subtitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 128,
|
|
constraints: const BoxConstraints(minHeight: 114),
|
|
padding: const EdgeInsets.all(11),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: .15),
|
|
borderRadius: BorderRadius.circular(22),
|
|
border: Border.all(color: Colors.white24),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, color: Colors.white, size: 25),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WeekBoard extends StatelessWidget {
|
|
const WeekBoard({
|
|
super.key,
|
|
required this.events,
|
|
required this.people,
|
|
required this.weekStart,
|
|
required this.selectedDay,
|
|
required this.onSelectDay,
|
|
required this.onPreviousWeek,
|
|
required this.onNextWeek,
|
|
});
|
|
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final DateTime weekStart;
|
|
final int selectedDay;
|
|
final ValueChanged<int> onSelectDay;
|
|
final VoidCallback onPreviousWeek;
|
|
final VoidCallback onNextWeek;
|
|
|
|
static const days = [
|
|
'H\u00e9tf\u0151',
|
|
'Kedd',
|
|
'Szerda',
|
|
'Cs\u00fct\u00f6rt\u00f6k',
|
|
'P\u00e9ntek',
|
|
'Szombat',
|
|
'Vas\u00e1rnap',
|
|
];
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.view_week_outlined, color: sage),
|
|
const SizedBox(width: 10),
|
|
const Expanded(
|
|
child: Text(
|
|
'Heti csal\u00e1di napt\u00e1r',
|
|
style: TextStyle(
|
|
color: ink,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'El\u0151z\u0151 h\u00e9t',
|
|
onPressed: onPreviousWeek,
|
|
icon: const Icon(Icons.chevron_left),
|
|
),
|
|
Text(
|
|
'${isoWeekNumber(weekStart)}. h\u00e9t',
|
|
style: const TextStyle(
|
|
color: sage,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'K\u00f6vetkez\u0151 h\u00e9t',
|
|
onPressed: onNextWeek,
|
|
icon: const Icon(Icons.chevron_right),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: List.generate(7, (index) {
|
|
final date = weekStart.add(Duration(days: index));
|
|
final dayEvents =
|
|
events
|
|
.where((event) => isSameDate(event.date, date))
|
|
.toList()
|
|
..sort((a, b) => a.start.compareTo(b.start));
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(right: index == 6 ? 0 : 6),
|
|
child: WeekDayColumn(
|
|
title: days[index],
|
|
dateLabel: formatShortDate(date),
|
|
active: selectedDay == index,
|
|
events: dayEvents,
|
|
people: people,
|
|
onTap: () => onSelectDay(index),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WeekDayColumn extends StatelessWidget {
|
|
const WeekDayColumn({
|
|
super.key,
|
|
required this.title,
|
|
required this.dateLabel,
|
|
required this.active,
|
|
required this.events,
|
|
required this.people,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String title;
|
|
final String dateLabel;
|
|
final bool active;
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(minHeight: 190),
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: active ? mint : const Color(0xFFF8F8F5),
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(
|
|
color: active ? sage : const Color(0xFFE8E5DD),
|
|
width: active ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 7),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: active ? sage : Colors.white,
|
|
borderRadius: BorderRadius.circular(14),
|
|
boxShadow: active
|
|
? [
|
|
BoxShadow(
|
|
color: sage.withValues(alpha: .18),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Text(
|
|
dateLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.fade,
|
|
softWrap: false,
|
|
style: TextStyle(
|
|
color: active ? Colors.white : sage,
|
|
fontSize: 17,
|
|
letterSpacing: .5,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (events.isEmpty)
|
|
const SizedBox(
|
|
height: 92,
|
|
child: Center(
|
|
child: Text(
|
|
'nincs program',
|
|
style: TextStyle(
|
|
color: Colors.black38,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
...events
|
|
.take(4)
|
|
.map(
|
|
(event) => WeekEventPill(
|
|
event: event,
|
|
person: personForEvent(event, people),
|
|
),
|
|
),
|
|
if (events.length > 4)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
'+${events.length - 4} tov\u00e1bbi',
|
|
style: const TextStyle(
|
|
color: Colors.black54,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WeekEventPill extends StatelessWidget {
|
|
const WeekEventPill({super.key, required this.event, required this.person});
|
|
|
|
final CalendarEvent event;
|
|
final Person? person;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = person?.color ?? (event.isSynced ? orange : sage);
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
padding: const EdgeInsets.all(7),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border(left: BorderSide(color: color, width: 3)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MiniAudienceAvatar(event: event, person: person),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.start,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 1),
|
|
Text(
|
|
event.title,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 11.5,
|
|
height: 1.15,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MiniAudienceAvatar extends StatelessWidget {
|
|
const MiniAudienceAvatar({
|
|
super.key,
|
|
required this.event,
|
|
required this.person,
|
|
});
|
|
|
|
final CalendarEvent event;
|
|
final Person? person;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (person != null) {
|
|
return FamilyAvatar(person: person!, radius: 13);
|
|
}
|
|
final icon = event.isSynced
|
|
? Icons.inbox_outlined
|
|
: Icons.groups_2_outlined;
|
|
final color = event.isSynced ? orange : sage;
|
|
return CircleAvatar(
|
|
radius: 13,
|
|
backgroundColor: color.withValues(alpha: .14),
|
|
child: Icon(icon, color: color, size: 15),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<CalendarEvent> googleImportedEvents(
|
|
List<GoogleCalendarConnection> connections,
|
|
) {
|
|
final enabledIds = connections
|
|
.expand((connection) => connection.enabledCalendarIds)
|
|
.toSet();
|
|
|
|
final imported = <CalendarEvent>[];
|
|
if (enabledIds.contains('primary')) {
|
|
imported.add(
|
|
CalendarEvent(
|
|
'Google: orvosi kontroll',
|
|
'14:00',
|
|
'14:45',
|
|
const Color(0xFF4285F4),
|
|
date: DateTime(2026, 7, 23),
|
|
place: 'Google Calendar',
|
|
),
|
|
);
|
|
}
|
|
if (enabledIds.contains('family')) {
|
|
imported.add(
|
|
CalendarEvent(
|
|
'Google: k\u00f6z\u00f6s csal\u00e1di eb\u00e9d',
|
|
'12:30',
|
|
'14:00',
|
|
const Color(0xFF34A853),
|
|
date: DateTime(2026, 7, 26),
|
|
place: 'Csal\u00e1di napt\u00e1r',
|
|
),
|
|
);
|
|
}
|
|
if (enabledIds.contains('school')) {
|
|
imported.add(
|
|
CalendarEvent(
|
|
'Google: ovis kir\u00e1ndul\u00e1s',
|
|
'08:30',
|
|
'12:00',
|
|
const Color(0xFFFBBC05),
|
|
date: DateTime(2026, 7, 24),
|
|
place: 'Ovi / iskola',
|
|
),
|
|
);
|
|
}
|
|
if (enabledIds.contains('work')) {
|
|
imported.add(
|
|
CalendarEvent(
|
|
'Google: projekt st\u00e1tusz',
|
|
'15:00',
|
|
'15:30',
|
|
const Color(0xFF9B78B4),
|
|
date: DateTime(2026, 7, 22),
|
|
place: 'Munka',
|
|
),
|
|
);
|
|
}
|
|
return imported;
|
|
}
|
|
|
|
class NextPersonalEventsTile extends StatelessWidget {
|
|
const NextPersonalEventsTile({
|
|
super.key,
|
|
required this.events,
|
|
required this.people,
|
|
required this.onEdit,
|
|
});
|
|
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final ValueChanged<CalendarEvent> onEdit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(15),
|
|
decoration: BoxDecoration(
|
|
color: mint,
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(Icons.event_available_outlined, color: sage, size: 24),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
'Csal\u00e1dtagok k\u00f6vetkez\u0151 esem\u00e9nyei',
|
|
style: TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 19,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (people.isEmpty)
|
|
const Text(
|
|
'M\u00e9g nincs felvett csal\u00e1dtag.',
|
|
style: TextStyle(color: Colors.black54),
|
|
)
|
|
else
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (people.length == 4 && constraints.maxWidth >= 420) {
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 10,
|
|
mainAxisSpacing: 10,
|
|
childAspectRatio: 2.05,
|
|
),
|
|
itemCount: people.length,
|
|
itemBuilder: (_, index) => _personCard(people[index]),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
...people.map(
|
|
(person) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _personCard(person),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _personCard(Person person) {
|
|
final nextEvents = findNextEventsForPerson(
|
|
person.id,
|
|
events,
|
|
person.nextEventLimit,
|
|
);
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
FamilyAvatar(person: person, radius: 18),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
person.name,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'${person.nextEventLimit} db',
|
|
style: const TextStyle(
|
|
color: Colors.black45,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 7),
|
|
if (nextEvents.isEmpty)
|
|
const Text(
|
|
'nincs',
|
|
style: TextStyle(
|
|
color: Colors.black45,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
)
|
|
else
|
|
...nextEvents.map(
|
|
(event) => InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () => onEdit(event),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 5,
|
|
child: Text(
|
|
event.title,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 82,
|
|
child: Text(
|
|
'${formatDate(event.date)}\n${event.start}',
|
|
textAlign: TextAlign.right,
|
|
style: const TextStyle(
|
|
color: sage,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w900,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.edit_outlined,
|
|
size: 15,
|
|
color: Colors.black38,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UncategorizedEventsTile extends StatelessWidget {
|
|
const UncategorizedEventsTile({
|
|
super.key,
|
|
required this.events,
|
|
required this.people,
|
|
required this.onAssignPerson,
|
|
});
|
|
|
|
final List<CalendarEvent> events;
|
|
final List<Person> people;
|
|
final void Function(CalendarEvent event, Person person)? onAssignPerson;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final uncategorized =
|
|
events
|
|
.where((event) => event.isSynced && event.isUncategorized)
|
|
.toList()
|
|
..sort((a, b) {
|
|
final dateCompare = a.date.compareTo(b.date);
|
|
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
|
|
});
|
|
|
|
if (uncategorized.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 14),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF4DF),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: const Color(0xFFFFD99A)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.inbox_outlined, color: orange),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
'Kategoriz\u00e1latlan esem\u00e9nyek (${uncategorized.length})',
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Koppints egy esem\u00e9nyre, \u00e9s rendeld csal\u00e1dtaghoz.',
|
|
style: TextStyle(color: Colors.black54),
|
|
),
|
|
const SizedBox(height: 8),
|
|
...uncategorized.map(
|
|
(event) => Card(
|
|
margin: const EdgeInsets.only(top: 8),
|
|
color: Colors.white,
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: orange.withValues(alpha: .14),
|
|
child: const Icon(Icons.inbox_outlined, color: orange),
|
|
),
|
|
title: Text(
|
|
event.title,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'${formatDate(event.date)} ${event.start}'
|
|
'${event.calendarName == null ? '' : ' \u00b7 ${event.calendarName}'}',
|
|
),
|
|
trailing: const Icon(Icons.person_add_alt_1_outlined),
|
|
onTap: people.isEmpty || onAssignPerson == null
|
|
? null
|
|
: () async {
|
|
final person = await showDialog<Person>(
|
|
context: context,
|
|
builder: (_) => AssignPersonDialog(
|
|
event: event,
|
|
people: people,
|
|
),
|
|
);
|
|
if (person != null) {
|
|
onAssignPerson!(event, person);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AssignPersonDialog extends StatelessWidget {
|
|
const AssignPersonDialog({
|
|
super.key,
|
|
required this.event,
|
|
required this.people,
|
|
});
|
|
|
|
final CalendarEvent event;
|
|
final List<Person> people;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Esem\u00e9ny besorol\u00e1sa'),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.title,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 17,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${formatDate(event.date)} ${event.start}',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
const SizedBox(height: 16),
|
|
...people.map(
|
|
(person) => Card(
|
|
child: ListTile(
|
|
leading: FamilyAvatar(person: person, radius: 18),
|
|
title: Text(person.name),
|
|
onTap: () => Navigator.pop(context, person),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class EventCard extends StatelessWidget {
|
|
const EventCard({super.key, required this.event, this.person, this.onTap});
|
|
|
|
final CalendarEvent event;
|
|
final Person? person;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 5,
|
|
height: 66,
|
|
decoration: BoxDecoration(
|
|
color: event.color,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
SizedBox(
|
|
width: 48,
|
|
child: Text(
|
|
event.start,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.title,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
event.place.isEmpty
|
|
? '${event.start}\u2013${event.end}'
|
|
: '${event.start}\u2013${event.end} \u00b7 ${event.place}',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
const SizedBox(height: 6),
|
|
AudienceChip(person: person, event: event),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
event.hasTodos ? Icons.task_alt_rounded : Icons.more_horiz,
|
|
color: event.hasTodos ? sage : Colors.black38,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class FamilyAvatar extends StatelessWidget {
|
|
const FamilyAvatar({super.key, required this.person, this.radius = 22});
|
|
|
|
final Person person;
|
|
final double radius;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CircleAvatar(
|
|
radius: radius,
|
|
backgroundColor: person.color,
|
|
child: Text(person.avatar, style: TextStyle(fontSize: radius * .95)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AudienceChip extends StatelessWidget {
|
|
const AudienceChip({super.key, required this.person, required this.event});
|
|
|
|
final Person? person;
|
|
final CalendarEvent event;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final chipColor = person?.color ?? (event.isUncategorized ? orange : sage);
|
|
final label =
|
|
person?.name ??
|
|
(event.isUncategorized ? 'Kategoriz\u00e1latlan' : 'Mindenki');
|
|
final icon = person != null
|
|
? Icons.person_outline
|
|
: event.isUncategorized
|
|
? Icons.inbox_outlined
|
|
: Icons.groups_2_outlined;
|
|
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: chipColor.withValues(alpha: .12),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: chipColor),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: chipColor,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|