ug_locations 0.3.0
ug_locations: ^0.3.0 copied to clipboard
Offline Flutter/Dart library for Uganda's administrative-unit hierarchy: village, parish, subcounty, and district lookup with fuzzy search, backed by SQLite.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:ug_locations/ug_locations.dart';
void main() {
// Desktop platforms need the FFI-based sqflite implementation; Android/iOS
// work out of the box with the default sqflite plugin; web needs neither
// (ug_locations uses its own sqlite3-wasm backend there). `Platform` isn't
// usable on web at all - it throws on first access - so `kIsWeb` must be
// checked first to short-circuit before touching it.
if (!kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS)) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
runApp(const UgLocationsExampleApp());
}
class UgLocationsExampleApp extends StatelessWidget {
const UgLocationsExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ug_locations example',
theme: ThemeData(colorSchemeSeed: Colors.green, useMaterial3: true),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
UgandaLocations? _ug;
List<UgandaLocation> _searchResults = <UgandaLocation>[];
bool _includeRegionHierarchy = false;
UgandaLocation? _searchFieldSelection;
UgandaLocation? _pickerSelection;
List<String> _districts = <String>[];
List<String> _subcounties = <String>[];
List<String> _parishes = <String>[];
List<String> _villages = <String>[];
String? _selectedDistrict;
String? _selectedSubcounty;
String? _selectedParish;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final ug = await UgandaLocations.getInstance();
final districts = await ug.getDistricts();
setState(() {
_ug = ug;
_districts = districts;
});
}
Future<void> _onSearchChanged(String query) async {
final ug = _ug;
if (ug == null || query.trim().isEmpty) {
setState(() => _searchResults = <UgandaLocation>[]);
return;
}
final results = await ug.search(query, limit: 20);
setState(() => _searchResults = results);
}
Future<void> _onDistrictSelected(String? district) async {
final ug = _ug;
setState(() {
_selectedDistrict = district;
_selectedSubcounty = null;
_selectedParish = null;
_subcounties = <String>[];
_parishes = <String>[];
_villages = <String>[];
});
if (ug == null || district == null) return;
final subcounties = await ug.getSubcountiesInDistrict(district);
setState(() => _subcounties = subcounties);
}
Future<void> _onSubcountySelected(String? subcounty) async {
final ug = _ug;
setState(() {
_selectedSubcounty = subcounty;
_selectedParish = null;
_parishes = <String>[];
_villages = <String>[];
});
if (ug == null || subcounty == null || _selectedDistrict == null) return;
final parishes = await ug.getParishesInSubcounty(_selectedDistrict!, subcounty);
setState(() => _parishes = parishes);
}
Future<void> _onParishSelected(String? parish) async {
final ug = _ug;
setState(() {
_selectedParish = parish;
_villages = <String>[];
});
if (ug == null ||
parish == null ||
_selectedDistrict == null ||
_selectedSubcounty == null) {
return;
}
final villages = await ug.getVillagesInParish(
_selectedDistrict!,
_selectedSubcounty!,
parish,
);
setState(() => _villages = villages);
}
@override
Widget build(BuildContext context) {
if (_ug == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('ug_locations example')),
body: DefaultTabController(
length: 3,
child: Column(
children: [
const TabBar(
tabs: [
Tab(text: 'Search'),
Tab(text: 'Cascading selector'),
Tab(text: 'Bundled widgets'),
],
),
Expanded(
child: TabBarView(
children: [_buildSearchTab(), _buildSelectorTab(), _buildBundledWidgetsTab()],
),
),
],
),
),
);
}
/// Demonstrates the ready-made [LocationSearchField] and [LocationPicker]
/// widgets exported by the package, as an alternative to the hand-rolled
/// UI in the other two tabs. Selections are kept on screen (not just in a
/// transient snackbar) so this tab is screenshot-friendly.
Widget _buildBundledWidgetsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('LocationSearchField', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
LocationSearchField(
onSelected: (loc) => setState(() => _searchFieldSelection = loc),
initialValue: const TextEditingValue(text: 'KASAMBYA I'),
debounceDuration: const Duration(milliseconds: 300),
),
const SizedBox(height: 8),
_SelectionSummary(location: _searchFieldSelection),
const SizedBox(height: 24),
Text('LocationPicker', style: Theme.of(context).textTheme.titleMedium),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include region / sub-region levels'),
value: _includeRegionHierarchy,
onChanged: (value) => setState(() {
_includeRegionHierarchy = value;
_pickerSelection = null;
}),
),
const SizedBox(height: 8),
// Rebuild with a new key when the toggle flips so LocationPicker's
// internal cascade state resets cleanly instead of carrying over
// selections made under the other mode.
LocationPicker(
key: ValueKey(_includeRegionHierarchy),
includeRegionHierarchy: _includeRegionHierarchy,
initialLocation: _pickerSelection,
onSelected: (loc) => setState(() => _pickerSelection = loc),
),
const SizedBox(height: 8),
_SelectionSummary(location: _pickerSelection),
],
),
);
}
Widget _buildSearchTab() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Search village / parish / subcounty / district',
border: OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final loc = _searchResults[index];
return ListTile(
title: Text(loc.village),
subtitle: Text('${loc.parish} → ${loc.subcounty} → ${loc.district}'),
);
},
),
),
],
),
);
}
Widget _buildSelectorTab() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedDistrict,
decoration: const InputDecoration(labelText: 'District'),
items: [
for (final d in _districts.toSet())
DropdownMenuItem(value: d, child: Text(d)),
],
onChanged: _onDistrictSelected,
),
DropdownButtonFormField<String>(
initialValue: _selectedSubcounty,
decoration: const InputDecoration(labelText: 'Subcounty'),
items: [
for (final s in _subcounties.toSet())
DropdownMenuItem(value: s, child: Text(s)),
],
onChanged: _subcounties.isEmpty ? null : _onSubcountySelected,
),
DropdownButtonFormField<String>(
initialValue: _selectedParish,
decoration: const InputDecoration(labelText: 'Parish'),
items: [
// Source data can list the same parish name more than once
// within a subcounty (e.g. ARUA / ARIVU has two entries) -
// dedupe since dropdown items must have unique values.
for (final p in _parishes.toSet())
DropdownMenuItem(value: p, child: Text(p)),
],
onChanged: _parishes.isEmpty ? null : _onParishSelected,
),
const SizedBox(height: 16),
Text('Villages (${_villages.length})', style: Theme.of(context).textTheme.titleMedium),
Expanded(
child: ListView.builder(
itemCount: _villages.length,
itemBuilder: (context, index) => ListTile(title: Text(_villages[index])),
),
),
],
),
);
}
}
/// Renders the full hierarchy of a selected [UgandaLocation] as a card, or a
/// placeholder prompt if nothing has been selected yet.
class _SelectionSummary extends StatelessWidget {
const _SelectionSummary({required this.location});
final UgandaLocation? location;
@override
Widget build(BuildContext context) {
final loc = location;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: loc == null
? Text(
'No selection yet',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).hintColor),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loc.village, style: Theme.of(context).textTheme.titleSmall),
Text('Parish: ${loc.parish}'),
Text('Subcounty: ${loc.subcounty}'),
Text('County: ${loc.county ?? '—'}'),
Text('District: ${loc.district}'),
Text('Sub-region: ${loc.subRegion ?? '—'}'),
Text('Region: ${loc.region ?? '—'}'),
],
),
),
);
}
}