Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion udie_backend_py/app/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def __init__(self, path: Path = DB_PATH) -> None:
def _init_schema(self) -> None:
with self._lock:
cur = self._conn.cursor()
# Enable WAL mode for better concurrent read throughput.
cur.execute("PRAGMA journal_mode=WAL")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS events_log (
Expand Down Expand Up @@ -104,6 +106,23 @@ def _init_schema(self) -> None:
)
"""
)
# Indexes to speed up bounding-box range queries on lat/lng columns.
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_events_active_lat_lng "
"ON events_active (lat, lng)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_events_active_city "
"ON events_active (city)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_risk_cells_lat_lng "
"ON risk_cells (lat, lng)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_events_log_lat_lng "
"ON events_log (lat, lng)"
)
self._conn.commit()

def append_events_log(self, events: list[DisruptionEvent]) -> list[DisruptionEvent]:
Expand Down Expand Up @@ -365,11 +384,13 @@ def readiness(self) -> dict[str, object]:
r["key"]: r["value"]
for r in cur.execute("SELECT key, value FROM system_state").fetchall()
}
row = cur.execute("SELECT COUNT(*) AS c FROM risk_cells").fetchone()
risk_cell_count = int(row["c"] if row else 0)
return {
"db": "ok" if db_ok else "error",
"lastProjectionStatus": state.get("last_projection_status", "unknown"),
"lastProjectionAt": state.get("last_projection_at", "never"),
"riskCells": self.count_risk_cells(),
"riskCells": risk_cell_count,
}

def _find_active_match(self, active: list[dict[str, object]], event: DisruptionEvent) -> dict[str, object] | None:
Expand Down
36 changes: 36 additions & 0 deletions udie_mobile/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@ import 'package:latlong2/latlong.dart';

enum SyncState { disconnected, connecting, connectedUnsynced, synced, error }

/// Canonical map from display city name → [latitude, longitude] for all
/// supported Indian cities. This is the single source of truth used by
/// both [AppStore] (validation) and the settings screen (coordinate auto-fill).
///
/// Aliases ('New Delhi', 'Bangalore') are included for backward-compatible
/// city name matching.
const Map<String, List<double>> kCityCoordinates = {
'Delhi': [28.6139, 77.2090],
'New Delhi': [28.6139, 77.2090],
'Mumbai': [19.0760, 72.8777],
'Bengaluru': [12.9716, 77.5946],
'Bangalore': [12.9716, 77.5946],
'Chennai': [13.0827, 80.2707],
'Hyderabad': [17.3850, 78.4867],
'Kolkata': [22.5726, 88.3639],
'Pune': [18.5204, 73.8567],
'Ahmedabad': [23.0225, 72.5714],
'Jaipur': [26.9124, 75.7873],
'Lucknow': [26.8467, 80.9462],
'Bhopal': [23.2599, 77.4126],
'Patna': [25.5941, 85.1376],
'Guwahati': [26.1445, 91.7362],
'Chandigarh': [30.7333, 76.7794],
'Srinagar': [34.0837, 74.7973],
'Kochi': [9.9312, 76.2673],
'Thiruvananthapuram': [8.5241, 76.9366],
'Nagpur': [21.1458, 79.0882],
'Indore': [22.7196, 75.8577],
'Surat': [21.1702, 72.8311],
'Kanpur': [26.4499, 80.3319],
'Varanasi': [25.3176, 82.9739],
'Visakhapatnam': [17.6868, 83.2185],
'Coimbatore': [11.0168, 76.9558],
'Madurai': [9.9252, 78.1198],
};

class GeoArea {
GeoArea({required this.city, required this.center, required this.radiusKm});

Expand Down
34 changes: 3 additions & 31 deletions udie_mobile/lib/src/state/app_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,6 @@ import '../api_client.dart';
import '../models.dart';

class AppStore extends ChangeNotifier {
static const Set<String> _supportedIndianCities = {
'new delhi',
'delhi',
'mumbai',
'bengaluru',
'bangalore',
'chennai',
'hyderabad',
'kolkata',
'pune',
'ahmedabad',
'jaipur',
'lucknow',
'bhopal',
'patna',
'guwahati',
'chandigarh',
'srinagar',
'kochi',
'thiruvananthapuram',
'nagpur',
'indore',
'surat',
'kanpur',
'varanasi',
'visakhapatnam',
'coimbatore',
'madurai',
};

AppStore()
: area = GeoArea(
city: 'Delhi',
Expand Down Expand Up @@ -175,7 +145,9 @@ class AppStore extends ChangeNotifier {
required double radiusKm,
}) {
final normalized = city.trim().toLowerCase();
if (!_supportedIndianCities.contains(normalized)) {
final isSupported = kCityCoordinates.keys
.any((k) => k.toLowerCase() == normalized);
if (!isSupported) {
syncState = SyncState.error;
lastError = 'Only supported Indian cities are allowed';
notifyListeners();
Expand Down
206 changes: 107 additions & 99 deletions udie_mobile/lib/src/ui/screens/home_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class HomeShell extends StatefulWidget {

class _HomeShellState extends State<HomeShell> {
int _index = 0;
late final List<Widget> _pages;

static const _destinations = [
(
Expand All @@ -47,37 +48,39 @@ class _HomeShellState extends State<HomeShell> {
];

@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.store,
builder: (context, _) {
final pages = [
MapScreen(store: widget.store),
NewsScreen(store: widget.store),
RouteScreen(store: widget.store),
SourcesScreen(store: widget.store),
];
void initState() {
super.initState();
// Pages are created once and reused. Each screen subscribes to the
// store via its own ListenableBuilder, so they update independently
// without causing the entire shell to rebuild.
_pages = [
MapScreen(store: widget.store),
NewsScreen(store: widget.store),
RouteScreen(store: widget.store),
SourcesScreen(store: widget.store),
];
}

return Container(
decoration: const BoxDecoration(
gradient: UdieTheme.bgGradient,
),
child: Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: _BlurAppBar(store: widget.store),
body: IndexedStack(
index: _index,
children: pages,
),
bottomNavigationBar: _BottomNav(
selectedIndex: _index,
onChanged: (v) => setState(() => _index = v),
destinations: _destinations,
),
),
);
},
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: UdieTheme.bgGradient,
),
child: Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: _BlurAppBar(store: widget.store),
body: IndexedStack(
index: _index,
children: _pages,
),
bottomNavigationBar: _BottomNav(
selectedIndex: _index,
onChanged: (v) => setState(() => _index = v),
destinations: _destinations,
),
),
);
}
}
Expand All @@ -94,85 +97,90 @@ class _BlurAppBar extends StatelessWidget implements PreferredSizeWidget {

@override
Widget build(BuildContext context) {
final top = MediaQuery.of(context).padding.top;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
height: kToolbarHeight + top,
decoration: BoxDecoration(
color: UdieTheme.bg.withValues(alpha: 0.72),
border: const Border(
bottom: BorderSide(color: UdieTheme.border),
),
),
padding: EdgeInsets.only(top: top, left: 16, right: 8),
child: Row(
children: [
// Logo mark
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: UdieTheme.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(UdieTheme.radiusSm),
border: Border.all(
color: UdieTheme.accent.withValues(alpha: 0.4),
),
),
child: const Icon(
Icons.radar_rounded,
color: UdieTheme.accent,
size: 18,
return ListenableBuilder(
listenable: store,
builder: (context, _) {
final top = MediaQuery.of(context).padding.top;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
height: kToolbarHeight + top,
decoration: BoxDecoration(
color: UdieTheme.bg.withValues(alpha: 0.72),
border: const Border(
bottom: BorderSide(color: UdieTheme.border),
),
),
const SizedBox(width: 10),
// Title + subtitle
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'UDIE',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: UdieTheme.textPrimary,
letterSpacing: 1.2,
padding: EdgeInsets.only(top: top, left: 16, right: 8),
child: Row(
children: [
// Logo mark
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: UdieTheme.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(UdieTheme.radiusSm),
border: Border.all(
color: UdieTheme.accent.withValues(alpha: 0.4),
),
),
Text(
'${store.area.city} · ${store.namespace}',
style: const TextStyle(
fontSize: 11,
child: const Icon(
Icons.radar_rounded,
color: UdieTheme.accent,
size: 18,
),
),
const SizedBox(width: 10),
// Title + subtitle
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'UDIE',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: UdieTheme.textPrimary,
letterSpacing: 1.2,
),
),
Text(
'${store.area.city} · ${store.namespace}',
style: const TextStyle(
fontSize: 11,
color: UdieTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Sync badge
SyncBadge(state: store.syncState),
// Refresh button
IconButton(
tooltip: 'Sync now',
onPressed: store.refreshAll,
icon: AnimatedRotation(
turns: store.syncState == SyncState.connecting ? 1 : 0,
duration: const Duration(milliseconds: 600),
child: const Icon(
Icons.sync_rounded,
size: 20,
color: UdieTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Sync badge
SyncBadge(state: store.syncState),
// Refresh button
IconButton(
tooltip: 'Sync now',
onPressed: store.refreshAll,
icon: AnimatedRotation(
turns: store.syncState == SyncState.connecting ? 1 : 0,
duration: const Duration(milliseconds: 600),
child: const Icon(
Icons.sync_rounded,
size: 20,
color: UdieTheme.textSecondary,
),
),
],
),
],
),
),
),
),
);
},
);
}
}
Expand Down
Loading
Loading