Skip to main content

turkmenistan_addresses 1.1.0 copy "turkmenistan_addresses: ^1.1.0" to clipboard
turkmenistan_addresses: ^1.1.0 copied to clipboard

Offline address search for Turkmenistan: cities and villages, streets, houses and public places. The database ships inside the package, no network needed.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:turkmenistan_addresses/turkmenistan_addresses.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Turkmenistan Addresses',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF00857A),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: const Color(0xFF00857A),
        brightness: Brightness.dark,
        useMaterial3: true,
      ),
      home: const SearchScreen(),
    );
  }
}

class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key});

  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _field = TextEditingController();
  AddressDatabase? _db;
  Object? _error;
  List<SearchHit> _hits = const [];
  Duration _spent = Duration.zero;

  @override
  void initState() {
    super.initState();
    _open();
  }

  Future<void> _open() async {
    try {
      final db = await loadTurkmenistanAddresses();
      // The keys are built once. Without the warm-up the very first
      // keystroke would pay for them — thirty milliseconds under a finger.
      db.warmUp();
      if (mounted) setState(() => _db = db);
    } catch (error) {
      if (mounted) setState(() => _error = error);
    }
  }

  void _search(String query) {
    final db = _db;
    if (db == null) return;
    // A query costs about a millisecond, so we search on every keystroke:
    // there is nothing here for a debounce to save.
    final started = Stopwatch()..start();
    final hits = db.search(query, limit: 50);
    started.stop();
    setState(() {
      _hits = hits;
      _spent = started.elapsed;
    });
  }

  @override
  void dispose() {
    _field.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Turkmenistan Addresses'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(64),
          child: Padding(
            padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
            child: SearchBar(
              controller: _field,
              hintText: 'City, street or house',
              leading: const Icon(Icons.search),
              trailing: [
                if (_field.text.isNotEmpty)
                  IconButton(
                    icon: const Icon(Icons.close),
                    onPressed: () {
                      _field.clear();
                      _search('');
                    },
                  ),
              ],
              onChanged: _search,
            ),
          ),
        ),
      ),
      body: _body(context),
    );
  }

  Widget _body(BuildContext context) {
    if (_error != null) {
      return _Centered(
        icon: Icons.error_outline,
        title: 'The database did not open',
        subtitle: '$_error',
      );
    }
    final db = _db;
    if (db == null) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_field.text.trim().isEmpty) {
      return _Welcome(db: db, onPick: (query) {
        _field.text = query;
        _search(query);
      });
    }
    if (_hits.isEmpty) {
      return const _Centered(
        icon: Icons.search_off,
        title: 'Nothing found',
        subtitle: 'Words match from their start: «rahat» will not find «Parahat»',
      );
    }
    return Column(
      children: [
        _Stats(text: '${_hits.length} matches in '
            '${(_spent.inMicroseconds / 1000).toStringAsFixed(1)} ms'),
        Expanded(
          child: ListView.separated(
            itemCount: _hits.length,
            separatorBuilder: (_, __) => const Divider(height: 1),
            itemBuilder: (context, index) => _HitTile(db: db, hit: _hits[index]),
          ),
        ),
      ],
    );
  }
}

class _HitTile extends StatelessWidget {
  const _HitTile({required this.db, required this.hit});

  final AddressDatabase db;
  final SearchHit hit;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final (icon, tint, subtitle) = switch (hit) {
      PlaceHit(:final value) => (
          _placeIcon(value.type),
          colors.primary,
          _placeLabel(value.type),
        ),
      StreetHit(:final value) => (
          Icons.signpost_outlined,
          colors.tertiary,
          [
            'street',
            if (value.place != null) value.place!.name,
            '${db.housesOn(value.id).length} houses',
          ].join(' · '),
        ),
      AddressHit(:final value) => (
          Icons.home_outlined,
          colors.secondary,
          [
            'house',
            if (value.place != null) value.place!.name,
            if (!value.streetIsExact) 'street inferred',
          ].join(' · '),
        ),
    };

    return ListTile(
      leading: CircleAvatar(
        backgroundColor: tint.withValues(alpha: 0.15),
        child: Icon(icon, color: tint, size: 20),
      ),
      title: Text(hit.title),
      subtitle: Text(subtitle),
      trailing: hit is StreetHit ? const Icon(Icons.chevron_right) : null,
      onTap: () {
        if (hit case StreetHit(:final value)) {
          Navigator.of(context).push(MaterialPageRoute(
            builder: (_) => StreetScreen(db: db, street: value),
          ));
        } else {
          _showDetails(context, db, hit);
        }
      },
    );
  }
}

IconData _placeIcon(PlaceType type) => switch (type) {
      PlaceType.city || PlaceType.town => Icons.location_city,
      PlaceType.village || PlaceType.hamlet => Icons.holiday_village_outlined,
      PlaceType.suburb || PlaceType.neighbourhood => Icons.apartment,
      PlaceType.locality => Icons.place_outlined,
    };

String _placeLabel(PlaceType type) => switch (type) {
      PlaceType.city => 'city',
      PlaceType.town => 'town',
      PlaceType.village => 'village',
      PlaceType.hamlet => 'hamlet',
      PlaceType.suburb => 'suburb',
      PlaceType.neighbourhood => 'neighbourhood',
      PlaceType.locality => 'locality',
    };

void _showDetails(BuildContext context, AddressDatabase db, SearchHit hit) {
  showModalBottomSheet<void>(
    context: context,
    showDragHandle: true,
    builder: (context) {
      final near = db.addressesNear(hit.lat, hit.lon, radiusMeters: 150, limit: 6);
      return SafeArea(
        child: ListView(
          shrinkWrap: true,
          padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
          children: [
            Text(hit.title, style: Theme.of(context).textTheme.headlineSmall),
            const SizedBox(height: 4),
            Text(
              '${hit.lat.toStringAsFixed(6)}, ${hit.lon.toStringAsFixed(6)}',
              style: Theme.of(context).textTheme.bodyMedium,
            ),
            if (hit case AddressHit(:final value)) ...[
              const SizedBox(height: 16),
              _Fact(label: 'Street', value: value.street.name),
              _Fact(
                label: 'Street came from',
                value: value.streetIsExact
                    ? 'addr:street'
                    : 'the nearest road',
              ),
              if (value.place != null)
                _Fact(label: 'Settlement', value: value.place!.name),
            ],
            const SizedBox(height: 16),
            Text('Within 150 m',
                style: Theme.of(context).textTheme.titleSmall),
            const SizedBox(height: 4),
            if (near.isEmpty)
              const Text('— nothing')
            else
              for (final house in near)
                Text('${house.street.name}, ${house.number}  ·  '
                    '${distanceMeters(hit.lat, hit.lon, house.lat, house.lon).round()} m'),
          ],
        ),
      );
    },
  );
}

class _Fact extends StatelessWidget {
  const _Fact({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 150,
            child: Text(label,
                style: TextStyle(color: Theme.of(context).hintColor)),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }
}

class StreetScreen extends StatelessWidget {
  const StreetScreen({super.key, required this.db, required this.street});

  final AddressDatabase db;
  final Street street;

  @override
  Widget build(BuildContext context) {
    // Houses are sorted by street at build time, so they come back as one
    // slice instead of a scan over seven thousand records.
    final houses = db.housesOn(street.id);
    return Scaffold(
      appBar: AppBar(
        title: Text(street.name),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(24),
          child: Padding(
            padding: const EdgeInsets.only(bottom: 8),
            child: Text(
              [
                if (street.place != null) street.place!.name,
                '${houses.length} houses',
              ].join(' · '),
            ),
          ),
        ),
      ),
      body: houses.isEmpty
          ? const _Centered(
              icon: Icons.home_outlined,
              title: 'No numbered houses',
              subtitle: '95% of buildings in OSM carry no addr:housenumber',
            )
          : ListView.separated(
              itemCount: houses.length,
              separatorBuilder: (_, __) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final house = houses[index];
                return ListTile(
                  leading: CircleAvatar(
                    backgroundColor: Theme.of(context)
                        .colorScheme
                        .secondary
                        .withValues(alpha: 0.15),
                    child: Text(house.number,
                        style: const TextStyle(fontSize: 12)),
                  ),
                  title: Text('${house.street.name}, ${house.number}'),
                  subtitle: Text(house.streetIsExact
                      ? '${house.lat.toStringAsFixed(5)}, '
                          '${house.lon.toStringAsFixed(5)}'
                      : 'street inferred · '
                          '${house.lat.toStringAsFixed(5)}, '
                          '${house.lon.toStringAsFixed(5)}'),
                  onTap: () => _showDetails(context, db, AddressHit(house)),
                );
              },
            ),
    );
  }
}

class _Welcome extends StatelessWidget {
  const _Welcome({required this.db, required this.onPick});

  final AddressDatabase db;
  final ValueChanged<String> onPick;

  static const _examples = <(String, String)>[
    ('par 2/4 1', 'words match from their start, in any order'),
    ('gorogly', 'diacritics folded: ö, ç, ň'),
    ('mary', 'the city comes before streets and houses'),
    ('gorogly kocesi gyzylarbat', 'the settlement separates namesakes'),
  ];

  @override
  Widget build(BuildContext context) {
    final style = Theme.of(context).textTheme;
    return ListView(
      padding: const EdgeInsets.all(20),
      children: [
        Text('Offline, entirely on device', style: style.titleMedium),
        const SizedBox(height: 8),
        Text(
          '${db.placeCount} settlements, ${db.streetCount} streets, '
          '${db.addressCount} houses — a 292 KB asset.',
          style: style.bodyMedium,
        ),
        const SizedBox(height: 24),
        Text('Try', style: style.titleMedium),
        const SizedBox(height: 8),
        for (final (query, hint) in _examples)
          Card(
            margin: const EdgeInsets.only(bottom: 8),
            child: ListTile(
              title: Text(query, style: const TextStyle(fontFamily: 'Menlo')),
              subtitle: Text(hint),
              onTap: () => onPick(query),
            ),
          ),
      ],
    );
  }
}

class _Stats extends StatelessWidget {
  const _Stats({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      child: Text(text, style: Theme.of(context).textTheme.labelMedium),
    );
  }
}

class _Centered extends StatelessWidget {
  const _Centered({
    required this.icon,
    required this.title,
    required this.subtitle,
  });

  final IconData icon;
  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 48, color: Theme.of(context).hintColor),
            const SizedBox(height: 12),
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 4),
            Text(subtitle,
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodySmall),
          ],
        ),
      ),
    );
  }
}
4
likes
160
points
269
downloads

Documentation

API reference

Publisher

verified publisherpetyaas.online

Weekly Downloads

Offline address search for Turkmenistan: cities and villages, streets, houses and public places. The database ships inside the package, no network needed.

Repository (GitHub)
View/report issues

Topics

#geocoding #offline #openstreetmap #addresses #turkmenistan

License

MIT (license)

Dependencies

flutter

More

Packages that depend on turkmenistan_addresses