1181 lines
40 KiB
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.
|
|
}
|
|
}
|
|
}
|