Skip to content

Commit c847256

Browse files
committed
✨ feat: implement location suggestions and comprehensive transport mode UX improvements
- Set default transport modes to Jeepney/Bus/Taxi only - Add word-wrap for origin/destination input fields (2-line limit) - Implement fare results persistence on route swap - Add real address display in map picker with debounce - Introduce Lowest Overall sort option for fare comparison - Fix storage bar accuracy in region management - Replace brackets with transport mode type chips - Improve fare result display with base name + chip formatting - Fix input field alignment (swap button and route indicator) - Correct per-person price calculation logic - Bump version to 2.2.0+4 - Update GitHub workflow for release notes generation
1 parent 5e60b90 commit c847256

16 files changed

Lines changed: 768 additions & 242 deletions

.github/workflows/release-apk.yml

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,26 +197,50 @@ jobs:
197197
| **ARM32 (armeabi-v7a)** | `${{ steps.apk_files.outputs.apk_armv7 }}` |
198198
| **x86_64** | `${{ steps.apk_files.outputs.apk_x86_64 }}` |
199199
200-
### Changes
200+
## ✨ What's New
201201
EOF
202202
203-
# Try to get commits since last tag, or last 10 commits if no previous tag
203+
# Determine range
204204
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
205-
206205
if [ -n "$LAST_TAG" ]; then
207-
echo "Changes since $LAST_TAG:" >> RELEASE_NOTES.md
208-
echo "" >> RELEASE_NOTES.md
209-
git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges >> RELEASE_NOTES.md
206+
RANGE="${LAST_TAG}..HEAD"
207+
echo "Generating notes for range: $RANGE"
208+
else
209+
RANGE="HEAD~10..HEAD"
210+
echo "No previous tag found, using last 10 commits"
211+
fi
212+
213+
# Features
214+
echo "🚀 **New Features:**" >> RELEASE_NOTES.md
215+
FEATURES=$(git log ${RANGE} --grep="✨\|feat" --pretty=format:"- %s" --no-merges || echo "")
216+
if [ -n "$FEATURES" ]; then
217+
echo "$FEATURES" >> RELEASE_NOTES.md
210218
else
211-
echo "Recent commits:" >> RELEASE_NOTES.md
212-
echo "" >> RELEASE_NOTES.md
213-
git log -10 --pretty=format:"- %s (%h)" --no-merges >> RELEASE_NOTES.md
219+
echo "- General improvements and updates" >> RELEASE_NOTES.md
214220
fi
221+
echo "" >> RELEASE_NOTES.md
215222
223+
# Bug Fixes
224+
echo "🐛 **Bug Fixes:**" >> RELEASE_NOTES.md
225+
FIXES=$(git log ${RANGE} --grep="🐛\|fix" --pretty=format:"- %s" --no-merges || echo "")
226+
if [ -n "$FIXES" ]; then
227+
echo "$FIXES" >> RELEASE_NOTES.md
228+
else
229+
echo "- Stability improvements" >> RELEASE_NOTES.md
230+
fi
216231
echo "" >> RELEASE_NOTES.md
232+
233+
# Improvements
234+
echo "🎨 **Improvements:**" >> RELEASE_NOTES.md
235+
IMPROVEMENTS=$(git log ${RANGE} --grep="🎨\|♻️\|🏗\|💄\|refactor\|perf" --pretty=format:"- %s" --no-merges || echo "")
236+
if [ -n "$IMPROVEMENTS" ]; then
237+
echo "$IMPROVEMENTS" >> RELEASE_NOTES.md
238+
else
239+
echo "- UI refinements and performance tweaks" >> RELEASE_NOTES.md
240+
fi
241+
217242
echo "" >> RELEASE_NOTES.md
218243
echo "---" >> RELEASE_NOTES.md
219-
echo "" >> RELEASE_NOTES.md
220244
echo "_Built with Flutter on GitHub Actions_" >> RELEASE_NOTES.md
221245
222246
# Output for debugging

lib/src/models/map_region.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,21 @@ class StorageInfo {
502502
/// Available storage in GB.
503503
double get availableGB => availableBytes / (1024 * 1024 * 1024);
504504

505-
/// Total used percentage.
506-
double get usedPercentage =>
505+
/// Total device storage used percentage (for informational purposes).
506+
double get deviceUsedPercentage =>
507507
totalBytes > 0 ? (totalBytes - availableBytes) / totalBytes : 0.0;
508508

509+
/// Map cache usage as a percentage of total usable space (cache + available).
510+
/// This is what the storage bar should display:
511+
/// - When cache is 0 and available is 10GB, bar shows nearly empty (0%)
512+
/// - When cache is 5GB and available is 5GB, bar shows 50%
513+
/// - When cache is 10GB and available is 0, bar shows 100%
514+
double get usedPercentage {
515+
final totalUsableSpace = mapCacheBytes + availableBytes;
516+
if (totalUsableSpace <= 0) return 0.0;
517+
return mapCacheBytes / totalUsableSpace;
518+
}
519+
509520
/// Formatted string for map cache size.
510521
String get mapCacheFormatted {
511522
if (mapCacheBytes < 1024 * 1024) {

lib/src/models/transport_mode.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,42 @@ enum TransportMode {
107107
orElse: () => TransportMode.jeepney, // Default fallback
108108
);
109109
}
110+
111+
/// Parses a transport mode string into its base mode name and optional subtype.
112+
///
113+
/// Examples:
114+
/// - "Jeepney (Traditional)" -> ("Jeepney", "Traditional")
115+
/// - "Bus (Aircon)" -> ("Bus", "Aircon")
116+
/// - "Jeepney (Modern (PUJ))" -> ("Jeepney", "Modern (PUJ)")
117+
/// - "Taxi" -> ("Taxi", null)
118+
///
119+
/// Returns a record with baseName and subtype (nullable).
120+
static ({String baseName, String? subtype}) parseTransportMode(String mode) {
121+
final trimmed = mode.trim();
122+
123+
// Find the first opening parenthesis
124+
final firstParenIndex = trimmed.indexOf('(');
125+
126+
if (firstParenIndex == -1) {
127+
// No subtype
128+
return (baseName: trimmed, subtype: null);
129+
}
130+
131+
// Extract base name (everything before the first '(')
132+
final baseName = trimmed.substring(0, firstParenIndex).trim();
133+
134+
// Extract subtype (everything between first '(' and last ')')
135+
final lastParenIndex = trimmed.lastIndexOf(')');
136+
if (lastParenIndex <= firstParenIndex) {
137+
// Malformed string, treat as no subtype
138+
return (baseName: baseName, subtype: null);
139+
}
140+
141+
final subtype = trimmed.substring(firstParenIndex + 1, lastParenIndex).trim();
142+
143+
return (
144+
baseName: baseName,
145+
subtype: subtype.isNotEmpty ? subtype : null,
146+
);
147+
}
110148
}

lib/src/presentation/controllers/main_screen_controller.dart

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,9 @@ class MainScreenController extends ChangeNotifier {
218218
}
219219
}
220220

221-
/// Swap origin and destination
221+
/// Swap origin and destination.
222+
/// Note: Fare results are preserved on swap since the route distance
223+
/// remains the same (just reversed direction).
222224
void swapLocations() {
223225
if (_originLocation == null && _destinationLocation == null) return;
224226

@@ -231,7 +233,12 @@ class MainScreenController extends ChangeNotifier {
231233
_destinationLocation = tempLocation;
232234
_destinationLatLng = tempLatLng;
233235

234-
_resetResult();
236+
// Note: We do NOT reset fare results on swap.
237+
// The fare is based on distance which is the same regardless of direction.
238+
// Only clear route points so they can be recalculated.
239+
_routePoints = [];
240+
_routeResult = null;
241+
235242
notifyListeners();
236243

237244
if (_originLocation != null && _destinationLocation != null) {
@@ -311,10 +318,18 @@ class MainScreenController extends ChangeNotifier {
311318

312319
final List<FareResult> results = [];
313320
final trafficFactor = await _settingsService.getTrafficFactor();
321+
final hasSetPrefs = await _settingsService.hasSetTransportModePreferences();
314322
final hiddenModes = await _settingsService.getHiddenTransportModes();
315323

316324
final visibleFormulas = _availableFormulas.where((formula) {
317325
final modeSubTypeKey = '${formula.mode}::${formula.subType}';
326+
327+
if (!hasSetPrefs) {
328+
// New user - use default enabled modes
329+
final defaultModes = SettingsService.getDefaultEnabledModes();
330+
return defaultModes.contains(modeSubTypeKey);
331+
}
332+
// Existing user - check hidden modes
318333
return !hiddenModes.contains(modeSubTypeKey);
319334
}).toList();
320335

@@ -352,7 +367,7 @@ class MainScreenController extends ChangeNotifier {
352367
results.add(
353368
FareResult(
354369
transportMode: '${formula.mode} (${formula.subType})',
355-
fare: fare,
370+
fare: _passengerCount > 0 ? fare / _passengerCount : fare,
356371
indicatorLevel: indicator,
357372
isRecommended: false,
358373
passengerCount: _passengerCount,

lib/src/presentation/screens/main_screen.dart

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class _MainScreenState extends State<MainScreen> {
8383
Future<void> _loadTransportModeCounts() async {
8484
try {
8585
final allFormulas = await _fareRepository.getAllFormulas();
86+
final hasSetPrefs = await _settingsService.hasSetTransportModePreferences();
8687
final hiddenModes = await _settingsService.getHiddenTransportModes();
8788

8889
// Count unique mode-subtype combinations
@@ -91,9 +92,15 @@ class _MainScreenState extends State<MainScreen> {
9192
allModeKeys.add('${formula.mode}::${formula.subType}');
9293
}
9394

94-
final enabledCount = allModeKeys
95-
.where((key) => !hiddenModes.contains(key))
96-
.length;
95+
int enabledCount;
96+
if (!hasSetPrefs) {
97+
// New user - count default enabled modes that exist in formulas
98+
final defaultModes = SettingsService.getDefaultEnabledModes();
99+
enabledCount = allModeKeys.where((key) => defaultModes.contains(key)).length;
100+
} else {
101+
// Existing user - count modes not in hidden set
102+
enabledCount = allModeKeys.where((key) => !hiddenModes.contains(key)).length;
103+
}
97104

98105
if (mounted) {
99106
setState(() {

lib/src/presentation/screens/map_picker_screen.dart

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter_map/flutter_map.dart';
35
import 'package:latlong2/latlong.dart';
@@ -36,8 +38,16 @@ class _MapPickerScreenState extends State<MapPickerScreen>
3638
LatLng? _selectedLocation;
3739
bool _isMapMoving = false;
3840
final ValueNotifier<bool> _isSearchingLocation = ValueNotifier<bool>(false);
41+
final ValueNotifier<bool> _isLoadingAddress = ValueNotifier<bool>(false);
3942
String _addressText = 'Move map to select location';
4043

44+
/// Debounce timer for reverse geocoding to avoid excessive API calls
45+
/// during rapid map movements
46+
Timer? _geocodeDebounceTimer;
47+
48+
/// Debounce duration for reverse geocoding (400ms)
49+
static const Duration _geocodeDebounceDuration = Duration(milliseconds: 400);
50+
4151
// Animation controller for pin bounce effect
4252
late final AnimationController _pinAnimationController;
4353
late final Animation<double> _pinBounceAnimation;
@@ -83,22 +93,26 @@ class _MapPickerScreenState extends State<MapPickerScreen>
8393

8494
@override
8595
void dispose() {
96+
_geocodeDebounceTimer?.cancel();
8697
_pinAnimationController.dispose();
8798
_isSearchingLocation.dispose();
99+
_isLoadingAddress.dispose();
88100
super.dispose();
89101
}
90102

91103
void _handleMapEvent(MapEvent event) {
92104
if (event is MapEventMoveStart) {
93105
setState(() => _isMapMoving = true);
94106
_pinAnimationController.forward();
107+
// Cancel any pending geocode request when movement starts
108+
_geocodeDebounceTimer?.cancel();
95109
} else if (event is MapEventMoveEnd) {
96110
setState(() {
97111
_isMapMoving = false;
98112
_selectedLocation = _mapController.camera.center;
99113
});
100114
_pinAnimationController.reverse();
101-
_updateAddress(_mapController.camera.center);
115+
_debouncedUpdateAddress(_mapController.camera.center);
102116
} else if (event is MapEventMove) {
103117
// Update position during movement
104118
setState(() {
@@ -107,15 +121,48 @@ class _MapPickerScreenState extends State<MapPickerScreen>
107121
}
108122
}
109123

110-
void _updateAddress(LatLng location) {
111-
// In a real app, this would call a geocoding service
112-
// For now, we display the coordinates in a formatted way
113-
setState(() {
114-
_addressText =
115-
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
124+
/// Debounced reverse geocoding to reduce API calls during rapid map movements.
125+
/// Cancels any pending request and schedules a new one after the debounce period.
126+
void _debouncedUpdateAddress(LatLng location) {
127+
// Cancel any existing pending request
128+
_geocodeDebounceTimer?.cancel();
129+
130+
// Show loading indicator immediately for better UX
131+
_isLoadingAddress.value = true;
132+
133+
// Schedule the actual geocoding after the debounce period
134+
_geocodeDebounceTimer = Timer(_geocodeDebounceDuration, () {
135+
_updateAddress(location);
116136
});
117137
}
118138

139+
void _updateAddress(LatLng location) async {
140+
// Perform reverse geocoding to get the human-readable address
141+
_isLoadingAddress.value = true;
142+
143+
try {
144+
final address = await _geocodingService.getAddressFromLatLng(
145+
location.latitude,
146+
location.longitude,
147+
);
148+
if (mounted) {
149+
setState(() {
150+
_addressText = address.name;
151+
});
152+
}
153+
} catch (e) {
154+
// Fallback to coordinates if geocoding fails
155+
if (mounted) {
156+
setState(() {
157+
_addressText =
158+
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
159+
});
160+
}
161+
} finally {
162+
_isLoadingAddress.value = false;
163+
}
164+
}
165+
119166
void _confirmLocation() {
120167
if (_selectedLocation != null) {
121168
Navigator.pop(context, _selectedLocation);
@@ -610,19 +657,56 @@ class _MapPickerScreenState extends State<MapPickerScreen>
610657
const SizedBox(height: 4),
611658
AnimatedSwitcher(
612659
duration: const Duration(milliseconds: 200),
613-
child: Text(
614-
_isMapMoving ? 'Moving...' : _addressText,
615-
key: ValueKey(
616-
_isMapMoving ? 'moving' : _addressText,
617-
),
618-
style: theme.textTheme.bodyLarge?.copyWith(
619-
fontWeight: FontWeight.w600,
620-
color: _isMapMoving
621-
? colorScheme.onSurfaceVariant
622-
: colorScheme.onSurface,
623-
),
624-
maxLines: 2,
625-
overflow: TextOverflow.ellipsis,
660+
child: ValueListenableBuilder<bool>(
661+
valueListenable: _isLoadingAddress,
662+
builder: (context, isLoading, child) {
663+
if (_isMapMoving) {
664+
return Text(
665+
'Moving...',
666+
key: const ValueKey('moving'),
667+
style: theme.textTheme.bodyLarge?.copyWith(
668+
fontWeight: FontWeight.w600,
669+
color: colorScheme.onSurfaceVariant,
670+
),
671+
maxLines: 2,
672+
overflow: TextOverflow.ellipsis,
673+
);
674+
} else if (isLoading) {
675+
return Row(
676+
key: const ValueKey('loading'),
677+
mainAxisSize: MainAxisSize.min,
678+
children: [
679+
SizedBox(
680+
width: 16,
681+
height: 16,
682+
child: CircularProgressIndicator(
683+
strokeWidth: 2,
684+
color: colorScheme.primary,
685+
),
686+
),
687+
const SizedBox(width: 8),
688+
Text(
689+
'Getting address...',
690+
style: theme.textTheme.bodyLarge?.copyWith(
691+
fontWeight: FontWeight.w600,
692+
color: colorScheme.onSurfaceVariant,
693+
),
694+
),
695+
],
696+
);
697+
} else {
698+
return Text(
699+
_addressText,
700+
key: ValueKey(_addressText),
701+
style: theme.textTheme.bodyLarge?.copyWith(
702+
fontWeight: FontWeight.w600,
703+
color: colorScheme.onSurface,
704+
),
705+
maxLines: 2,
706+
overflow: TextOverflow.ellipsis,
707+
);
708+
}
709+
},
626710
),
627711
),
628712
],

0 commit comments

Comments
 (0)