From 40ee16d2d51f838bdf1e6085e8fd0445d4ee0936 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Thu, 11 Dec 2025 01:08:30 +0000 Subject: [PATCH] initial codex commit --- .gitea/workflows/release.yml | 12 +- lib/components/calculator/calculator.dart | 25 +- .../dashboard/leaderboardPanel.dart | 49 +- .../dashboard/topTractionPanel.dart | 52 +- lib/components/login/login.dart | 124 ++- lib/components/pages/calculator.dart | 7 +- lib/components/pages/dashboard.dart | 367 +++++++-- lib/components/pages/legs.dart | 319 +++++++- lib/components/pages/newEntry.dart | 10 - lib/components/pages/new_entry.dart | 653 ++++++++++++++++ lib/components/pages/traction.dart | 703 +++++++++++++++++- lib/components/pages/trips.dart | 253 ++++++- lib/main.dart | 39 +- lib/objects/objects.dart | 149 +++- lib/services/apiService.dart | 10 +- lib/services/authservice.dart | 62 ++ lib/services/dataService.dart | 215 +++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 130 +++- pubspec.yaml | 4 +- 20 files changed, 2902 insertions(+), 283 deletions(-) delete mode 100644 lib/components/pages/newEntry.dart create mode 100644 lib/components/pages/new_entry.dart diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0ac260b..ff6e63a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -31,6 +31,12 @@ jobs: $SUDO apt-get update $SUDO apt-get install -y unzip xz-utils zip libstdc++6 libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev curl + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + - name: Install Android SDK run: | mkdir -p "$ANDROID_SDK_ROOT"/cmdline-tools @@ -43,12 +49,6 @@ jobs: echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH" - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - name: Setup Flutter uses: subosito/flutter-action@v2 with: diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index ed45a0a..3c7fffc 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -97,7 +97,10 @@ class _StationAutocompleteState extends State { } class RouteCalculator extends StatefulWidget { - const RouteCalculator({super.key}); + const RouteCalculator({super.key, this.onDistanceComputed, this.onApplyRoute}); + + final ValueChanged? onDistanceComputed; + final ValueChanged? onApplyRoute; @override State createState() => _RouteCalculatorState(); @@ -143,11 +146,11 @@ class _RouteCalculatorState extends State { setState(() { _routeResult = RouteResult.fromJson(res); }); + final distance = (_routeResult?.distance ?? 0); + widget.onDistanceComputed?.call(distance); } else { setState(() { - _errorMessage = - RouteError.fromJson(res["error_obj"][0]).msg ?? - 'Unknown error occurred'; + _errorMessage = RouteError.fromJson(res["error_obj"][0]).msg; }); } } @@ -248,11 +251,21 @@ class _RouteCalculatorState extends State { style: TextStyle(color: Theme.of(context).colorScheme.error), ), ) - else if (_routeResult != null) + else if (_routeResult != null) ...[ RouteSummaryWidget( distance: _routeResult!.distance, onDetailsPressed: () => setState(() => _showDetails = true), - ) + ), + if (widget.onApplyRoute != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ElevatedButton.icon( + onPressed: () => widget.onApplyRoute!(_routeResult!), + icon: const Icon(Icons.check), + label: const Text('Apply to entry'), + ), + ), + ] else SizedBox.shrink(), const SizedBox(height: 10), diff --git a/lib/components/dashboard/leaderboardPanel.dart b/lib/components/dashboard/leaderboardPanel.dart index 6268f29..e5b7935 100644 --- a/lib/components/dashboard/leaderboardPanel.dart +++ b/lib/components/dashboard/leaderboardPanel.dart @@ -4,8 +4,18 @@ import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; class LeaderboardPanel extends StatelessWidget { + const LeaderboardPanel({super.key}); + + @override Widget build(BuildContext context) { final data = context.watch(); + final leaderboard = data.homepageStats?.leaderboard ?? []; + if (data.isHomepageLoading && leaderboard.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } return Padding( padding: const EdgeInsets.all(10.0), child: Card( @@ -20,19 +30,23 @@ class LeaderboardPanel extends StatelessWidget { decoration: TextDecoration.underline, ), ), - Column( - children: List.generate( - data.homepageStats?.leaderboard.length ?? 0, - (index) { - final leaderboardEntry = - data.homepageStats!.leaderboard[index]; - return Container( - width: double.infinity, - child: Container( - margin: EdgeInsets.symmetric(horizontal: 0, vertical: 8), + if (leaderboard.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('No leaderboard data yet'), + ) + else + Column( + children: List.generate( + leaderboard.length, + (index) { + final leaderboardEntry = leaderboard[index]; + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric( + horizontal: 0, vertical: 8), child: Padding( - padding: EdgeInsets.all(8), - + padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -41,12 +55,12 @@ class LeaderboardPanel extends StatelessWidget { children: [ TextSpan( text: '${index + 1}. ', - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, ), ), TextSpan( - text: '${leaderboardEntry.userFullName}', + text: leaderboardEntry.userFullName, ), ], ), @@ -57,11 +71,10 @@ class LeaderboardPanel extends StatelessWidget { ], ), ), - ), - ); - }, + ); + }, + ), ), - ), ], ), ), diff --git a/lib/components/dashboard/topTractionPanel.dart b/lib/components/dashboard/topTractionPanel.dart index 1ce3b91..1782cce 100644 --- a/lib/components/dashboard/topTractionPanel.dart +++ b/lib/components/dashboard/topTractionPanel.dart @@ -4,8 +4,19 @@ import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; class TopTractionPanel extends StatelessWidget { + const TopTractionPanel({super.key}); + + @override Widget build(BuildContext context) { final data = context.watch(); + final stats = data.homepageStats; + final locos = stats?.topLocos ?? []; + if (data.isHomepageLoading && locos.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } return Padding( padding: const EdgeInsets.all(10.0), child: Card( @@ -20,18 +31,23 @@ class TopTractionPanel extends StatelessWidget { decoration: TextDecoration.underline, ), ), - Column( - children: List.generate( - data.homepageStats?.topLocos.length ?? 0, - (index) { - final loco = data.homepageStats!.topLocos[index]; - return Container( - width: double.infinity, - child: Container( - margin: EdgeInsets.symmetric(horizontal: 0, vertical: 8), + if (locos.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('No traction data yet'), + ) + else + Column( + children: List.generate( + locos.length, + (index) { + final loco = locos[index]; + return Container( + width: double.infinity, + margin: + const EdgeInsets.symmetric(horizontal: 0, vertical: 8), child: Padding( - padding: EdgeInsets.all(8), - + padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -43,7 +59,7 @@ class TopTractionPanel extends StatelessWidget { children: [ TextSpan( text: '${index + 1}. ', - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, ), ), @@ -55,8 +71,9 @@ class TopTractionPanel extends StatelessWidget { ), ), Text( - '${loco.name}', - style: TextStyle(fontStyle: FontStyle.italic), + loco.name ?? '', + style: + const TextStyle(fontStyle: FontStyle.italic), ), ], ), @@ -64,11 +81,10 @@ class TopTractionPanel extends StatelessWidget { ], ), ), - ), - ); - }, + ); + }, + ), ), - ), ], ), ), diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index 3db5461..6ed9741 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -13,7 +13,6 @@ class LoginScreen extends StatelessWidget { color: Theme.of(context).scaffoldBackgroundColor, child: Center( child: Column( - spacing: 50, mainAxisAlignment: MainAxisAlignment.center, children: [ Text.rich( @@ -39,14 +38,15 @@ class LoginScreen extends StatelessWidget { style: TextStyle( decoration: TextDecoration.none, color: Colors.white, - fontFamily: "Tomatoes", - fontSize: 50, - ), + fontFamily: "Tomatoes", + fontSize: 50, ), ), - LoginPanel(), - ], - ), + ), + const SizedBox(height: 50), + LoginPanel(), + ], + ), ), ), ); @@ -115,7 +115,7 @@ class _LoginPanelContentState extends State { bool _loggingIn = false; - void login() async { + Future login() async { final username = _usernameController.text; final password = _passwordController.text; @@ -126,19 +126,18 @@ class _LoginPanelContentState extends State { }); try { await auth.login(username, password); - print('Login successful'); + if (!mounted) return; setState(() { _loggingIn = false; }); } catch (e) { - // Handle error - print('Login failed: $e'); + if (!mounted) return; setState(() { _loggingIn = false; }); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Login failed'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login failed: $e')), + ); } } @@ -163,7 +162,6 @@ class _LoginPanelContentState extends State { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - spacing: 8, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50), @@ -172,6 +170,7 @@ class _LoginPanelContentState extends State { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), ), ), + const SizedBox(height: 8), TextFormField( controller: _usernameController, decoration: InputDecoration( @@ -180,6 +179,7 @@ class _LoginPanelContentState extends State { ), onFieldSubmitted: (_) => login(), ), + const SizedBox(height: 8), TextFormField( controller: _passwordController, obscureText: true, @@ -189,11 +189,12 @@ class _LoginPanelContentState extends State { ), onFieldSubmitted: (_) => login(), ), + const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, - spacing: 10, children: [ FilledButton(onPressed: login, child: loginButtonContent), + const SizedBox(width: 10), ElevatedButton( onPressed: widget.registerCb, child: Text("Register"), @@ -205,7 +206,7 @@ class _LoginPanelContentState extends State { } } -class RegisterPanelContent extends StatelessWidget { +class RegisterPanelContent extends StatefulWidget { const RegisterPanelContent({ super.key, required this.onBack, @@ -213,20 +214,64 @@ class RegisterPanelContent extends StatelessWidget { }); final VoidCallback onBack; final AuthService authService; - void register() {} + @override + State createState() => _RegisterPanelContentState(); +} + +class _RegisterPanelContentState extends State { + final _usernameController = TextEditingController(); + final _displayNameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _inviteController = TextEditingController(); + bool _registering = false; + + @override + void dispose() { + _usernameController.dispose(); + _displayNameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _inviteController.dispose(); + super.dispose(); + } + + Future _register() async { + setState(() => _registering = true); + try { + await widget.authService.register( + username: _usernameController.text.trim(), + email: _emailController.text.trim(), + fullName: _displayNameController.text.trim(), + password: _passwordController.text, + inviteCode: _inviteController.text.trim(), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Registration successful. Please log in.')), + ); + widget.onBack(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Registration failed: $e')), + ); + } finally { + if (mounted) setState(() => _registering = false); + } + } @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - spacing: 8, children: [ Row( children: [ IconButton( - icon: Icon(Icons.arrow_back), - onPressed: onBack, + icon: const Icon(Icons.arrow_back), + onPressed: widget.onBack, tooltip: 'Back to login', ), Expanded( @@ -237,47 +282,64 @@ class RegisterPanelContent extends StatelessWidget { ), ), ), - // Spacer to balance the row visually - SizedBox(width: 48), // matches IconButton size + const SizedBox(width: 48), ], ), - SizedBox(height: 16), + const SizedBox(height: 16), TextField( - decoration: InputDecoration( + controller: _usernameController, + decoration: const InputDecoration( border: OutlineInputBorder(), labelText: "Username", ), ), + const SizedBox(height: 8), TextField( - decoration: InputDecoration( + controller: _displayNameController, + decoration: const InputDecoration( border: OutlineInputBorder(), labelText: "Display Name", ), ), + const SizedBox(height: 8), TextField( - decoration: InputDecoration( + controller: _emailController, + decoration: const InputDecoration( border: OutlineInputBorder(), labelText: "Email", ), ), + const SizedBox(height: 8), TextField( + controller: _passwordController, obscureText: true, - decoration: InputDecoration( + decoration: const InputDecoration( border: OutlineInputBorder(), labelText: "Password", ), ), + const SizedBox(height: 8), TextField( - decoration: InputDecoration( + controller: _inviteController, + decoration: const InputDecoration( border: OutlineInputBorder(), labelText: "Invite Code", ), ), + const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, - spacing: 10, children: [ - FilledButton(onPressed: register, child: Text("Register")), + FilledButton( + onPressed: _registering ? null : _register, + child: _registering + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text("Register"), + ), ], ), ], diff --git a/lib/components/pages/calculator.dart b/lib/components/pages/calculator.dart index 4954838..2600c96 100644 --- a/lib/components/pages/calculator.dart +++ b/lib/components/pages/calculator.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart'; -import 'package:provider/provider.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; class CalculatorPage extends StatelessWidget { + const CalculatorPage({super.key}); + + @override Widget build(BuildContext context) { - return RouteCalculator(); + return const RouteCalculator(); } } diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 012c11c..04ec979 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -1,98 +1,319 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart'; +import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/dataService.dart'; -import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; - import 'package:provider/provider.dart'; class Dashboard extends StatelessWidget { const Dashboard({super.key}); + @override Widget build(BuildContext context) { final data = context.watch(); final auth = context.watch(); - return DashboardHeader(auth: auth, data: data); + final stats = data.homepageStats; + + return RefreshIndicator( + onRefresh: () async { + await data.fetchHomepageStats(); + await Future.wait([ + data.fetchOnThisDay(), + data.fetchTripDetails(), + data.fetchHadTraction(), + ]); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 1100; + final metricChips = _buildMetricChips( + context, + totalMileage: stats?.totalMileage ?? 0, + currentYearMileage: data.getMileageForCurrentYear(), + trips: data.trips.length, + ); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildHeader(context, auth, stats, data.isHomepageLoading), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: metricChips, + ), + const SizedBox(height: 16), + isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildMainColumn(context, data)), + const SizedBox(width: 16), + SizedBox( + width: 360, + child: _buildSidebar(context, data), + ), + ], + ) + : Column( + children: [ + _buildMainColumn(context, data), + const SizedBox(height: 16), + _buildSidebar(context, data), + ], + ), + ], + ); + }, + ), + ); } -} -class DashboardHeader extends StatelessWidget { - const DashboardHeader({super.key, required this.auth, required this.data}); - - final AuthService auth; - final DataService data; - - @override - Widget build(BuildContext context) { - return Column( + Widget _buildHeader(BuildContext context, AuthService auth, + HomepageStats? stats, bool loading) { + final greetingName = + stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there'; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan(text: "Total Mileage: "), - TextSpan( - text: - data.homepageStats?.totalMileage - .toString() ?? - "0", - ), - ], - ), - ), - Text.rich( - TextSpan( - children: [ - TextSpan(text: DateTime.now().year.toString()), - TextSpan(text: " Mileage: "), - TextSpan( - text: data - .getMileageForCurrentYear() - .toString(), - ), - ], - ), - ), - ], - ), - ), - ), - Card( - child: Padding( - padding: EdgeInsets.all(8), - child: Column( - children: [ - Text("Total Winners: 123"), - Text("Average mileage: 45.6"), - ], - ), - ), - ), - ], + Text( + 'Dashboard', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + 'Welcome back, $greetingName', + style: Theme.of(context).textTheme.headlineSmall, ), ], ), - - Expanded( - child: ListView( - scrollDirection: Axis.vertical, - children: [ - TopTractionPanel(), - LeaderboardPanel(), - SizedBox(height: 80), - ], + if (loading) const Padding( + padding: EdgeInsets.only(right: 8.0), + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ); } + + List _buildMetricChips( + BuildContext context, { + required double totalMileage, + required double currentYearMileage, + required int trips, + }) { + final textTheme = Theme.of(context).textTheme; + Widget metricCard(String label, String value) { + return Card( + elevation: 1, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label.toUpperCase(), + style: textTheme.labelSmall?.copyWith( + letterSpacing: 0.7, + color: textTheme.bodySmall?.color?.withOpacity(0.7), + )), + const SizedBox(height: 4), + Text( + value, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } + + return [ + metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), + metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), + metricCard('Trips logged', trips.toString()), + ]; + } + + Widget _buildMainColumn(BuildContext context, DataService data) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCard( + context, + title: 'On this day', + trailing: data.isOnThisDayLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + child: _buildLegList( + context, + data.onThisDay, + emptyMessage: 'No historical moves for today yet.', + ), + ), + const SizedBox(height: 12), + _buildQuickCalcCard(context), + const SizedBox(height: 12), + _buildTripsCard(context, data), + ], + ); + } + + Widget _buildSidebar(BuildContext context, DataService data) { + return Column( + children: [ + TopTractionPanel(), + const SizedBox(height: 12), + LeaderboardPanel(), + ], + ); + } + + Widget _buildCard( + BuildContext context, { + required String title, + required Widget child, + Widget? trailing, + Widget? action, + }) { + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (action != null) action, + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing, + ], + ], + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } + + Widget _buildLegList( + BuildContext context, + List legs, { + required String emptyMessage, + }) { + if (legs.isEmpty) { + return Text( + emptyMessage, + style: Theme.of(context).textTheme.bodyMedium, + ); + } + return Column( + children: legs.take(5).map((leg) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: const Icon(Icons.train), + ), + title: Text('${leg.start} → ${leg.end}'), + subtitle: Text(_formatDate(leg.beginTime)), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${leg.mileage.toStringAsFixed(1)} mi'), + if (leg.headcode.isNotEmpty) + Text( + leg.headcode, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildQuickCalcCard(BuildContext context) { + return _buildCard( + context, + title: 'Quick mileage calculator', + action: TextButton.icon( + onPressed: () => context.push('/calculator'), + icon: const Icon(Icons.open_in_new), + label: const Text('Open calculator'), + ), + child: Text( + 'Jump into the route calculator to quickly total a journey before saving it.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + Widget _buildTripsCard(BuildContext context, DataService data) { + final trips = data.trips; + return _buildCard( + context, + title: 'Trips', + action: TextButton( + onPressed: () => context.push('/trips'), + child: const Text('View all'), + ), + child: trips.isEmpty + ? Text( + 'No trips logged yet. Add one from the Trips page.', + style: Theme.of(context).textTheme.bodyMedium, + ) + : Column( + children: trips.take(5).map((trip) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(trip.tripName), + subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), + trailing: const Icon(Icons.chevron_right), + ); + }).toList(), + ), + ); + } + + String _formatDate(DateTime? dt) { + if (dt == null) return ''; + return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } } diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index 81e1570..ba20625 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -1,31 +1,300 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:provider/provider.dart'; +class LegsPage extends StatefulWidget { + const LegsPage({super.key}); -class LegsPage extends StatelessWidget { - Widget build(BuildContext context){ - final data = context.watch(); - return ListView.builder( - itemCount: data.legs.length, - itemBuilder: (context, index) { - final leg = data.legs[index]; - return Card( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${leg.start} → ${leg.end}', style: TextStyle(fontSize: 16)), - Text('Mileage: ${leg.mileage.toStringAsFixed(2)} km'), - Text('Headcode: ${leg.headcode}'), - Text('Begin: ${leg.beginTime}'), - ], - ), - ), - ); - }, + @override + State createState() => _LegsPageState(); +} + +class _LegsPageState extends State { + int _sortDirection = 0; + DateTime? _startDate; + DateTime? _endDate; + bool _initialised = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialised) { + _initialised = true; + _refreshLegs(); + } + } + + Future _refreshLegs() async { + final data = context.read(); + await data.fetchLegs( + sortDirection: _sortDirection, + dateRangeStart: _formatDate(_startDate), + dateRangeEnd: _formatDate(_endDate), ); } -} \ No newline at end of file + + Future _loadMore() async { + final data = context.read(); + await data.fetchLegs( + sortDirection: _sortDirection, + dateRangeStart: _formatDate(_startDate), + dateRangeEnd: _formatDate(_endDate), + offset: data.legs.length, + append: true, + ); + } + + double _pageMileage(List legs) { + return legs.fold( + 0, + (prev, leg) => prev + (leg.mileage as double? ?? 0), + ); + } + + Future _pickDate({required bool start}) async { + final initial = start + ? _startDate ?? DateTime.now() + : _endDate ?? _startDate ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() { + if (start) { + _startDate = picked; + if (_endDate != null && _endDate!.isBefore(picked)) { + _endDate = picked; + } + } else { + _endDate = picked; + } + }); + await _refreshLegs(); + } + } + + void _clearFilters() { + setState(() { + _startDate = null; + _endDate = null; + _sortDirection = 0; + }); + _refreshLegs(); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + final legs = data.legs; + final pageMileage = _pageMileage(legs); + + return RefreshIndicator( + onRefresh: _refreshLegs, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Logbook', + style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 2), + Text('Entries', + style: Theme.of(context).textTheme.headlineSmall), + ], + ), + Card( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Page mileage', + style: Theme.of(context).textTheme.labelSmall), + Text('${pageMileage.toStringAsFixed(1)} mi', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700)), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filters', + style: Theme.of(context).textTheme.titleMedium), + TextButton.icon( + onPressed: _clearFilters, + icon: const Icon(Icons.refresh), + label: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: 0, + icon: Icon(Icons.south), + label: Text('Newest first'), + ), + ButtonSegment( + value: 1, + icon: Icon(Icons.north), + label: Text('Oldest first'), + ), + ], + selected: {_sortDirection}, + onSelectionChanged: (selection) { + setState(() => _sortDirection = selection.first); + _refreshLegs(); + }, + ), + FilledButton.tonalIcon( + onPressed: () => _pickDate(start: true), + icon: const Icon(Icons.calendar_month), + label: Text( + _startDate == null + ? 'Start date' + : _formatDate(_startDate!)!, + ), + ), + FilledButton.tonalIcon( + onPressed: () => _pickDate(start: false), + icon: const Icon(Icons.event), + label: Text( + _endDate == null + ? 'End date' + : _formatDate(_endDate!)!, + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), + if (data.isLegsLoading && legs.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: CircularProgressIndicator(), + ), + ) + else if (legs.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No entries found', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + const Text('Adjust the filters or add a new leg.'), + ], + ), + ), + ) + else + Column( + children: [ + ...legs.map((leg) => Card( + child: ListTile( + leading: const Icon(Icons.train), + title: Text('${leg.start} → ${leg.end}'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_formatDateTime(leg.beginTime)), + if (leg.headcode.isNotEmpty) + Text('Headcode: ${leg.headcode}'), + if (leg.route.isNotEmpty) + Text( + leg.route, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${leg.mileage.toStringAsFixed(1)} mi'), + Text( + leg.network, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + isThreeLine: true, + ), + )), + const SizedBox(height: 8), + if (data.legsHasMore || data.isLegsLoading) + Align( + alignment: Alignment.center, + child: OutlinedButton.icon( + onPressed: + data.isLegsLoading ? null : () => _loadMore(), + icon: data.isLegsLoading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text( + data.isLegsLoading ? 'Loading...' : 'Load more', + ), + ), + ), + ], + ), + ], + ), + ); + } + + String? _formatDate(DateTime? date) { + if (date == null) return null; + return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + String _formatDateTime(DateTime date) { + final dateStr = _formatDate(date) ?? ''; + final timeStr = + '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return '$dateStr · $timeStr'; + } +} diff --git a/lib/components/pages/newEntry.dart b/lib/components/pages/newEntry.dart deleted file mode 100644 index cd87422..0000000 --- a/lib/components/pages/newEntry.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; - -class NewEntryPage extends StatelessWidget { - Widget build(BuildContext context) { - final data = context.watch(); - return Center(child: Text("New Entry Page")); - } -} diff --git a/lib/components/pages/new_entry.dart b/lib/components/pages/new_entry.dart new file mode 100644 index 0000000..b4c8f44 --- /dev/null +++ b/lib/components/pages/new_entry.dart @@ -0,0 +1,653 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mileograph_flutter/components/calculator/calculator.dart'; +import 'package:mileograph_flutter/components/pages/traction.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/apiService.dart'; +import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:provider/provider.dart'; + +class NewEntryPage extends StatefulWidget { + const NewEntryPage({super.key}); + + @override + State createState() => _NewEntryPageState(); +} + +class _NewEntryPageState extends State { + final _formKey = GlobalKey(); + DateTime _selectedDate = DateTime.now(); + TimeOfDay _selectedTime = TimeOfDay.now(); + final _startController = TextEditingController(); + final _endController = TextEditingController(); + final _headcodeController = TextEditingController(); + final _notesController = TextEditingController(); + final _mileageController = TextEditingController(); + final _networkController = TextEditingController(); + bool _submitting = false; + bool _initialised = false; + bool _useManualMileage = false; + RouteResult? _routeResult; + final List<_TractionItem> _tractionItems = [_TractionItem.marker()]; + int? _selectedTripId; + bool _tripsRequested = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_tripsRequested) { + _tripsRequested = true; + context.read().fetchTrips(); + } + }); + } + + @override + void dispose() { + _startController.dispose(); + _endController.dispose(); + _headcodeController.dispose(); + _notesController.dispose(); + _mileageController.dispose(); + _networkController.dispose(); + super.dispose(); + } + + Widget _buildTripSelector(BuildContext context) { + final trips = context.watch().tripList; + final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); + return Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _selectedTripId, + decoration: const InputDecoration( + labelText: 'Trip', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('No trip'), + ), + ...sorted.map( + (t) => DropdownMenuItem( + value: t.tripId, + child: Text(t.tripName), + ), + ), + ], + onChanged: (val) => setState(() => _selectedTripId = val), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () => _showAddTripDialog(context), + icon: const Icon(Icons.add), + label: const Text('New Trip'), + ), + ], + ); + } + + Future _showAddTripDialog(BuildContext context) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('New Trip'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Trip name', + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('Add'), + ), + ], + ), + ); + if (!mounted) return; + if (result != null && result.isNotEmpty) { + final api = context.read(); + final data = context.read(); + final messenger = ScaffoldMessenger.of(context); + try { + await api.put('/trips/new', {"trip_name": result}); + await data.fetchTrips(); + if (!mounted) return; + final trips = data.tripList; + final match = trips.firstWhere( + (t) => t.tripName == result, + orElse: () => trips.isNotEmpty ? trips.first : TripSummary(tripId: 0, tripName: result, tripMileage: 0), + ); + setState(() => _selectedTripId = match.tripId); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Failed to add trip: $e')), + ); + } + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialised) { + _initialised = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchClassList(); + if (!_tripsRequested) { + _tripsRequested = true; + context.read().fetchTrips(); + } + }); + } + } + + Future _openCalculator() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _CalculatorPickerPage( + onResult: (res) => Navigator.of(context).pop(res), + ), + ), + ); + if (result != null) { + setState(() { + _routeResult = result; + _mileageController.text = result.distance.toStringAsFixed(2); + _useManualMileage = false; + }); + } + } + + Future _openTractionPicker() async { + final selectedKeys = _tractionItems + .where((e) => !e.isMarker && e.loco != null) + .map((e) => '${e.loco!.locoClass}-${e.loco!.number}') + .toSet(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TractionPage( + selectionMode: true, + selectedKeys: selectedKeys, + onSelect: (loco) { + final markerIndex = + _tractionItems.indexWhere((element) => element.isMarker); + final key = '${loco.locoClass}-${loco.number}'; + setState(() { + final existingIndex = _tractionItems.indexWhere( + (e) => !e.isMarker && e.loco != null && '${e.loco!.locoClass}-${e.loco!.number}' == key); + if (existingIndex != -1) { + _tractionItems.removeAt(existingIndex); + } else { + _tractionItems.insert( + markerIndex, + _TractionItem(loco: loco, powering: true), + ); + } + }); + }, + ), + ), + ); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _selectedDate = picked); + } + + Future _pickTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedTime, + ); + if (picked != null) setState(() => _selectedTime = picked); + } + + DateTime get _legDateTime => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + _selectedTime.hour, + _selectedTime.minute, + ); + + List> _buildTractionPayload() { + final markerIndex = + _tractionItems.indexWhere((element) => element.isMarker); + final payload = >[]; + for (var i = 0; i < _tractionItems.length; i++) { + final item = _tractionItems[i]; + if (item.isMarker || item.loco == null) continue; + int allocPos; + if (i > markerIndex) { + allocPos = -(i - markerIndex); + } else { + allocPos = (markerIndex - 1) - i; + } + payload.add({ + "loco_type": item.loco!.type, + "loco_number": item.loco!.number, + "alloc_pos": allocPos, + "alloc_powering": item.powering ? 1 : 0, + }); + } + return payload; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (!_useManualMileage && _routeResult == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please calculate mileage first')), + ); + return; + } + setState(() => _submitting = true); + final api = context.read(); + final routeStations = _routeResult?.calculatedRoute ?? []; + final startVal = _useManualMileage + ? _startController.text.trim() + : (routeStations.isNotEmpty ? routeStations.first : ''); + final endVal = _useManualMileage + ? _endController.text.trim() + : (routeStations.isNotEmpty ? routeStations.last : ''); + final mileageVal = _useManualMileage + ? double.tryParse(_mileageController.text.trim()) ?? 0 + : (_routeResult?.distance ?? 0); + final tractionPayload = _buildTractionPayload(); + + if (_useManualMileage) { + final body = { + "leg_trip": _selectedTripId ?? 0, + "leg_start": startVal, + "leg_end": endVal, + "leg_begin_time": _legDateTime.toIso8601String(), + "leg_network": _networkController.text.trim(), + "leg_distance": mileageVal, + "isKilometers": false, + "leg_notes": _notesController.text.trim(), + "leg_headcode": _headcodeController.text.trim(), + "locos": tractionPayload, + }; + await api.post('/add/manual', body); + } else { + final body = { + "leg_trip": _selectedTripId ?? 0, + "leg_start": startVal, + "leg_end": endVal, + "leg_begin_time": _legDateTime.toIso8601String(), + "leg_route": routeStations, + "leg_mileage": mileageVal, + "leg_notes": _notesController.text.trim(), + "leg_headcode": _headcodeController.text.trim(), + "leg_network": _networkController.text.trim(), + "locos": tractionPayload, + }; + await api.post('/add', body); + } + try { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Entry submitted')), + ); + _formKey.currentState!.reset(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to submit: $e')), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 700; + return Scaffold( + appBar: null, + body: Form( + key: _formKey, + child: LayoutBuilder( + builder: (context, constraints) { + final twoCol = !isMobile && constraints.maxWidth > 1000; + + final detailPanel = _section( + 'Details', + [ + _buildTripSelector(context), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _pickDate, + icon: const Icon(Icons.calendar_today), + label: Text(DateFormat.yMMMd().format(_selectedDate)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _pickTime, + icon: const Icon(Icons.schedule), + label: Text(_selectedTime.format(context)), + ), + ), + ], + ), + if (_useManualMileage) + Row( + children: [ + Expanded( + child: TextFormField( + controller: _startController, + decoration: const InputDecoration( + labelText: 'From', + border: OutlineInputBorder(), + ), + validator: (v) => !_useManualMileage + ? null + : (v == null || v.isEmpty ? 'Required' : null), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _endController, + decoration: const InputDecoration( + labelText: 'To', + border: OutlineInputBorder(), + ), + validator: (v) => !_useManualMileage + ? null + : (v == null || v.isEmpty ? 'Required' : null), + ), + ), + ], + ), + TextFormField( + controller: _headcodeController, + decoration: const InputDecoration( + labelText: 'Headcode', + border: OutlineInputBorder(), + ), + ), + TextFormField( + controller: _networkController, + decoration: const InputDecoration( + labelText: 'Network', + border: OutlineInputBorder(), + ), + ), + TextFormField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Notes', + border: OutlineInputBorder(), + ), + ), + ], + ); + + final tractionPanel = _section( + 'Traction', + [ + Align( + alignment: Alignment.centerLeft, + child: ElevatedButton.icon( + onPressed: _openTractionPicker, + icon: const Icon(Icons.search), + label: const Text('Search traction'), + ), + ), + _buildTractionList(), + ], + ); + + final mileagePanel = _section( + 'Mileage', + [ + SwitchListTile( + title: const Text('Use manual mileage'), + subtitle: const Text( + 'Turn off to calculate mileage automatically'), + value: _useManualMileage, + onChanged: (val) { + setState(() { + _useManualMileage = val; + }); + }, + ), + if (_useManualMileage) + TextFormField( + controller: _mileageController, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Mileage (mi)', + border: OutlineInputBorder(), + ), + ) + else if (_routeResult != null) + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Calculated mileage'), + subtitle: + Text('${_routeResult!.distance.toStringAsFixed(2)} mi'), + ), + if (!_useManualMileage) + Align( + alignment: Alignment.centerLeft, + child: ElevatedButton.icon( + onPressed: _openCalculator, + icon: const Icon(Icons.calculate), + label: const Text('Open mileage calculator'), + ), + ), + ], + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + detailPanel, + const SizedBox(height: 16), + twoCol + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: tractionPanel), + const SizedBox(width: 16), + Expanded(child: mileagePanel), + ], + ) + : Column( + children: [ + tractionPanel, + const SizedBox(height: 16), + mileagePanel, + ], + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + label: Text(_submitting ? 'Submitting...' : 'Submit entry'), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _buildTractionList() { + if (_tractionItems.length == 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Text('No traction selected yet.'), + ); + } + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + setState(() { + final item = _tractionItems.removeAt(oldIndex); + _tractionItems.insert(newIndex, item); + }); + }, + itemCount: _tractionItems.length, + itemBuilder: (context, index) { + final item = _tractionItems[index]; + if (item.isMarker) { + return Card( + key: const ValueKey('marker'), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const ListTile( + leading: Icon(Icons.train), + title: Text('Rolling stock marker'), + subtitle: Text( + 'Place locomotives above/below. Positions set relative to this.'), + ), + ); + } + final loco = item.loco!; + final markerIndex = + _tractionItems.indexWhere((element) => element.isMarker); + final pos = index > markerIndex ? -(index - markerIndex) : (markerIndex - 1) - index; + return Card( + key: ValueKey('${loco.locoClass}-${loco.number}-$index'), + child: ListTile( + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_indicator), + ), + title: Text('${loco.locoClass} ${loco.number}'), + subtitle: Text('${loco.name ?? ''} · Position $pos'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Powering'), + Switch( + value: item.powering, + onChanged: (v) { + setState(() { + _tractionItems[index] = + item.copyWith(powering: v); + }); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + _tractionItems.removeAt(index); + }); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _section(String title, List children) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ...children.map( + (w) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: w, + ), + ), + ], + ), + ), + ); + } +} + +class _CalculatorPickerPage extends StatelessWidget { + const _CalculatorPickerPage({required this.onResult}); + final ValueChanged onResult; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text('Mileage calculator'), + ), + body: RouteCalculator( + onApplyRoute: onResult, + ), + ); + } +} + +class _TractionItem { + final LocoSummary? loco; + final bool powering; + final bool isMarker; + + _TractionItem({required this.loco, this.powering = true, this.isMarker = false}); + + factory _TractionItem.marker() => _TractionItem(loco: null, powering: false, isMarker: true); + + _TractionItem copyWith({LocoSummary? loco, bool? powering, bool? isMarker}) { + return _TractionItem( + loco: loco ?? this.loco, + powering: powering ?? this.powering, + isMarker: isMarker ?? this.isMarker, + ); + } +} diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 253ed29..9146d4a 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -1,41 +1,700 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:provider/provider.dart'; import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:provider/provider.dart'; -class TractionPage extends StatelessWidget { +class TractionPage extends StatefulWidget { + const TractionPage({ + super.key, + this.selectionMode = false, + this.onSelect, + this.selectedKeys = const {}, + }); + + final bool selectionMode; + final ValueChanged? onSelect; + final Set selectedKeys; + + @override + State createState() => _TractionPageState(); +} + +class _TractionPageState extends State { + final _classController = TextEditingController(); + final _numberController = TextEditingController(); + bool _hadOnly = true; + bool _initialised = false; + bool _showAdvancedFilters = false; + String? _selectedClass; + late Set _selectedKeys; + final _nameController = TextEditingController(); + final _operatorController = TextEditingController(); + final _statusController = TextEditingController(); + final _evnController = TextEditingController(); + final _ownerController = TextEditingController(); + final _locationController = TextEditingController(); + final _liveryController = TextEditingController(); + final _domainController = TextEditingController(); + final _typeController = TextEditingController(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialised) { + _initialised = true; + _selectedKeys = {...widget.selectedKeys}; + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchClassList(); + _refreshTraction(); + }); + } + } + + @override + void dispose() { + _classController.dispose(); + _numberController.dispose(); + _nameController.dispose(); + _operatorController.dispose(); + _statusController.dispose(); + _evnController.dispose(); + _ownerController.dispose(); + _locationController.dispose(); + _liveryController.dispose(); + _domainController.dispose(); + _typeController.dispose(); + super.dispose(); + } + + bool get _hasFilters { + return [ + _selectedClass, + _classController.text, + _numberController.text, + _nameController.text, + _operatorController.text, + _statusController.text, + _evnController.text, + _ownerController.text, + _locationController.text, + _liveryController.text, + _domainController.text, + _typeController.text, + ].any((value) => (value ?? '').toString().trim().isNotEmpty); + } + + Future _refreshTraction({bool append = false}) async { + final data = context.read(); + final filters = { + "name": _nameController.text.trim(), + "operator": _operatorController.text.trim(), + "status": _statusController.text.trim(), + "evn": _evnController.text.trim(), + "owner": _ownerController.text.trim(), + "location": _locationController.text.trim(), + "livery": _liveryController.text.trim(), + "domain": _domainController.text.trim(), + "type": _typeController.text.trim(), + }..removeWhere((key, value) => value.isEmpty); + await data.fetchTraction( + hadOnly: _hadOnly && !_hasFilters, + locoClass: _selectedClass ?? _classController.text.trim(), + locoNumber: _numberController.text.trim(), + offset: append ? data.traction.length : 0, + append: append, + filters: filters, + ); + } + + void _clearFilters() { + for (final controller in [ + _classController, + _numberController, + _nameController, + _operatorController, + _statusController, + _evnController, + _ownerController, + _locationController, + _liveryController, + _domainController, + _typeController, + ]) { + controller.clear(); + } + setState(() { + _selectedClass = null; + }); + setState(() { + _hadOnly = true; + }); + _refreshTraction(); + } + + @override Widget build(BuildContext context) { final data = context.watch(); - return ListView.builder( - itemCount: data.traction.length, - itemBuilder: (context, index) { - final loco = data.traction[index]; - return Card( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [Text('${loco.locoClass} ${loco.number}')], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final traction = data.traction; + final classOptions = data.locoClasses; + final isMobile = MediaQuery.of(context).size.width < 700; + + final listView = RefreshIndicator( + onRefresh: _refreshTraction, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Fleet', + style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 2), + Text('Traction', + style: Theme.of(context).textTheme.headlineSmall), + ], + ), + IconButton( + tooltip: 'Refresh', + onPressed: _refreshTraction, + icon: const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filters', + style: Theme.of(context).textTheme.titleMedium), + TextButton( + onPressed: _clearFilters, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: isMobile ? double.infinity : 240, + child: Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + if (query.isEmpty) { + return classOptions; + } + return classOptions.where( + (c) => c.toLowerCase().contains(query), + ); + }, + initialValue: + TextEditingValue(text: _classController.text), + fieldViewBuilder: (context, controller, focusNode, + onFieldSubmitted) { + controller.value = _classController.value; + return TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Class', + border: OutlineInputBorder(), + ), + onChanged: (val) { + _classController.text = val; + }, + onSubmitted: (_) => _refreshTraction(), + ); + }, + onSelected: (String selection) { + setState(() { + _selectedClass = selection; + _classController.text = selection; + }); + _refreshTraction(); + }, + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _numberController, + decoration: const InputDecoration( + labelText: 'Number', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + FilterChip( + label: const Text('Had only'), + selected: _hadOnly, + onSelected: (v) { + setState(() => _hadOnly = v); + _refreshTraction(); + }, + ), + TextButton.icon( + onPressed: () => setState( + () => _showAdvancedFilters = !_showAdvancedFilters), + icon: Icon(_showAdvancedFilters + ? Icons.expand_less + : Icons.expand_more), + label: Text(_showAdvancedFilters + ? 'Hide filters' + : 'More filters'), + ), + ElevatedButton.icon( + onPressed: _refreshTraction, + icon: const Icon(Icons.search), + label: const Text('Search'), + ), + ], + ), + AnimatedCrossFade( + crossFadeState: _showAdvancedFilters + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _operatorController, + decoration: const InputDecoration( + labelText: 'Operator', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _statusController, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _evnController, + decoration: const InputDecoration( + labelText: 'EVN', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _ownerController, + decoration: const InputDecoration( + labelText: 'Owner', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Location', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _liveryController, + decoration: const InputDecoration( + labelText: 'Livery', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _domainController, + decoration: const InputDecoration( + labelText: 'Domain', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + SizedBox( + width: isMobile ? double.infinity : 220, + child: TextField( + controller: _typeController, + decoration: const InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _refreshTraction(), + ), + ), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + if (data.isTractionLoading && traction.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: CircularProgressIndicator(), + ), + ) + else if (traction.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${loco.name}', - style: TextStyle(fontStyle: FontStyle.italic), + 'No traction found', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), ), - Text('${loco.mileage} mi'), + const SizedBox(height: 8), + const Text('Try relaxing the filters or sync again.'), ], ), + ), + ) + else + Column( + children: [ + ...traction.map((loco) => _buildTractionCard(context, loco)), + if (data.tractionHasMore || data.isTractionLoading) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: OutlinedButton.icon( + onPressed: data.isTractionLoading + ? null + : () => _refreshTraction(append: true), + icon: data.isTractionLoading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text( + data.isTractionLoading ? 'Loading...' : 'Load more', + ), + ), + ), + ], + ), + ], + ), + ); + + if (widget.selectionMode) { + return Scaffold( + appBar: AppBar( + leadingWidth: 140, + leading: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: TextButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + title: null, + ), + body: listView, + ); + } + + return listView; + } + + Widget _buildTractionCard(BuildContext context, LocoSummary loco) { + final keyVal = '${loco.locoClass}-${loco.number}'; + final isSelected = _selectedKeys.contains(keyVal); + final status = loco.status ?? 'Unknown'; + final operatorName = loco.operator ?? ''; + final domain = loco.domain ?? ''; + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${loco.locoClass} ${loco.number}', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + if ((loco.name ?? '').isNotEmpty) + Text( + loco.name ?? '', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + Chip( + label: Text(status), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), ], ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + onPressed: () => _showLocoInfo(loco), + icon: const Icon(Icons.info_outline), + label: const Text('Details'), + ), + const Spacer(), + if (widget.selectionMode) + TextButton.icon( + onPressed: () { + if (widget.onSelect != null) { + widget.onSelect!(loco); + } + setState(() { + if (isSelected) { + _selectedKeys.remove(keyVal); + } else { + _selectedKeys.add(keyVal); + } + }); + }, + icon: Icon(isSelected + ? Icons.remove_circle_outline + : Icons.add_circle_outline), + label: Text(isSelected ? 'Remove' : 'Add to entry'), + ), + ], + ), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _statPill( + context, + label: 'Miles', + value: _formatNumber(loco.mileage), + ), + _statPill( + context, + label: 'Trips', + value: (loco.trips ?? loco.journeys ?? 0).toString(), + ), + if (operatorName.isNotEmpty) + _statPill( + context, + label: 'Operator', + value: operatorName, + ), + if (domain.isNotEmpty) + _statPill( + context, + label: 'Domain', + value: domain, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _statPill(BuildContext context, + {required String label, required String value}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: Theme.of(context).textTheme.labelSmall, ), + Text( + value, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } + + Future _showLocoInfo(LocoSummary loco) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return DraggableScrollableSheet( + expand: false, + maxChildSize: 0.9, + initialChildSize: 0.65, + builder: (_, controller) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + const SizedBox(width: 8), + Text( + '${loco.locoClass} ${loco.number}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + if ((loco.name ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 52.0, bottom: 12), + child: Text( + loco.name ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Expanded( + child: ListView( + controller: controller, + children: [ + _detailRow('Status', loco.status ?? 'Unknown'), + _detailRow('Operator', loco.operator ?? ''), + _detailRow('Domain', loco.domain ?? ''), + _detailRow('Owner', loco.owner ?? ''), + _detailRow('Livery', loco.livery ?? ''), + _detailRow('Location', loco.location ?? ''), + _detailRow( + 'Mileage', _formatNumber(loco.mileage ?? 0)), + _detailRow('Trips', + (loco.trips ?? loco.journeys ?? 0).toString()), + _detailRow('EVN', loco.evn ?? ''), + if (loco.notes != null && loco.notes!.isNotEmpty) + _detailRow('Notes', loco.notes!), + ], + ), + ), + ], + ), + ); + }, ); }, ); } + + Widget _detailRow(String label, String value) { + if (value.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + String _formatNumber(double? value) { + if (value == null) return '0'; + return value.toStringAsFixed(1); + } } diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index c733214..21a9de8 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -1,28 +1,251 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:provider/provider.dart'; -class TripsPage extends StatelessWidget { +class TripsPage extends StatefulWidget { + const TripsPage({super.key}); + + @override + State createState() => _TripsPageState(); +} + +class _TripsPageState extends State { + bool _initialised = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialised) { + _initialised = true; + _refreshTrips(); + } + } + + Future _refreshTrips() async { + await context.read().fetchTripDetails(); + } + + @override Widget build(BuildContext context) { final data = context.watch(); - return ListView.builder( - itemCount: data.legs.length, - itemBuilder: (context, index) { - final leg = data.legs[index]; - return Card( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + final tripDetails = data.tripDetails; + final tripSummaries = data.trips; + final isMobile = MediaQuery.of(context).size.width < 700; + final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; + + return RefreshIndicator( + onRefresh: _refreshTrips, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Journeys', + style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 2), + Text('Trips', + style: Theme.of(context).textTheme.headlineSmall), + ], + ), + Row( + children: [ + IconButton( + onPressed: _refreshTrips, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh trips', + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (showLoading) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: CircularProgressIndicator(), + ), + ) + else if (tripDetails.isEmpty && tripSummaries.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No trips yet', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + const Text( + 'Use the Add entry flow to start grouping legs into trips.', + ), + ], + ), + ), + ) + else if (tripDetails.isEmpty) + Column( + children: tripSummaries + .map( + (trip) => Card( + child: ListTile( + title: Text(trip.tripName), + subtitle: + Text('${trip.tripMileage.toStringAsFixed(1)} mi'), + ), + ), + ) + .toList(), + ) + else + Column( + children: + tripDetails.map((trip) => _buildTripCard(context, trip, isMobile)).toList(), + ), + ], + ), + ); + } + + Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) { + final legs = trip.legs; + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + trip.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + '${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + IconButton( + icon: const Icon(Icons.open_in_new), + tooltip: 'Details', + onPressed: () => _showTripDetail(context, trip), + ), + ], + ), + const SizedBox(height: 8), + if (legs.isNotEmpty) + Column( + children: legs.take(isMobile ? 2 : 3).map((leg) { + return ListTile( + dense: isMobile, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.train), + title: Text('${leg.start} → ${leg.end}'), + subtitle: Text( + _formatDate(leg.beginTime), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + leg.mileage?.toStringAsFixed(1) ?? '-', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + }).toList(), + ), + if (legs.length > 3) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + '+${legs.length - 3} more legs', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ); + } + + String _formatDate(DateTime? date) { + if (date == null) return ''; + return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + void _showTripDetail(BuildContext context, TripDetail trip) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) { + return SafeArea( child: Padding( - padding: EdgeInsets.all(16), + padding: const EdgeInsets.all(16.0), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${leg.start} → ${leg.end}', - style: TextStyle(fontSize: 16), + 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), + ), + ); + }, + ), ), - Text('Mileage: ${leg.mileage.toStringAsFixed(2)} km'), - Text('Headcode: ${leg.headcode}'), - Text('Begin: ${leg.beginTime}'), ], ), ), diff --git a/lib/main.dart b/lib/main.dart index 0f22107..69b8a4c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart'; -import 'package:mileograph_flutter/components/pages/newEntry.dart'; +import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:provider/provider.dart'; @@ -13,7 +13,6 @@ import 'package:mileograph_flutter/services/dataService.dart'; import 'components/login/login.dart'; import 'components/pages/dashboard.dart'; -import 'components/dashboard/topTractionPanel.dart'; import 'package:go_router/go_router.dart'; @@ -188,18 +187,28 @@ class _MyHomePageState extends State { if (!_fetched) { _fetched = true; WidgetsBinding.instance.addPostFrameCallback((_) { - final data = context.read(); - final auth = context.read(); - api.setTokenProvider(() => auth.token); - if (data.homepageStats == null) { - data.fetchHomepageStats(); - } - if (data.legs.isEmpty) { - data.fetchLegs(); - } - if (data.traction.isEmpty) { - data.fetchHadTraction(); - } + Future(() async { + final data = context.read(); + final auth = context.read(); + api.setTokenProvider(() => auth.token); + await auth.tryRestoreSession(); + if (!auth.isLoggedIn) return; + if (data.homepageStats == null) { + data.fetchHomepageStats(); + } + if (data.legs.isEmpty) { + data.fetchLegs(); + } + if (data.traction.isEmpty) { + data.fetchHadTraction(); + } + if (data.onThisDay.isEmpty) { + data.fetchOnThisDay(); + } + if (data.tripDetails.isEmpty) { + data.fetchTripDetails(); + } + }); }); } } @@ -212,7 +221,7 @@ class _MyHomePageState extends State { final data = context.watch(); final auth = context.read(); - if (data.homepageStats != null) { + if (data.homepageStats != null || !data.isHomepageLoading) { currentPage = widget.child; } else { currentPage = Center(child: CircularProgressIndicator()); diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 26fe77e..8254a20 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -41,6 +41,7 @@ class HomepageStats { final List topLocos; final List leaderboard; final List trips; + final UserData? user; HomepageStats({ required this.totalMileage, @@ -48,23 +49,37 @@ class HomepageStats { required this.topLocos, required this.leaderboard, required this.trips, + this.user, }); factory HomepageStats.fromJson(Map json) { + final userData = json['user_data']; + final mileageData = json['milage_data']; + final totalMileage = mileageData is Map && mileageData['mileage'] != null + ? (mileageData['mileage'] as num).toDouble() + : 0.0; return HomepageStats( - totalMileage: (json['milage_data']['mileage'] as num).toDouble(), - yearlyMileage: (json['yearly_mileage'] as List) + totalMileage: totalMileage, + yearlyMileage: (json['yearly_mileage'] as List? ?? []) .map((e) => YearlyMileage.fromJson(e)) .toList(), - topLocos: (json['top_locos'] as List) + topLocos: (json['top_locos'] as List? ?? []) .map((e) => LocoSummary.fromJson(e)) .toList(), - leaderboard: (json['leaderboard_data'] as List) + leaderboard: (json['leaderboard_data'] as List? ?? []) .map((e) => LeaderboardEntry.fromJson(e)) .toList(), - trips: (json['trip_data'] as List) + trips: (json['trip_data'] as List? ?? []) .map((e) => TripSummary.fromJson(e)) .toList(), + user: userData == null + ? null + : UserData( + userData['username'] ?? '', + userData['full_name'] ?? '', + userData['user_id'] ?? '', + userData['email'] ?? '', + ), ); } } @@ -112,6 +127,13 @@ class Loco { class LocoSummary extends Loco { final double? mileage; final int? journeys; + final int? trips; + final String? status; + final String? domain; + final String? owner; + final String? livery; + final String? location; + final Map extra; LocoSummary({ required int locoId, @@ -124,7 +146,15 @@ class LocoSummary extends Loco { String? locoEvn, this.mileage, this.journeys, - }) : super( + this.trips, + this.status, + this.domain, + this.owner, + this.livery, + this.location, + Map? extra, + }) : extra = extra ?? const {}, + super( id: locoId, type: locoType, number: locoNumber, @@ -136,17 +166,30 @@ class LocoSummary extends Loco { ); factory LocoSummary.fromJson(Map json) => LocoSummary( - locoId: json['loco_id'], - locoType: json['type'], - locoNumber: json['number'], - locoName: json['name'] ?? "", - locoClass: json['class'], - locoOperator: json['operator'], - locoNotes: json['notes'], - locoEvn: json['evn'], - mileage: (json['loco_mileage'] as num?)?.toDouble() ?? 0, - journeys: json['loco_journeys'] ?? 0, - ); + locoId: json['loco_id'] ?? json['id'] ?? 0, + locoType: json['type'] ?? json['loco_type'] ?? '', + locoNumber: json['number'] ?? json['loco_number'] ?? '', + locoName: json['name'] ?? json['loco_name'] ?? "", + locoClass: json['class'] ?? json['loco_class'] ?? '', + locoOperator: json['operator'] ?? json['loco_operator'] ?? '', + locoNotes: json['notes'], + locoEvn: json['evn'] ?? json['loco_evn'], + mileage: ((json['loco_mileage'] ?? json['mileage']) as num?) + ?.toDouble() ?? + 0, + journeys: (json['loco_journeys'] ?? json['journeys'] ?? 0) is num + ? (json['loco_journeys'] ?? json['journeys'] ?? 0).toInt() + : 0, + trips: (json['loco_trips'] ?? json['trips']) is num + ? (json['loco_trips'] ?? json['trips']).toInt() + : null, + status: json['status'] ?? json['loco_status'], + domain: json['domain'], + owner: json['owner'] ?? json['loco_owner'], + livery: json['livery'], + location: json['location'], + extra: Map.from(json), + ); } class LeaderboardEntry { @@ -285,3 +328,75 @@ class Station { country: json['country'], ); } + +class TripLeg { + final int? id; + final String start; + final String end; + final DateTime? beginTime; + final String? network; + final String? route; + final double? mileage; + final String? notes; + final List locos; + + TripLeg({ + required this.id, + required this.start, + required this.end, + required this.beginTime, + required this.network, + required this.route, + required this.mileage, + required this.notes, + required this.locos, + }); + + factory TripLeg.fromJson(Map json) => TripLeg( + id: json['leg_id'], + start: json['leg_start'] ?? '', + end: json['leg_end'] ?? '', + beginTime: + json['leg_begin_time'] != null && json['leg_begin_time'] is String + ? DateTime.tryParse(json['leg_begin_time']) + : (json['leg_begin_time'] is DateTime + ? json['leg_begin_time'] + : null), + network: json['leg_network'], + route: json['leg_route'], + mileage: (json['leg_mileage'] as num?)?.toDouble(), + notes: json['leg_notes'], + locos: (json['locos'] as List?) + ?.map((e) => Loco.fromJson(e as Map)) + .toList() ?? + [], + ); +} + +class TripDetail { + final int id; + final String name; + final double mileage; + final int legCount; + final List legs; + + TripDetail({ + required this.id, + required this.name, + required this.mileage, + required this.legCount, + required this.legs, + }); + + factory TripDetail.fromJson(Map json) => TripDetail( + id: json['trip_id'] ?? 0, + name: json['trip_name'] ?? '', + mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0, + legCount: json['leg_count'] ?? + ((json['trip_legs'] as List?)?.length ?? 0), + legs: (json['trip_legs'] as List?) + ?.map((e) => TripLeg.fromJson(e as Map)) + .toList() ?? + [], + ); +} diff --git a/lib/services/apiService.dart b/lib/services/apiService.dart index 5f0baa4..442d5e1 100644 --- a/lib/services/apiService.dart +++ b/lib/services/apiService.dart @@ -35,10 +35,11 @@ class ApiService { dynamic data, { Map? headers, }) async { + final hasBody = data != null; final response = await http.post( Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(_jsonHeaders(headers)), - body: jsonEncode(data), + headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), + body: hasBody ? jsonEncode(data) : null, ); return _processResponse(response); } @@ -60,10 +61,11 @@ class ApiService { dynamic data, { Map? headers, }) async { + final hasBody = data != null; final response = await http.put( Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(_jsonHeaders(headers)), - body: jsonEncode(data), + headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), + body: hasBody ? jsonEncode(data) : null, ); return _processResponse(response); } diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index 23e7366..6144ea5 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/apiService.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class AuthService extends ChangeNotifier { final ApiService api; + static const _tokenKey = 'auth_token'; + bool _restoring = false; AuthService({required this.api}); @@ -29,6 +32,7 @@ class AuthService extends ChangeNotifier { access_token: accessToken, email: email, ); + _persistToken(accessToken); notifyListeners(); } @@ -65,8 +69,66 @@ class AuthService extends ChangeNotifier { ); } + Future tryRestoreSession() async { + if (_restoring || _user != null) return; + _restoring = true; + try { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(_tokenKey); + if (token == null || token.isEmpty) return; + final userResponse = await api.get( + '/users/me', + headers: { + 'Authorization': 'Bearer $token', + 'accept': 'application/json', + }, + ); + setLoginData( + userId: userResponse['user_id'], + username: userResponse['username'], + fullName: userResponse['full_name'], + accessToken: token, + email: userResponse['email'], + ); + } catch (_) { + await _clearToken(); + } finally { + _restoring = false; + } + } + + Future _persistToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + } + + Future _clearToken() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + } + + Future register({ + required String username, + required String email, + required String fullName, + required String password, + String inviteCode = '', + }) async { + final formData = { + 'user_name': username, + 'email': email, + 'full_name': fullName, + 'password': password, + 'invitation_code': inviteCode, + 'empty': '', + 'empty2': '', + }; + await api.postForm('/register', formData); + } + void logout() { _user = null; + _clearToken(); notifyListeners(); } } diff --git a/lib/services/dataService.dart b/lib/services/dataService.dart index 1f2153e..b314413 100644 --- a/lib/services/dataService.dart +++ b/lib/services/dataService.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file @@ -14,10 +17,32 @@ class DataService extends ChangeNotifier { // Legs Data List _legs = []; List get legs => _legs; + List _onThisDay = []; + List get onThisDay => _onThisDay; + bool _isLegsLoading = false; + bool get isLegsLoading => _isLegsLoading; + bool _legsHasMore = false; + bool get legsHasMore => _legsHasMore; // Traction Data List _traction = []; List get traction => _traction; + bool _isTractionLoading = false; + bool get isTractionLoading => _isTractionLoading; + bool _tractionHasMore = false; + bool get tractionHasMore => _tractionHasMore; + + // Trips + List _trips = []; + List get trips => _trips; + List _tripDetails = []; + List get tripDetails => _tripDetails; + bool _isTripDetailsLoading = false; + bool get isTripDetailsLoading => _isTripDetailsLoading; + List _locoClasses = []; + List get locoClasses => _locoClasses; + List _tripList = []; + List get tripList => _tripList; // Station Data List? _cachedStations; @@ -27,20 +52,30 @@ class DataService extends ChangeNotifier { bool _isHomepageLoading = false; bool get isHomepageLoading => _isHomepageLoading; + bool _isOnThisDayLoading = false; + bool get isOnThisDayLoading => _isOnThisDayLoading; + + void _notifyAsync() { + // Always defer to the next frame to avoid setState during build. + SchedulerBinding.instance.addPostFrameCallback((_) { + notifyListeners(); + }); + } Future fetchHomepageStats() async { _isHomepageLoading = true; - notifyListeners(); try { final json = await api.get('/stats/homepage'); _homepageStats = HomepageStats.fromJson(json); + _trips = _homepageStats?.trips ?? []; } catch (e) { debugPrint('Failed to fetch homepage stats: $e'); _homepageStats = null; + _trips = []; } finally { _isHomepageLoading = false; - notifyListeners(); + _notifyAsync(); } } @@ -49,34 +84,178 @@ class DataService extends ChangeNotifier { int limit = 100, String sortBy = 'date', int sortDirection = 0, + String? dateRangeStart, + String? dateRangeEnd, + bool append = false, }) async { - final query = - '?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit'; - final json = await api.get('/user/legs$query'); + _isLegsLoading = true; + final buffer = StringBuffer( + '?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit'); + if (dateRangeStart != null && dateRangeStart.isNotEmpty) { + buffer.write('&date_range_start=$dateRangeStart'); + } + if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) { + buffer.write('&date_range_end=$dateRangeEnd'); + } + try { + final json = await api.get('/user/legs${buffer.toString()}'); - if (json is List) { - _legs = json.map((e) => Leg.fromJson(e)).toList(); - notifyListeners(); - } else { - throw Exception('Unexpected legs response: $json'); + if (json is List) { + final newLegs = json.map((e) => Leg.fromJson(e)).toList(); + _legs = append ? [..._legs, ...newLegs] : newLegs; + _legsHasMore = newLegs.length >= limit; + } else { + throw Exception('Unexpected legs response: $json'); + } + } catch (e) { + debugPrint('Failed to fetch legs: $e'); + if (!append) _legs = []; + _legsHasMore = false; + } finally { + _isLegsLoading = false; + _notifyAsync(); } } Future fetchHadTraction({int offset = 0, int limit = 100}) async { - final query = '?offset=$offset&limit=$limit'; - final json = await api.get('/loco/mileage$query'); + await fetchTraction( + hadOnly: true, + offset: offset, + limit: limit, + append: offset > 0, + ); + } - if (json is List) { - _traction = json.map((e) => LocoSummary.fromJson(e)).toList(); - notifyListeners(); - } else { - throw Exception('Unexpected traction response: $json'); + Future fetchTraction({ + bool hadOnly = false, + int offset = 0, + int limit = 50, + String? locoClass, + String? locoNumber, + bool mileageFirst = true, + bool append = false, + Map? filters, + }) async { + _isTractionLoading = true; + + try { + final params = StringBuffer('?limit=$limit&offset=$offset'); + if (hadOnly) params.write('&had_only=true'); + if (mileageFirst) params.write('&mileage_first=true'); + + final payload = {}; + if (locoClass != null && locoClass.isNotEmpty) { + payload['class'] = locoClass; + } + if (locoNumber != null && locoNumber.isNotEmpty) { + payload['number'] = locoNumber; + } + if (filters != null) { + filters.forEach((key, value) { + if (value == null) return; + if (value is String && value.trim().isEmpty) return; + payload[key] = value; + }); + } + + final json = await api.post( + '/locos/search/v2${params.toString()}', + payload.isEmpty ? null : payload, + ); + + if (json is List) { + final newItems = json.map((e) => LocoSummary.fromJson(e)).toList(); + _traction = append ? [..._traction, ...newItems] : newItems; + _tractionHasMore = newItems.length >= limit; + } else { + throw Exception('Unexpected traction response: $json'); + } + } catch (e) { + debugPrint('Failed to fetch traction: $e'); + if (!append) { + _traction = []; + } + _tractionHasMore = false; + } finally { + _isTractionLoading = false; + _notifyAsync(); } } + Future fetchOnThisDay({DateTime? date}) async { + _isOnThisDayLoading = true; + final target = date ?? DateTime.now(); + final formatted = + "${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}"; + try { + final json = await api.get('/legs/on-this-day?date=$formatted'); + if (json is List) { + _onThisDay = json.map((e) => Leg.fromJson(e)).toList(); + } else { + _onThisDay = []; + } + } catch (e) { + debugPrint('Failed to fetch on-this-day legs: $e'); + _onThisDay = []; + } finally { + _isOnThisDayLoading = false; + _notifyAsync(); + } + } + + Future fetchTripDetails() async { + _isTripDetailsLoading = true; + try { + final json = await api.get('/trips/legs-and-stats'); + if (json is List) { + _tripDetails = json.map((e) => TripDetail.fromJson(e)).toList(); + } else { + _tripDetails = []; + } + } catch (e) { + debugPrint('Failed to fetch trips: $e'); + _tripDetails = []; + } finally { + _isTripDetailsLoading = false; + _notifyAsync(); + } + } + + Future fetchTrips() async { + try { + final json = await api.get('/trips'); + if (json is List) { + _tripList = json.map((e) => TripSummary.fromJson(e)).toList(); + } + } catch (e) { + debugPrint('Failed to fetch trip list: $e'); + _tripList = []; + } finally { + _notifyAsync(); + } + } + + Future> fetchClassList() async { + if (_locoClasses.isNotEmpty) return _locoClasses; + try { + final json = await api.get('/loco/classlist'); + if (json is List) { + _locoClasses = json.map((e) => e.toString()).toList(); + _notifyAsync(); + } + } catch (e) { + debugPrint('Failed to fetch class list: $e'); + } + return _locoClasses; + } + void clear() { _homepageStats = null; - notifyListeners(); + _legs = []; + _onThisDay = []; + _trips = []; + _tripDetails = []; + _notifyAsync(); } double getMileageForCurrentYear() { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 507ed46..b5dc9c5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import dynamic_color +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2995498..136d950 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -112,6 +128,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: @@ -192,6 +216,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" provider: dependency: "direct main" description: @@ -200,6 +264,62 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -285,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.27.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 25ec526..eada652 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: 1.0.0+1 +version: 0.1.0+1 environment: sdk: ^3.8.1 @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + intl: ^0.19.0 + shared_preferences: ^2.2.2 http: ^1.4.0 provider: ^6.1.5 dynamic_color: ^1.6.6