1284 lines
41 KiB
Dart
1284 lines
41 KiB
Dart
part of 'main.dart';
|
|
|
|
class PersonDraft {
|
|
PersonDraft({
|
|
required this.name,
|
|
required this.color,
|
|
required this.avatar,
|
|
required this.nextEventLimit,
|
|
});
|
|
|
|
final String name;
|
|
final Color color;
|
|
final String avatar;
|
|
final int nextEventLimit;
|
|
}
|
|
|
|
class PersonDialog extends StatefulWidget {
|
|
const PersonDialog({
|
|
super.key,
|
|
required this.title,
|
|
this.initialName = '',
|
|
required this.initialColor,
|
|
required this.initialAvatar,
|
|
this.initialNextEventLimit = 1,
|
|
});
|
|
|
|
final String title;
|
|
final String initialName;
|
|
final Color initialColor;
|
|
final String initialAvatar;
|
|
final int initialNextEventLimit;
|
|
|
|
@override
|
|
State<PersonDialog> createState() => _PersonDialogState();
|
|
}
|
|
|
|
class _PersonDialogState extends State<PersonDialog> {
|
|
late final TextEditingController name;
|
|
late Color selectedColor;
|
|
late String selectedAvatar;
|
|
late int selectedNextEventLimit;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
name = TextEditingController(text: widget.initialName);
|
|
selectedColor = widget.initialColor;
|
|
selectedAvatar = widget.initialAvatar;
|
|
selectedNextEventLimit = widget.initialNextEventLimit;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
name.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: SizedBox(
|
|
width: 440,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: name,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(labelText: 'N\u00e9v'),
|
|
),
|
|
const SizedBox(height: 18),
|
|
const Text(
|
|
'Sz\u00edn',
|
|
style: TextStyle(color: ink, fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: personColors.map((color) {
|
|
final selected = color.toARGB32() == selectedColor.toARGB32();
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: () => setState(() => selectedColor = color),
|
|
child: CircleAvatar(
|
|
backgroundColor: color,
|
|
child: selected
|
|
? const Icon(Icons.check, color: Colors.white)
|
|
: null,
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 18),
|
|
const Text(
|
|
'Avatar / ovis jel',
|
|
style: TextStyle(color: ink, fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: avatarPresets.map((preset) {
|
|
final selected = preset.symbol == selectedAvatar;
|
|
return ChoiceChip(
|
|
selected: selected,
|
|
label: Text('${preset.symbol} ${preset.label}'),
|
|
onSelected: (_) =>
|
|
setState(() => selectedAvatar = preset.symbol),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 18),
|
|
const Text(
|
|
'K\u00f6vetkez\u0151 esem\u00e9nyek sz\u00e1ma a csal\u00e1di widgeten',
|
|
style: TextStyle(color: ink, fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SegmentedButton<int>(
|
|
segments: const [
|
|
ButtonSegment(value: 1, label: Text('1')),
|
|
ButtonSegment(value: 2, label: Text('2')),
|
|
ButtonSegment(value: 3, label: Text('3')),
|
|
ButtonSegment(value: 5, label: Text('5')),
|
|
],
|
|
selected: {selectedNextEventLimit},
|
|
onSelectionChanged: (values) =>
|
|
setState(() => selectedNextEventLimit = values.first),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final trimmed = name.text.trim();
|
|
if (trimmed.isEmpty) return;
|
|
Navigator.pop(
|
|
context,
|
|
PersonDraft(
|
|
name: trimmed,
|
|
color: selectedColor,
|
|
avatar: selectedAvatar,
|
|
nextEventLimit: selectedNextEventLimit,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Ment\u00e9s'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class EventInput {
|
|
EventInput({
|
|
required this.title,
|
|
required this.start,
|
|
required this.date,
|
|
required this.personId,
|
|
required this.note,
|
|
required this.hasTodos,
|
|
required this.todos,
|
|
});
|
|
|
|
final String title;
|
|
final String start;
|
|
final DateTime date;
|
|
final String? personId;
|
|
final String note;
|
|
final bool hasTodos;
|
|
final List<String> todos;
|
|
}
|
|
|
|
class EventAddDialog extends StatefulWidget {
|
|
const EventAddDialog({
|
|
super.key,
|
|
required this.people,
|
|
this.event,
|
|
this.todoSuggestions = const [],
|
|
});
|
|
|
|
final List<Person> people;
|
|
final CalendarEvent? event;
|
|
final List<String> todoSuggestions;
|
|
|
|
@override
|
|
State<EventAddDialog> createState() => _EventAddDialogState();
|
|
}
|
|
|
|
class _EventAddDialogState extends State<EventAddDialog> {
|
|
late final TextEditingController title;
|
|
late final TextEditingController start;
|
|
late final TextEditingController date;
|
|
late final TextEditingController note;
|
|
final todo = TextEditingController();
|
|
late bool hasTodos;
|
|
late List<String> todos;
|
|
String? personId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final event = widget.event;
|
|
title = TextEditingController(text: event?.title ?? '');
|
|
start = TextEditingController(text: event?.start ?? '');
|
|
date = TextEditingController(
|
|
text: event == null ? formatDate(DateTime.now()) : formatDate(event.date),
|
|
);
|
|
note = TextEditingController(text: event?.note ?? '');
|
|
hasTodos = event?.hasTodos ?? false;
|
|
todos = [...event?.todos ?? const <String>[]];
|
|
personId = event?.personId == everyonePersonId ? null : event?.personId;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
title.dispose();
|
|
start.dispose();
|
|
date.dispose();
|
|
note.dispose();
|
|
todo.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _addTodo(String value) {
|
|
final cleaned = value.trim();
|
|
if (cleaned.isEmpty || todos.contains(cleaned)) return;
|
|
setState(() {
|
|
todos.add(cleaned);
|
|
todo.clear();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(
|
|
widget.event == null
|
|
? '\u00daj esem\u00e9ny'
|
|
: 'Esem\u00e9ny szerkeszt\u00e9se',
|
|
),
|
|
content: SizedBox(
|
|
width: 460,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: title,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Mi legyen az esem\u00e9ny?',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: start,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Id\u0151pont (pl. 14:30)',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: date,
|
|
decoration: const InputDecoration(
|
|
labelText: 'D\u00e1tum (pl. 2026.08.01)',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String?>(
|
|
initialValue: personId,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Kire vonatkozik?',
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text('Mindenki'),
|
|
),
|
|
...widget.people.map(
|
|
(person) => DropdownMenuItem<String?>(
|
|
value: person.id,
|
|
child: Text('${person.avatar} ${person.name}'),
|
|
),
|
|
),
|
|
],
|
|
onChanged: (value) => setState(() => personId = value),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: note,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(labelText: 'Megjegyz\u00e9s'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
value: hasTodos,
|
|
onChanged: (value) => setState(() => hasTodos = value ?? false),
|
|
title: const Text('Teend\u0151 van'),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
),
|
|
if (hasTodos) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: RawAutocomplete<String>(
|
|
textEditingController: todo,
|
|
focusNode: FocusNode(),
|
|
optionsBuilder: (value) {
|
|
final query = value.text.trim().toLowerCase();
|
|
if (query.isEmpty) return widget.todoSuggestions;
|
|
return widget.todoSuggestions.where(
|
|
(item) => item.toLowerCase().contains(query),
|
|
);
|
|
},
|
|
fieldViewBuilder:
|
|
(context, controller, focusNode, onFieldSubmitted) {
|
|
return TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Teend\u0151 hozz\u00e1ad\u00e1sa',
|
|
),
|
|
onSubmitted: _addTodo,
|
|
);
|
|
},
|
|
optionsViewBuilder: (context, onSelected, options) {
|
|
return Material(
|
|
elevation: 4,
|
|
child: ListView(
|
|
padding: EdgeInsets.zero,
|
|
shrinkWrap: true,
|
|
children: options
|
|
.map(
|
|
(option) => ListTile(
|
|
title: Text(option),
|
|
onTap: () => onSelected(option),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
);
|
|
},
|
|
onSelected: _addTodo,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filledTonal(
|
|
onPressed: () => _addTodo(todo.text),
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
...todos.map(
|
|
(item) => ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(
|
|
Icons.check_circle_outline,
|
|
color: sage,
|
|
),
|
|
title: Text(item),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => setState(() => todos.remove(item)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final eventDate = parseDate(date.text) ?? DateTime.now();
|
|
Navigator.pop(
|
|
context,
|
|
EventInput(
|
|
title: title.text,
|
|
start: start.text,
|
|
date: eventDate,
|
|
personId: personId,
|
|
note: note.text,
|
|
hasTodos: hasTodos,
|
|
todos: hasTodos ? todos : const [],
|
|
),
|
|
);
|
|
},
|
|
child: Text(
|
|
widget.event == null ? 'Hozz\u00e1ad\u00e1s' : 'Ment\u00e9s',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class EmptyState extends StatelessWidget {
|
|
const EmptyState({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 48, color: sage),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(subtitle, style: const TextStyle(color: Colors.black45)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class QuickAddDialog extends StatefulWidget {
|
|
const QuickAddDialog({
|
|
super.key,
|
|
required this.title,
|
|
required this.firstLabel,
|
|
required this.secondLabel,
|
|
});
|
|
|
|
final String title;
|
|
final String firstLabel;
|
|
final String secondLabel;
|
|
|
|
@override
|
|
State<QuickAddDialog> createState() => _QuickAddDialogState();
|
|
}
|
|
|
|
class _QuickAddDialogState extends State<QuickAddDialog> {
|
|
final first = TextEditingController();
|
|
final second = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
first.dispose();
|
|
second.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: SizedBox(
|
|
width: 380,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: first,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: widget.firstLabel),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: second,
|
|
decoration: InputDecoration(labelText: widget.secondLabel),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, [first.text, second.text]),
|
|
child: const Text('Hozz\u00e1ad\u00e1s'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class StockInput {
|
|
StockInput({
|
|
required this.name,
|
|
required this.barcode,
|
|
required this.amount,
|
|
required this.unit,
|
|
required this.location,
|
|
required this.expiry,
|
|
});
|
|
|
|
final String name;
|
|
final String barcode;
|
|
final int amount;
|
|
final String unit;
|
|
final String location;
|
|
final String expiry;
|
|
}
|
|
|
|
class StockAddDialog extends StatefulWidget {
|
|
const StockAddDialog({
|
|
super.key,
|
|
required this.jarvisSettings,
|
|
this.initialItem,
|
|
});
|
|
|
|
final JarvisSettings jarvisSettings;
|
|
final StockItem? initialItem;
|
|
|
|
@override
|
|
State<StockAddDialog> createState() => _StockAddDialogState();
|
|
}
|
|
|
|
class _StockAddDialogState extends State<StockAddDialog> {
|
|
final name = TextEditingController();
|
|
final barcode = TextEditingController();
|
|
final amount = TextEditingController(text: '1');
|
|
final unit = TextEditingController(text: 'db');
|
|
final expiry = TextEditingController();
|
|
final productFocus = FocusNode();
|
|
String location = 'Kamra';
|
|
bool loadingProducts = false;
|
|
String? apiMessage;
|
|
|
|
bool get editing => widget.initialItem != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final item = widget.initialItem;
|
|
if (item == null) return;
|
|
name.text = item.name;
|
|
amount.text = item.amount.toString();
|
|
unit.text = item.unit;
|
|
location = item.location;
|
|
expiry.text = item.expiry == 'nincs megadva' ? '' : item.expiry;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
name.dispose();
|
|
barcode.dispose();
|
|
amount.dispose();
|
|
unit.dispose();
|
|
expiry.dispose();
|
|
productFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(editing ? 'K\u00e9szlet szerkeszt\u00e9se' : 'K\u00e9szlet felv\u00e9tele'),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: barcode,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Vonalk\u00f3d',
|
|
hintText: 'kamera vagy k\u00e9zi bevitel',
|
|
),
|
|
onSubmitted: (_) => _lookupBarcode(),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
IconButton.filledTonal(
|
|
tooltip: 'Vonalk\u00f3d beolvas\u00e1sa',
|
|
onPressed: _scanBarcode,
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: 'Keres\u00e9s JARVIS API-ban',
|
|
onPressed: loadingProducts ? null : _lookupBarcode,
|
|
icon: loadingProducts
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.manage_search),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
RawAutocomplete<JarvisProduct>(
|
|
textEditingController: name,
|
|
focusNode: productFocus,
|
|
displayStringForOption: (product) => product.displayName,
|
|
optionsBuilder: (value) async {
|
|
final query = value.text.trim();
|
|
if (query.length < 2) {
|
|
return const Iterable<JarvisProduct>.empty();
|
|
}
|
|
return _searchProducts(query);
|
|
},
|
|
onSelected: _applyProduct,
|
|
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
|
|
return TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: '\u00c9lelmiszer neve',
|
|
hintText:
|
|
'G\u00e9pelj, \u00e9s a JARVIS felk\u00edn\u00e1lja a mentett term\u00e9keket',
|
|
),
|
|
);
|
|
},
|
|
optionsViewBuilder: (context, onSelected, options) => Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Material(
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxHeight: 260,
|
|
maxWidth: 420,
|
|
),
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: options.length,
|
|
itemBuilder: (context, index) {
|
|
final product = options.elementAt(index);
|
|
return ListTile(
|
|
leading: const Icon(Icons.inventory_2_outlined),
|
|
title: Text(product.name),
|
|
subtitle: Text(
|
|
[
|
|
if (product.brand != null) product.brand!,
|
|
if (product.quantity != null) product.quantity!,
|
|
product.barcode,
|
|
].join(' \u00b7 '),
|
|
),
|
|
onTap: () => onSelected(product),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (apiMessage != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
apiMessage!,
|
|
style: TextStyle(
|
|
color: apiMessage!.startsWith('Hiba')
|
|
? Colors.red.shade700
|
|
: sage,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: amount,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Mennyis\u00e9g',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: unit,
|
|
decoration: const InputDecoration(labelText: 'Egys\u00e9g'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: location,
|
|
decoration: const InputDecoration(labelText: 'Hely'),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'H\u0171t\u0151',
|
|
child: Text('H\u0171t\u0151szekr\u00e9ny'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'Kamra',
|
|
child: Text('Sz\u00e1raz\u00e1ru'),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
setState(() => location = value);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: expiry,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Lej\u00e1rat',
|
|
hintText: 'pl. 2026.08.31 vagy nincs megadva',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final trimmedName = name.text.trim();
|
|
if (trimmedName.isEmpty) return;
|
|
Navigator.pop(
|
|
context,
|
|
StockInput(
|
|
name: trimmedName,
|
|
barcode: barcode.text.trim(),
|
|
amount: int.tryParse(amount.text.trim()) ?? 1,
|
|
unit: unit.text.trim().isEmpty ? 'db' : unit.text.trim(),
|
|
location: location,
|
|
expiry: expiry.text.trim().isEmpty
|
|
? 'nincs megadva'
|
|
: expiry.text.trim(),
|
|
),
|
|
);
|
|
},
|
|
child: Text(editing ? 'Ment\u00e9s' : 'Hozz\u00e1ad\u00e1s'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
bool get _jarvisConfigured =>
|
|
widget.jarvisSettings.apiUrl.trim().isNotEmpty &&
|
|
widget.jarvisSettings.apiToken.trim().isNotEmpty;
|
|
|
|
Map<String, String> get _headers => {
|
|
'X-Otthon-Token': widget.jarvisSettings.apiToken.trim(),
|
|
};
|
|
|
|
Future<List<JarvisProduct>> _searchProducts(String query) async {
|
|
if (!_jarvisConfigured) return const [];
|
|
try {
|
|
final baseUri = Uri.parse(
|
|
normalizeJarvisUrl(widget.jarvisSettings.apiUrl),
|
|
);
|
|
final uri = baseUri.resolve(
|
|
'/api/products?query=${Uri.encodeQueryComponent(query)}&take=20',
|
|
);
|
|
final response = await http
|
|
.get(uri, headers: _headers)
|
|
.timeout(const Duration(seconds: 8));
|
|
if (response.statusCode != 200) return const [];
|
|
final payload = jsonDecode(response.body);
|
|
if (payload is! Map<String, dynamic>) return const [];
|
|
final rawProducts = payload['products'];
|
|
if (rawProducts is! List) return const [];
|
|
return rawProducts
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(JarvisProduct.fromJson)
|
|
.where((product) => product.name.isNotEmpty)
|
|
.toList();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<void> _lookupBarcode() async {
|
|
final normalized = barcode.text.replaceAll(RegExp(r'\D'), '');
|
|
if (normalized.isEmpty) {
|
|
setState(() => apiMessage = 'Adj meg vagy olvass be vonalk\u00f3dot.');
|
|
return;
|
|
}
|
|
if (!_jarvisConfigured) {
|
|
setState(() => apiMessage = 'A JARVIS API nincs be\u00e1ll\u00edtva.');
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
loadingProducts = true;
|
|
apiMessage = 'JARVIS keres\u00e9s: $normalized';
|
|
});
|
|
try {
|
|
final baseUri = Uri.parse(
|
|
normalizeJarvisUrl(widget.jarvisSettings.apiUrl),
|
|
);
|
|
final response = await http
|
|
.get(baseUri.resolve('/api/products/$normalized'), headers: _headers)
|
|
.timeout(const Duration(seconds: 12));
|
|
if (response.statusCode == 404) {
|
|
setState(
|
|
() => apiMessage =
|
|
'Nem tal\u00e1ltam ilyen term\u00e9ket ($normalized). K\u00e9zzel felviheted.',
|
|
);
|
|
return;
|
|
}
|
|
if (response.statusCode != 200) {
|
|
setState(() => apiMessage = 'Hiba: API ${response.statusCode}');
|
|
return;
|
|
}
|
|
final payload = jsonDecode(response.body);
|
|
if (payload is! Map<String, dynamic>) {
|
|
setState(() => apiMessage = 'Hiba: hib\u00e1s API v\u00e1lasz.');
|
|
return;
|
|
}
|
|
final product = JarvisProduct.fromJson(payload);
|
|
if (product.name.trim().isEmpty) {
|
|
setState(
|
|
() => apiMessage =
|
|
'A JARVIS v\u00e1laszban nem volt term\u00e9kn\u00e9v.',
|
|
);
|
|
return;
|
|
}
|
|
_applyProduct(product);
|
|
setState(
|
|
() => apiMessage = product.fromCache
|
|
? 'Tal\u00e1lat cache-b\u0151l: ${product.name}'
|
|
: 'Tal\u00e1lat Open Food Facts alapj\u00e1n: ${product.name}',
|
|
);
|
|
} catch (error) {
|
|
setState(() => apiMessage = 'Hiba: $error');
|
|
} finally {
|
|
if (mounted) setState(() => loadingProducts = false);
|
|
}
|
|
}
|
|
|
|
void _applyProduct(JarvisProduct product) {
|
|
setState(() {
|
|
barcode.text = product.barcode;
|
|
name.text = product.name;
|
|
if (product.quantity != null && product.quantity!.trim().isNotEmpty) {
|
|
unit.text = product.quantity!;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _scanBarcode() async {
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => const BarcodeScanDialog(),
|
|
);
|
|
if (result == null || result.trim().isEmpty) return;
|
|
setState(() => apiMessage = 'Vonalk\u00f3d beolvasva: ${result.trim()}');
|
|
barcode.text = result.trim();
|
|
await _lookupBarcode();
|
|
}
|
|
}
|
|
|
|
class BarcodeScanDialog extends StatefulWidget {
|
|
const BarcodeScanDialog({super.key});
|
|
|
|
@override
|
|
State<BarcodeScanDialog> createState() => _BarcodeScanDialogState();
|
|
}
|
|
|
|
class _BarcodeScanDialogState extends State<BarcodeScanDialog> {
|
|
final controller = MobileScannerController(
|
|
facing: CameraFacing.front,
|
|
formats: const [
|
|
BarcodeFormat.ean13,
|
|
BarcodeFormat.ean8,
|
|
BarcodeFormat.upcA,
|
|
BarcodeFormat.upcE,
|
|
BarcodeFormat.code128,
|
|
],
|
|
);
|
|
bool handled = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Vonalk\u00f3d beolvas\u00e1sa'),
|
|
content: SizedBox(
|
|
width: 520,
|
|
height: 360,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
MobileScanner(
|
|
controller: controller,
|
|
onDetect: (capture) {
|
|
if (handled) return;
|
|
for (final code in capture.barcodes) {
|
|
final value = code.rawValue;
|
|
if (value == null || value.trim().isEmpty) continue;
|
|
handled = true;
|
|
Navigator.pop(context, value);
|
|
break;
|
|
}
|
|
},
|
|
),
|
|
const Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
child: Text(
|
|
'Tartsd a vonalk\u00f3dot az el\u0151lapi kamera el\u00e9.',
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef JarvisRegisterCallback =
|
|
Future<JarvisSettings> Function(JarvisSettings settings, String deviceName);
|
|
typedef JarvisStatusCallback =
|
|
Future<JarvisSettings> Function(JarvisSettings settings);
|
|
|
|
class JarvisSettingsDialog extends StatefulWidget {
|
|
const JarvisSettingsDialog({
|
|
super.key,
|
|
required this.settings,
|
|
required this.onRegister,
|
|
required this.onRefreshStatus,
|
|
});
|
|
|
|
final JarvisSettings settings;
|
|
final JarvisRegisterCallback onRegister;
|
|
final JarvisStatusCallback onRefreshStatus;
|
|
|
|
@override
|
|
State<JarvisSettingsDialog> createState() => _JarvisSettingsDialogState();
|
|
}
|
|
|
|
class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
|
late final TextEditingController apiUrl;
|
|
late final TextEditingController apiToken;
|
|
late final TextEditingController workspaceToken;
|
|
late final TextEditingController deviceName;
|
|
late JarvisSettings currentSettings;
|
|
bool registering = false;
|
|
bool refreshing = false;
|
|
String? message;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
currentSettings = widget.settings;
|
|
apiUrl = TextEditingController(text: currentSettings.apiUrl);
|
|
apiToken = TextEditingController(text: currentSettings.apiToken);
|
|
workspaceToken = TextEditingController(
|
|
text: currentSettings.workspaceToken ?? '',
|
|
);
|
|
deviceName = TextEditingController(
|
|
text: currentSettings.deviceName ?? 'Family tablet',
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
apiUrl.dispose();
|
|
apiToken.dispose();
|
|
workspaceToken.dispose();
|
|
deviceName.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
JarvisSettings get draft => currentSettings.copyWith(
|
|
apiUrl: normalizeJarvisUrl(apiUrl.text),
|
|
apiToken: apiToken.text.trim(),
|
|
workspaceToken: workspaceToken.text.trim().isEmpty
|
|
? null
|
|
: workspaceToken.text.trim(),
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final status = currentSettings.registrationStatus;
|
|
final registered =
|
|
currentSettings.deviceId != null ||
|
|
(currentSettings.deviceToken?.isNotEmpty ?? false);
|
|
final approved = status == 'approved';
|
|
final pending = status == 'pending';
|
|
final deviceToken = currentSettings.deviceToken;
|
|
final shortDeviceToken = deviceToken == null || deviceToken.length <= 10
|
|
? deviceToken
|
|
: '${deviceToken.substring(0, 6)}...${deviceToken.substring(deviceToken.length - 4)}';
|
|
|
|
return AlertDialog(
|
|
title: const Text('JARVIS Backend be\u00e1ll\u00edt\u00e1sok'),
|
|
content: SizedBox(
|
|
width: 500,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: apiUrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'API URL',
|
|
hintText: defaultJarvisApiUrl,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: apiToken,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Szerver token',
|
|
hintText: 'JARVIS API token',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: workspaceToken,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'K\u00f6z\u00f6s munkat\u00e9r token',
|
|
hintText:
|
|
'Ugyanez ker\u00fclj\u00f6n minden csal\u00e1di eszk\u00f6zre',
|
|
helperText:
|
|
'Ezzel l\u00e1tja t\u00f6bb app ugyanazt a csal\u00e1dot, k\u00e9szletet \u00e9s bev\u00e1s\u00e1rl\u00f3list\u00e1t.',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: deviceName,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Eszk\u00f6z neve',
|
|
hintText: 'pl. Konyhai tablet',
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: approved
|
|
? mint
|
|
: pending
|
|
? const Color(0xFFFFF4DD)
|
|
: const Color(0xFFF1F3EF),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
approved
|
|
? Icons.verified_outlined
|
|
: pending
|
|
? Icons.hourglass_top
|
|
: Icons.info_outline,
|
|
color: approved
|
|
? sage
|
|
: pending
|
|
? orange
|
|
: Colors.black45,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
approved
|
|
? 'Az eszk\u00f6z j\u00f3v\u00e1hagyva \u00e9s haszn\u00e1lhat\u00f3.'
|
|
: pending
|
|
? 'A regisztr\u00e1ci\u00f3 bek\u00fcldve, admin j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.'
|
|
: registered
|
|
? 'Az eszk\u00f6z regisztr\u00e1lva van, de jelenleg nem akt\u00edv.'
|
|
: 'M\u00e9g nincs bek\u00fcld\u00f6tt regisztr\u00e1ci\u00f3.',
|
|
style: const TextStyle(
|
|
color: ink,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
if (registered) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Eszk\u00f6z: ${currentSettings.deviceName ?? deviceName.text}',
|
|
style: const TextStyle(
|
|
color: Colors.black54,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (currentSettings.deviceId != null)
|
|
Text(
|
|
'Azonos\u00edt\u00f3: #${currentSettings.deviceId}',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
if (shortDeviceToken != null)
|
|
Text(
|
|
'Eszk\u00f6z token: $shortDeviceToken',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (message != null) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
message!,
|
|
style: TextStyle(
|
|
color: message!.startsWith('Hiba')
|
|
? Colors.red.shade700
|
|
: sage,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: registering ? null : () => Navigator.pop(context),
|
|
child: const Text('M\u00e9gse'),
|
|
),
|
|
if (registered)
|
|
TextButton.icon(
|
|
onPressed: registering || refreshing ? null : _clearRegistration,
|
|
icon: const Icon(Icons.link_off),
|
|
label: const Text('Regisztr\u00e1ci\u00f3 t\u00f6rl\u00e9se'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: registering || refreshing ? null : _refreshStatus,
|
|
icon: refreshing
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
label: Text(refreshing ? 'Friss\u00edt\u00e9s...' : 'St\u00e1tusz'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: registering || refreshing ? null : _register,
|
|
icon: registering
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.app_registration),
|
|
label: Text(
|
|
registering ? 'K\u00fcld\u00e9s...' : 'Regisztr\u00e1ci\u00f3',
|
|
),
|
|
),
|
|
FilledButton(
|
|
onPressed: registering ? null : () => Navigator.pop(context, draft),
|
|
child: const Text('Ment\u00e9s'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _clearRegistration() {
|
|
setState(() {
|
|
currentSettings = draft.copyWith(clearDevice: true);
|
|
deviceName.text = 'Family tablet';
|
|
message =
|
|
'A tablet helyi regisztr\u00e1ci\u00f3ja t\u00f6r\u00f6lve. A szerveren nincs m\u00f3dos\u00edt\u00e1s.';
|
|
});
|
|
}
|
|
|
|
Future<void> _register() async {
|
|
setState(() {
|
|
registering = true;
|
|
message = null;
|
|
});
|
|
try {
|
|
final registered = await widget.onRegister(draft, deviceName.text);
|
|
setState(() {
|
|
currentSettings = registered;
|
|
apiUrl.text = registered.apiUrl;
|
|
apiToken.text = registered.apiToken;
|
|
message =
|
|
'Regisztr\u00e1ci\u00f3 bek\u00fcldve. A JARVIS admin fel\u00fcleten kell j\u00f3v\u00e1hagyni.';
|
|
});
|
|
} catch (error) {
|
|
setState(() => message = 'Hiba: $error');
|
|
} finally {
|
|
if (mounted) setState(() => registering = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshStatus() async {
|
|
setState(() {
|
|
refreshing = true;
|
|
message = null;
|
|
});
|
|
try {
|
|
final refreshed = await widget.onRefreshStatus(draft);
|
|
setState(() {
|
|
currentSettings = refreshed;
|
|
message = refreshed.registrationStatus == 'approved'
|
|
? 'Az eszk\u00f6z j\u00f3v\u00e1 van hagyva.'
|
|
: 'Az eszk\u00f6z m\u00e9g j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.';
|
|
});
|
|
} catch (error) {
|
|
setState(() => message = 'Hiba: $error');
|
|
} finally {
|
|
if (mounted) setState(() => refreshing = false);
|
|
}
|
|
}
|
|
}
|