From c3d23e2f7820f2cadac2fa97134c73f486d0c5b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:11:37 +0000 Subject: [PATCH 1/3] Initial plan From a457f1c8a812e3ab22ed05e2193306b31e15828d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:21:50 +0000 Subject: [PATCH 2/3] chore: outline full improvement plan Co-authored-by: fallofpheonix <160165035+fallofpheonix@users.noreply.github.com> --- udie_mobile/test/widget_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/udie_mobile/test/widget_test.dart b/udie_mobile/test/widget_test.dart index effc698..c2867ca 100644 --- a/udie_mobile/test/widget_test.dart +++ b/udie_mobile/test/widget_test.dart @@ -168,7 +168,7 @@ void main() { test('produces correct query map keys', () { final area = GeoArea( city: 'Mumbai', - center: const LatLngCoordinate(19.076, 72.8777).toLatLng(), + center: LatLng(19.076, 72.8777), radiusKm: 10, ); final query = area.toQuery(); From 924dbcd02315921144c22923adc9dc9952ab6134 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:29:54 +0000 Subject: [PATCH 3/3] feat: architectural improvements - single city source of truth, targeted widget rebuilds, SQLite WAL + indexes, readiness() lock fix Co-authored-by: fallofpheonix <160165035+fallofpheonix@users.noreply.github.com> --- udie_backend_py/app/storage.py | 23 +- udie_mobile/lib/src/models.dart | 36 +++ udie_mobile/lib/src/state/app_store.dart | 34 +-- .../lib/src/ui/screens/home_shell.dart | 206 +++++++++--------- .../lib/src/ui/screens/map_screen.dart | 203 ++++++++--------- .../lib/src/ui/screens/news_screen.dart | 150 +++++++------ .../lib/src/ui/screens/route_screen.dart | 41 ++-- .../lib/src/ui/screens/sources_screen.dart | 200 ++++++++--------- 8 files changed, 473 insertions(+), 420 deletions(-) diff --git a/udie_backend_py/app/storage.py b/udie_backend_py/app/storage.py index ea0e548..51cc401 100644 --- a/udie_backend_py/app/storage.py +++ b/udie_backend_py/app/storage.py @@ -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 ( @@ -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]: @@ -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: diff --git a/udie_mobile/lib/src/models.dart b/udie_mobile/lib/src/models.dart index 74107f5..c7a5af5 100644 --- a/udie_mobile/lib/src/models.dart +++ b/udie_mobile/lib/src/models.dart @@ -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> 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}); diff --git a/udie_mobile/lib/src/state/app_store.dart b/udie_mobile/lib/src/state/app_store.dart index a882773..1d2356d 100644 --- a/udie_mobile/lib/src/state/app_store.dart +++ b/udie_mobile/lib/src/state/app_store.dart @@ -7,36 +7,6 @@ import '../api_client.dart'; import '../models.dart'; class AppStore extends ChangeNotifier { - static const Set _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', @@ -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(); diff --git a/udie_mobile/lib/src/ui/screens/home_shell.dart b/udie_mobile/lib/src/ui/screens/home_shell.dart index c50c3a8..2f0b49d 100644 --- a/udie_mobile/lib/src/ui/screens/home_shell.dart +++ b/udie_mobile/lib/src/ui/screens/home_shell.dart @@ -22,6 +22,7 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _index = 0; + late final List _pages; static const _destinations = [ ( @@ -47,37 +48,39 @@ class _HomeShellState extends State { ]; @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, + ), + ), ); } } @@ -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, ), - ), + ], ), - ], + ), ), - ), - ), + ); + }, ); } } diff --git a/udie_mobile/lib/src/ui/screens/map_screen.dart b/udie_mobile/lib/src/ui/screens/map_screen.dart index 5465d93..51ad9fe 100644 --- a/udie_mobile/lib/src/ui/screens/map_screen.dart +++ b/udie_mobile/lib/src/ui/screens/map_screen.dart @@ -44,116 +44,121 @@ class _MapScreenState extends State { @override Widget build(BuildContext context) { - final store = widget.store; - final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight; - - final markers = store.events - .take(_maxMarkers) - .map( - (event) => Marker( - point: event.point, - width: 56, - height: 56, - child: EventMarker( - event: event, - onTap: () => _showEventDetail(event), - ), - ), - ) - .toList(growable: false); + return ListenableBuilder( + listenable: widget.store, + builder: (context, _) { + final store = widget.store; + final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight; + + final markers = store.events + .take(_maxMarkers) + .map( + (event) => Marker( + point: event.point, + width: 56, + height: 56, + child: EventMarker( + event: event, + onTap: () => _showEventDetail(event), + ), + ), + ) + .toList(growable: false); - final isLoading = store.syncState == SyncState.connecting; + final isLoading = store.syncState == SyncState.connecting; - return Stack( - children: [ - // ── Map ───────────────────────────────────────────────────────────── - FlutterMap( - mapController: _mapController, - key: ValueKey( - '${store.area.center.latitude}-' - '${store.area.center.longitude}-' - '${store.area.radiusKm}', - ), - options: MapOptions( - initialCenter: store.area.center, - initialZoom: 11, - interactionOptions: const InteractionOptions( - flags: InteractiveFlag.all, - ), - ), + return Stack( children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.udie.mobile', - ), - CircleLayer( - circles: [ - CircleMarker( - point: store.area.center, - radius: store.area.radiusKm * 1000, - useRadiusInMeter: true, - color: UdieTheme.accent.withValues(alpha: 0.07), - borderColor: UdieTheme.accent.withValues(alpha: 0.5), - borderStrokeWidth: 1.5, + // ── Map ─────────────────────────────────────────────────────────── + FlutterMap( + mapController: _mapController, + key: ValueKey( + '${store.area.center.latitude}-' + '${store.area.center.longitude}-' + '${store.area.radiusKm}', + ), + options: MapOptions( + initialCenter: store.area.center, + initialZoom: 11, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all, ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.udie.mobile', + ), + CircleLayer( + circles: [ + CircleMarker( + point: store.area.center, + radius: store.area.radiusKm * 1000, + useRadiusInMeter: true, + color: UdieTheme.accent.withValues(alpha: 0.07), + borderColor: UdieTheme.accent.withValues(alpha: 0.5), + borderStrokeWidth: 1.5, + ), + ], + ), + MarkerLayer(markers: markers), ], ), - MarkerLayer(markers: markers), - ], - ), - // ── Loading bar ────────────────────────────────────────────────────── - if (isLoading) - Positioned( - top: 0, - left: 0, - right: 0, - child: LinearProgressIndicator( - minHeight: 2, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation(UdieTheme.accent), - ), - ), + // ── Loading bar ──────────────────────────────────────────────────── + if (isLoading) + Positioned( + top: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + minHeight: 2, + backgroundColor: Colors.transparent, + valueColor: AlwaysStoppedAnimation(UdieTheme.accent), + ), + ), - // ── Floating action buttons (right side) ───────────────────────────── - Positioned( - top: topPadding + 12, - right: 12, - child: _MapFABColumn( - onCenter: () => _mapController.move( - store.area.center, - _mapController.camera.zoom, - ), - onZoomIn: () => _mapController.move( - _mapController.camera.center, - _mapController.camera.zoom + 1, - ), - onZoomOut: () => _mapController.move( - _mapController.camera.center, - _mapController.camera.zoom - 1, + // ── Floating action buttons (right side) ─────────────────────────── + Positioned( + top: topPadding + 12, + right: 12, + child: _MapFABColumn( + onCenter: () => _mapController.move( + store.area.center, + _mapController.camera.zoom, + ), + onZoomIn: () => _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom + 1, + ), + onZoomOut: () => _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom - 1, + ), + onRefresh: store.refreshAll, + ), ), - onRefresh: store.refreshAll, - ), - ), - // ── Severity legend ────────────────────────────────────────────────── - Positioned( - top: topPadding + 12, - left: 12, - child: const _MapLegend(), - ), + // ── Severity legend ──────────────────────────────────────────────── + Positioned( + top: topPadding + 12, + left: 12, + child: const _MapLegend(), + ), - // ── Bottom info panel ──────────────────────────────────────────────── - Positioned( - left: 12, - right: 12, - bottom: 12, - child: _BottomPanel( - store: store, - mapController: _mapController, - ), - ), - ], + // ── Bottom info panel ────────────────────────────────────────────── + Positioned( + left: 12, + right: 12, + bottom: 12, + child: _BottomPanel( + store: store, + mapController: _mapController, + ), + ), + ], + ); + }, ); } } diff --git a/udie_mobile/lib/src/ui/screens/news_screen.dart b/udie_mobile/lib/src/ui/screens/news_screen.dart index f9d9d5e..f6e10d1 100644 --- a/udie_mobile/lib/src/ui/screens/news_screen.dart +++ b/udie_mobile/lib/src/ui/screens/news_screen.dart @@ -7,86 +7,104 @@ import '../../state/app_store.dart'; import '../../theme.dart'; import '../widgets/skeleton_loader.dart'; -class NewsScreen extends StatelessWidget { +class NewsScreen extends StatefulWidget { const NewsScreen({super.key, required this.store}); final AppStore store; + @override + State createState() => _NewsScreenState(); +} + +class _NewsScreenState extends State { + late final DateFormat _formatter; + + @override + void initState() { + super.initState(); + _formatter = DateFormat('dd MMM, HH:mm'); + } + @override Widget build(BuildContext context) { - final formatter = DateFormat('dd MMM, HH:mm'); - final topPad = MediaQuery.of(context).padding.top + kToolbarHeight + 8; + return ListenableBuilder( + listenable: widget.store, + builder: (context, _) { + final store = widget.store; + final topPad = MediaQuery.of(context).padding.top + kToolbarHeight + 8; - return Column( - children: [ - SizedBox(height: topPad), - // ── Category filter strip ────────────────────────────────────────── - SizedBox( - height: 48, - child: ListView( - padding: const EdgeInsets.symmetric( - horizontal: UdieTheme.sp12, - vertical: UdieTheme.sp6, - ), - scrollDirection: Axis.horizontal, - children: [ - // "All" chip - _CategoryChip( - category: 'All', - icon: Icons.apps_rounded, - selected: store.activeNewsCategories.isEmpty, - color: UdieTheme.accent, - onTap: () { - store.clearActiveCategories(); - store.refreshNewsOnly(); - }, - ), - const SizedBox(width: UdieTheme.sp6), - ...store.availableCategories.map( - (c) => Padding( - padding: const EdgeInsets.only(right: UdieTheme.sp6), - child: _CategoryChip( - category: c, - icon: UdieTheme.categoryIcon(c), - selected: store.activeNewsCategories.contains(c), - color: UdieTheme.categoryColor(c), + return Column( + children: [ + SizedBox(height: topPad), + // ── Category filter strip ────────────────────────────────────────── + SizedBox( + height: 48, + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: UdieTheme.sp12, + vertical: UdieTheme.sp6, + ), + scrollDirection: Axis.horizontal, + children: [ + // "All" chip + _CategoryChip( + category: 'All', + icon: Icons.apps_rounded, + selected: store.activeNewsCategories.isEmpty, + color: UdieTheme.accent, onTap: () { - store.toggleCategory(c); + store.clearActiveCategories(); store.refreshNewsOnly(); }, ), - ), - ), - ], - ), - ), - // ── News list ────────────────────────────────────────────────────── - Expanded( - child: store.syncState == SyncState.connecting && store.news.isEmpty - ? const _LoadingSkeleton() - : store.news.isEmpty - ? const _EmptyState() - : RefreshIndicator( - onRefresh: store.refreshNewsOnly, - color: UdieTheme.accent, - backgroundColor: UdieTheme.surface1, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB( - UdieTheme.sp12, - UdieTheme.sp8, - UdieTheme.sp12, - UdieTheme.sp24, - ), - itemCount: store.news.length, - itemBuilder: (context, index) { - final item = store.news[index]; - return _NewsCard(item: item, formatter: formatter); + const SizedBox(width: UdieTheme.sp6), + ...store.availableCategories.map( + (c) => Padding( + padding: const EdgeInsets.only(right: UdieTheme.sp6), + child: _CategoryChip( + category: c, + icon: UdieTheme.categoryIcon(c), + selected: store.activeNewsCategories.contains(c), + color: UdieTheme.categoryColor(c), + onTap: () { + store.toggleCategory(c); + store.refreshNewsOnly(); }, ), ), - ), - ], + ), + ], + ), + ), + // ── News list ────────────────────────────────────────────────────── + Expanded( + child: store.syncState == SyncState.connecting && store.news.isEmpty + ? const _LoadingSkeleton() + : store.news.isEmpty + ? const _EmptyState() + : RefreshIndicator( + onRefresh: store.refreshNewsOnly, + color: UdieTheme.accent, + backgroundColor: UdieTheme.surface1, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + UdieTheme.sp12, + UdieTheme.sp8, + UdieTheme.sp12, + UdieTheme.sp24, + ), + itemCount: store.news.length, + itemBuilder: (context, index) { + final item = store.news[index]; + return _NewsCard(item: item, formatter: _formatter); + }, + ), + ), + ), + ], + ); + }, ); } } diff --git a/udie_mobile/lib/src/ui/screens/route_screen.dart b/udie_mobile/lib/src/ui/screens/route_screen.dart index bc70052..db36e37 100644 --- a/udie_mobile/lib/src/ui/screens/route_screen.dart +++ b/udie_mobile/lib/src/ui/screens/route_screen.dart @@ -64,7 +64,6 @@ class _RouteScreenState extends State { @override Widget build(BuildContext context) { final topPad = MediaQuery.of(context).padding.top + kToolbarHeight + 8; - final risk = widget.store.lastRisk; return ListView( padding: EdgeInsets.fromLTRB( @@ -189,23 +188,29 @@ class _RouteScreenState extends State { const SizedBox(height: UdieTheme.sp20), // ── Risk result card (animated) ────────────────────────────────────── - AnimatedSwitcher( - duration: UdieTheme.durationMedium, - switchInCurve: UdieTheme.curveDefault, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.15), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ), - child: risk != null - ? _RiskResultCard(key: ValueKey(risk.riskScore), risk: risk) - : const SizedBox.shrink(), + ListenableBuilder( + listenable: widget.store, + builder: (context, _) { + final risk = widget.store.lastRisk; + return AnimatedSwitcher( + duration: UdieTheme.durationMedium, + switchInCurve: UdieTheme.curveDefault, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: risk != null + ? _RiskResultCard(key: ValueKey(risk.riskScore), risk: risk) + : const SizedBox.shrink(), + ); + }, ), ], ); diff --git a/udie_mobile/lib/src/ui/screens/sources_screen.dart b/udie_mobile/lib/src/ui/screens/sources_screen.dart index c60130d..d581025 100644 --- a/udie_mobile/lib/src/ui/screens/sources_screen.dart +++ b/udie_mobile/lib/src/ui/screens/sources_screen.dart @@ -15,34 +15,6 @@ class SourcesScreen extends StatefulWidget { } class _SourcesScreenState extends State { - static const Map> _indianCityCenters = { - 'Delhi': [28.6139, 77.2090], - 'Mumbai': [19.0760, 72.8777], - 'Bengaluru': [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], - }; - late final TextEditingController _baseUrl; late final TextEditingController _lat; late final TextEditingController _lng; @@ -55,7 +27,7 @@ class _SourcesScreenState extends State { super.initState(); final area = widget.store.area; _baseUrl = TextEditingController(text: widget.store.baseUrl); - _selectedCity = _indianCityCenters.keys.contains(area.city) + _selectedCity = kCityCoordinates.keys.contains(area.city) ? area.city : 'Delhi'; _lat = TextEditingController(text: area.center.latitude.toStringAsFixed(6)); @@ -134,7 +106,7 @@ class _SourcesScreenState extends State { color: UdieTheme.textMuted, ), ), - items: _indianCityCenters.keys + items: kCityCoordinates.keys .map( (city) => DropdownMenuItem( value: city, @@ -144,7 +116,7 @@ class _SourcesScreenState extends State { .toList(growable: false), onChanged: (value) { if (value == null) return; - final center = _indianCityCenters[value]!; + final center = kCityCoordinates[value]!; setState(() { _selectedCity = value; _lat.text = center[0].toStringAsFixed(6); @@ -239,93 +211,109 @@ class _SourcesScreenState extends State { ), ), // Error message - if (store.lastError != null) - Padding( - padding: const EdgeInsets.only(top: UdieTheme.sp10), - child: Container( - padding: const EdgeInsets.all(UdieTheme.sp10), - decoration: BoxDecoration( - color: UdieTheme.danger.withValues(alpha: 0.1), - borderRadius: - BorderRadius.circular(UdieTheme.radiusSm), - border: Border.all( - color: UdieTheme.danger.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - const Icon( - Icons.error_outline_rounded, - size: 16, - color: UdieTheme.danger, + ListenableBuilder( + listenable: widget.store, + builder: (context, _) { + if (widget.store.lastError == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: UdieTheme.sp10), + child: Container( + padding: const EdgeInsets.all(UdieTheme.sp10), + decoration: BoxDecoration( + color: UdieTheme.danger.withValues(alpha: 0.1), + borderRadius: + BorderRadius.circular(UdieTheme.radiusSm), + border: Border.all( + color: UdieTheme.danger.withValues(alpha: 0.3), ), - const SizedBox(width: UdieTheme.sp8), - Expanded( - child: Text( - store.lastError!, - style: const TextStyle( - color: UdieTheme.danger, - fontSize: 12, + ), + child: Row( + children: [ + const Icon( + Icons.error_outline_rounded, + size: 16, + color: UdieTheme.danger, + ), + const SizedBox(width: UdieTheme.sp8), + Expanded( + child: Text( + widget.store.lastError!, + style: const TextStyle( + color: UdieTheme.danger, + fontSize: 12, + ), ), ), - ), - ], + ], + ), ), - ), - ), + ); + }, + ), ], ), ), const SizedBox(height: UdieTheme.sp24), // ── Source diagnostics section ───────────────────────────────────── - SectionHeader( - 'Source Diagnostics', - trailing: Text( - '${store.sources.length} sources', - style: const TextStyle( - fontSize: 11, - color: UdieTheme.textMuted, - ), - ), - ), - const SizedBox(height: UdieTheme.sp12), - if (store.sources.isEmpty) - Container( - padding: const EdgeInsets.all(UdieTheme.sp20), - decoration: BoxDecoration( - color: UdieTheme.surface1, - borderRadius: BorderRadius.circular(UdieTheme.radiusLg), - border: Border.all(color: UdieTheme.border), - ), - child: const Center( - child: Text( - 'No source data – sync to load diagnostics.', - style: TextStyle( - fontSize: 13, - color: UdieTheme.textMuted, - ), - ), - ), - ) - else - Container( - decoration: BoxDecoration( - color: UdieTheme.surface1, - borderRadius: BorderRadius.circular(UdieTheme.radiusLg), - border: Border.all(color: UdieTheme.border), - ), - clipBehavior: Clip.antiAlias, - child: Column( + ListenableBuilder( + listenable: widget.store, + builder: (context, _) { + final sources = widget.store.sources; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < store.sources.length; i++) ...[ - if (i > 0) - const Divider(height: 1, indent: 16, endIndent: 16), - _SourceRow(source: store.sources[i]), - ], + SectionHeader( + 'Source Diagnostics', + trailing: Text( + '${sources.length} sources', + style: const TextStyle( + fontSize: 11, + color: UdieTheme.textMuted, + ), + ), + ), + const SizedBox(height: UdieTheme.sp12), + if (sources.isEmpty) + Container( + padding: const EdgeInsets.all(UdieTheme.sp20), + decoration: BoxDecoration( + color: UdieTheme.surface1, + borderRadius: BorderRadius.circular(UdieTheme.radiusLg), + border: Border.all(color: UdieTheme.border), + ), + child: const Center( + child: Text( + 'No source data – sync to load diagnostics.', + style: TextStyle( + fontSize: 13, + color: UdieTheme.textMuted, + ), + ), + ), + ) + else + Container( + decoration: BoxDecoration( + color: UdieTheme.surface1, + borderRadius: BorderRadius.circular(UdieTheme.radiusLg), + border: Border.all(color: UdieTheme.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < sources.length; i++) ...[ + if (i > 0) + const Divider(height: 1, indent: 16, endIndent: 16), + _SourceRow(source: sources[i]), + ], + ], + ), + ), ], - ), - ), + ); + }, + ), ], ); }