feat: Adresszusatz (Ortsteil/Etage/Hinweis) anzeigen, Land nur im Ausland

- Address.addition aus dem Backend (ERP-Freitext, praktisch Ortsteil,
  Etage oder Hinweis); API-Client neu generiert.
- Listen (Sortieren, Beladen, Liefer-Übersicht): Adresse mit Zusatz in
  Klammern, z. B. „… 41472 Neuss (Holzheim)".
- Lieferdetails: „Zusatz: …" unter der Anschrift von Empfänger und
  Besteller; Land nur außerhalb Deutschlands.
- Maps-Suche weiter ohne Zusatz (sonst stört „3 Etage" die Suche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 16:13:28 +02:00
parent d650c735f8
commit 335cf02a9f
13 changed files with 142 additions and 14 deletions

View File

@ -31,6 +31,7 @@ extension ApiAddressMapper on api.Address {
postalCode: postalCode, postalCode: postalCode,
city: city, city: city,
country: country, country: country,
addition: addition,
); );
} }

View File

@ -535,6 +535,7 @@ class TourRepositoryImpl implements TourRepository {
postalCode: snap['postalCode'] as String, postalCode: snap['postalCode'] as String,
city: snap['city'] as String, city: snap['city'] as String,
country: snap['country'] as String, country: snap['country'] as String,
addition: snap['addition'] as String?,
), ),
erpBelegartId: (j['erpBelegartId'] as num).toInt(), erpBelegartId: (j['erpBelegartId'] as num).toInt(),
erpBelegnummer: j['erpBelegnummer'] as String, erpBelegnummer: j['erpBelegnummer'] as String,

View File

@ -12,17 +12,44 @@ class Address {
required this.postalCode, required this.postalCode,
required this.city, required this.city,
required this.country, required this.country,
this.addition,
}); });
final String street; final String street;
final String houseNumber; final String houseNumber;
final String postalCode; final String postalCode;
final String city; final String city;
/// Land laut ERP-Länderstamm (z. B. „Deutschland"), leer wenn unbekannt.
final String country; final String country;
/// Einzeilige Darstellung für Listen/Header. /// Freier Adresszusatz aus dem ERP — praktisch Ortsteil, Etage oder
String get oneLine => /// Hinweis („Holzheim", „3 Etage", „Parterre"). `null`, wenn leer.
'$street $houseNumber, $postalCode $city'; final String? addition;
static const Set<String> _germany = {
'd', 'de', 'deu', 'deutschland', 'germany', 'brd',
};
/// Land für die Anzeige — nur außerhalb Deutschlands, sonst `null`.
String? get foreignCountry {
final c = country.trim();
if (c.isEmpty || _germany.contains(c.toLowerCase())) return null;
return c;
}
/// Einzeilige Anschrift „Straße Nr, PLZ Ort" (+ Land, falls Ausland).
/// Ohne Zusatz — so auch als Such-Query für Maps geeignet.
String get oneLine => [
'$street $houseNumber, $postalCode $city',
if (foreignCountry case final c?) c,
].join(', ');
/// Wie [oneLine], mit Adresszusatz in Klammern — für Listen, damit der
/// Fahrer Ortsteil/Etage auf einen Blick sieht.
String get oneLineWithAddition {
final a = addition?.trim();
return a == null || a.isEmpty ? oneLine : '$oneLine ($a)';
}
Address copyWith({ Address copyWith({
String? street, String? street,
@ -30,6 +57,7 @@ class Address {
String? postalCode, String? postalCode,
String? city, String? city,
String? country, String? country,
String? addition,
}) { }) {
return Address( return Address(
street: street ?? this.street, street: street ?? this.street,
@ -37,6 +65,7 @@ class Address {
postalCode: postalCode ?? this.postalCode, postalCode: postalCode ?? this.postalCode,
city: city ?? this.city, city: city ?? this.city,
country: country ?? this.country, country: country ?? this.country,
addition: addition ?? this.addition,
); );
} }
@ -48,8 +77,10 @@ class Address {
other.houseNumber == houseNumber && other.houseNumber == houseNumber &&
other.postalCode == postalCode && other.postalCode == postalCode &&
other.city == city && other.city == city &&
other.country == country; other.country == country &&
other.addition == addition;
@override @override
int get hashCode => Object.hash(street, houseNumber, postalCode, city, country); int get hashCode =>
Object.hash(street, houseNumber, postalCode, city, country, addition);
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/domain/entity/address.dart';
import 'package:hl_lieferservice/domain/entity/contact_source.dart'; import 'package:hl_lieferservice/domain/entity/contact_source.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart'; import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart';
@ -116,6 +117,23 @@ class _SectionHeader extends StatelessWidget {
} }
} }
// ─── Adresse ────────────────────────────────────────────────────────────
/// Zeilen unter „PLZ Ort": Land (nur Ausland) und Adresszusatz aus dem ERP
/// (Ortsteil, Etage, Hinweis — für den Fahrer vor Ort wichtig).
List<Widget> _addressExtras(Address address, {required double fontSize}) {
final addition = address.addition?.trim();
return [
if (address.foreignCountry case final country?)
Text(country, style: TextStyle(fontSize: fontSize)),
if (addition != null && addition.isNotEmpty)
Text(
'Zusatz: $addition',
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w700),
),
];
}
// ─── Abweichender Empfänger ───────────────────────────────────────────── // ─── Abweichender Empfänger ─────────────────────────────────────────────
/// Hervorgehobene Karte, wenn Lieferadresse oder Empfänger vom Besteller /// Hervorgehobene Karte, wenn Lieferadresse oder Empfänger vom Besteller
@ -210,6 +228,7 @@ class _DifferentRecipientCard extends StatelessWidget {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
..._addressExtras(address, fontSize: 15),
], ],
), ),
), ),
@ -701,6 +720,7 @@ class _CustomerCard extends StatelessWidget {
'${c.address.postalCode} ${c.address.city}', '${c.address.postalCode} ${c.address.city}',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
..._addressExtras(c.address, fontSize: 14),
], ],
), ),
), ),

View File

@ -439,7 +439,7 @@ class _NextDeliveryCard extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
_InfoRow( _InfoRow(
icon: Icons.location_on_outlined, icon: Icons.location_on_outlined,
text: recipient.address.oneLine, text: recipient.address.oneLineWithAddition,
emphasized: true, emphasized: true,
), ),
if (recipient.differsFromOrderer) if (recipient.differsFromOrderer)
@ -846,7 +846,7 @@ class _DeliveryTile extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
recipient.address.oneLine, recipient.address.oneLineWithAddition,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,

View File

@ -637,7 +637,7 @@ class _CustomerBody extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
recipient.address.oneLine, recipient.address.oneLineWithAddition,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (recipient.differsFromOrderer) if (recipient.differsFromOrderer)

View File

@ -556,7 +556,7 @@ class _OverviewTile extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
recipient.address.oneLine, recipient.address.oneLineWithAddition,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,

View File

@ -60,7 +60,7 @@ class RecipientSubtitle extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(recipient.address.oneLine, style: addressStyle), Text(recipient.address.oneLineWithAddition, style: addressStyle),
if (recipient.differsFromOrderer) if (recipient.differsFromOrderer)
OrdererHint(ordererName: ordererName), OrdererHint(ordererName: ordererName),
], ],

View File

@ -2021,11 +2021,19 @@
"country" "country"
], ],
"properties": { "properties": {
"addition": {
"type": [
"string",
"null"
],
"description": "Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das\ndie Sachbearbeitung praktisch f\u00fcr Ortsteil, Etage oder Hinweise nutzt\n(z. B. \u201eHolzheim\", \u201e3. Etage\", \u201eParterre\"). `None`, wenn leer."
},
"city": { "city": {
"type": "string" "type": "string"
}, },
"country": { "country": {
"type": "string" "type": "string",
"description": "Land laut ERP-L\u00e4nderstamm (`Adressen.LandID` \u2192 `Laender.Land`), z. B.\n\u201eDeutschland\" oder \u201eBelgien\". Leer, wenn im ERP kein Land verkn\u00fcpft ist."
}, },
"houseNumber": { "houseNumber": {
"type": "string" "type": "string"

View File

@ -8,8 +8,9 @@ import 'package:holzleitner_api/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**addition** | **String** | Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer. | [optional]
**city** | **String** | | **city** | **String** | |
**country** | **String** | | **country** | **String** | Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist. |
**houseNumber** | **String** | | **houseNumber** | **String** | |
**postalCode** | **String** | | **postalCode** | **String** | |
**street** | **String** | | **street** | **String** | |

View File

@ -11,16 +11,22 @@ part 'address.g.dart';
/// Postanschrift — wird sowohl als aktuelle Kundenanschrift in [`Customer`] als auch als unveränderlicher Snapshot in [`crate::domain::Delivery`] verwendet (`delivery_address_snapshot`). Bewusst als Value Object modelliert: gleiche Adresse = gleicher Wert. Strikte Equality erleichtert Sync-Diffs zwischen ERP und Backend. [`Customer`]: crate::domain::Customer /// Postanschrift — wird sowohl als aktuelle Kundenanschrift in [`Customer`] als auch als unveränderlicher Snapshot in [`crate::domain::Delivery`] verwendet (`delivery_address_snapshot`). Bewusst als Value Object modelliert: gleiche Adresse = gleicher Wert. Strikte Equality erleichtert Sync-Diffs zwischen ERP und Backend. [`Customer`]: crate::domain::Customer
/// ///
/// Properties: /// Properties:
/// * [addition] - Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer.
/// * [city] /// * [city]
/// * [country] /// * [country] - Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist.
/// * [houseNumber] /// * [houseNumber]
/// * [postalCode] /// * [postalCode]
/// * [street] /// * [street]
@BuiltValue() @BuiltValue()
abstract class Address implements Built<Address, AddressBuilder> { abstract class Address implements Built<Address, AddressBuilder> {
/// Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer.
@BuiltValueField(wireName: r'addition')
String? get addition;
@BuiltValueField(wireName: r'city') @BuiltValueField(wireName: r'city')
String get city; String get city;
/// Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist.
@BuiltValueField(wireName: r'country') @BuiltValueField(wireName: r'country')
String get country; String get country;
@ -56,6 +62,13 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
Address object, { Address object, {
FullType specifiedType = FullType.unspecified, FullType specifiedType = FullType.unspecified,
}) sync* { }) sync* {
if (object.addition != null) {
yield r'addition';
yield serializers.serialize(
object.addition,
specifiedType: const FullType.nullable(String),
);
}
yield r'city'; yield r'city';
yield serializers.serialize( yield serializers.serialize(
object.city, object.city,
@ -104,6 +117,14 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
final key = serializedList[i] as String; final key = serializedList[i] as String;
final value = serializedList[i + 1]; final value = serializedList[i + 1];
switch (key) { switch (key) {
case r'addition':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.addition = valueDes;
break;
case r'city': case r'city':
final valueDes = serializers.deserialize( final valueDes = serializers.deserialize(
value, value,

View File

@ -7,6 +7,8 @@ part of 'address.dart';
// ************************************************************************** // **************************************************************************
class _$Address extends Address { class _$Address extends Address {
@override
final String? addition;
@override @override
final String city; final String city;
@override @override
@ -22,7 +24,8 @@ class _$Address extends Address {
(AddressBuilder()..update(updates))._build(); (AddressBuilder()..update(updates))._build();
_$Address._( _$Address._(
{required this.city, {this.addition,
required this.city,
required this.country, required this.country,
required this.houseNumber, required this.houseNumber,
required this.postalCode, required this.postalCode,
@ -39,6 +42,7 @@ class _$Address extends Address {
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(other, this)) return true; if (identical(other, this)) return true;
return other is Address && return other is Address &&
addition == other.addition &&
city == other.city && city == other.city &&
country == other.country && country == other.country &&
houseNumber == other.houseNumber && houseNumber == other.houseNumber &&
@ -49,6 +53,7 @@ class _$Address extends Address {
@override @override
int get hashCode { int get hashCode {
var _$hash = 0; var _$hash = 0;
_$hash = $jc(_$hash, addition.hashCode);
_$hash = $jc(_$hash, city.hashCode); _$hash = $jc(_$hash, city.hashCode);
_$hash = $jc(_$hash, country.hashCode); _$hash = $jc(_$hash, country.hashCode);
_$hash = $jc(_$hash, houseNumber.hashCode); _$hash = $jc(_$hash, houseNumber.hashCode);
@ -61,6 +66,7 @@ class _$Address extends Address {
@override @override
String toString() { String toString() {
return (newBuiltValueToStringHelper(r'Address') return (newBuiltValueToStringHelper(r'Address')
..add('addition', addition)
..add('city', city) ..add('city', city)
..add('country', country) ..add('country', country)
..add('houseNumber', houseNumber) ..add('houseNumber', houseNumber)
@ -73,6 +79,10 @@ class _$Address extends Address {
class AddressBuilder implements Builder<Address, AddressBuilder> { class AddressBuilder implements Builder<Address, AddressBuilder> {
_$Address? _$v; _$Address? _$v;
String? _addition;
String? get addition => _$this._addition;
set addition(String? addition) => _$this._addition = addition;
String? _city; String? _city;
String? get city => _$this._city; String? get city => _$this._city;
set city(String? city) => _$this._city = city; set city(String? city) => _$this._city = city;
@ -100,6 +110,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
AddressBuilder get _$this { AddressBuilder get _$this {
final $v = _$v; final $v = _$v;
if ($v != null) { if ($v != null) {
_addition = $v.addition;
_city = $v.city; _city = $v.city;
_country = $v.country; _country = $v.country;
_houseNumber = $v.houseNumber; _houseNumber = $v.houseNumber;
@ -126,6 +137,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
_$Address _build() { _$Address _build() {
final _$result = _$v ?? final _$result = _$v ??
_$Address._( _$Address._(
addition: addition,
city: BuiltValueNullFieldError.checkNotNull(city, r'Address', 'city'), city: BuiltValueNullFieldError.checkNotNull(city, r'Address', 'city'),
country: BuiltValueNullFieldError.checkNotNull( country: BuiltValueNullFieldError.checkNotNull(
country, r'Address', 'country'), country, r'Address', 'country'),

View File

@ -152,4 +152,37 @@ void main() {
expect(r.name, 'Kraus Heike'); expect(r.name, 'Kraus Heike');
expect(r.differsFromOrderer, isTrue); expect(r.differsFromOrderer, isTrue);
}); });
group('Adressanzeige', () {
const base = Address(
street: 'Am Sandberg',
houseNumber: '15',
postalCode: '41472',
city: 'Neuss',
country: 'Deutschland',
addition: 'Holzheim',
);
test('Deutschland erscheint nicht, Zusatz nur in der Listenzeile', () {
expect(base.oneLine, 'Am Sandberg 15, 41472 Neuss');
expect(base.oneLineWithAddition, 'Am Sandberg 15, 41472 Neuss (Holzheim)');
});
test('Ausland wird angehängt', () {
final belgien = base.copyWith(country: 'Belgien');
expect(belgien.foreignCountry, 'Belgien');
expect(belgien.oneLine, 'Am Sandberg 15, 41472 Neuss, Belgien');
});
test('Zusatz zählt nicht als abweichende Adresse', () {
final details = _details(
deliveryAddress: _ordererAddress.copyWith(addition: '3 Etage'),
sources: [_source(ContactRole.header, 'Kraus Heike')],
);
expect(
details.recipientOf(details.deliveries.single).differsFromOrderer,
isFalse,
);
});
});
} }