more localization

This commit is contained in:
stopflock
2025-08-31 14:01:03 -05:00
parent 3150297bb0
commit f05a31f40b
21 changed files with 1963 additions and 1184 deletions
+170 -163
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import 'refine_tags_sheet.dart';
class AddNodeSheet extends StatelessWidget {
@@ -13,181 +14,187 @@ class AddNodeSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
void _commit() {
appState.commitSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Node queued for upload')),
);
}
void _commit() {
appState.commitSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
);
}
void _cancel() {
appState.cancelSession();
Navigator.pop(context);
}
void _cancel() {
appState.cancelSession();
Navigator.pop(context);
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateSession(operatorProfile: result);
}
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateSession(operatorProfile: result);
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateSession(profile: p ?? session.profile),
),
),
ListTile(
title: Text('Direction ${session.directionDegrees.round()}°'),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'You must be logged in to submit new nodes. Please log in via Settings.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('addNode.profile')),
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateSession(profile: p ?? session.profile),
),
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to submit new nodes.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
ListTile(
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile is for map viewing only. Please select a submittable profile to submit new nodes.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? 'Refine Tags (${session.operatorProfile!.name})'
: 'Refine Tags'),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: const Text('Submit'),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.mustBeLoggedIn'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.enableSubmittableProfile'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileViewOnlyWarning'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
: locService.t('addNode.refineTags')),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: Text(locService.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: Text(locService.t('actions.submit')),
),
),
],
),
),
const SizedBox(height: 20),
],
),
const SizedBox(height: 20),
],
),
);
},
);
}
}
+187 -177
View File
@@ -6,6 +6,7 @@ import 'dart:math' as math;
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/offline_area_service.dart';
import '../services/offline_areas/offline_tile_utils.dart';
@@ -87,193 +88,202 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final bounds = widget.controller.camera.visibleBounds;
final maxZoom = _zoom.toInt();
final isOfflineMode = appState.offlineMode;
// Use the calculated max possible zoom instead of fixed span
final sliderMin = _minZoom?.toDouble() ?? 12.0;
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final bounds = widget.controller.camera.visibleBounds;
final maxZoom = _zoom.toInt();
final isOfflineMode = appState.offlineMode;
// Use the calculated max possible zoom instead of fixed span
final sliderMin = _minZoom?.toDouble() ?? 12.0;
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
return AlertDialog(
title: Row(
children: const [
Icon(Icons.download_for_offline),
SizedBox(width: 10),
Text("Download Map Area"),
],
),
content: SizedBox(
width: 350,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return AlertDialog(
title: Row(
children: [
const Icon(Icons.download_for_offline),
const SizedBox(width: 10),
Text(locService.t('download.title')),
],
),
content: SizedBox(
width: 350,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Max zoom level'),
Text('Z${_zoom.toStringAsFixed(0)}'),
],
),
Slider(
min: sliderMin,
max: sliderMax,
divisions: sliderDivisions,
label: 'Z${_zoom.toStringAsFixed(0)}',
value: sliderValue,
onChanged: (v) {
setState(() => _zoom = v);
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Storage estimate:'),
Expanded(
child: Text(
_mbEstimate == null
? ''
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB',
textAlign: TextAlign.end,
style: const TextStyle(fontSize: 14),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('download.maxZoomLevel')),
Text('Z${_zoom.toStringAsFixed(0)}'),
],
),
],
),
if (_minZoom != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Min zoom:'),
Text('Z$_minZoom'),
],
),
if (_maxPossibleZoom != null && _tileCount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Slider(
min: sliderMin,
max: sliderMax,
divisions: sliderDivisions,
label: 'Z${_zoom.toStringAsFixed(0)}',
value: sliderValue,
onChanged: (v) {
setState(() => _zoom = v);
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('download.storageEstimate')),
Expanded(
child: Text(
_mbEstimate == null
? ''
: locService.t('download.tilesAndSize', params: [
_tileCount.toString(),
_mbEstimate!.toStringAsFixed(1)
]),
textAlign: TextAlign.end,
style: const TextStyle(fontSize: 14),
),
),
],
),
if (_minZoom != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Max recommended zoom: Z$_maxPossibleZoom',
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[700]
: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit'
: 'Within ${kMaxReasonableTileCount.toString()} tile limit',
style: TextStyle(
fontSize: 11,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[600]
: Colors.green[600],
),
),
Text(locService.t('download.minZoom')),
Text('Z$_minZoom'),
],
),
),
),
if (isOfflineMode)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Downloads disabled while in offline mode. Disable offline mode to download new areas.',
style: TextStyle(
fontSize: 12,
color: Colors.orange[700],
if (_maxPossibleZoom != null && _tileCount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[700]
: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? locService.t('download.exceedsTileLimit', params: [kMaxReasonableTileCount.toString()])
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
style: TextStyle(
fontSize: 11,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[600]
: Colors.green[600],
),
),
],
),
],
),
),
),
),
if (isOfflineMode)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('download.offlineModeWarning'),
style: TextStyle(
fontSize: 12,
color: Colors.orange[700],
),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.cancel),
),
ElevatedButton(
onPressed: isOfflineMode ? null : () async {
try {
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
final dir = "${appDocDir.path}/$id";
// Get current tile provider info
final appState = context.read<AppState>();
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
// Fire and forget: don't await download, so dialog closes immediately
// ignore: unawaited_futures
OfflineAreaService().downloadArea(
id: id,
bounds: bounds,
minZoom: _minZoom ?? 12,
maxZoom: maxZoom,
directory: dir,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: selectedProvider?.id,
tileProviderName: selectedProvider?.name,
tileTypeId: selectedTileType?.id,
tileTypeName: selectedTileType?.name,
);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('download.downloadStarted')),
),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
),
);
}
},
child: Text(locService.download),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isOfflineMode ? null : () async {
try {
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
final dir = "${appDocDir.path}/$id";
// Get current tile provider info
final appState = context.read<AppState>();
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
// Fire and forget: don't await download, so dialog closes immediately
// ignore: unawaited_futures
OfflineAreaService().downloadArea(
id: id,
bounds: bounds,
minZoom: _minZoom ?? 12,
maxZoom: maxZoom,
directory: dir,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: selectedProvider?.id,
tileProviderName: selectedProvider?.name,
tileTypeId: selectedTileType?.id,
tileTypeName: selectedTileType?.name,
);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download started! Fetching tiles and cameras...'),
),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start download: $e'),
),
);
}
},
child: const Text('Download'),
),
],
);
},
);
}
}
+191 -184
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
@@ -14,203 +15,209 @@ class EditNodeSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
void _commit() {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Node edit queued for upload')),
);
}
void _commit() {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
);
}
void _cancel() {
appState.cancelEditSession();
Navigator.pop(context);
}
void _cancel() {
appState.cancelEditSession();
Navigator.pop(context);
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateEditSession(operatorProfile: result);
}
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateEditSession(operatorProfile: result);
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 8),
Text(
'Edit Node #${session.originalNode.id}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateEditSession(profile: p ?? session.profile),
),
),
ListTile(
title: Text('Direction ${session.directionDegrees.round()}°'),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'You must be logged in to edit nodes. Please log in via Settings.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
const SizedBox(height: 8),
Text(
locService.t('editNode.title', params: [session.originalNode.id.toString()]),
style: Theme.of(context).textTheme.titleMedium,
),
)
else if (isSandboxMode)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.',
style: TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('editNode.profile')),
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateEditSession(profile: p ?? session.profile),
),
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to edit nodes.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
ListTile(
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile is for map viewing only. Please select a submittable profile to edit nodes.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? 'Refine Tags (${session.operatorProfile!.name})'
: 'Refine Tags'),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: const Text('Save Edit'),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.mustBeLoggedIn'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (isSandboxMode)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blue, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.sandboxModeWarning'),
style: const TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.enableSubmittableProfile'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileViewOnlyWarning'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
: locService.t('editNode.refineTags')),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: Text(locService.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: Text(locService.t('actions.saveEdit')),
),
),
],
),
),
const SizedBox(height: 20),
],
),
const SizedBox(height: 20),
],
),
);
},
);
}
}