part of 'main.dart'; class AccountsPage extends StatefulWidget { const AccountsPage({ super.key, required this.connections, required this.cachedEvents, required this.people, required this.jarvisSettings, required this.serverClientId, required this.onChanged, required this.onSyncedEventsChanged, }); final List connections; final List cachedEvents; final List people; final JarvisSettings jarvisSettings; final String serverClientId; final ValueChanged> onChanged; final ValueChanged> onSyncedEventsChanged; @override State createState() => _AccountsPageState(); } class _AccountsPageState extends State { static const calendarScopes = [ 'https://www.googleapis.com/auth/calendar.readonly', ]; final googleSignIn = GoogleSignIn.instance; List googleCalendars = []; GoogleSignInAccount? signedInAccount; String? runtimeServerClientId; bool initialized = false; bool loadingCalendars = false; bool syncing = false; String? statusMessage; @override void initState() { super.initState(); _loadGoogleAppConfigAndInitialize(); } GoogleCalendarConnection? get connection => widget.connections.isEmpty ? null : widget.connections.first; Future _loadGoogleAppConfigAndInitialize() async { setState(() { loadingCalendars = true; statusMessage = 'Google napt\u00e1rkapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; }); runtimeServerClientId = widget.serverClientId.trim().isNotEmpty ? widget.serverClientId.trim() : await _loadGoogleServerClientId(); if (!mounted) return; await _initializeGoogle(); } Future _loadGoogleServerClientId() async { if (googleCalendarServerClientId.trim().isNotEmpty) { return googleCalendarServerClientId.trim(); } final apiToken = widget.jarvisSettings.apiToken.trim(); if (apiToken.isEmpty) { return null; } try { final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); final uri = baseUri.replace(path: '/api/app-config', query: ''); final response = await http .get(uri, headers: {'X-Otthon-Token': apiToken}) .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { return null; } final payload = jsonDecode(response.body); if (payload is! Map) { return null; } return payload['googleCalendarServerClientId'] as String?; } catch (_) { return null; } } Future _initializeGoogle() async { setState(() { loadingCalendars = true; statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; }); try { await _ensureGoogleInitialized(); initialized = true; final lightweight = googleSignIn.attemptLightweightAuthentication(); final account = lightweight == null ? null : await lightweight; if (!mounted) return; if (account != null) { signedInAccount = account; await _loadGoogleCalendars(promptIfNecessary: false); } else { setState(() { loadingCalendars = false; statusMessage = 'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rak szinkroniz\u00e1ljanak.'; }); } } catch (error) { if (!mounted) return; setState(() { loadingCalendars = false; statusMessage = _friendlyGoogleError( 'Google inicializ\u00e1l\u00e1s nem siker\u00fclt', error, ); }); } } Future> _googleHeaders({ bool promptIfNecessary = true, }) async { final account = signedInAccount; final headers = account == null ? await googleSignIn.authorizationClient.authorizationHeaders( calendarScopes, promptIfNecessary: promptIfNecessary, ) : await account.authorizationClient.authorizationHeaders( calendarScopes, promptIfNecessary: promptIfNecessary, ); if (headers == null) { throw Exception('A Google Calendar jogosults\u00e1g nincs megadva.'); } return headers; } Future _signInGoogle() async { setState(() { loadingCalendars = true; statusMessage = 'Google bejelentkez\u00e9s...'; }); try { if (!initialized) { runtimeServerClientId = widget.serverClientId.trim().isNotEmpty ? widget.serverClientId.trim() : await _loadGoogleServerClientId(); await _ensureGoogleInitialized(); initialized = true; } final account = await googleSignIn.authenticate(); signedInAccount = account; await account.authorizationClient.authorizeScopes(calendarScopes); await _loadGoogleCalendars(promptIfNecessary: true); } catch (error) { if (!mounted) return; final friendlyMessage = _friendlyGoogleError( 'Google bejelentkez\u00e9s nem siker\u00fclt', error, ); setState(() { loadingCalendars = false; statusMessage = friendlyMessage; }); await _showGoogleErrorDialog(friendlyMessage); } } String _friendlyGoogleError(String prefix, Object error) { final raw = error.toString(); if (raw.contains('serverClientId must be provided') || raw.contains('CredentialManager requires a serverClientId')) { return '$prefix: hi\u00e1nyzik az app Google konfigur\u00e1ci\u00f3ja. ' 'A bel\u00e9p\u00e9si ablakos m\u0171k\u00f6d\u00e9shez tedd be az android/app/google-services.json f\u00e1jlt, ' 'amely tartalmaz Web application OAuth client ID-t, majd buildeld \u00fajra az APK-t.'; } if (raw.contains('Account reauth failed')) { return '$prefix: a Google fi\u00f3k \u00fajrahiteles\u00edt\u00e9se nem siker\u00fclt. ' 'Pr\u00f3b\u00e1ld meg a tableten a Google fi\u00f3kot friss\u00edteni/bejelentkeztetni a Be\u00e1ll\u00edt\u00e1sokban, ' 'vagy t\u00f6r\u00f6ld az app Google hozz\u00e1f\u00e9r\u00e9s\u00e9t, majd jelentkezz be \u00fajra.'; } return '$prefix: $raw'; } Future _showGoogleErrorDialog(String message) async { if (!mounted) return; await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Google bel\u00e9p\u00e9s nem indult el'), content: Text( '$message\n\n' 'A v\u00e1rt m\u0171k\u00f6d\u00e9s az, hogy a Google saj\u00e1t bel\u00e9p\u00e9si/enged\u00e9lyez\u00e9si ablaka ny\u00edlik meg. ' 'Ehhez az appnak el\u0151re regisztr\u00e1lt Google konfigur\u00e1ci\u00f3 kell, \u00e9s az eszk\u00f6z\u00f6n Google Play Services / Google fi\u00f3k is sz\u00fcks\u00e9ges.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Rendben'), ), ], ), ); } Future _ensureGoogleInitialized() async { final clientId = runtimeServerClientId; if (clientId == null || clientId.isEmpty) { await googleSignIn.initialize(); return; } await googleSignIn.initialize( serverClientId: clientId, ); } Future _loadGoogleCalendars({bool promptIfNecessary = true}) async { setState(() { loadingCalendars = true; statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...'; }); try { final headers = await _googleHeaders( promptIfNecessary: promptIfNecessary, ); final uri = Uri.https( 'www.googleapis.com', '/calendar/v3/users/me/calendarList', {'minAccessRole': 'reader', 'showHidden': 'true'}, ); final response = await http .get(uri, headers: headers) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw Exception( 'CalendarList API ${response.statusCode}: ${response.body}', ); } final payload = jsonDecode(response.body); if (payload is! Map) { throw Exception('Hib\u00e1s Google Calendar v\u00e1lasz.'); } final items = payload['items']; final calendars = items is List ? items .whereType>() .map(GoogleCalendarInfo.fromJson) .toList() : []; calendars.sort((a, b) { if (a.primary != b.primary) return a.primary ? -1 : 1; return a.name.compareTo(b.name); }); final account = signedInAccount; final current = connection; if (account != null) { widget.onChanged([ GoogleCalendarConnection( id: current?.id ?? uniqueId(), accountEmail: account.email, enabledCalendarIds: current?.enabledCalendarIds ?? const [], calendarPersonIds: current?.calendarPersonIds ?? const {}, lastSync: current?.lastSync, ), ]); } if (!mounted) return; setState(() { googleCalendars = calendars; loadingCalendars = false; statusMessage = calendars.isEmpty ? 'A Google fi\u00f3kban nem tal\u00e1ltam olvashat\u00f3 napt\u00e1rat.' : '${calendars.length} Google napt\u00e1r el\u00e9rhet\u0151. V\u00e1laszd ki, melyik ker\u00fclj\u00f6n be a csal\u00e1di napt\u00e1rba.'; }); } catch (error) { if (!mounted) return; setState(() { loadingCalendars = false; statusMessage = 'Google napt\u00e1rlista lek\u00e9r\u00e9se nem siker\u00fclt: $error'; }); } } Future _syncSelectedCalendars() async { final current = connection; if (current == null || current.enabledCalendarIds.isEmpty) { setState( () => statusMessage = 'V\u00e1lassz ki legal\u00e1bb egy Google napt\u00e1rat.', ); return; } setState(() { syncing = true; statusMessage = 'Google esem\u00e9nyek beolvas\u00e1sa...'; }); try { if (googleCalendars.isEmpty) { await _loadGoogleCalendars(promptIfNecessary: true); } final headers = await _googleHeaders(promptIfNecessary: true); final selectedCalendars = googleCalendars .where((calendar) => current.enabledCalendarIds.contains(calendar.id)) .toList(); final synced = []; final syncDetails = []; 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 uri = Uri.https( 'www.googleapis.com', '/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events', { 'singleEvents': 'true', 'orderBy': 'startTime', 'timeMin': start.toUtc().toIso8601String(), 'timeMax': end.toUtc().toIso8601String(), 'maxResults': '2500', }, ); final response = await http .get(uri, headers: headers) .timeout(const Duration(seconds: 20)); if (response.statusCode != 200) { throw Exception('${calendar.name}: Events API ${response.statusCode}'); } final payload = jsonDecode(response.body); if (payload is! Map) continue; final rawEvents = payload['items']; final events = rawEvents is List ? rawEvents.whereType>().toList() : >[]; syncDetails.add('${calendar.name}: ${events.length}'); for (final event in events) { if (event['status'] == 'cancelled') continue; synced.add( CalendarEvent.fromGoogleEvent( 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} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.' '${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}'; }); } catch (error) { setState(() { syncing = false; statusMessage = 'A Google szinkron nem siker\u00fclt: $error'; }); } } @override Widget build(BuildContext context) { final current = connection; final hasConnection = current != null && current.accountEmail.isNotEmpty; final cachedUncategorized = widget.cachedEvents .where((event) => event.isUncategorized) .length; return PageFrame( eyebrow: 'Google Calendar API szinkron', title: 'Google napt\u00e1rak', action: FilledButton.icon( onPressed: loadingCalendars ? null : _signInGoogle, icon: const Icon(Icons.login), label: Text( hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s', ), ), 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( 'Google Calendar API-b\u00f3l el\u00e9rhet\u0151 napt\u00e1rak', style: TextStyle( color: ink, fontWeight: FontWeight.w800, fontSize: 18, ), ), const SizedBox(height: 10), if (loadingCalendars) const LinearProgressIndicator() else ...googleCalendars.map( (calendar) => GoogleCalendarToggleCard( 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) { 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...' : 'Szinkroniz\u00e1l\u00e1s most', ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: loadingCalendars ? null : () => _loadGoogleCalendars(promptIfNecessary: true), icon: const Icon(Icons.refresh), label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () async { await googleSignIn.disconnect(); widget.onChanged([]); widget.onSyncedEventsChanged([]); setState(() { signedInAccount = null; googleCalendars = []; statusMessage = 'Google kapcsolat lev\u00e1lasztva.'; }); }, icon: const Icon(Icons.link_off), label: const Text('Google kapcsolat 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: a Google Calendar API-b\u00f3l olvas, az esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza Google napt\u00e1rba. Offline \u00e1llapotban a kor\u00e1bban beolvasott cache marad l\u00e1that\u00f3.', style: TextStyle(color: ink, height: 1.45), ), ), ], ), ), ], ), ); } } 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 Google 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' : 'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.', style: const TextStyle(color: Colors.white70, height: 1.35), ), ], ), ), ], ), ); } } 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( 'Google bel\u00e9p\u00e9s ut\u00e1n az app k\u00f6zvetlen\u00fcl a Google Calendar API-b\u00f3l k\u00e9ri le a napt\u00e1rakat. Android rendszer-napt\u00e1r szinkron m\u00e1r nem kell hozz\u00e1.', style: TextStyle(color: ink, height: 1.4), ), ), ], ), ), ); } } class GoogleCalendarToggleCard extends StatelessWidget { const GoogleCalendarToggleCard({ super.key, required this.calendar, required this.enabled, required this.people, required this.selectedPersonId, required this.onChanged, required this.onPersonChanged, }); final GoogleCalendarInfo calendar; final bool enabled; final List people; final String? selectedPersonId; final ValueChanged onChanged; final ValueChanged onPersonChanged; @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.only(bottom: 10), child: Column( children: [ SwitchListTile( value: enabled, activeThumbColor: calendar.color, onChanged: onChanged, secondary: CircleAvatar( backgroundColor: calendar.color.withValues(alpha: .14), child: Icon( calendar.primary ? Icons.star_rounded : Icons.calendar_today, color: calendar.color, size: 19, ), ), title: Text( calendar.name, style: const TextStyle(color: ink, fontWeight: FontWeight.w800), ), subtitle: Text( calendar.description.isEmpty ? calendar.id : '${calendar.description} \u00b7 ${calendar.id}', ), ), if (enabled) Padding( padding: const EdgeInsets.fromLTRB(72, 0, 16, 0), child: DropdownButtonFormField( initialValue: selectedPersonId, decoration: const InputDecoration( labelText: 'Kihez tartozzanak az esem\u00e9nyek?', ), items: [ const DropdownMenuItem( value: null, child: Text('Kategoriz\u00e1latlan'), ), const DropdownMenuItem( value: everyonePersonId, child: Text('Mindenki'), ), ...people.map( (person) => DropdownMenuItem( value: person.id, child: Text('${person.avatar} ${person.name}'), ), ), ], onChanged: onPersonChanged, ), ), ], ), ), ); } }