Add Google calendar API and workspace sync

This commit is contained in:
zsolt
2026-08-13 00:50:47 +02:00
parent 164f7ccad7
commit ab6ce48a83
11 changed files with 1119 additions and 677 deletions
-608
View File
@@ -420,614 +420,6 @@ class _StockPageState extends State<StockPage> {
}
}
class AccountsPage extends StatefulWidget {
const AccountsPage({
super.key,
required this.connections,
required this.cachedEvents,
required this.people,
required this.onChanged,
required this.onSyncedEventsChanged,
});
final List<GoogleCalendarConnection> connections;
final List<CalendarEvent> cachedEvents;
final List<Person> people;
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
@override
State<AccountsPage> createState() => _AccountsPageState();
}
class _AccountsPageState extends State<AccountsPage> {
final calendarPlugin = device_calendar.DeviceCalendarPlugin();
List<device_calendar.Calendar> deviceCalendars = [];
bool loadingCalendars = false;
bool syncing = false;
String? statusMessage;
@override
void initState() {
super.initState();
_loadDeviceCalendars();
}
GoogleCalendarConnection? get connection =>
widget.connections.isEmpty ? null : widget.connections.first;
Future<void> _loadDeviceCalendars() async {
setState(() {
loadingCalendars = true;
statusMessage = null;
});
try {
final permissions = await calendarPlugin.requestPermissions();
if (permissions.isSuccess != true || permissions.data != true) {
setState(() {
loadingCalendars = false;
statusMessage =
'Napt\u00e1r olvas\u00e1si enged\u00e9ly n\u00e9lk\u00fcl nem lehet szinkroniz\u00e1lni.';
});
return;
}
final result = await calendarPlugin.retrieveCalendars();
final calendars =
(result.data ?? const <device_calendar.Calendar>[])
.where((calendar) => calendar.id != null)
.toList()
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
setState(() {
deviceCalendars = calendars;
loadingCalendars = false;
if (calendars.isEmpty) {
statusMessage =
'Nem tal\u00e1ltam lok\u00e1lis napt\u00e1rat az eszk\u00f6z\u00f6n. Androidon ellen\u0151rizd: Be\u00e1ll\u00edt\u00e1sok > Fi\u00f3kok > Google > Fi\u00f3kszinkroniz\u00e1l\u00e1s > Napt\u00e1r legyen bekapcsolva.';
} else {
final accountTypes = calendars
.map((calendar) => calendar.accountType)
.whereType<String>()
.where((type) => type.trim().isNotEmpty)
.toSet()
.toList()
..sort();
statusMessage =
'${calendars.length} napt\u00e1rat l\u00e1tok az eszk\u00f6z\u00f6n.'
'${accountTypes.isEmpty ? '' : ' Account t\u00edpusok: ${accountTypes.join(', ')}.'}';
}
});
} catch (error) {
setState(() {
loadingCalendars = false;
statusMessage =
'Nem siker\u00fclt lek\u00e9rni az eszk\u00f6z napt\u00e1rait: $error';
});
}
}
Future<void> _syncSelectedCalendars() async {
final current = connection;
if (current == null || current.enabledCalendarIds.isEmpty) {
setState(
() =>
statusMessage = 'V\u00e1lassz ki legal\u00e1bb egy napt\u00e1rat.',
);
return;
}
setState(() {
syncing = true;
statusMessage =
'Esem\u00e9nyek beolvas\u00e1sa a lok\u00e1lis napt\u00e1rt\u00e1rb\u00f3l\u2026';
});
try {
final selectedCalendars = deviceCalendars
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
.toList();
final synced = <CalendarEvent>[];
final syncDetails = <String>[];
final now = DateTime.now();
final start = DateTime(
now.year,
now.month,
now.day,
).subtract(const Duration(days: 30));
final end = start.add(const Duration(days: 180));
for (final calendar in selectedCalendars) {
final result = await calendarPlugin.retrieveEvents(
calendar.id,
device_calendar.RetrieveEventsParams(startDate: start, endDate: end),
);
final events = result.data ?? const <device_calendar.Event>[];
syncDetails.add('${calendar.name ?? 'Napt\u00e1r'}: ${events.length}');
for (final event in events) {
synced.add(
CalendarEvent.fromDeviceEvent(
event,
calendar,
personId: current.calendarPersonIds[calendar.id],
),
);
}
}
synced.sort((a, b) {
final dateCompare = a.date.compareTo(b.date);
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
});
widget.onSyncedEventsChanged(synced);
widget.onChanged([current.copyWith(lastSync: DateTime.now())]);
setState(() {
syncing = false;
statusMessage =
'${synced.length} esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.'
'${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}';
});
} catch (error) {
setState(() {
syncing = false;
statusMessage = 'A szinkroniz\u00e1l\u00e1s nem siker\u00fclt: $error';
});
}
}
@override
Widget build(BuildContext context) {
final current = connection;
final hasConnection = current != null;
final cachedUncategorized = widget.cachedEvents
.where((event) => event.isUncategorized)
.length;
return PageFrame(
eyebrow: 'Lok\u00e1lis napt\u00e1r szinkron',
title: 'Eszk\u00f6z napt\u00e1rak',
action: FilledButton.icon(
onPressed: () => _connectGoogle(context),
icon: const Icon(Icons.add_link),
label: Text(
hasConnection
? 'Fi\u00f3k c\u00edmk\u00e9je'
: 'Fi\u00f3k c\u00edmke',
),
),
child: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
GoogleSyncHero(
accountEmail: current?.accountEmail,
enabledCount: current?.enabledCalendarIds.length ?? 0,
cachedCount: widget.cachedEvents.length,
uncategorizedCount: cachedUncategorized,
),
const SizedBox(height: 22),
if (!hasConnection)
const EmptyGoogleConnectionCard()
else ...[
const Text(
'Eszk\u00f6z\u00f6n el\u00e9rhet\u0151 napt\u00e1rak',
style: TextStyle(
color: ink,
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
const SizedBox(height: 10),
if (loadingCalendars)
const LinearProgressIndicator()
else
...deviceCalendars.map(
(calendar) => DeviceCalendarToggleCard(
calendar: calendar,
enabled: current.enabledCalendarIds.contains(calendar.id),
people: widget.people,
selectedPersonId: current.calendarPersonIds[calendar.id],
onChanged: (enabled) {
final nextIds = [...current.enabledCalendarIds];
final nextPersonIds = {...current.calendarPersonIds};
if (enabled) {
if (!nextIds.contains(calendar.id)) {
nextIds.add(calendar.id!);
}
} else {
nextIds.remove(calendar.id);
nextPersonIds.remove(calendar.id);
}
widget.onChanged([
current.copyWith(
enabledCalendarIds: nextIds,
calendarPersonIds: nextPersonIds,
lastSync: current.lastSync,
),
]);
},
onPersonChanged: (personId) {
if (calendar.id == null) return;
final nextPersonIds = {...current.calendarPersonIds};
if (personId == null) {
nextPersonIds.remove(calendar.id);
} else {
nextPersonIds[calendar.id!] = personId;
}
widget.onChanged([
current.copyWith(
calendarPersonIds: nextPersonIds,
lastSync: current.lastSync,
),
]);
},
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: syncing ? null : _syncSelectedCalendars,
icon: syncing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(
syncing
? 'Szinkroniz\u00e1l\u00e1s\u2026'
: 'Szinkroniz\u00e1l\u00e1s most',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _loadDeviceCalendars,
icon: const Icon(Icons.refresh),
label: const Text('Napt\u00e1rlista friss\u00edt\u00e9se'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
widget.onChanged([]);
widget.onSyncedEventsChanged([]);
},
icon: const Icon(Icons.link_off),
label: const Text('Napt\u00e1rkapcsolat lev\u00e1laszt\u00e1sa'),
),
],
if (statusMessage != null) ...[
const SizedBox(height: 14),
Text(statusMessage!, style: const TextStyle(color: Colors.black54)),
],
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: mint,
borderRadius: BorderRadius.circular(18),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: sage),
SizedBox(width: 13),
Expanded(
child: Text(
'Ez a szinkron egyir\u00e1ny\u00fa: az eszk\u00f6z lok\u00e1lis napt\u00e1rt\u00e1r\u00e1b\u00f3l olvas, a beolvasott esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza k\u00fcls\u0151 napt\u00e1rba. '
'Google, Apple/iCloud, Outlook vagy m\u00e1s napt\u00e1r akkor jelenik meg itt, ha az oper\u00e1ci\u00f3s rendszer napt\u00e1rt\u00e1r\u00e1ban el\u00e9rhet\u0151.',
style: TextStyle(color: ink, height: 1.45),
),
),
],
),
),
],
),
);
}
Future<void> _connectGoogle(BuildContext context) async {
final email = await showDialog<String>(
context: context,
builder: (context) =>
GoogleAccountDialog(initialEmail: connection?.accountEmail ?? ''),
);
if (email == null || email.isEmpty) return;
widget.onChanged([
GoogleCalendarConnection(
id: connection?.id ?? uniqueId(),
accountEmail: email,
enabledCalendarIds: connection?.enabledCalendarIds ?? const [],
lastSync: DateTime.now(),
),
]);
}
}
class GoogleSyncHero extends StatelessWidget {
const GoogleSyncHero({
super.key,
required this.accountEmail,
required this.enabledCount,
required this.cachedCount,
required this.uncategorizedCount,
});
final String? accountEmail;
final int enabledCount;
final int cachedCount;
final int uncategorizedCount;
@override
Widget build(BuildContext context) {
final connected = accountEmail != null && accountEmail!.isNotEmpty;
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF18332D), Color(0xFF2F6B5B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: sage.withValues(alpha: .22),
blurRadius: 28,
offset: const Offset(0, 16),
),
],
),
child: Row(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .16),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.g_mobiledata_rounded,
color: Colors.white,
size: 44,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connected
? accountEmail!
: 'M\u00e9g nincs napt\u00e1rkapcsolat',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
const SizedBox(height: 5),
Text(
connected
? '$enabledCount napt\u00e1r bekapcsolva \u00b7 $cachedCount esem\u00e9ny cache-ben \u00b7 $uncategorizedCount kategoriz\u00e1latlan'
: 'Adj meg egy c\u00edmk\u00e9t, majd v\u00e1laszd ki, mely eszk\u00f6znapt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.',
style: const TextStyle(color: Colors.white70, height: 1.35),
),
],
),
),
],
),
);
}
}
class GoogleAccountDialog extends StatefulWidget {
const GoogleAccountDialog({super.key, required this.initialEmail});
final String initialEmail;
@override
State<GoogleAccountDialog> createState() => _GoogleAccountDialogState();
}
class _GoogleAccountDialogState extends State<GoogleAccountDialog> {
late final TextEditingController controller;
@override
void initState() {
super.initState();
controller = TextEditingController(
text: widget.initialEmail.isEmpty
? 'csalad@gmail.com'
: widget.initialEmail,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Napt\u00e1rkapcsolat c\u00edmk\u00e9je'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Fi\u00f3k / kapcsolat neve',
helperText: 'Pl. csalad@gmail.com, iCloud, munkahelyi napt\u00e1r',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('M\u00e9gse'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('Ment\u00e9s'),
),
],
);
}
}
class EmptyGoogleConnectionCard extends StatelessWidget {
const EmptyGoogleConnectionCard({super.key});
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Row(
children: [
Icon(Icons.calendar_month_outlined, color: sage),
SizedBox(width: 14),
Expanded(
child: Text(
'Adj hozz\u00e1 egy Google fi\u00f3kot, majd v\u00e1laszd ki, mely napt\u00e1rak jelenjenek meg a helyi csal\u00e1di n\u00e9zetben.',
style: TextStyle(color: ink, height: 1.4),
),
),
],
),
),
);
}
}
class GoogleCalendarToggleCard extends StatelessWidget {
const GoogleCalendarToggleCard({
super.key,
required this.calendar,
required this.enabled,
required this.onChanged,
});
final GoogleCalendarOption calendar;
final bool enabled;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Card(
child: SwitchListTile(
value: enabled,
activeThumbColor: calendar.color,
onChanged: onChanged,
secondary: CircleAvatar(
backgroundColor: calendar.color.withValues(alpha: .14),
child: Icon(Icons.calendar_today, color: calendar.color, size: 19),
),
title: Text(
calendar.name,
style: const TextStyle(color: ink, fontWeight: FontWeight.w800),
),
subtitle: Text(calendar.description),
),
);
}
}
class DeviceCalendarToggleCard extends StatelessWidget {
const DeviceCalendarToggleCard({
super.key,
required this.calendar,
required this.enabled,
required this.people,
required this.selectedPersonId,
required this.onChanged,
required this.onPersonChanged,
});
final device_calendar.Calendar calendar;
final bool enabled;
final List<Person> people;
final String? selectedPersonId;
final ValueChanged<bool> onChanged;
final ValueChanged<String?> onPersonChanged;
@override
Widget build(BuildContext context) {
final calendarColor = Color(
calendar.color ?? const Color(0xFF4285F4).toARGB32(),
);
return Card(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
SwitchListTile(
value: enabled,
activeThumbColor: calendarColor,
onChanged: onChanged,
secondary: CircleAvatar(
backgroundColor: calendarColor.withValues(alpha: .14),
child: Icon(
Icons.calendar_today,
color: calendarColor,
size: 19,
),
),
title: Text(
calendar.name ?? 'N\u00e9vtelen napt\u00e1r',
style: const TextStyle(color: ink, fontWeight: FontWeight.w800),
),
subtitle: Text(
[
calendar.accountName,
calendar.accountType,
if (calendar.isReadOnly == true) 'csak olvashat\u00f3',
]
.whereType<String>()
.where((part) => part.isNotEmpty)
.join(' \u00b7 '),
),
),
if (enabled)
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
child: DropdownButtonFormField<String?>(
initialValue: selectedPersonId,
decoration: const InputDecoration(
labelText: 'Kihez tartozzanak az esem\u00e9nyek?',
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Kategoriz\u00e1latlan'),
),
const DropdownMenuItem<String?>(
value: everyonePersonId,
child: Text('Mindenki'),
),
...people.map(
(person) => DropdownMenuItem<String?>(
value: person.id,
child: Text('${person.avatar} ${person.name}'),
),
),
],
onChanged: onPersonChanged,
),
),
],
),
),
);
}
}
class InfoBox extends StatelessWidget {
const InfoBox({super.key});