Files
naptar-es-feladatkezelo/lib/app_shell.dart
T

1484 lines
49 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();
String googleServerClientId = googleCalendarServerClientId;
WeatherSnapshot? weather;
String themeId = 'sage';
String languageCode = defaultLanguageCode;
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;
googleServerClientId = settings.googleServerClientId;
themeId = settings.themeId;
languageCode = settings.languageCode;
loaded = true;
});
_loadRemoteAppConfig();
}
Future<void> _saveSettings() async {
await store.save(
AppSettings(
people: people,
themeId: themeId,
languageCode: languageCode,
googleConnections: googleConnections,
syncedEvents: syncedEvents,
todoSuggestions: todoSuggestions,
jarvisSettings: jarvisSettings,
googleServerClientId: googleServerClientId,
),
);
}
Future<void> _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<String, dynamic>) 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<Person> nextPeople) {
setState(() => people = nextPeople);
_saveSettings();
}
void _updateTheme(String nextThemeId) {
setState(() => themeId = nextThemeId);
_saveSettings();
}
void _updateLanguage(String nextLanguageCode) {
setState(() => languageCode = nextLanguageCode == 'en' ? 'en' : 'hu');
_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();
_loadRemoteAppConfig();
}
Future<void> _loadWeather() async {
final snapshot = await weatherService.loadCurrentWeather();
if (!mounted) return;
setState(() => weather = snapshot);
}
@override
Widget build(BuildContext context) {
final theme = activeTheme;
final text = AppText.of(languageCode);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: text.appTitle,
locale: text.locale,
supportedLocales: const [Locale('hu'), Locale('en')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
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,
languageCode: languageCode,
loaded: loaded,
onPeopleChanged: _updatePeople,
onThemeChanged: _updateTheme,
onLanguageChanged: _updateLanguage,
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.languageCode,
required this.loaded,
required this.onPeopleChanged,
required this.onThemeChanged,
required this.onLanguageChanged,
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 String googleServerClientId;
final WeatherSnapshot? weather;
final String themeId;
final String languageCode;
final bool loaded;
final ValueChanged<List<Person>> onPeopleChanged;
final ValueChanged<String> onThemeChanged;
final ValueChanged<String> onLanguageChanged;
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 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 text = AppText.of(widget.languageCode);
final labels = [
text.calendar,
text.shopping,
text.stock,
text.familyMembers,
text.style,
text.accounts,
];
final body = IndexedStack(
index: selected,
children: [
CalendarPage(
events: allEvents,
people: widget.people,
weather: widget.weather,
onAdd: _addEvent,
onEdit: _editEvent,
onAssignPerson: _assignSyncedEventToPerson,
),
ShoppingPage(
items: shopping,
languageCode: widget.languageCode,
onChanged: () => setState(() {}),
onAdd: _addShopping,
),
StockPage(
items: stock,
languageCode: widget.languageCode,
onChanged: () => setState(() {}),
onAdd: _addStock,
onEdit: _editStock,
jarvisSettings: widget.jarvisSettings,
),
PersonsPage(
people: widget.people,
events: allEvents,
languageCode: widget.languageCode,
onAdd: _addPerson,
onEdit: _editPerson,
onDelete: _deletePerson,
),
StylePage(
themeId: widget.themeId,
languageCode: widget.languageCode,
onThemeChanged: widget.onThemeChanged,
),
AccountsPage(
connections: widget.googleConnections,
cachedEvents: widget.syncedEvents,
people: widget.people,
jarvisSettings: widget.jarvisSettings,
serverClientId: widget.googleServerClientId,
languageCode: widget.languageCode,
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: 38,
height: 38,
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: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
text.appTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: ink,
letterSpacing: .2,
),
),
Text(
widget.loaded
? text.appSubtitle
: text.loadingJson,
style: const TextStyle(
fontSize: 11,
color: Colors.black45,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
CompactLanguageSwitcher(
languageCode: widget.languageCode,
onChanged: widget.onLanguageChanged,
labels: text,
),
const SizedBox(height: 2),
const Text(
appVersionLabel,
style: TextStyle(
fontSize: 10,
color: Colors.black38,
fontWeight: FontWeight.w800,
),
),
],
),
),
IconButton(
tooltip: text.jarvisSettings,
onPressed: _openJarvisSettings,
icon: const Icon(
Icons.settings_outlined,
size: 20,
),
style: IconButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: sage,
minimumSize: const Size(32, 32),
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) {
if (widget.googleConnections.isNotEmpty) {
final currentConnection = widget.googleConnections.first;
final nextEventPersonIds = {...currentConnection.eventPersonIds};
nextEventPersonIds[importedEventKey(event)] = person.id;
widget.onGoogleConnectionsChanged([
currentConnection.copyWith(eventPersonIds: nextEventPersonIds),
]);
}
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.
}
}
}
class LanguageSwitcher extends StatelessWidget {
const LanguageSwitcher({
super.key,
required this.languageCode,
required this.onChanged,
required this.labels,
});
final String languageCode;
final ValueChanged<String> onChanged;
final AppText labels;
@override
Widget build(BuildContext context) {
return Container(
height: 24,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: sage.withValues(alpha: .16)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_LanguagePill(
selected: languageCode != 'en',
flag: '🇭🇺',
label: labels.hunShort,
onTap: () => onChanged('hu'),
),
_LanguagePill(
selected: languageCode == 'en',
flag: '🇬🇧',
label: labels.engShort,
onTap: () => onChanged('en'),
),
],
),
);
}
}
class _LanguagePill extends StatelessWidget {
const _LanguagePill({
required this.selected,
required this.flag,
required this.label,
required this.onTap,
});
final bool selected;
final String flag;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: selected ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: selected ? sage : Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(flag, style: const TextStyle(fontSize: 11)),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: 9,
height: 1,
fontWeight: FontWeight.w900,
color: selected ? Colors.white : Colors.black45,
),
),
],
),
),
);
}
}
class CompactLanguageSwitcher extends StatelessWidget {
const CompactLanguageSwitcher({
super.key,
required this.languageCode,
required this.onChanged,
required this.labels,
});
final String languageCode;
final ValueChanged<String> onChanged;
final AppText labels;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Container(
height: 22,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: sage.withValues(alpha: .16)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_CompactLanguagePill(
selected: languageCode != 'en',
colors: const [
Color(0xFFCE2939),
Colors.white,
Color(0xFF477050),
],
label: labels.hunShort,
onTap: () => onChanged('hu'),
),
_CompactLanguagePill(
selected: languageCode == 'en',
colors: const [
Color(0xFF012169),
Colors.white,
Color(0xFFC8102E),
],
label: labels.engShort,
onTap: () => onChanged('en'),
),
],
),
),
),
);
}
}
class _CompactLanguagePill extends StatelessWidget {
const _CompactLanguagePill({
required this.selected,
required this.colors,
required this.label,
required this.onTap,
});
final bool selected;
final List<Color> colors;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: selected ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? sage : Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MiniFlag(colors: colors),
const SizedBox(width: 2),
Text(
label,
style: TextStyle(
fontSize: 8,
height: 1,
fontWeight: FontWeight.w900,
color: selected ? Colors.white : Colors.black45,
),
),
],
),
),
);
}
}
class _MiniFlag extends StatelessWidget {
const _MiniFlag({required this.colors});
final List<Color> colors;
@override
Widget build(BuildContext context) {
return Container(
width: 12,
height: 8,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: Colors.black.withValues(alpha: .10),
width: .5,
),
),
child: Column(
children: colors
.map((color) => Expanded(child: Container(color: color)))
.toList(),
),
);
}
}