Terepbejárás geometria hang és képi dokumentáció létrehozása. Gradle verzió frissítése
This commit is contained in:
parent
ab5ce48f9c
commit
0828630a5b
@ -14,10 +14,11 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.app_dev.terepi_seged"
|
namespace = "hu.app_dev.terepi_seged"
|
||||||
compileSdk 35
|
compileSdk 36
|
||||||
// ndkVersion "25.1.8937393"
|
// ndkVersion "25.1.8937393"
|
||||||
// ndkVersion flutter.ndkVersion
|
// ndkVersion flutter.ndkVersion
|
||||||
ndkVersion "27.0.12077973"
|
//ndkVersion "27.0.12077973"
|
||||||
|
ndkVersion "28.2.13676358"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
// sourceCompatibility JavaVersion.VERSION_1_8
|
// sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
|||||||
@ -62,5 +62,7 @@
|
|||||||
android:usesPermissionFlags="neverForLocation" tools:targetApi="s"/>
|
android:usesPermissionFlags="neverForLocation" tools:targetApi="s"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@ -18,8 +18,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.7.1" apply false
|
id "com.android.application" version "8.11.1" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "2.2.0" apply false
|
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
|
||||||
id "com.google.gms.google-services" version "4.4.0" apply false
|
id "com.google.gms.google-services" version "4.4.0" apply false
|
||||||
id "com.google.firebase.crashlytics" version "2.9.9" apply false
|
id "com.google.firebase.crashlytics" version "2.9.9" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import 'package:terepi_seged/services/app_database.dart';
|
|||||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||||
|
import 'package:terepi_seged/services/note_audio_service.dart';
|
||||||
|
import 'package:terepi_seged/services/note_photo_service.dart';
|
||||||
import 'package:terepi_seged/services/ntrip_service.dart';
|
import 'package:terepi_seged/services/ntrip_service.dart';
|
||||||
import 'package:terepi_seged/services/project_service.dart';
|
import 'package:terepi_seged/services/project_service.dart';
|
||||||
|
|
||||||
@ -30,6 +32,8 @@ Future<void> main() async {
|
|||||||
Get.put(GnssService());
|
Get.put(GnssService());
|
||||||
Get.put(NtripService());
|
Get.put(NtripService());
|
||||||
Get.put(TrackingController(), permanent: true);
|
Get.put(TrackingController(), permanent: true);
|
||||||
|
Get.put(NotePhotoService(), permanent: true);
|
||||||
|
Get.put(NoteAudioService(), permanent: true);
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
70
lib/models/note_item_audio.dart
Normal file
70
lib/models/note_item_audio.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
class NoteItemAudio {
|
||||||
|
final int? id;
|
||||||
|
final int noteItemId;
|
||||||
|
final String localPath; // .m4a fájl
|
||||||
|
final String caption; // opcionális felirat
|
||||||
|
final int durationSeconds; // másodpercben
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const NoteItemAudio({
|
||||||
|
this.id,
|
||||||
|
required this.noteItemId,
|
||||||
|
required this.localPath,
|
||||||
|
this.caption = '',
|
||||||
|
this.durationSeconds = 0,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
File get file => File(localPath);
|
||||||
|
bool get fileExists => file.existsSync();
|
||||||
|
LatLng? get location => latitude != null && longitude != null
|
||||||
|
? LatLng(latitude!, longitude!)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// Formázott időtartam: "0:42" vagy "1:23"
|
||||||
|
String get durationFormatted {
|
||||||
|
final m = durationSeconds ~/ 60;
|
||||||
|
final s = (durationSeconds % 60).toString().padLeft(2, '0');
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteItemAudio copyWith({String? caption}) => NoteItemAudio(
|
||||||
|
id: id,
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
localPath: localPath,
|
||||||
|
caption: caption ?? this.caption,
|
||||||
|
durationSeconds: durationSeconds,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'note_item_id': noteItemId,
|
||||||
|
'local_path': localPath,
|
||||||
|
'caption': caption,
|
||||||
|
'duration_seconds': durationSeconds,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NoteItemAudio.fromMap(Map<String, dynamic> m) => NoteItemAudio(
|
||||||
|
id: m['id'] as int?,
|
||||||
|
noteItemId: m['note_item_id'] as int,
|
||||||
|
localPath: m['local_path'] as String,
|
||||||
|
caption: m['caption'] as String? ?? '',
|
||||||
|
durationSeconds: m['duration_seconds'] as int? ?? 0,
|
||||||
|
latitude: (m['latitude'] as num?)?.toDouble(),
|
||||||
|
longitude: (m['longitude'] as num?)?.toDouble(),
|
||||||
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
70
lib/models/note_item_photo.dart
Normal file
70
lib/models/note_item_photo.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// lib/models/note_item_photo.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
class NoteItemPhoto {
|
||||||
|
final int? id;
|
||||||
|
final int noteItemId;
|
||||||
|
final String localPath; // teljes elérési út a fájlhoz
|
||||||
|
final String? storagePath; // Supabase Storage (megosztáskor)
|
||||||
|
final String caption; // opcionális megjegyzés
|
||||||
|
final double? latitude; // ahol a fotó készült
|
||||||
|
final double? longitude;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const NoteItemPhoto({
|
||||||
|
this.id,
|
||||||
|
required this.noteItemId,
|
||||||
|
required this.localPath,
|
||||||
|
this.storagePath,
|
||||||
|
this.caption = '',
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
File get file => File(localPath);
|
||||||
|
bool get fileExists => file.existsSync();
|
||||||
|
bool get isShared => storagePath != null;
|
||||||
|
LatLng? get location => latitude != null && longitude != null
|
||||||
|
? LatLng(latitude!, longitude!)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
NoteItemPhoto copyWith({
|
||||||
|
String? caption,
|
||||||
|
String? storagePath,
|
||||||
|
}) =>
|
||||||
|
NoteItemPhoto(
|
||||||
|
id: id,
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
localPath: localPath,
|
||||||
|
storagePath: storagePath ?? this.storagePath,
|
||||||
|
caption: caption ?? this.caption,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'note_item_id': noteItemId,
|
||||||
|
'local_path': localPath,
|
||||||
|
'storage_path': storagePath,
|
||||||
|
'caption': caption,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NoteItemPhoto.fromMap(Map<String, dynamic> m) => NoteItemPhoto(
|
||||||
|
id: m['id'] as int?,
|
||||||
|
noteItemId: m['note_item_id'] as int,
|
||||||
|
localPath: m['local_path'] as String,
|
||||||
|
storagePath: m['storage_path'] as String?,
|
||||||
|
caption: m['caption'] as String? ?? '',
|
||||||
|
latitude: (m['latitude'] as num?)?.toDouble(),
|
||||||
|
longitude: (m['longitude'] as num?)?.toDouble(),
|
||||||
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -194,8 +194,8 @@ class MapSurveyController extends GetxController {
|
|||||||
bool get isMapEditing => activeEditTool.value != MapEditTool.none;
|
bool get isMapEditing => activeEditTool.value != MapEditTool.none;
|
||||||
final selectedNoteItemId = Rx<int?>(null);
|
final selectedNoteItemId = Rx<int?>(null);
|
||||||
final selectedNoteItemType = NoteType.line.obs;
|
final selectedNoteItemType = NoteType.line.obs;
|
||||||
int? _editingNoteItemId;
|
int? editingNoteItemId;
|
||||||
bool get isGeometryEditing => _editingNoteItemId != null;
|
bool get isGeometryEditing => editingNoteItemId != null;
|
||||||
final draftLengthMeters = 0.0.obs;
|
final draftLengthMeters = 0.0.obs;
|
||||||
final draftAreaSquareMeters = 0.0.obs;
|
final draftAreaSquareMeters = 0.0.obs;
|
||||||
|
|
||||||
@ -1107,7 +1107,7 @@ class MapSurveyController extends GetxController {
|
|||||||
void cancelEditing() {
|
void cancelEditing() {
|
||||||
polygonEditorController.clear();
|
polygonEditorController.clear();
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
selectedNoteItemId.value = null;
|
selectedNoteItemId.value = null;
|
||||||
editorPointCount.value = 0;
|
editorPointCount.value = 0;
|
||||||
draftAreaSquareMeters.value = 0.0;
|
draftAreaSquareMeters.value = 0.0;
|
||||||
@ -1243,7 +1243,7 @@ class MapSurveyController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> finishDraft() async {
|
Future<void> finishDraft() async {
|
||||||
if (_editingNoteItemId != null) {
|
if (editingNoteItemId != null) {
|
||||||
if (polygonEditorController.points.isEmpty) {
|
if (polygonEditorController.points.isEmpty) {
|
||||||
await _finishStyleUpdate();
|
await _finishStyleUpdate();
|
||||||
} else {
|
} else {
|
||||||
@ -1257,7 +1257,7 @@ class MapSurveyController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finishStyleUpdate() async {
|
Future<void> _finishStyleUpdate() async {
|
||||||
final id = _editingNoteItemId!;
|
final id = editingNoteItemId!;
|
||||||
final existing = await AppDatabase.instance.getNoteItem(id);
|
final existing = await AppDatabase.instance.getNoteItem(id);
|
||||||
if (existing == null) return;
|
if (existing == null) return;
|
||||||
|
|
||||||
@ -1270,18 +1270,18 @@ class MapSurveyController extends GetxController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await updateNoteItem(updated);
|
await updateNoteItem(updated);
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
draftAreaSquareMeters.value = 0.0;
|
draftAreaSquareMeters.value = 0.0;
|
||||||
draftLengthMeters.value = 0.0;
|
draftLengthMeters.value = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEditingItem() async {
|
Future<void> deleteEditingItem() async {
|
||||||
final id = _editingNoteItemId;
|
final id = editingNoteItemId;
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
final item = await AppDatabase.instance.getNoteItem(id);
|
final item = await AppDatabase.instance.getNoteItem(id);
|
||||||
if (item != null) await deleteNoteItem(item);
|
if (item != null) await deleteNoteItem(item);
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
selectedNoteItemId.value = null;
|
selectedNoteItemId.value = null;
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
draftAreaSquareMeters.value = 0.0;
|
draftAreaSquareMeters.value = 0.0;
|
||||||
@ -1323,10 +1323,10 @@ class MapSurveyController extends GetxController {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
void saveEditedPoint({required LatLng point}) {
|
void saveEditedPoint({required LatLng point}) {
|
||||||
if (_editingNoteItemId != null) {
|
if (editingNoteItemId != null) {
|
||||||
// ── PONT FRISSÍTÉSI MÓD ──────────────────────────────────────
|
// ── PONT FRISSÍTÉSI MÓD ──────────────────────────────────────
|
||||||
final id = _editingNoteItemId!;
|
final id = editingNoteItemId!;
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
|
|
||||||
AppDatabase.instance.getNoteItem(id).then((existing) async {
|
AppDatabase.instance.getNoteItem(id).then((existing) async {
|
||||||
@ -1375,7 +1375,7 @@ class MapSurveyController extends GetxController {
|
|||||||
final item = await AppDatabase.instance.getNoteItem(id);
|
final item = await AppDatabase.instance.getNoteItem(id);
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
|
|
||||||
_editingNoteItemId = item.id;
|
editingNoteItemId = item.id;
|
||||||
activeEditColor.value = item.color;
|
activeEditColor.value = item.color;
|
||||||
activeEditOpacity.value = item.opacity;
|
activeEditOpacity.value = item.opacity;
|
||||||
activeEditStrokeWidth.value = item.strokeWidth;
|
activeEditStrokeWidth.value = item.strokeWidth;
|
||||||
@ -1464,7 +1464,7 @@ class MapSurveyController extends GetxController {
|
|||||||
// ── Geometria szerkesztés indítása ────────────────────────────────────
|
// ── Geometria szerkesztés indítása ────────────────────────────────────
|
||||||
|
|
||||||
Future<void> startGeometryEdit() async {
|
Future<void> startGeometryEdit() async {
|
||||||
final id = _editingNoteItemId;
|
final id = editingNoteItemId;
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
final item = await AppDatabase.instance.getNoteItem(id);
|
final item = await AppDatabase.instance.getNoteItem(id);
|
||||||
@ -1501,14 +1501,14 @@ class MapSurveyController extends GetxController {
|
|||||||
// ── Geometria szerkesztés megszakítása ────────────────────────────────
|
// ── Geometria szerkesztés megszakítása ────────────────────────────────
|
||||||
|
|
||||||
void cancelGeometryEdit() {
|
void cancelGeometryEdit() {
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
polygonEditorController.clear();
|
polygonEditorController.clear();
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
editorPointCount.value = 0;
|
editorPointCount.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finishGeometryUpdate() async {
|
Future<void> _finishGeometryUpdate() async {
|
||||||
final id = _editingNoteItemId!;
|
final id = editingNoteItemId!;
|
||||||
|
|
||||||
if (polygonEditorController.points.length < _minPoints) {
|
if (polygonEditorController.points.length < _minPoints) {
|
||||||
Get.snackbar(
|
Get.snackbar(
|
||||||
@ -1538,7 +1538,7 @@ class MapSurveyController extends GetxController {
|
|||||||
await updateNoteItem(updated);
|
await updateNoteItem(updated);
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
_editingNoteItemId = null;
|
editingNoteItemId = null;
|
||||||
polygonEditorController.clear();
|
polygonEditorController.clear();
|
||||||
activeEditTool.value = MapEditTool.none;
|
activeEditTool.value = MapEditTool.none;
|
||||||
editorPointCount.value = 0;
|
editorPointCount.value = 0;
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import 'package:sqflite/sqflite.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:terepi_seged/enums/note_type.dart';
|
import 'package:terepi_seged/enums/note_type.dart';
|
||||||
import 'package:terepi_seged/models/note_item.dart';
|
import 'package:terepi_seged/models/note_item.dart';
|
||||||
|
import 'package:terepi_seged/models/note_item_audio.dart';
|
||||||
|
import 'package:terepi_seged/models/note_item_photo.dart';
|
||||||
import 'package:terepi_seged/models/track.dart';
|
import 'package:terepi_seged/models/track.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/project.dart';
|
import '../models/project.dart';
|
||||||
@ -132,6 +134,36 @@ class AppDatabase {
|
|||||||
await db
|
await db
|
||||||
.execute('CREATE INDEX idx_notes_project ON note_items(project_id)');
|
.execute('CREATE INDEX idx_notes_project ON note_items(project_id)');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS note_item_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_item_id INTEGER NOT NULL REFERENCES note_items(id) ON DELETE CASCADE,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
storage_path TEXT,
|
||||||
|
caption TEXT NOT NULL DEFAULT '',
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_photos_note ON note_item_photos(note_item_id)');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS note_item_audios (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_item_id INTEGER NOT NULL REFERENCES note_items(id) ON DELETE CASCADE,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
caption TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_audios_note ON note_item_audios(note_item_id)');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS pending_points (
|
CREATE TABLE IF NOT EXISTS pending_points (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -459,4 +491,72 @@ class AppDatabase {
|
|||||||
whereArgs: [projectId],
|
whereArgs: [projectId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- NoteItemPhoto
|
||||||
|
|
||||||
|
Future<int> insertNotePhoto(NoteItemPhoto photo) async {
|
||||||
|
final db = await database;
|
||||||
|
return db.insert('note_item_photos', photo.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateNotePhoto(NoteItemPhoto photo) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.update(
|
||||||
|
'note_item_photos',
|
||||||
|
photo.toMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [photo.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNotePhoto(int id) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('note_item_photos', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<NoteItemPhoto>> listNotePhotos(int noteItemId) async {
|
||||||
|
final db = await database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'note_item_photos',
|
||||||
|
where: 'note_item_id = ?',
|
||||||
|
whereArgs: [noteItemId],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows.map(NoteItemPhoto.fromMap).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAllNotePhotos(int noteItemId) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('note_item_photos',
|
||||||
|
where: 'note_item_id = ?', whereArgs: [noteItemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------- NoteItemAudio
|
||||||
|
|
||||||
|
Future<int> insertNoteAudio(NoteItemAudio audio) async {
|
||||||
|
final db = await database;
|
||||||
|
return db.insert('note_item_audios', audio.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateNoteAudio(NoteItemAudio audio) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.update('note_item_audios', audio.toMap(),
|
||||||
|
where: 'id = ?', whereArgs: [audio.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNoteAudio(int id) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('note_item_audios', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<NoteItemAudio>> listNoteAudios(int noteItemId) async {
|
||||||
|
final db = await database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'note_item_audios',
|
||||||
|
where: 'note_item_id = ?',
|
||||||
|
whereArgs: [noteItemId],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows.map(NoteItemAudio.fromMap).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
265
lib/services/note_audio_service.dart
Normal file
265
lib/services/note_audio_service.dart
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
// Hangjegyzet service:
|
||||||
|
// - Felvétel (record csomag, AAC/m4a)
|
||||||
|
// - Lejátszás (audioplayers csomag)
|
||||||
|
// - Fájl kezelés + SQLite mentés
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:record/record.dart';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
|
||||||
|
import '../models/note_item_audio.dart';
|
||||||
|
import '../services/app_database.dart';
|
||||||
|
|
||||||
|
// ─── Felvétel állapot ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum AudioRecordState { idle, recording, stopped }
|
||||||
|
|
||||||
|
enum AudioPlayState { idle, playing, paused }
|
||||||
|
|
||||||
|
// ─── NoteAudioService ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NoteAudioService extends GetxService {
|
||||||
|
static NoteAudioService get to => Get.find();
|
||||||
|
|
||||||
|
// ── Reaktív állapot ────────────────────────────────────────────────────────
|
||||||
|
final recordState = AudioRecordState.idle.obs;
|
||||||
|
final recordDurationMs = 0.obs; // eltelt ms felvétel közben
|
||||||
|
final playingAudioId = Rxn<int>(); // melyik clip játszik épp
|
||||||
|
final playState = AudioPlayState.idle.obs;
|
||||||
|
final playPositionMs = 0.obs;
|
||||||
|
|
||||||
|
// ── Belső ──────────────────────────────────────────────────────────────────
|
||||||
|
final _recorder = AudioRecorder();
|
||||||
|
final _player = AudioPlayer();
|
||||||
|
String? _audioDir;
|
||||||
|
String? _currentRecordPath;
|
||||||
|
Timer? _recordTimer;
|
||||||
|
int? _recordStartMs;
|
||||||
|
|
||||||
|
// ── Inicializálás ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
super.onInit();
|
||||||
|
await _initAudioDir();
|
||||||
|
_initPlayerListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initAudioDir() async {
|
||||||
|
final ext = await getExternalStorageDirectory();
|
||||||
|
final dir = Directory(p.join(ext!.path, 'audio'));
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
_audioDir = dir.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initPlayerListeners() {
|
||||||
|
// Lejátszás pozíció frissítése
|
||||||
|
_player.onPositionChanged.listen((pos) {
|
||||||
|
playPositionMs.value = pos.inMilliseconds;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lejátszás vége
|
||||||
|
_player.onPlayerComplete.listen((_) {
|
||||||
|
playState.value = AudioPlayState.idle;
|
||||||
|
playingAudioId.value = null;
|
||||||
|
playPositionMs.value = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Felvétel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<bool> startRecording(int noteItemId) async {
|
||||||
|
// Engedély ellenőrzés
|
||||||
|
final hasPermission = await _recorder.hasPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
Get.snackbar(
|
||||||
|
'Engedély szükséges', 'Mikrofon hozzáférés engedélyezése szükséges.',
|
||||||
|
snackPosition: SnackPosition.BOTTOM);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktív lejátszás leállítása
|
||||||
|
if (playState.value != AudioPlayState.idle) {
|
||||||
|
await stopPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileName = 'audio_${noteItemId}_'
|
||||||
|
'${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||||
|
_currentRecordPath = p.join(_audioDir!, fileName);
|
||||||
|
|
||||||
|
await _recorder.start(
|
||||||
|
const RecordConfig(
|
||||||
|
encoder: AudioEncoder.aacLc, // jó minőség, kis méret
|
||||||
|
bitRate: 64000, // 64kbps elegendő hanghoz
|
||||||
|
sampleRate: 22050,
|
||||||
|
),
|
||||||
|
path: _currentRecordPath!,
|
||||||
|
);
|
||||||
|
|
||||||
|
_recordStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
recordState.value = AudioRecordState.recording;
|
||||||
|
recordDurationMs.value = 0;
|
||||||
|
|
||||||
|
// Másodpercenkénti számláló
|
||||||
|
_recordTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
recordDurationMs.value =
|
||||||
|
DateTime.now().millisecondsSinceEpoch - _recordStartMs!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Felvétel leállítása és SQLite mentés
|
||||||
|
Future<NoteItemAudio?> stopRecording(int noteItemId) async {
|
||||||
|
if (recordState.value != AudioRecordState.recording) return null;
|
||||||
|
|
||||||
|
_recordTimer?.cancel();
|
||||||
|
_recordTimer = null;
|
||||||
|
|
||||||
|
final path = await _recorder.stop();
|
||||||
|
recordState.value = AudioRecordState.idle;
|
||||||
|
|
||||||
|
if (path == null) return null;
|
||||||
|
|
||||||
|
final durationSec =
|
||||||
|
(DateTime.now().millisecondsSinceEpoch - _recordStartMs!) ~/ 1000;
|
||||||
|
recordDurationMs.value = 0;
|
||||||
|
_recordStartMs = null;
|
||||||
|
|
||||||
|
// Nagyon rövid felvétel dobja el (véletlen érintés)
|
||||||
|
if (durationSec < 1) {
|
||||||
|
await File(path).delete().catchError((_) => File(path));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPS pozíció
|
||||||
|
final pos = await _currentPosition();
|
||||||
|
|
||||||
|
final audio = NoteItemAudio(
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
localPath: path,
|
||||||
|
durationSeconds: durationSec,
|
||||||
|
latitude: pos?.latitude,
|
||||||
|
longitude: pos?.longitude,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final id = await AppDatabase.instance.insertNoteAudio(audio);
|
||||||
|
return NoteItemAudio(
|
||||||
|
id: id,
|
||||||
|
noteItemId: audio.noteItemId,
|
||||||
|
localPath: audio.localPath,
|
||||||
|
durationSeconds: audio.durationSeconds,
|
||||||
|
latitude: audio.latitude,
|
||||||
|
longitude: audio.longitude,
|
||||||
|
createdAt: audio.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Felvétel megszakítása (mentés nélkül)
|
||||||
|
Future<void> cancelRecording() async {
|
||||||
|
if (recordState.value != AudioRecordState.recording) return;
|
||||||
|
_recordTimer?.cancel();
|
||||||
|
_recordTimer = null;
|
||||||
|
recordDurationMs.value = 0;
|
||||||
|
|
||||||
|
final path = await _recorder.stop();
|
||||||
|
recordState.value = AudioRecordState.idle;
|
||||||
|
if (path != null) {
|
||||||
|
await File(path).delete().catchError((_) => File(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lejátszás ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> playAudio(NoteItemAudio audio) async {
|
||||||
|
if (!audio.fileExists) {
|
||||||
|
Get.snackbar('Fájl nem található', 'A hangjegyzet fájlja törlődött.',
|
||||||
|
snackPosition: SnackPosition.BOTTOM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ha ugyanaz játszik → pause/resume toggle
|
||||||
|
if (playingAudioId.value == audio.id) {
|
||||||
|
if (playState.value == AudioPlayState.playing) {
|
||||||
|
await _player.pause();
|
||||||
|
playState.value = AudioPlayState.paused;
|
||||||
|
} else {
|
||||||
|
await _player.resume();
|
||||||
|
playState.value = AudioPlayState.playing;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Más vagy új clip
|
||||||
|
await _player.stop();
|
||||||
|
playingAudioId.value = audio.id;
|
||||||
|
playState.value = AudioPlayState.playing;
|
||||||
|
playPositionMs.value = 0;
|
||||||
|
|
||||||
|
await _player.play(DeviceFileSource(audio.localPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopPlayback() async {
|
||||||
|
await _player.stop();
|
||||||
|
playState.value = AudioPlayState.idle;
|
||||||
|
playingAudioId.value = null;
|
||||||
|
playPositionMs.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Törlés ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> deleteAudio(NoteItemAudio audio) async {
|
||||||
|
// Leállítás ha épp játszik
|
||||||
|
if (playingAudioId.value == audio.id) await stopPlayback();
|
||||||
|
|
||||||
|
final file = File(audio.localPath);
|
||||||
|
if (await file.exists()) await file.delete();
|
||||||
|
await AppDatabase.instance.deleteNoteAudio(audio.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<NoteItemAudio> updateCaption(
|
||||||
|
NoteItemAudio audio, String caption) async {
|
||||||
|
final updated = audio.copyWith(caption: caption);
|
||||||
|
await AppDatabase.instance.updateNoteAudio(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<NoteItemAudio>> loadAudios(int noteItemId) =>
|
||||||
|
AppDatabase.instance.listNoteAudios(noteItemId);
|
||||||
|
|
||||||
|
// ── Segéd ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<Position?> _currentPosition() async {
|
||||||
|
try {
|
||||||
|
return await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.medium, timeLimit: Duration(seconds: 3)),
|
||||||
|
).timeout(const Duration(seconds: 3));
|
||||||
|
} catch (_) {
|
||||||
|
return Geolocator.getLastKnownPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formázott időtartam: ms → "0:42"
|
||||||
|
String formatMs(int ms) {
|
||||||
|
final s = ms ~/ 1000;
|
||||||
|
final m = s ~/ 60;
|
||||||
|
return '$m:${(s % 60).toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_recorder.dispose();
|
||||||
|
_player.dispose();
|
||||||
|
_recordTimer?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
146
lib/services/note_photo_service.dart
Normal file
146
lib/services/note_photo_service.dart
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// lib/services/note_photo_service.dart
|
||||||
|
//
|
||||||
|
// Fotó kezelő service:
|
||||||
|
// - Kamera / galéria hozzáférés (image_picker)
|
||||||
|
// - Fájl mentés külső tárhelyre
|
||||||
|
// - SQLite CRUD
|
||||||
|
// - Opcionális Supabase Storage szinkron
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../models/note_item_photo.dart';
|
||||||
|
import '../services/app_database.dart';
|
||||||
|
|
||||||
|
class NotePhotoService extends GetxService {
|
||||||
|
static NotePhotoService get to => Get.find();
|
||||||
|
|
||||||
|
final _picker = ImagePicker();
|
||||||
|
String? _photoDir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
super.onInit();
|
||||||
|
await _initPhotoDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPhotoDir() async {
|
||||||
|
final ext = await getExternalStorageDirectory();
|
||||||
|
final dir = Directory(p.join(ext!.path, 'photos'));
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
_photoDir = dir.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fotó készítése kamerával ─────────────────────────────────────
|
||||||
|
|
||||||
|
Future<NoteItemPhoto?> takePhoto(int noteItemId) async {
|
||||||
|
return _pickAndSave(
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
source: ImageSource.camera,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fotó választása galériából ───────────────────────────────────
|
||||||
|
|
||||||
|
Future<NoteItemPhoto?> pickFromGallery(int noteItemId) async {
|
||||||
|
return _pickAndSave(
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Közös mentési logika ─────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<NoteItemPhoto?> _pickAndSave({
|
||||||
|
required int noteItemId,
|
||||||
|
required ImageSource source,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final picked = await _picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 85, // tömörítés a tárhelyért
|
||||||
|
maxWidth: 2048,
|
||||||
|
maxHeight: 2048,
|
||||||
|
);
|
||||||
|
if (picked == null) return null; // felhasználó visszalépett
|
||||||
|
|
||||||
|
// GPS pozíció a fotókészítés pillanatában
|
||||||
|
final pos = await _currentPosition();
|
||||||
|
|
||||||
|
// Fájl másolása az app saját könyvtárába
|
||||||
|
final fileName =
|
||||||
|
'photo_${noteItemId}_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final destPath = p.join(_photoDir!, fileName);
|
||||||
|
await File(picked.path).copy(destPath);
|
||||||
|
|
||||||
|
// SQLite mentés
|
||||||
|
final photo = NoteItemPhoto(
|
||||||
|
noteItemId: noteItemId,
|
||||||
|
localPath: destPath,
|
||||||
|
latitude: pos?.latitude,
|
||||||
|
longitude: pos?.longitude,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final id = await AppDatabase.instance.insertNotePhoto(photo);
|
||||||
|
return NoteItemPhoto(
|
||||||
|
id: id,
|
||||||
|
noteItemId: photo.noteItemId,
|
||||||
|
localPath: photo.localPath,
|
||||||
|
latitude: photo.latitude,
|
||||||
|
longitude: photo.longitude,
|
||||||
|
createdAt: photo.createdAt,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('NotePhotoService hiba: $e');
|
||||||
|
Get.snackbar('Hiba', 'Fotó mentése sikertelen.',
|
||||||
|
snackPosition: SnackPosition.BOTTOM);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fotó törlése ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> deletePhoto(NoteItemPhoto photo) async {
|
||||||
|
// Fájl törlése
|
||||||
|
final file = File(photo.localPath);
|
||||||
|
if (await file.exists()) await file.delete();
|
||||||
|
|
||||||
|
// SQLite törlése
|
||||||
|
await AppDatabase.instance.deleteNotePhoto(photo.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Felirat frissítése ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<NoteItemPhoto> updateCaption(
|
||||||
|
NoteItemPhoto photo, String caption) async {
|
||||||
|
final updated = photo.copyWith(caption: caption);
|
||||||
|
await AppDatabase.instance.updateNotePhoto(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GPS pozíció ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<Position?> _currentPosition() async {
|
||||||
|
try {
|
||||||
|
return await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.medium,
|
||||||
|
timeLimit: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
).timeout(const Duration(seconds: 3));
|
||||||
|
} catch (_) {
|
||||||
|
return Geolocator.getLastKnownPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fotók betöltése ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<NoteItemPhoto>> loadPhotos(int noteItemId) =>
|
||||||
|
AppDatabase.instance.listNotePhotos(noteItemId);
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:terepi_seged/enums/map_edit_tool.dart';
|
import 'package:terepi_seged/enums/map_edit_tool.dart';
|
||||||
import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart';
|
import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart';
|
||||||
|
import 'package:terepi_seged/widgets/map_edit_tools/note_audio_widget.dart';
|
||||||
|
import 'package:terepi_seged/widgets/map_edit_tools/note_photo_gallery.dart';
|
||||||
|
|
||||||
import 'color_row.dart';
|
import 'color_row.dart';
|
||||||
import 'label_field.dart';
|
import 'label_field.dart';
|
||||||
@ -66,6 +68,10 @@ class MapFeatureSaveSheet extends StatelessWidget {
|
|||||||
])
|
])
|
||||||
: const SizedBox.shrink()),
|
: const SizedBox.shrink()),
|
||||||
LabelField(ctrl: ctrl),
|
LabelField(ctrl: ctrl),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
NotePhotoGallery(noteItemId: ctrl.editingNoteItemId),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
NoteAudioWidget(noteItemId: ctrl.editingNoteItemId),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SaveSheetActions(ctrl: ctrl),
|
SaveSheetActions(ctrl: ctrl),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
471
lib/widgets/map_edit_tools/note_audio_widget.dart
Normal file
471
lib/widgets/map_edit_tools/note_audio_widget.dart
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
// Hangjegyzet widget a MapFeatureSaveSheet-ben:
|
||||||
|
// - Felvétel gomb animált mikrofonnal
|
||||||
|
// - Felvett klipek listája play/pause/delete gombokkal
|
||||||
|
// - Haladásjelző csúszka lejátszás közben
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../models/note_item_audio.dart';
|
||||||
|
import '../../services/note_audio_service.dart';
|
||||||
|
|
||||||
|
class NoteAudioWidget extends StatefulWidget {
|
||||||
|
final int? noteItemId;
|
||||||
|
const NoteAudioWidget({super.key, required this.noteItemId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NoteAudioWidget> createState() => _NoteAudioWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteAudioWidgetState extends State<NoteAudioWidget> {
|
||||||
|
List<NoteItemAudio> _audios = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (widget.noteItemId == null) return;
|
||||||
|
final list = await NoteAudioService.to.loadAudios(widget.noteItemId!);
|
||||||
|
if (mounted) setState(() => _audios = list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.noteItemId == null) {
|
||||||
|
return const _DisabledAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Fejléc sor
|
||||||
|
Row(children: [
|
||||||
|
Text('Hangjegyzetek',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
if (_audios.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text('${_audios.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Felvétel gomb
|
||||||
|
_RecordButton(
|
||||||
|
noteItemId: widget.noteItemId!,
|
||||||
|
onRecorded: (audio) {
|
||||||
|
setState(() => _audios.add(audio));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Felvett klipek listája
|
||||||
|
if (_audios.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
..._audios.map((audio) => _AudioClipTile(
|
||||||
|
audio: audio,
|
||||||
|
onDelete: () async {
|
||||||
|
await NoteAudioService.to.deleteAudio(audio);
|
||||||
|
setState(() => _audios.removeWhere((a) => a.id == audio.id));
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Felvétel gomb ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _RecordButton extends StatelessWidget {
|
||||||
|
final int noteItemId;
|
||||||
|
final ValueChanged<NoteItemAudio> onRecorded;
|
||||||
|
|
||||||
|
const _RecordButton({
|
||||||
|
required this.noteItemId,
|
||||||
|
required this.onRecorded,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final svc = NoteAudioService.to;
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
final isRecording = svc.recordState.value == AudioRecordState.recording;
|
||||||
|
final durationMs = svc.recordDurationMs.value;
|
||||||
|
|
||||||
|
return Row(children: [
|
||||||
|
// Mikrofon gomb
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => isRecording ? _stopRecording(svc) : _startRecording(svc),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isRecording
|
||||||
|
? Colors.red
|
||||||
|
: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: isRecording
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.red.withOpacity(0.4),
|
||||||
|
blurRadius: 12,
|
||||||
|
spreadRadius: 2,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isRecording ? Icons.stop : Icons.mic,
|
||||||
|
color: isRecording
|
||||||
|
? Colors.white
|
||||||
|
: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: isRecording
|
||||||
|
// Felvétel közben: időmérő + animált hullám
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
_PulsingDot(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Felvétel: ${svc.formatMs(durationMs)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Megállításhoz nyomd meg a gombot',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Alap állapot
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Hangjegyzet rögzítése',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500, fontSize: 14)),
|
||||||
|
Text('Nyomd meg a mikrofon gombot',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11, color: Colors.grey.shade500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Mégse — csak felvétel közben
|
||||||
|
if (isRecording)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await svc.cancelRecording();
|
||||||
|
},
|
||||||
|
child: const Text('Mégse', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startRecording(NoteAudioService svc) async {
|
||||||
|
await svc.startRecording(noteItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopRecording(NoteAudioService svc) async {
|
||||||
|
final audio = await svc.stopRecording(noteItemId);
|
||||||
|
if (audio != null) onRecorded(audio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Egy felvett klip sor ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AudioClipTile extends StatelessWidget {
|
||||||
|
final NoteItemAudio audio;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _AudioClipTile({
|
||||||
|
required this.audio,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final svc = NoteAudioService.to;
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
final isThisPlaying = svc.playingAudioId.value == audio.id;
|
||||||
|
final pState = svc.playState.value;
|
||||||
|
final posMs = svc.playPositionMs.value;
|
||||||
|
final totalMs = audio.durationSeconds * 1000;
|
||||||
|
|
||||||
|
// Haladás 0.0 – 1.0
|
||||||
|
final progress = (isThisPlaying && totalMs > 0)
|
||||||
|
? (posMs / totalMs).clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isThisPlaying
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.4)
|
||||||
|
: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: isThisPlaying
|
||||||
|
? Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.4))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
// Play / Pause gomb
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => svc.playAudio(audio),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isThisPlaying && pState == AudioPlayState.playing
|
||||||
|
? Icons.pause
|
||||||
|
: Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Felirat vagy dátum
|
||||||
|
if (audio.caption.isNotEmpty)
|
||||||
|
Text(audio.caption,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
_formatDate(audio.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Haladásjelző csúszka
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: isThisPlaying && totalMs > 0
|
||||||
|
? (posMs / totalMs).clamp(0.0, 1.0)
|
||||||
|
: 0.0,
|
||||||
|
minHeight: 3,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
|
||||||
|
// Időtartam
|
||||||
|
Text(
|
||||||
|
isThisPlaying
|
||||||
|
? '${svc.formatMs(posMs)} / '
|
||||||
|
'${audio.durationFormatted}'
|
||||||
|
: audio.durationFormatted,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
|
||||||
|
// Menü: felirat / törlés
|
||||||
|
PopupMenuButton<_AudioAction>(
|
||||||
|
icon: Icon(Icons.more_vert,
|
||||||
|
size: 18, color: Colors.grey.shade500),
|
||||||
|
onSelected: (a) => _onAction(a, context),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AudioAction.caption,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.edit_outlined),
|
||||||
|
title: Text('Felirat'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AudioAction.delete,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
title:
|
||||||
|
Text('Törlés', style: TextStyle(color: Colors.red)),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAction(_AudioAction action, BuildContext context) {
|
||||||
|
switch (action) {
|
||||||
|
case _AudioAction.caption:
|
||||||
|
_editCaption();
|
||||||
|
case _AudioAction.delete:
|
||||||
|
_confirmDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editCaption() async {
|
||||||
|
final ctrl = TextEditingController(text: audio.caption);
|
||||||
|
await Get.dialog(AlertDialog(
|
||||||
|
title: const Text('Felirat'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: const InputDecoration(hintText: 'Hangjegyzet leírása...'),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Get.back();
|
||||||
|
await NoteAudioService.to.updateCaption(audio, ctrl.text.trim());
|
||||||
|
},
|
||||||
|
child: const Text('Mentés'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete() async {
|
||||||
|
final ok = await Get.dialog<bool>(AlertDialog(
|
||||||
|
title: const Text('Hangjegyzet törlése'),
|
||||||
|
content: const Text('Ez a felvétel véglegesen törlődik.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Get.back(result: true),
|
||||||
|
child: const Text('Törlés'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
if (ok == true) onDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) =>
|
||||||
|
'${dt.year}.${dt.month.toString().padLeft(2, '0')}.'
|
||||||
|
'${dt.day.toString().padLeft(2, '0')} '
|
||||||
|
'${dt.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Segéd widgetek ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _DisabledAudio extends StatelessWidget {
|
||||||
|
const _DisabledAudio();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text('Mentés után adható hangjegyzet',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PulsingDot extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_PulsingDot> createState() => _PulsingDotState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PulsingDotState extends State<_PulsingDot>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _ctrl;
|
||||||
|
late Animation<double> _anim;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this, duration: const Duration(milliseconds: 600))
|
||||||
|
..repeat(reverse: true);
|
||||||
|
_anim = Tween(begin: 0.3, end: 1.0).animate(_ctrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
|
animation: _anim,
|
||||||
|
builder: (_, __) => Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.withOpacity(_anim.value),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AudioAction { caption, delete }
|
||||||
457
lib/widgets/map_edit_tools/note_photo_gallery.dart
Normal file
457
lib/widgets/map_edit_tools/note_photo_gallery.dart
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
// lib/widgets/map_edit_tools/note_photo_gallery.dart
|
||||||
|
//
|
||||||
|
// Fotó galéria a MapFeatureSaveSheet-ben:
|
||||||
|
// - Vízszintes görgetős sor
|
||||||
|
// - + gomb: kamera / galéria választó
|
||||||
|
// - Fotóra koppintva: teljes képernyős nézet + felirat szerkesztés
|
||||||
|
// - Fotóra hosszan nyomva: törlés
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../models/note_item_photo.dart';
|
||||||
|
import '../../services/note_photo_service.dart';
|
||||||
|
|
||||||
|
class NotePhotoGallery extends StatefulWidget {
|
||||||
|
/// A szerkesztett NoteItem id-ja — null ha az elem még nincs elmentve
|
||||||
|
final int? noteItemId;
|
||||||
|
|
||||||
|
const NotePhotoGallery({super.key, required this.noteItemId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NotePhotoGallery> createState() => _NotePhotoGalleryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotePhotoGalleryState extends State<NotePhotoGallery> {
|
||||||
|
List<NoteItemPhoto> _photos = [];
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPhotos() async {
|
||||||
|
if (widget.noteItemId == null) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
_photos = await NotePhotoService.to.loadPhotos(widget.noteItemId!);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// NoteItem nem mentett még — nem lehet fotót hozzáadni
|
||||||
|
if (widget.noteItemId == null) {
|
||||||
|
return const _DisabledGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Text('Fotók',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
if (_photos.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_photos.length}',
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_loading)
|
||||||
|
const SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: 88,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
// Meglévő fotók
|
||||||
|
..._photos.map((photo) => _PhotoThumb(
|
||||||
|
photo: photo,
|
||||||
|
onTap: () => _openViewer(photo),
|
||||||
|
onDelete: () => _delete(photo),
|
||||||
|
)),
|
||||||
|
|
||||||
|
// + Fotó hozzáadása gomb
|
||||||
|
_AddPhotoButton(
|
||||||
|
onCamera: () => _addPhoto(fromCamera: true),
|
||||||
|
onGallery: () => _addPhoto(fromCamera: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addPhoto({required bool fromCamera}) async {
|
||||||
|
final svc = NotePhotoService.to;
|
||||||
|
final photo = fromCamera
|
||||||
|
? await svc.takePhoto(widget.noteItemId!)
|
||||||
|
: await svc.pickFromGallery(widget.noteItemId!);
|
||||||
|
|
||||||
|
if (photo != null && mounted) {
|
||||||
|
setState(() => _photos.add(photo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(NoteItemPhoto photo) async {
|
||||||
|
final ok = await Get.dialog<bool>(AlertDialog(
|
||||||
|
title: const Text('Fotó törlése'),
|
||||||
|
content: const Text('Ez a fotó véglegesen törlődik.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Get.back(result: true),
|
||||||
|
child: const Text('Törlés'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
if (ok == true && mounted) {
|
||||||
|
await NotePhotoService.to.deletePhoto(photo);
|
||||||
|
setState(() => _photos.removeWhere((p) => p.id == photo.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openViewer(NoteItemPhoto photo) {
|
||||||
|
Get.to(() => _PhotoViewerPage(
|
||||||
|
photos: _photos,
|
||||||
|
initialIndex: _photos.indexWhere((p) => p.id == photo.id),
|
||||||
|
onCaptionSaved: (updated) {
|
||||||
|
setState(() {
|
||||||
|
final idx = _photos.indexWhere((p) => p.id == updated.id);
|
||||||
|
if (idx >= 0) _photos[idx] = updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fotó bélyegkép ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PhotoThumb extends StatelessWidget {
|
||||||
|
final NoteItemPhoto photo;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _PhotoThumb({
|
||||||
|
required this.photo,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onDelete,
|
||||||
|
child: Stack(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: photo.fileExists
|
||||||
|
? Image.file(
|
||||||
|
photo.file,
|
||||||
|
width: 80, height: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: 160, // memória optimalizálás
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.broken_image, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Felirat jelzése ha van
|
||||||
|
if (photo.caption.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(bottom: Radius.circular(8)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
photo.caption,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 9),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// GPS jelzése ha van
|
||||||
|
if (photo.location != null)
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.location_on,
|
||||||
|
color: Colors.white, size: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hozzáadás gomb ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AddPhotoButton extends StatelessWidget {
|
||||||
|
final VoidCallback onCamera;
|
||||||
|
final VoidCallback onGallery;
|
||||||
|
|
||||||
|
const _AddPhotoButton({
|
||||||
|
required this.onCamera,
|
||||||
|
required this.onGallery,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showPicker(context),
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add_a_photo_outlined,
|
||||||
|
size: 24, color: Colors.grey.shade500),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Fotó',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.grey.shade500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPicker(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SafeArea(
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt_outlined),
|
||||||
|
title: const Text('Kamera'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onCamera();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library_outlined),
|
||||||
|
title: const Text('Galéria'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onGallery();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Letiltott galéria (elem még nincs mentve) ───────────────────────────────
|
||||||
|
|
||||||
|
class _DisabledGallery extends StatelessWidget {
|
||||||
|
const _DisabledGallery();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Mentés után adhatók hozzá fotók',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Teljes képernyős fotónézegető ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PhotoViewerPage extends StatefulWidget {
|
||||||
|
final List<NoteItemPhoto> photos;
|
||||||
|
final int initialIndex;
|
||||||
|
final ValueChanged<NoteItemPhoto> onCaptionSaved;
|
||||||
|
|
||||||
|
const _PhotoViewerPage({
|
||||||
|
required this.photos,
|
||||||
|
required this.initialIndex,
|
||||||
|
required this.onCaptionSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PhotoViewerPage> createState() => _PhotoViewerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotoViewerPageState extends State<_PhotoViewerPage> {
|
||||||
|
late PageController _pageCtrl;
|
||||||
|
late int _currentIdx;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_currentIdx = widget.initialIndex;
|
||||||
|
_pageCtrl = PageController(initialPage: widget.initialIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteItemPhoto get _current => widget.photos[_currentIdx];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: widget.photos.length > 1
|
||||||
|
? Text('${_currentIdx + 1} / ${widget.photos.length}')
|
||||||
|
: null,
|
||||||
|
actions: [
|
||||||
|
// Felirat szerkesztés
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined, color: Colors.white),
|
||||||
|
tooltip: 'Felirat szerkesztése',
|
||||||
|
onPressed: _editCaption,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(children: [
|
||||||
|
// Fotó
|
||||||
|
Expanded(
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageCtrl,
|
||||||
|
itemCount: widget.photos.length,
|
||||||
|
onPageChanged: (i) => setState(() => _currentIdx = i),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final photo = widget.photos[i];
|
||||||
|
return InteractiveViewer(
|
||||||
|
child: Center(
|
||||||
|
child: photo.fileExists
|
||||||
|
? Image.file(photo.file, fit: BoxFit.contain)
|
||||||
|
: const Icon(Icons.broken_image,
|
||||||
|
color: Colors.white54, size: 64),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Felirat + helyadatok
|
||||||
|
if (_current.caption.isNotEmpty || _current.location != null)
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withOpacity(0.7),
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
16,
|
||||||
|
10 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_current.caption.isNotEmpty)
|
||||||
|
Text(_current.caption,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 14)),
|
||||||
|
if (_current.location != null)
|
||||||
|
Text(
|
||||||
|
'📍 ${_current.latitude!.toStringAsFixed(6)}, '
|
||||||
|
'${_current.longitude!.toStringAsFixed(6)}',
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editCaption() async {
|
||||||
|
final ctrl = TextEditingController(text: _current.caption);
|
||||||
|
final result = await Get.dialog<String>(AlertDialog(
|
||||||
|
title: const Text('Felirat'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Fotó leírása...',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Get.back(result: ctrl.text.trim()),
|
||||||
|
child: const Text('Mentés'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
final updated = await NotePhotoService.to.updateCaption(_current, result);
|
||||||
|
widget.onCaptionSaved(updated);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
version: 1.0.0+15
|
version: 1.0.0+15
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <3.20.0"
|
sdk: ">=3.0.0 <3.50.0"
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@ -72,6 +72,9 @@ dependencies:
|
|||||||
flutter_foreground_task: ^9.2.2
|
flutter_foreground_task: ^9.2.2
|
||||||
flutter_blue_plus: ^2.3.2
|
flutter_blue_plus: ^2.3.2
|
||||||
flutter_dotenv: ^6.0.1
|
flutter_dotenv: ^6.0.1
|
||||||
|
image_picker: ^1.2.2
|
||||||
|
record: ^7.1.0
|
||||||
|
audioplayers: ^6.7.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user