Compare commits
2 Commits
v0.1.1-dev
...
v0.1.2-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 292163bda6 | |||
| 53eaf0b4af |
@@ -134,46 +134,6 @@ jobs:
|
||||
name: linux-bundle
|
||||
path: app-linux-x64.tar.gz
|
||||
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
needs: meta
|
||||
# Job always runs; steps are conditional so it won't block anything when Windows is disabled.
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: ${{ env.FLUTTER_CHANNEL }}
|
||||
|
||||
- name: Allow all git directories (CI)
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
run: git config --global --add safe.directory '*'
|
||||
|
||||
- name: Flutter dependencies
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
run: flutter pub get
|
||||
|
||||
- name: Enable Windows desktop
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
run: flutter config --enable-windows-desktop
|
||||
|
||||
- name: Build Windows binary (release)
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
run: |
|
||||
flutter build windows --release
|
||||
powershell -Command "Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath app-windows-x64.zip"
|
||||
|
||||
- name: Upload Windows artifact
|
||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows-zip
|
||||
path: app-windows-x64.zip
|
||||
|
||||
release-dev:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
||||
@@ -39,11 +39,7 @@ class Dashboard extends StatelessWidget {
|
||||
children: [
|
||||
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: metricChips,
|
||||
),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
||||
const SizedBox(height: 16),
|
||||
isWide
|
||||
? Row(
|
||||
@@ -71,8 +67,12 @@ class Dashboard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, AuthService auth,
|
||||
HomepageStats? stats, bool loading) {
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
AuthService auth,
|
||||
HomepageStats? stats,
|
||||
bool loading,
|
||||
) {
|
||||
final greetingName =
|
||||
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there';
|
||||
return Row(
|
||||
@@ -82,10 +82,7 @@ class Dashboard extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Welcome back, $greetingName',
|
||||
@@ -93,14 +90,15 @@ class Dashboard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (loading) const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
if (loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -121,11 +119,13 @@ class Dashboard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 0.7,
|
||||
color: textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
)),
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 0.7,
|
||||
color: textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
@@ -203,10 +203,9 @@ class Dashboard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -234,10 +233,7 @@ class Dashboard extends StatelessWidget {
|
||||
required String emptyMessage,
|
||||
}) {
|
||||
if (legs.isEmpty) {
|
||||
return Text(
|
||||
emptyMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
||||
}
|
||||
return Column(
|
||||
children: legs.take(5).map((leg) {
|
||||
@@ -257,10 +253,9 @@ class Dashboard extends StatelessWidget {
|
||||
if (leg.headcode.isNotEmpty)
|
||||
Text(
|
||||
leg.headcode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -286,7 +281,11 @@ class Dashboard extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildTripsCard(BuildContext context, DataService data) {
|
||||
final trips = data.trips;
|
||||
final trips_unsorted = data.trips;
|
||||
List trips = [];
|
||||
if (trips_unsorted.isNotEmpty) {
|
||||
trips = [...trips_unsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||
}
|
||||
return _buildCard(
|
||||
context,
|
||||
title: 'Trips',
|
||||
|
||||
@@ -38,6 +38,8 @@ class _TractionPageState extends State<TractionPage> {
|
||||
final _domainController = TextEditingController();
|
||||
final _typeController = TextEditingController();
|
||||
|
||||
int offset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -239,40 +241,39 @@ class _TractionPageState extends State<TractionPage> {
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder:
|
||||
(context, onSelected, options) {
|
||||
final optionList = options.toList();
|
||||
if (optionList.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final maxWidth = isMobile
|
||||
? MediaQuery.of(context).size.width - 64
|
||||
: 240.0;
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: 240,
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: optionList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = optionList[index];
|
||||
return ListTile(
|
||||
title: Text(option),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
final optionList = options.toList();
|
||||
if (optionList.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final maxWidth = isMobile
|
||||
? MediaQuery.of(context).size.width - 64
|
||||
: 240.0;
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: 240,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: optionList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = optionList[index];
|
||||
return ListTile(
|
||||
title: Text(option),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSelected: (String selection) {
|
||||
setState(() {
|
||||
_selectedClass = selection;
|
||||
@@ -306,7 +307,7 @@ class _TractionPageState extends State<TractionPage> {
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(
|
||||
_mileageFirst ? 'Mileage first' : 'Had first',
|
||||
_mileageFirst ? 'Mileage first' : 'Number order',
|
||||
),
|
||||
selected: _mileageFirst,
|
||||
onSelected: (v) {
|
||||
|
||||
@@ -46,11 +46,15 @@ class _TripsPageState extends State<TripsPage> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Journeys',
|
||||
style: Theme.of(context).textTheme.labelMedium),
|
||||
Text(
|
||||
'Journeys',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('Trips',
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
Text(
|
||||
'Trips',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
@@ -81,10 +85,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
children: [
|
||||
Text(
|
||||
'No trips yet',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
@@ -101,8 +104,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
(trip) => Card(
|
||||
child: ListTile(
|
||||
title: Text(trip.tripName),
|
||||
subtitle:
|
||||
Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
|
||||
subtitle: Text(
|
||||
'${trip.tripMileage.toStringAsFixed(1)} mi',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -110,8 +114,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children:
|
||||
tripDetails.map((trip) => _buildTripCard(context, trip, isMobile)).toList(),
|
||||
children: tripDetails
|
||||
.map((trip) => _buildTripCard(context, trip, isMobile))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -134,10 +139,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
children: [
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs',
|
||||
@@ -145,10 +149,20 @@ class _TripsPageState extends State<TripsPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
tooltip: 'Details',
|
||||
onPressed: () => _showTripDetail(context, trip),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.train),
|
||||
tooltip: 'Traction',
|
||||
onPressed: () => _showTripWinners(context, trip),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
tooltip: 'Details',
|
||||
onPressed: () => _showTripDetail(context, trip),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -168,10 +182,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
),
|
||||
trailing: Text(
|
||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -215,10 +228,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
),
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||
@@ -237,9 +249,63 @@ class _TripsPageState extends State<TripsPage> {
|
||||
subtitle: Text(_formatDate(leg.beginTime)),
|
||||
trailing: Text(
|
||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showTripWinners(BuildContext context, TripDetail trip) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: ListView.builder(
|
||||
itemCount: trip.legs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final leg = trip.legs[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${leg.start} → ${leg.end}'),
|
||||
subtitle: Text(_formatDate(leg.beginTime)),
|
||||
trailing: Text(
|
||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ void main() {
|
||||
providers: [
|
||||
Provider<ApiService>(
|
||||
create: (_) {
|
||||
api = ApiService(baseUrl: 'https://dev.mileograph.co.uk/api/v1');
|
||||
api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1');
|
||||
return api;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -117,7 +117,8 @@ class DataService extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
final buffer = StringBuffer(
|
||||
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit');
|
||||
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit',
|
||||
);
|
||||
if (dateRangeStart != null && dateRangeStart.isNotEmpty) {
|
||||
buffer.write('&date_range_start=$dateRangeStart');
|
||||
}
|
||||
@@ -178,7 +179,7 @@ class DataService extends ChangeNotifier {
|
||||
try {
|
||||
final params = StringBuffer('?limit=$limit&offset=$offset');
|
||||
if (hadOnly) params.write('&had_only=true');
|
||||
if (mileageFirst) params.write('&mileage_first=true');
|
||||
if (!mileageFirst) params.write('&mileage_first=false');
|
||||
|
||||
final payload = <String, dynamic>{};
|
||||
if (locoClass != null && locoClass.isNotEmpty) {
|
||||
@@ -203,7 +204,7 @@ class DataService extends ChangeNotifier {
|
||||
if (json is List) {
|
||||
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
|
||||
_traction = append ? [..._traction, ...newItems] : newItems;
|
||||
_tractionHasMore = newItems.length >= limit;
|
||||
_tractionHasMore = newItems.length >= limit - 1;
|
||||
} else {
|
||||
throw Exception('Unexpected traction response: $json');
|
||||
}
|
||||
@@ -245,12 +246,13 @@ class DataService extends ChangeNotifier {
|
||||
try {
|
||||
final json = await api.get('/trips/legs-and-stats');
|
||||
if (json is List) {
|
||||
_tripDetails = json.map((e) => TripDetail.fromJson(e)).toList();
|
||||
final trip_map = json.map((e) => TripDetail.fromJson(e)).toList();
|
||||
_tripDetails = [...trip_map]..sort((a, b) => b.id.compareTo(a.id));
|
||||
} else {
|
||||
_tripDetails = [];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch trips: $e');
|
||||
debugPrint('Failed to fetch trip_map: $e');
|
||||
_tripDetails = [];
|
||||
} finally {
|
||||
_isTripDetailsLoading = false;
|
||||
@@ -260,7 +262,7 @@ class DataService extends ChangeNotifier {
|
||||
|
||||
Future<void> fetchTrips() async {
|
||||
try {
|
||||
final json = await api.get('/trips');
|
||||
final json = await api.get('/trips/mileage');
|
||||
Iterable<dynamic>? raw;
|
||||
if (json is List) {
|
||||
raw = json;
|
||||
@@ -274,10 +276,12 @@ class DataService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
if (raw != null) {
|
||||
_tripList = raw
|
||||
final trip_map = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => TripSummary.fromJson(e))
|
||||
.toList();
|
||||
|
||||
_tripList = [...trip_map]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||
} else {
|
||||
debugPrint('Unexpected trip list response: $json');
|
||||
_tripList = [];
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.1.1+1
|
||||
version: 0.1.2+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user