refactor: Projektstruktur in app/ und backend/ aufgeteilt
This commit is contained in:
358
app/lib/widgets/chat_photo_dialog.dart
Normal file
358
app/lib/widgets/chat_photo_dialog.dart
Normal file
@@ -0,0 +1,358 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_selector/file_selector.dart' as file_selector;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class ChatPhotoDialog extends StatefulWidget {
|
||||
const ChatPhotoDialog({super.key});
|
||||
|
||||
@override
|
||||
State<ChatPhotoDialog> createState() => _ChatPhotoDialogState();
|
||||
}
|
||||
|
||||
class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
|
||||
CameraController? _cameraController;
|
||||
bool _isCameraInitialized = false;
|
||||
bool _useCamera = false;
|
||||
bool _useFilePicker = false;
|
||||
bool _isBusy = false;
|
||||
Uint8List? _previewBytes;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detectAndInit();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cameraController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _detectAndInit() async {
|
||||
if (kIsWeb) {
|
||||
setState(() {
|
||||
_useCamera = true;
|
||||
_useFilePicker = true;
|
||||
});
|
||||
await _initializeCamera();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
setState(() {
|
||||
_useCamera = true;
|
||||
_useFilePicker = true;
|
||||
});
|
||||
await _initializeCamera();
|
||||
return;
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
case TargetPlatform.linux:
|
||||
setState(() {
|
||||
_useFilePicker = true;
|
||||
});
|
||||
return;
|
||||
default:
|
||||
setState(() {
|
||||
_errorMessage = 'Dieses Gerät unterstützt keine Fotoaufnahme.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeCamera() async {
|
||||
try {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = 'Keine Kamera gefunden.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final controller = CameraController(
|
||||
cameras.first,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
);
|
||||
await controller.initialize();
|
||||
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_cameraController = controller;
|
||||
_isCameraInitialized = true;
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Fehler beim Initialisieren der Kamera: $e',
|
||||
name: 'ChatPhotoDialog',
|
||||
);
|
||||
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = 'Kamera konnte nicht gestartet werden.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _capturePhoto() async {
|
||||
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
||||
setState(() {
|
||||
_errorMessage = 'Kamera ist nicht bereit.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState(() {
|
||||
_isBusy = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final XFile photo = await _cameraController!.takePicture();
|
||||
final Uint8List bytes = await photo.readAsBytes();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_previewBytes = bytes;
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Fehler beim Aufnehmen des Fotos: $e',
|
||||
name: 'ChatPhotoDialog',
|
||||
);
|
||||
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = 'Foto konnte nicht aufgenommen werden.';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPhotoFromFile() async {
|
||||
try {
|
||||
setState(() {
|
||||
_isBusy = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
if (kIsWeb ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux) {
|
||||
final group = file_selector.XTypeGroup(
|
||||
label: 'images',
|
||||
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
|
||||
);
|
||||
final file = await file_selector.openFile(acceptedTypeGroups: [group]);
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
final bytes = await file.readAsBytes();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_previewBytes = bytes;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: false,
|
||||
type: FileType.image,
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final picked = result.files.first;
|
||||
final bytes = picked.bytes;
|
||||
if (bytes == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Die ausgewählte Datei konnte nicht gelesen werden.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_previewBytes = bytes;
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Fehler beim Auswählen eines Fotos: $e',
|
||||
name: 'ChatPhotoDialog',
|
||||
);
|
||||
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = 'Foto konnte nicht geladen werden.';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _resetPreview() {
|
||||
setState(() {
|
||||
_previewBytes = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context).takePhoto),
|
||||
content: SizedBox(width: 320, child: _buildDialogBody()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(AppLocalizations.of(context).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_previewBytes != null && !_isBusy
|
||||
? () => Navigator.of(context).pop(_previewBytes)
|
||||
: null,
|
||||
child: Text(AppLocalizations.of(context).send),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogBody() {
|
||||
if (_previewBytes != null) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.memory(_previewBytes!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: _isBusy ? null : _resetPreview,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(AppLocalizations.of(context).retakePhoto),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange[700], size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(_errorMessage!, textAlign: TextAlign.center),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_useCamera) {
|
||||
if (!_isCameraInitialized) {
|
||||
return const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: _cameraController?.value.aspectRatio ?? (3 / 4),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CameraPreview(_cameraController!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isBusy ? null : _capturePhoto,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: Text(AppLocalizations.of(context).takePhoto),
|
||||
),
|
||||
if (_useFilePicker)
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isBusy ? null : _pickPhotoFromFile,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: Text(AppLocalizations.of(context).selectFromLibrary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_useFilePicker) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_camera_back,
|
||||
color: Colors.deepPurple[400],
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Wähle ein Foto von deinem Gerät aus.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isBusy ? null : _pickPhotoFromFile,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: Text(AppLocalizations.of(context).selectPhoto),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox(
|
||||
height: 160,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
app/lib/widgets/offline_banner.dart
Normal file
168
app/lib/widgets/offline_banner.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
import 'package:votianlt_app/services/websocket_service.dart';
|
||||
import 'package:votianlt_app/services/dart_mq.dart';
|
||||
|
||||
class OfflineBanner extends StatefulWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
State<OfflineBanner> createState() => _OfflineBannerState();
|
||||
}
|
||||
|
||||
class _OfflineBannerState extends State<OfflineBanner> {
|
||||
final StompService _stompService = StompService();
|
||||
DartMQSubscription? _connSub;
|
||||
Timer? _countdownTimer;
|
||||
int _secondsToRetry = 15;
|
||||
bool _hadConnection = false; // Track if we ever had a successful connection
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Check if we're already connected (e.g., coming back to this screen)
|
||||
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
|
||||
// Initialize countdown based on current connection state
|
||||
_onConnectionChange(_stompService.isConnected && _stompService.isAuthenticated);
|
||||
_connSub = DartMQ().subscribe<bool>(MQTopics.connectionStatus, _onConnectionChange);
|
||||
}
|
||||
|
||||
void _onConnectionChange(bool isConnected) {
|
||||
if (!mounted) return;
|
||||
if (isConnected) {
|
||||
_hadConnection = true; // Mark that we had a successful connection
|
||||
_stopCountdown();
|
||||
setState(() {});
|
||||
} else {
|
||||
_startCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_stopCountdown();
|
||||
setState(() {
|
||||
_secondsToRetry = 15;
|
||||
});
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
|
||||
if (!mounted) return;
|
||||
if (_stompService.isConnected) {
|
||||
_stopCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrement until 0, then attempt reconnect
|
||||
if (_secondsToRetry > 1) {
|
||||
setState(() {
|
||||
_secondsToRetry = _secondsToRetry - 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show 0 for one tick and try to reconnect now
|
||||
setState(() {
|
||||
_secondsToRetry = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
// Only auto-reconnect if we already know the target; discovery remains user-initiated
|
||||
await _stompService.connect();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log('Auto-reconnect attempt failed: $e', name: 'OfflineBanner');
|
||||
developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (!_stompService.isConnected) {
|
||||
// Still offline -> reset countdown for next attempt
|
||||
setState(() {
|
||||
_secondsToRetry = 15;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopCountdown() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopCountdown();
|
||||
_connSub?.cancel();
|
||||
_connSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOnline = _stompService.isConnected && _stompService.isAuthenticated;
|
||||
if (isOnline) return const SizedBox.shrink();
|
||||
|
||||
// Different messages for initial connection vs connection lost
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color? bgColor;
|
||||
final Color? iconColor;
|
||||
final Color? titleColor;
|
||||
final Color? subtitleColor;
|
||||
|
||||
if (_hadConnection) {
|
||||
// Connection was lost
|
||||
title = 'Offline – Verbindung verloren';
|
||||
subtitle = 'Verbindung wird wiederhergestellt.';
|
||||
icon = Icons.wifi_off;
|
||||
bgColor = Colors.red[50];
|
||||
iconColor = Colors.red[700];
|
||||
titleColor = Colors.red[900];
|
||||
subtitleColor = Colors.red[800];
|
||||
} else {
|
||||
// Initial connection attempt
|
||||
title = 'Verbinde mit Server...';
|
||||
subtitle = 'Bitte warten.';
|
||||
icon = Icons.sync;
|
||||
bgColor = Colors.orange[50];
|
||||
iconColor = Colors.orange[700];
|
||||
titleColor = Colors.orange[900];
|
||||
subtitleColor = Colors.orange[800];
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
color: bgColor,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: titleColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: subtitleColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user