From 644201dc8a9bd003fa6e1596dcf86b595b2f499a Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Fri, 12 Jun 2026 10:59:38 +0200 Subject: [PATCH] =?UTF-8?q?Akt=C3=ADv=20projekt=20kiv=C3=A1laszt=C3=A1sa,?= =?UTF-8?q?=20projekt=20l=C3=A9trehoz=C3=A1sa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/project.dart | 4 +- lib/services/app_database.dart | 2 +- lib/services/project_service.dart | 27 +- lib/widgets/app_drawer.dart | 42 +- lib/widgets/project/project_picker_view.dart | 608 +++++++++++++++++++ 5 files changed, 666 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/project/project_picker_view.dart diff --git a/lib/models/project.dart b/lib/models/project.dart index bfea2ef..7f86613 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -65,8 +65,8 @@ class Project { 'crs': crs.name, 'color': color, 'status': status.name, - 'is_default': isDefault, - 'is_local_only': isLocalOnly, + 'is_default': isDefault ? 1 : 0, + 'is_local_only': isLocalOnly ? 1 : 0, if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt!.toIso8601String(), 'created_at': createdAt.toIso8601String(), diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 473e46d..bd35948 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -158,7 +158,7 @@ class AppDatabase { await db.insert('projects', { 'uuid': const Uuid().v4(), 'name': 'Alapértelmezett projekt', - 'is_default': true, + 'is_default': 1, 'is_local_only': 0, 'status': 'active', 'created_at': now, diff --git a/lib/services/project_service.dart b/lib/services/project_service.dart index 3f26b9b..e8c4f52 100644 --- a/lib/services/project_service.dart +++ b/lib/services/project_service.dart @@ -98,7 +98,9 @@ class ProjectService extends GetxService { final id = await AppDatabase.instance.insertProject(project); // Supabase-be is - await Supabase.instance.client.from('projects').insert(project.toMap()); + await Supabase.instance.client + .from('TerepiSeged_Projects') + .insert(project.toMap()); await _loadProjects(); return await AppDatabase.instance.getProject(id) ?? project; @@ -127,4 +129,27 @@ class ProjectService extends GetxService { await _loadProjects(); return await AppDatabase.instance.getProject(id) ?? project; } + + Future reloadProjects() => _loadProjects(); + + Future> getStats(int projectId) => + AppDatabase.instance.getProjectStats(projectId); + + Future archiveProject(int id) async { + await AppDatabase.instance.archiveProject(id); + if (activeProject.value?.id == id) { + activeProject.value = projects.isNotEmpty + ? projects.firstWhereOrNull((p) => p.id != id) + : null; + } + } + + Future updateProject(Project project) async { + await AppDatabase.instance.updateProject(project); + await _loadProjects(); + + if (activeProject.value?.id == project.id) { + activeProject.value = project; + } + } } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index c5cf898..b7ec551 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:terepi_seged/services/project_service.dart'; import '../services/gnss/gnss_connection.dart'; import '../services/gnss/gnss_device_service.dart'; import '../services/gnss/gnss_service.dart'; import 'gnss_device_picker_dialog.dart'; +import 'project/project_picker_view.dart'; class AppDrawer extends StatelessWidget { const AppDrawer({super.key}); @@ -32,19 +34,25 @@ class AppDrawer extends StatelessWidget { padding: EdgeInsets.zero, children: [ // Projekt - ListTile( - leading: const Icon(Icons.folder_outlined), - title: const Text('Aktív projekt'), - subtitle: const Text( - 'Nincs projekt', - style: TextStyle(fontSize: 12), - ), - trailing: const Icon(Icons.arrow_forward_ios, size: 14), - onTap: () { - Get.back(); - // Get.to(() => const ProjectPickerView()); - }, - ), + Obx(() { + final project = ProjectService.to.activeProject.value; + return ListTile( + leading: Icon(Icons.folder_outlined, + color: + project != null ? _hexColor(project.color) : null), + title: const Text('Aktív projekt'), + subtitle: Text( + project?.name ?? 'Nincs projekt', + style: TextStyle(fontSize: 12), + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 14), + onTap: () { + Get.back(); + Get.to(() => const ProjectPickerView(), + transition: Transition.rightToLeft); + }, + ); + }), // GNSS eszköz Obx(() { @@ -283,3 +291,11 @@ class _SectionLabel extends StatelessWidget { ); } } + +Color _hexColor(String hex) { + final h = hex.replaceFirst('#', ''); + return Color(int.parse( + h.length == 6 ? 'FF$h' : h, + radix: 16, + )); +} diff --git a/lib/widgets/project/project_picker_view.dart b/lib/widgets/project/project_picker_view.dart new file mode 100644 index 0000000..f924bb4 --- /dev/null +++ b/lib/widgets/project/project_picker_view.dart @@ -0,0 +1,608 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import '../../models/project.dart'; +import '../../services/project_service.dart'; + +// ─── Segéd: hex string → Flutter Color ─────────────────────────────────────── + +Color _hexColor(String hex) { + final h = hex.replaceFirst('#', ''); + return Color(int.parse(h.length == 6 ? 'FF$h' : h, radix: 16)); +} + +// ─── ProjectPickerView ──────────────────────────────────────────────────────── + +class ProjectPickerView extends StatelessWidget { + const ProjectPickerView({super.key}); + + @override + Widget build(BuildContext context) { + final svc = ProjectService.to; + return Scaffold( + appBar: AppBar( + title: const Text('Projekt választó'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Új projekt', + onPressed: () => ProjectCreateDialog.show(), + ), + ], + ), + body: Obx(() { + if (svc.projects.isEmpty) { + return _EmptyState(onCreateTap: ProjectCreateDialog.show); + } + return RefreshIndicator( + onRefresh: svc.reloadProjects, + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: svc.projects.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _ProjectTile(project: svc.projects[i]), + ), + ); + }), + ); + } +} + +// ─── Projekt sor ────────────────────────────────────────────────────────────── + +class _ProjectTile extends StatefulWidget { + final Project project; + const _ProjectTile({required this.project}); + + @override + State<_ProjectTile> createState() => _ProjectTileState(); +} + +class _ProjectTileState extends State<_ProjectTile> { + Map _stats = {'points': 0, 'tracks': 0, 'notes': 0}; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + if (widget.project.id == null) return; + final s = await ProjectService.to.getStats(widget.project.id!); + if (mounted) setState(() => _stats = s); + } + + @override + Widget build(BuildContext context) { + final svc = ProjectService.to; + final project = widget.project; + final color = _hexColor(project.color); + + return Obx(() { + final isActive = svc.activeProject.value?.id == project.id; + return ListTile( + selected: isActive, + selectedTileColor: color.withOpacity(0.08), + leading: Stack(children: [ + CircleAvatar( + radius: 22, + backgroundColor: color.withOpacity(0.15), + child: Text( + project.name.substring(0, 1).toUpperCase(), + style: TextStyle( + color: color, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1.5, + ), + ), + ), + ), + ]), + title: Row(children: [ + Expanded( + child: Text( + project.name, + style: TextStyle( + fontWeight: FontWeight.w600, + color: isActive ? color : null, + ), + ), + ), + _SyncBadge(isLocalOnly: project.isLocalOnly), + ]), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (project.client.isNotEmpty) ...[ + Text(project.client, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 2), + ], + Row(children: [ + _StatBadge( + Icons.location_on_outlined, _stats['points'] ?? 0, 'pont'), + const SizedBox(width: 8), + _StatBadge(Icons.route, _stats['tracks'] ?? 0, 'track'), + const SizedBox(width: 8), + _StatBadge(Icons.sticky_note_2_outlined, _stats['notes'] ?? 0, + 'jegyzet'), + ]), + const SizedBox(height: 2), + Text( + 'Létrehozva: ${DateFormat('yyyy.MM.dd').format(project.createdAt)}' + ' · ${project.crs.name.toUpperCase()}', + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + isThreeLine: true, + trailing: PopupMenuButton<_Action>( + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (a) => _onAction(a), + itemBuilder: (_) => [ + const PopupMenuItem( + value: _Action.edit, + child: ListTile( + leading: Icon(Icons.edit_outlined), + title: Text('Szerkesztés'), + dense: true, + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: _Action.archive, + child: ListTile( + leading: Icon(Icons.archive_outlined, color: Colors.orange), + title: + Text('Archiválás', style: TextStyle(color: Colors.orange)), + dense: true, + ), + ), + ], + ), + onTap: () async { + await svc.setActiveProject(project); + Get.back(); + }, + ); + }); + } + + void _onAction(_Action action) { + switch (action) { + case _Action.edit: + ProjectCreateDialog.show(existing: widget.project); + case _Action.archive: + _confirmArchive(); + } + } + + void _confirmArchive() { + Get.dialog(AlertDialog( + title: const Text('Projekt archiválása'), + content: Text( + '"${widget.project.name}" archiválásra kerül.\n' + 'Az adatok megmaradnak, de a projekt nem lesz aktívan elérhető.', + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.orange), + onPressed: () { + Get.back(); + ProjectService.to.archiveProject(widget.project.id!); + }, + child: const Text('Archiválás'), + ), + ], + )); + } +} + +enum _Action { edit, archive } + +// ─── Üres állapot ───────────────────────────────────────────────────────────── + +class _EmptyState extends StatelessWidget { + final VoidCallback onCreateTap; + const _EmptyState({required this.onCreateTap}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder_outlined, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text('Nincs még projekt', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.grey)), + const SizedBox(height: 8), + Text('Hozz létre egy projektet a kezdéshez.', + style: TextStyle(color: Colors.grey.shade500)), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(Icons.add), + label: const Text('Új projekt létrehozása'), + onPressed: onCreateTap, + ), + ], + ), + ); + } +} + +// ─── Segéd widgetek ─────────────────────────────────────────────────────────── + +class _SyncBadge extends StatelessWidget { + final bool isLocalOnly; + const _SyncBadge({required this.isLocalOnly}); + + @override + Widget build(BuildContext context) { + final color = isLocalOnly ? Colors.grey : Colors.blue; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon( + isLocalOnly ? Icons.phone_android : Icons.cloud_outlined, + size: 10, + color: color, + ), + const SizedBox(width: 3), + Text(isLocalOnly ? 'Lokális' : 'Felhős', + style: TextStyle(fontSize: 10, color: color)), + ]), + ); + } +} + +class _StatBadge extends StatelessWidget { + final IconData icon; + final int count; + final String label; + const _StatBadge(this.icon, this.count, this.label); + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: Colors.grey), + const SizedBox(width: 2), + Text('$count $label', + style: const TextStyle(fontSize: 11, color: Colors.grey)), + ], + ); +} + +// ─── ProjectCreateDialog ────────────────────────────────────────────────────── + +class ProjectCreateDialog extends StatefulWidget { + final Project? existing; + const ProjectCreateDialog({super.key, this.existing}); + + static void show({Project? existing}) => Get.dialog( + ProjectCreateDialog(existing: existing), + barrierDismissible: false, + ); + + @override + State createState() => _ProjectCreateDialogState(); +} + +class _ProjectCreateDialogState extends State { + late final TextEditingController _nameCtrl; + late final TextEditingController _clientCtrl; + late final TextEditingController _descCtrl; + + late ProjectCrs _crs; + late String _colorHex; + late bool _isLocalOnly; + bool _isSubmitting = false; + + bool get _isEditing => widget.existing != null; + + static const _colorOptions = [ + '#185FA5', + '#2E7D32', + '#C62828', + '#E65100', + '#6A1B9A', + '#00695C', + '#283593', + '#4E342E', + ]; + + @override + void initState() { + super.initState(); + final p = widget.existing; + _nameCtrl = TextEditingController(text: p?.name ?? ''); + _clientCtrl = TextEditingController(text: p?.client ?? ''); + _descCtrl = TextEditingController(text: p?.description ?? ''); + _crs = p?.crs ?? ProjectCrs.eov; + _colorHex = p?.color ?? _colorOptions.first; + _isLocalOnly = p?.isLocalOnly ?? true; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _clientCtrl.dispose(); + _descCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Fejléc + Row(children: [ + Text( + _isEditing ? 'Projekt szerkesztése' : 'Új projekt', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton(icon: const Icon(Icons.close), onPressed: Get.back), + ]), + const SizedBox(height: 20), + + // Név + TextField( + controller: _nameCtrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Projekt neve *', + hintText: 'pl. Dunántúli 2D vonal', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.folder_outlined), + ), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 12), + + // // Megrendelő + // TextField( + // controller: _clientCtrl, + // decoration: const InputDecoration( + // labelText: 'Megrendelő', + // hintText: 'pl. MOL Nyrt.', + // border: OutlineInputBorder(), + // prefixIcon: Icon(Icons.business_outlined), + // ), + // ), + // const SizedBox(height: 12), + + // Leírás + TextField( + controller: _descCtrl, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Megjegyzés', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.notes), + ), + ), + const SizedBox(height: 16), + + // Koordináta-rendszer + const _Label('Koordináta-rendszer'), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ProjectCrs.eov, + label: Text('EOV (HD72)'), + icon: Icon(Icons.straighten, size: 14), + ), + ButtonSegment( + value: ProjectCrs.wgs84, + label: Text('WGS84'), + icon: Icon(Icons.public, size: 14), + ), + ], + selected: {_crs}, + onSelectionChanged: (s) => setState(() => _crs = s.first), + ), + + // Tárolás — csak új projektnél + if (!_isEditing) ...[ + const SizedBox(height: 16), + const _Label('Adattárolás'), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + label: Text('Csak lokális'), + icon: Icon(Icons.phone_android, size: 14), + ), + ButtonSegment( + value: false, + label: Text('Felhős mentés'), + icon: Icon(Icons.cloud_outlined, size: 14), + ), + ], + selected: {_isLocalOnly}, + onSelectionChanged: (s) => + setState(() => _isLocalOnly = s.first), + ), + const SizedBox(height: 6), + Text( + _isLocalOnly + ? '📱 Az adatok csak ezen a készüléken maradnak.' + : '☁ Az adatok szinkronizálódnak a szerverre.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + const SizedBox(height: 16), + + // Szín + const _Label('Projekt szín'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 6, + children: _colorOptions.map((hex) { + final selected = _colorHex == hex; + return GestureDetector( + onTap: () => setState(() => _colorHex = hex), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 32, + height: 32, + decoration: BoxDecoration( + color: _hexColor(hex), + shape: BoxShape.circle, + border: selected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 2.5) + : null, + boxShadow: selected + ? [ + BoxShadow( + color: _hexColor(hex).withOpacity(0.5), + blurRadius: 6, + spreadRadius: 1) + ] + : null, + ), + child: selected + ? const Icon(Icons.check, + color: Colors.white, size: 16) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Gombok + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isSubmitting ? null : Get.back, + child: const Text('Mégse'), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : Text(_isEditing ? 'Mentés' : 'Létrehozás'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _submit() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + Get.snackbar('Hiányzó adat', 'A projekt neve kötelező.', + snackPosition: SnackPosition.TOP); + return; + } + setState(() => _isSubmitting = true); + try { + final svc = ProjectService.to; + if (_isEditing) { + await svc.updateProject(widget.existing!.copyWith( + name: name, + client: _clientCtrl.text.trim(), + description: _descCtrl.text.trim(), + crs: _crs, + color: _colorHex, + )); + Get.back(); + Get.snackbar('Projekt frissítve', name, + snackPosition: SnackPosition.BOTTOM); + } else { + final Project created; + if (_isLocalOnly) { + created = await svc.createLocalProject( + name: name, + client: _clientCtrl.text.trim(), + crs: _crs, + color: _colorHex, + ); + } else { + created = await svc.createOnlineProject( + name: name, + client: _clientCtrl.text.trim(), + crs: _crs, + color: _colorHex, + ); + } + await svc.setActiveProject(created); + Get.snackbar( + 'Projekt létrehozva', + '$name — aktív projektnek beállítva.', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 3), + ); + } + } catch (e) { + Get.snackbar('Hiba', e.toString(), + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.TOP); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } +} + +class _Label extends StatelessWidget { + final String text; + const _Label(this.text); + + @override + Widget build(BuildContext context) => Text( + text, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ); +}