part of 'main.dart'; class OtthonApp extends StatefulWidget { const OtthonApp({super.key}); @override State createState() => _OtthonAppState(); } class _OtthonAppState extends State { final store = AppJsonStore(); final weatherService = WeatherService(); List people = defaultPeople(); List googleConnections = defaultGoogleConnections(); List syncedEvents = []; List todoSuggestions = []; JarvisSettings jarvisSettings = JarvisSettings.defaults(); String googleServerClientId = googleCalendarServerClientId; WeatherSnapshot? weather; String themeId = 'sage'; bool loaded = false; AppThemeStyle get activeTheme => appThemes.firstWhere( (theme) => theme.id == themeId, orElse: () => appThemes.first, ); @override void initState() { super.initState(); _loadSettings(); _loadWeather(); } Future _loadSettings() async { final settings = await store.load(); if (!mounted) return; setState(() { people = settings.people; googleConnections = settings.googleConnections; syncedEvents = settings.syncedEvents; todoSuggestions = settings.todoSuggestions; jarvisSettings = settings.jarvisSettings; googleServerClientId = settings.googleServerClientId; themeId = settings.themeId; loaded = true; }); _loadRemoteAppConfig(); } Future _saveSettings() async { await store.save( AppSettings( people: people, themeId: themeId, googleConnections: googleConnections, syncedEvents: syncedEvents, todoSuggestions: todoSuggestions, jarvisSettings: jarvisSettings, googleServerClientId: googleServerClientId, ), ); } Future _loadRemoteAppConfig() async { final apiToken = jarvisSettings.apiToken.trim(); if (apiToken.isEmpty) return; try { final baseUri = Uri.parse(normalizeJarvisUrl(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; final payload = jsonDecode(response.body); if (payload is! Map) return; final remoteGoogleClientId = payload['googleCalendarServerClientId'] as String?; if (remoteGoogleClientId == null || remoteGoogleClientId.trim().isEmpty || remoteGoogleClientId == googleServerClientId) { return; } if (!mounted) return; setState(() => googleServerClientId = remoteGoogleClientId.trim()); _saveSettings(); } catch (_) { // Offline mode keeps the locally cached app configuration. } } void _updatePeople(List nextPeople) { setState(() => people = nextPeople); _saveSettings(); } void _updateTheme(String nextThemeId) { setState(() => themeId = nextThemeId); _saveSettings(); } void _updateGoogleConnections( List nextConnections, ) { setState(() => googleConnections = nextConnections); _saveSettings(); } void _updateSyncedEvents(List nextEvents) { setState(() => syncedEvents = nextEvents); _saveSettings(); } void _updateTodoSuggestions(List nextSuggestions) { setState(() => todoSuggestions = nextSuggestions); _saveSettings(); } void _updateJarvisSettings(JarvisSettings nextSettings) { setState(() => jarvisSettings = nextSettings); _saveSettings(); _loadRemoteAppConfig(); } Future _loadWeather() async { final snapshot = await weatherService.loadCurrentWeather(); if (!mounted) return; setState(() => weather = snapshot); } @override Widget build(BuildContext context) { final theme = activeTheme; return MaterialApp( debugShowCheckedModeBanner: false, title: 'K\u00f6z\u00f6sT\u00e9r', theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: theme.seed, surface: theme.surface, ), scaffoldBackgroundColor: theme.surface, fontFamily: 'Arial', cardTheme: const CardThemeData( elevation: 0, color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: const Color(0xFFF1F3EF), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), ), ), home: HomeShell( people: people, googleConnections: googleConnections, syncedEvents: syncedEvents, todoSuggestions: todoSuggestions, jarvisSettings: jarvisSettings, googleServerClientId: googleServerClientId, weather: weather, themeId: themeId, loaded: loaded, onPeopleChanged: _updatePeople, onThemeChanged: _updateTheme, onGoogleConnectionsChanged: _updateGoogleConnections, onSyncedEventsChanged: _updateSyncedEvents, onTodoSuggestionsChanged: _updateTodoSuggestions, onJarvisSettingsChanged: _updateJarvisSettings, ), ); } } class HomeShell extends StatefulWidget { const HomeShell({ super.key, required this.people, required this.googleConnections, required this.syncedEvents, required this.todoSuggestions, required this.jarvisSettings, required this.googleServerClientId, required this.weather, required this.themeId, required this.loaded, required this.onPeopleChanged, required this.onThemeChanged, required this.onGoogleConnectionsChanged, required this.onSyncedEventsChanged, required this.onTodoSuggestionsChanged, required this.onJarvisSettingsChanged, }); final List people; final List googleConnections; final List syncedEvents; final List todoSuggestions; final JarvisSettings jarvisSettings; final String googleServerClientId; final WeatherSnapshot? weather; final String themeId; final bool loaded; final ValueChanged> onPeopleChanged; final ValueChanged onThemeChanged; final ValueChanged> onGoogleConnectionsChanged; final ValueChanged> onSyncedEventsChanged; final ValueChanged> onTodoSuggestionsChanged; final ValueChanged onJarvisSettingsChanged; @override State createState() => _HomeShellState(); } class _HomeShellState extends State { int selected = 0; bool jarvisBackendOnline = false; bool workspaceSyncing = false; Timer? jarvisBackendTimer; @override void initState() { super.initState(); _checkJarvisBackend(); jarvisBackendTimer = Timer.periodic( const Duration(seconds: 15), (_) => _checkJarvisBackend(), ); } @override void dispose() { jarvisBackendTimer?.cancel(); super.dispose(); } final events = [ CalendarEvent( 'Csal\u00e1di reggeli', '07:30', '08:15', const Color(0xFF79A894), date: DateTime(2026, 7, 20), ), CalendarEvent( 'Csapatmegbesz\u00e9l\u00e9s', '10:00', '11:00', const Color(0xFF7189C8), date: DateTime(2026, 7, 20), place: 'Google Meet', ), CalendarEvent( 'Fogorvos', '16:30', '17:15', const Color(0xFFE89552), date: DateTime(2026, 7, 21), place: 'R\u00f3zsadomb', ), CalendarEvent( 'Farsang', '10:00', '12:00', const Color(0xFF7189C8), date: DateTime(2026, 7, 22), personId: 'balint', ), CalendarEvent( 'Vacsora Ann\u00e1\u00e9kkal', '19:00', '21:30', const Color(0xFF9B78B4), date: DateTime(2026, 7, 24), place: 'Kert Bisztr\u00f3', ), CalendarEvent( 'Piac', '09:00', '10:30', const Color(0xFF79A894), date: DateTime(2026, 7, 25), ), CalendarEvent( 'Ballag\u00e1s', '09:00', '11:00', const Color(0xFFE89552), date: DateTime(2026, 8, 1), personId: 'emilia', ), ]; final shopping = [ ShoppingItem('Zabtej', '2 doboz', 'Tejterm\u00e9k'), ShoppingItem('Paradicsom', '1 kg', 'Z\u00f6lds\u00e9g'), ShoppingItem('Kov\u00e1szos keny\u00e9r', '1 db', 'P\u00e9k\u00e1ru'), ShoppingItem( 'Mosogat\u00f3g\u00e9p tabletta', '1 csomag', 'H\u00e1ztart\u00e1s', ), ShoppingItem('Ban\u00e1n', '6 db', 'Gy\u00fcm\u00f6lcs', done: true), ]; final stock = [ StockItem('Toj\u00e1s', 8, 'db', 'H\u0171t\u0151', 'j\u00fal. 24.'), StockItem( 'G\u00f6r\u00f6g joghurt', 2, 'db', 'H\u0171t\u0151', 'j\u00fal. 22.', ), StockItem('Vaj', 1, 'csomag', 'H\u0171t\u0151', 'j\u00fal. 29.'), StockItem('Rizs', 2, 'kg', 'Kamra', '2027. m\u00e1rc.'), StockItem('T\u00e9szta', 3, 'csomag', 'Kamra', '2027. jan.'), StockItem('K\u00e1v\u00e9', 1, 'csomag', 'Kamra', '2026. nov.'), ]; List get allEvents => [...events, ...widget.syncedEvents]; final labels = const [ 'Napt\u00e1r', 'Bev\u00e1s\u00e1rl\u00e1s', 'K\u00e9szlet', 'Csal\u00e1dtagok', 'St\u00edlus', 'Fi\u00f3kok', ]; final icons = const [ Icons.calendar_month_rounded, Icons.shopping_basket_outlined, Icons.kitchen_outlined, Icons.group_outlined, Icons.palette_outlined, Icons.sync_rounded, ]; @override Widget build(BuildContext context) { final wide = MediaQuery.sizeOf(context).width >= 850; final body = IndexedStack( index: selected, children: [ CalendarPage( events: allEvents, people: widget.people, weather: widget.weather, onAdd: _addEvent, onEdit: _editEvent, onAssignPerson: _assignSyncedEventToPerson, ), ShoppingPage( items: shopping, onChanged: () => setState(() {}), onAdd: _addShopping, ), StockPage( items: stock, onChanged: () => setState(() {}), onAdd: _addStock, onEdit: _editStock, jarvisSettings: widget.jarvisSettings, ), PersonsPage( people: widget.people, events: allEvents, onAdd: _addPerson, onEdit: _editPerson, onDelete: _deletePerson, ), StylePage( themeId: widget.themeId, onThemeChanged: widget.onThemeChanged, ), AccountsPage( connections: widget.googleConnections, cachedEvents: widget.syncedEvents, people: widget.people, jarvisSettings: widget.jarvisSettings, serverClientId: widget.googleServerClientId, onChanged: widget.onGoogleConnectionsChanged, onSyncedEventsChanged: widget.onSyncedEventsChanged, ), ], ); return Scaffold( body: SafeArea( child: Row( children: [ if (wide) SizedBox( width: 220, child: Stack( children: [ NavigationRail( backgroundColor: Colors.white, extended: true, minExtendedWidth: 220, selectedIndex: selected, onDestinationSelected: (i) => setState(() => selected = i), leading: Padding( padding: const EdgeInsets.fromLTRB(12, 16, 12, 28), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFFF6F4EF), borderRadius: BorderRadius.circular(20), border: Border.all( color: sage.withValues(alpha: .10), ), boxShadow: [ BoxShadow( color: sage.withValues(alpha: .08), blurRadius: 18, offset: const Offset(0, 10), ), ], ), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: sage, borderRadius: BorderRadius.circular(15), ), clipBehavior: Clip.antiAlias, child: const Icon( Icons.dashboard_customize_outlined, color: Colors.white, size: 24, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const Text( 'K\u00f6z\u00f6sT\u00e9r', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w900, color: ink, letterSpacing: .2, ), ), Text( widget.loaded ? 'Tervez\u0151' : 'JSON bet\u00f6lt\u00e9se\u2026', style: const TextStyle( fontSize: 11, color: Colors.black45, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), const Text( appVersionLabel, style: TextStyle( fontSize: 10, color: Colors.black38, fontWeight: FontWeight.w800, ), ), ], ), ), IconButton( tooltip: 'JARVIS be\u00e1ll\u00edt\u00e1sok', onPressed: _openJarvisSettings, icon: const Icon( Icons.settings_outlined, size: 20, ), style: IconButton.styleFrom( backgroundColor: Colors.white, foregroundColor: sage, minimumSize: const Size(36, 36), tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: EdgeInsets.zero, ), ), ], ), ), ), destinations: List.generate( labels.length, (i) => NavigationRailDestination( icon: Icon(icons[i]), selectedIcon: Icon(icons[i]), label: Text(labels[i]), ), ), ), Positioned( left: 16, bottom: 12, right: 16, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.black.withValues(alpha: .92), borderRadius: BorderRadius.circular(18), border: Border.all( color: Colors.white.withValues(alpha: .08), ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: .08), blurRadius: 18, offset: const Offset(0, 10), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Container( width: 27, height: 27, decoration: BoxDecoration( color: Colors.white.withValues(alpha: .08), borderRadius: BorderRadius.circular(10), ), clipBehavior: Clip.antiAlias, child: Image.asset( 'assets/branding/jarvis-api-logo.png', fit: BoxFit.cover, ), ), const SizedBox(width: 8), const Expanded( child: Text( 'JARVIS Backend', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, ), ), ), ], ), const SizedBox(height: 8), Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 11, height: 11, decoration: BoxDecoration( color: jarvisBackendOnline ? Colors.greenAccent : Colors.redAccent, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: (jarvisBackendOnline ? Colors.greenAccent : Colors.redAccent) .withValues(alpha: .45), blurRadius: 8, ), ], ), ), const SizedBox(height: 4), Text( jarvisBackendOnline ? 'online' : 'offline', style: TextStyle( color: jarvisBackendOnline ? Colors.greenAccent : Colors.redAccent, fontSize: 10, fontWeight: FontWeight.w800, ), ), ], ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: OutlinedButton( onPressed: workspaceSyncing ? null : _downloadWorkspaceState, style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: BorderSide( color: Colors.white.withValues( alpha: .22, ), ), padding: const EdgeInsets.symmetric( vertical: 6, ), ), child: const Icon( Icons.cloud_download_outlined, size: 16, ), ), ), const SizedBox(width: 6), Expanded( child: OutlinedButton( onPressed: workspaceSyncing ? null : _uploadWorkspaceState, style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: BorderSide( color: Colors.white.withValues( alpha: .22, ), ), padding: const EdgeInsets.symmetric( vertical: 6, ), ), child: workspaceSyncing ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon( Icons.cloud_upload_outlined, size: 16, ), ), ), ], ), const SizedBox(height: 10), Center( child: Opacity( opacity: .78, child: Image.asset( 'assets/branding/abdai-design-logo.png', fit: BoxFit.contain, alignment: Alignment.center, height: 28, ), ), ), ], ), ), ), ], ), ), Expanded(child: body), ], ), ), bottomNavigationBar: wide ? null : NavigationBar( selectedIndex: selected, onDestinationSelected: (i) => setState(() => selected = i), destinations: List.generate( labels.length, (i) => NavigationDestination( icon: Icon(icons[i]), label: labels[i], ), ), ), ); } Future _checkJarvisBackend() async { final online = await _isJarvisBackendOnline(); if (!mounted || jarvisBackendOnline == online) return; setState(() => jarvisBackendOnline = online); } Future _isJarvisBackendOnline() async { try { final baseUri = Uri.parse( normalizeJarvisUrl(widget.jarvisSettings.apiUrl), ); final response = await http .get(baseUri.resolve('/health')) .timeout(const Duration(seconds: 3)); return response.statusCode >= 200 && response.statusCode < 300; } catch (_) { return false; } } Future _openJarvisSettings() async { final result = await showDialog( context: context, builder: (_) => JarvisSettingsDialog( settings: widget.jarvisSettings, onRegister: _registerJarvisDevice, onRefreshStatus: _refreshJarvisDeviceStatus, ), ); if (result == null) return; widget.onJarvisSettingsChanged(result); await Future.delayed(Duration.zero); _checkJarvisBackend(); } Future _downloadWorkspaceState() async { final workspaceToken = widget.jarvisSettings.workspaceToken?.trim(); if (workspaceToken == null || workspaceToken.isEmpty) { _showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.'); return; } setState(() => workspaceSyncing = true); try { final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); final response = await http .get( baseUri.resolve('/api/workspace/state'), headers: { 'X-Otthon-Token': widget.jarvisSettings.apiToken, 'X-Workspace-Token': workspaceToken, }, ) .timeout(const Duration(seconds: 12)); if (response.statusCode != 200) { throw Exception('workspace GET ${response.statusCode}'); } final payload = jsonDecode(response.body); if (payload is! Map) { throw Exception('Invalid workspace response.'); } final stateJson = payload['stateJson'] as String? ?? '{}'; final state = jsonDecode(stateJson); if (state is! Map) { throw Exception('Invalid workspace state.'); } final nextPeople = state['people'] is List ? (state['people'] as List) .whereType>() .map(Person.fromJson) .toList() : widget.people; final nextShopping = state['shopping'] is List ? (state['shopping'] as List) .whereType>() .map(ShoppingItem.fromJson) .toList() : shopping; final nextStock = state['stock'] is List ? (state['stock'] as List) .whereType>() .map(StockItem.fromJson) .toList() : stock; setState(() { shopping ..clear() ..addAll(nextShopping); stock ..clear() ..addAll(nextStock); }); widget.onPeopleChanged(nextPeople); _showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r let\u00f6ltve.'); } catch (error) { _showWorkspaceMessage('Workspace hiba: $error'); } finally { if (mounted) setState(() => workspaceSyncing = false); } } Future _uploadWorkspaceState() async { final workspaceToken = widget.jarvisSettings.workspaceToken?.trim(); if (workspaceToken == null || workspaceToken.isEmpty) { _showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.'); return; } setState(() => workspaceSyncing = true); try { final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); final stateJson = jsonEncode({ 'version': 1, 'updatedAt': DateTime.now().toIso8601String(), 'people': widget.people.map((person) => person.toJson()).toList(), 'shopping': shopping.map((item) => item.toJson()).toList(), 'stock': stock.map((item) => item.toJson()).toList(), }); final response = await http .put( baseUri.resolve('/api/workspace/state'), headers: { 'Content-Type': 'application/json', 'X-Otthon-Token': widget.jarvisSettings.apiToken, 'X-Workspace-Token': workspaceToken, }, body: jsonEncode({'stateJson': stateJson}), ) .timeout(const Duration(seconds: 12)); if (response.statusCode != 200) { throw Exception('workspace PUT ${response.statusCode}'); } _showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r felt\u00f6ltve.'); } catch (error) { _showWorkspaceMessage('Workspace hiba: $error'); } finally { if (mounted) setState(() => workspaceSyncing = false); } } void _showWorkspaceMessage(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } Future _registerJarvisDevice( JarvisSettings settings, String deviceName, ) async { final baseUri = Uri.parse(normalizeJarvisUrl(settings.apiUrl)); final trimmedDeviceName = deviceName.trim().isEmpty ? 'Family tablet' : deviceName.trim(); final response = await http .post( baseUri.resolve('/api/devices/register'), headers: { 'Content-Type': 'application/json', 'X-Otthon-Token': settings.apiToken, }, body: jsonEncode({ 'deviceName': trimmedDeviceName, 'platform': Platform.operatingSystem, }), ) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('JARVIS registration failed: ${response.statusCode}'); } final payload = jsonDecode(response.body); if (payload is! Map) { throw Exception('Invalid JARVIS response.'); } return settings.copyWith( deviceName: trimmedDeviceName, deviceId: payload['id'] as int?, deviceToken: payload['deviceToken'] as String?, registrationStatus: payload['status'] as String? ?? 'pending', ); } Future _refreshJarvisDeviceStatus( JarvisSettings settings, ) async { final deviceId = settings.deviceId; if (deviceId == null) { throw Exception('Nincs regisztr\u00e1lt eszk\u00f6z.'); } final baseUri = Uri.parse(normalizeJarvisUrl(settings.apiUrl)); final response = await http .get( baseUri.resolve('/api/devices/$deviceId/status'), headers: {'X-Otthon-Token': settings.apiToken}, ) .timeout(const Duration(seconds: 8)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('JARVIS status failed: ${response.statusCode}'); } final payload = jsonDecode(response.body); if (payload is! Map) { throw Exception('Invalid JARVIS response.'); } if (payload['found'] == false || payload['status'] == 'notRegistered' || payload['status'] == 'notFound') { return settings.copyWith(clearDevice: true); } return settings.copyWith( registrationStatus: payload['status'] as String? ?? 'pending', ); } Future _addEvent() async { final data = await showDialog( context: context, builder: (_) => EventAddDialog( people: widget.people, todoSuggestions: widget.todoSuggestions, ), ); if (data != null && data.title.trim().isNotEmpty) { final assignedPersonId = data.personId ?? everyonePersonId; setState( () => events.add( CalendarEvent( data.title.trim(), data.start.isEmpty ? '12:00' : data.start, '13:00', assignedPersonId == everyonePersonId ? sage : widget.people .firstWhere((p) => p.id == assignedPersonId) .color, personId: assignedPersonId, date: data.date, note: data.note, hasTodos: data.hasTodos, todos: data.todos, ), ), ); _rememberTodos(data.todos); } } Future _editEvent(CalendarEvent event) async { final data = await showDialog( context: context, builder: (_) => EventAddDialog( people: widget.people, event: event, todoSuggestions: widget.todoSuggestions, ), ); if (data == null || data.title.trim().isEmpty) return; final assignedPersonId = data.personId ?? everyonePersonId; final color = assignedPersonId == everyonePersonId ? sage : widget.people.firstWhere((p) => p.id == assignedPersonId).color; final updated = event.copyWith( title: data.title.trim(), start: data.start.isEmpty ? '12:00' : data.start, date: data.date, color: color, personId: assignedPersonId, note: data.note, hasTodos: data.hasTodos, todos: data.todos, ); setState(() { for (var i = 0; i < events.length; i++) { if (identical(events[i], event)) { events[i] = updated; return; } } }); if (event.isSynced) { final nextSyncedEvents = [ for (final syncedEvent in widget.syncedEvents) if (identical(syncedEvent, event)) updated else syncedEvent, ]; widget.onSyncedEventsChanged(nextSyncedEvents); } _rememberTodos(data.todos); } void _rememberTodos(List todos) { final nextSuggestions = [ ...widget.todoSuggestions, ...todos.where((todo) => todo.trim().isNotEmpty), ].map((todo) => todo.trim()).toSet().toList()..sort(); widget.onTodoSuggestionsChanged(nextSuggestions); } Future _addPerson() async { final draft = await showDialog( context: context, builder: (_) => PersonDialog( title: '\u00daj csal\u00e1dtag', initialColor: personColors[widget.people.length % personColors.length], initialAvatar: avatarPresets[widget.people.length % avatarPresets.length].symbol, ), ); if (draft == null) return; widget.onPeopleChanged([ ...widget.people, Person( id: uniqueId(), name: draft.name, colorValue: draft.color.toARGB32(), avatar: draft.avatar, nextEventLimit: draft.nextEventLimit, ), ]); } Future _editPerson(Person person) async { final draft = await showDialog( context: context, builder: (_) => PersonDialog( title: '${person.name} szerkeszt\u00e9se', initialName: person.name, initialColor: person.color, initialAvatar: person.avatar, initialNextEventLimit: person.nextEventLimit, ), ); if (draft == null) return; widget.onPeopleChanged( widget.people .map( (item) => item.id == person.id ? item.copyWith( name: draft.name, colorValue: draft.color.toARGB32(), avatar: draft.avatar, nextEventLimit: draft.nextEventLimit, ) : item, ) .toList(), ); } Future _deletePerson(Person person) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('${person.name} t\u00f6rl\u00e9se?'), content: const Text( 'A csal\u00e1dtag t\u00f6rl\u0151dik a JSON-b\u0151l. A hozz\u00e1 k\u00f6t\u00f6tt esem\u00e9nyek megmaradnak, de "Mindenki" esem\u00e9nny\u00e9 v\u00e1lnak.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('M\u00e9gse'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('T\u00f6rl\u00e9s'), ), ], ), ); if (confirmed != true) return; setState(() { for (var i = 0; i < events.length; i++) { if (events[i].personId == person.id) { events[i] = events[i].copyWith(clearPerson: true); } } }); widget.onPeopleChanged( widget.people.where((item) => item.id != person.id).toList(), ); } void _assignSyncedEventToPerson(CalendarEvent event, Person person) { final nextSyncedEvents = widget.syncedEvents .map( (item) => sameImportedEvent(item, event) ? item.copyWith(personId: person.id) : item, ) .toList(); widget.onSyncedEventsChanged(nextSyncedEvents); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${event.title} hozz\u00e1rendelve: ${person.name}'), ), ); } Future _addShopping() async { final data = await showDialog>( context: context, builder: (_) => const QuickAddDialog( title: 'T\u00e9tel hozz\u00e1ad\u00e1sa', firstLabel: 'Mit vegy\u00fcnk?', secondLabel: 'Mennyis\u00e9g', ), ); if (data != null && data[0].trim().isNotEmpty) { setState( () => shopping.add( ShoppingItem( data[0], data[1].isEmpty ? '1 db' : data[1], 'Egy\u00e9b', ), ), ); } } Future _addStock() async { final data = await showDialog( context: context, builder: (_) => StockAddDialog(jarvisSettings: widget.jarvisSettings), ); if (data != null && data.name.trim().isNotEmpty) { setState( () => stock.add( StockItem( data.name, data.amount, data.unit, data.location, data.expiry, ), ), ); unawaited(_sendFoodLearningEvent(data, action: 'stock-added')); } } Future _editStock(StockItem item) async { final data = await showDialog( context: context, builder: (_) => StockAddDialog( jarvisSettings: widget.jarvisSettings, initialItem: item, ), ); if (data == null || data.name.trim().isEmpty) return; setState(() { item.name = data.name.trim(); item.amount = data.amount; item.unit = data.unit.trim().isEmpty ? 'db' : data.unit.trim(); item.location = data.location; item.expiry = data.expiry.trim().isEmpty ? 'nincs megadva' : data.expiry.trim(); }); unawaited(_sendFoodLearningEvent(data, action: 'stock-updated')); } Future _sendFoodLearningEvent( StockInput input, { required String action, }) async { final apiUrl = widget.jarvisSettings.apiUrl.trim(); final apiToken = widget.jarvisSettings.apiToken.trim(); if (apiUrl.isEmpty || apiToken.isEmpty) return; try { final baseUri = Uri.parse(normalizeJarvisUrl(apiUrl)); await http .post( baseUri.resolve('/api/food-events'), headers: { 'Content-Type': 'application/json', 'X-Otthon-Token': apiToken, }, body: jsonEncode({ 'productName': input.name.trim(), 'barcode': input.barcode.trim().isEmpty ? null : input.barcode.trim(), 'action': action, 'amount': input.amount, 'unit': input.unit, 'location': input.location, 'localTime': DateTime.now().toIso8601String(), }), ) .timeout(const Duration(seconds: 8)); } catch (_) { // A tanul\u00e1s kieg\u00e9sz\u00edt\u0151 funkci\u00f3: offline/hiba eset\u00e9n a k\u00e9szlet ment\u00e9se nem akad meg. } } }