naptar-es-feladatkezelo/lib/app_shell.dart

1181 lines
40 KiB
Dart

part of 'main.dart';
class OtthonApp extends StatefulWidget {
const OtthonApp({super.key});
@override
State<OtthonApp> createState() => _OtthonAppState();
}
class _OtthonAppState extends State<OtthonApp> {
final store = AppJsonStore();
final weatherService = WeatherService();
List<Person> people = defaultPeople();
List<GoogleCalendarConnection> googleConnections = defaultGoogleConnections();
List<CalendarEvent> syncedEvents = [];
List<String> todoSuggestions = [];
JarvisSettings jarvisSettings = JarvisSettings.defaults();
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<void> _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;
themeId = settings.themeId;
loaded = true;
});
}
Future<void> _saveSettings() async {
await store.save(
AppSettings(
people: people,
themeId: themeId,
googleConnections: googleConnections,
syncedEvents: syncedEvents,
todoSuggestions: todoSuggestions,
jarvisSettings: jarvisSettings,
),
);
}
void _updatePeople(List<Person> nextPeople) {
setState(() => people = nextPeople);
_saveSettings();
}
void _updateTheme(String nextThemeId) {
setState(() => themeId = nextThemeId);
_saveSettings();
}
void _updateGoogleConnections(
List<GoogleCalendarConnection> nextConnections,
) {
setState(() => googleConnections = nextConnections);
_saveSettings();
}
void _updateSyncedEvents(List<CalendarEvent> nextEvents) {
setState(() => syncedEvents = nextEvents);
_saveSettings();
}
void _updateTodoSuggestions(List<String> nextSuggestions) {
setState(() => todoSuggestions = nextSuggestions);
_saveSettings();
}
void _updateJarvisSettings(JarvisSettings nextSettings) {
setState(() => jarvisSettings = nextSettings);
_saveSettings();
}
Future<void> _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,
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.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<Person> people;
final List<GoogleCalendarConnection> googleConnections;
final List<CalendarEvent> syncedEvents;
final List<String> todoSuggestions;
final JarvisSettings jarvisSettings;
final WeatherSnapshot? weather;
final String themeId;
final bool loaded;
final ValueChanged<List<Person>> onPeopleChanged;
final ValueChanged<String> onThemeChanged;
final ValueChanged<List<GoogleCalendarConnection>> onGoogleConnectionsChanged;
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
final ValueChanged<List<String>> onTodoSuggestionsChanged;
final ValueChanged<JarvisSettings> onJarvisSettingsChanged;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
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>[
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>[
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>[
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<CalendarEvent> 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,
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<void> _checkJarvisBackend() async {
final online = await _isJarvisBackendOnline();
if (!mounted || jarvisBackendOnline == online) return;
setState(() => jarvisBackendOnline = online);
}
Future<bool> _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<void> _openJarvisSettings() async {
final result = await showDialog<JarvisSettings>(
context: context,
builder: (_) => JarvisSettingsDialog(
settings: widget.jarvisSettings,
onRegister: _registerJarvisDevice,
onRefreshStatus: _refreshJarvisDeviceStatus,
),
);
if (result == null) return;
widget.onJarvisSettingsChanged(result);
await Future<void>.delayed(Duration.zero);
_checkJarvisBackend();
}
Future<void> _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<String, dynamic>) {
throw Exception('Invalid workspace response.');
}
final stateJson = payload['stateJson'] as String? ?? '{}';
final state = jsonDecode(stateJson);
if (state is! Map<String, dynamic>) {
throw Exception('Invalid workspace state.');
}
final nextPeople = state['people'] is List
? (state['people'] as List)
.whereType<Map<String, dynamic>>()
.map(Person.fromJson)
.toList()
: widget.people;
final nextShopping = state['shopping'] is List
? (state['shopping'] as List)
.whereType<Map<String, dynamic>>()
.map(ShoppingItem.fromJson)
.toList()
: shopping;
final nextStock = state['stock'] is List
? (state['stock'] as List)
.whereType<Map<String, dynamic>>()
.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<void> _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<JarvisSettings> _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<String, dynamic>) {
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<JarvisSettings> _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<String, dynamic>) {
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<void> _addEvent() async {
final data = await showDialog<EventInput>(
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<void> _editEvent(CalendarEvent event) async {
final data = await showDialog<EventInput>(
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<String> todos) {
final nextSuggestions = [
...widget.todoSuggestions,
...todos.where((todo) => todo.trim().isNotEmpty),
].map((todo) => todo.trim()).toSet().toList()..sort();
widget.onTodoSuggestionsChanged(nextSuggestions);
}
Future<void> _addPerson() async {
final draft = await showDialog<PersonDraft>(
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<void> _editPerson(Person person) async {
final draft = await showDialog<PersonDraft>(
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<void> _deletePerson(Person person) async {
final confirmed = await showDialog<bool>(
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<void> _addShopping() async {
final data = await showDialog<List<String>>(
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<void> _addStock() async {
final data = await showDialog<StockInput>(
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<void> _editStock(StockItem item) async {
final data = await showDialog<StockInput>(
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<void> _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.
}
}
}