Compare commits
4 Commits
v0.1.1-dev
...
v0.1.2-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 8116cfe7b1 | |||
| 4d483495fc | |||
| 292163bda6 | |||
| 53eaf0b4af |
@@ -107,7 +107,7 @@ jobs:
|
|||||||
SUDO=""
|
SUDO=""
|
||||||
fi
|
fi
|
||||||
$SUDO apt-get update
|
$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 jq
|
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev liblzma-dev curl jq
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
@@ -134,46 +134,6 @@ jobs:
|
|||||||
name: linux-bundle
|
name: linux-bundle
|
||||||
path: app-linux-x64.tar.gz
|
path: app-linux-x64.tar.gz
|
||||||
|
|
||||||
windows-build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
needs: meta
|
|
||||||
# Job always runs; steps are conditional so it won't block anything when Windows is disabled.
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: ${{ env.FLUTTER_CHANNEL }}
|
|
||||||
|
|
||||||
- name: Allow all git directories (CI)
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
run: git config --global --add safe.directory '*'
|
|
||||||
|
|
||||||
- name: Flutter dependencies
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Enable Windows desktop
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
run: flutter config --enable-windows-desktop
|
|
||||||
|
|
||||||
- name: Build Windows binary (release)
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
run: |
|
|
||||||
flutter build windows --release
|
|
||||||
powershell -Command "Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath app-windows-x64.zip"
|
|
||||||
|
|
||||||
- name: Upload Windows artifact
|
|
||||||
if: ${{ env.BUILD_WINDOWS == 'true' }}
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: windows-zip
|
|
||||||
path: app-windows-x64.zip
|
|
||||||
|
|
||||||
release-dev:
|
release-dev:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
@@ -181,6 +141,15 @@ jobs:
|
|||||||
- android-build
|
- android-build
|
||||||
- linux-build
|
- linux-build
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install jq
|
||||||
|
run: |
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y jq
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
if: ${{ github.ref == 'refs/heads/dev' }}
|
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
@@ -260,7 +229,6 @@ jobs:
|
|||||||
>/dev/null
|
>/dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
release-master:
|
release-master:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ class Dashboard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: metricChips,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
isWide
|
isWide
|
||||||
? Row(
|
? Row(
|
||||||
@@ -71,8 +67,12 @@ class Dashboard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, AuthService auth,
|
Widget _buildHeader(
|
||||||
HomepageStats? stats, bool loading) {
|
BuildContext context,
|
||||||
|
AuthService auth,
|
||||||
|
HomepageStats? stats,
|
||||||
|
bool loading,
|
||||||
|
) {
|
||||||
final greetingName =
|
final greetingName =
|
||||||
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there';
|
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there';
|
||||||
return Row(
|
return Row(
|
||||||
@@ -82,10 +82,7 @@ class Dashboard extends StatelessWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
|
||||||
'Dashboard',
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Welcome back, $greetingName',
|
'Welcome back, $greetingName',
|
||||||
@@ -93,7 +90,8 @@ class Dashboard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (loading) const Padding(
|
if (loading)
|
||||||
|
const Padding(
|
||||||
padding: EdgeInsets.only(right: 8.0),
|
padding: EdgeInsets.only(right: 8.0),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -121,11 +119,13 @@ class Dashboard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(label.toUpperCase(),
|
Text(
|
||||||
|
label.toUpperCase(),
|
||||||
style: textTheme.labelSmall?.copyWith(
|
style: textTheme.labelSmall?.copyWith(
|
||||||
letterSpacing: 0.7,
|
letterSpacing: 0.7,
|
||||||
color: textTheme.bodySmall?.color?.withOpacity(0.7),
|
color: textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
@@ -203,10 +203,9 @@ class Dashboard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.w700,
|
||||||
.titleMedium
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -234,10 +233,7 @@ class Dashboard extends StatelessWidget {
|
|||||||
required String emptyMessage,
|
required String emptyMessage,
|
||||||
}) {
|
}) {
|
||||||
if (legs.isEmpty) {
|
if (legs.isEmpty) {
|
||||||
return Text(
|
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
||||||
emptyMessage,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: legs.take(5).map((leg) {
|
children: legs.take(5).map((leg) {
|
||||||
@@ -257,10 +253,9 @@ class Dashboard extends StatelessWidget {
|
|||||||
if (leg.headcode.isNotEmpty)
|
if (leg.headcode.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
leg.headcode,
|
leg.headcode,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
.textTheme
|
color: Theme.of(context).hintColor,
|
||||||
.labelSmall
|
),
|
||||||
?.copyWith(color: Theme.of(context).hintColor),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -286,7 +281,11 @@ class Dashboard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTripsCard(BuildContext context, DataService data) {
|
Widget _buildTripsCard(BuildContext context, DataService data) {
|
||||||
final trips = data.trips;
|
final trips_unsorted = data.trips;
|
||||||
|
List trips = [];
|
||||||
|
if (trips_unsorted.isNotEmpty) {
|
||||||
|
trips = [...trips_unsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||||
|
}
|
||||||
return _buildCard(
|
return _buildCard(
|
||||||
context,
|
context,
|
||||||
title: 'Trips',
|
title: 'Trips',
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final _domainController = TextEditingController();
|
final _domainController = TextEditingController();
|
||||||
final _typeController = TextEditingController();
|
final _typeController = TextEditingController();
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -239,8 +241,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
onSubmitted: (_) => _refreshTraction(),
|
onSubmitted: (_) => _refreshTraction(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
optionsViewBuilder:
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
(context, onSelected, options) {
|
|
||||||
final optionList = options.toList();
|
final optionList = options.toList();
|
||||||
if (optionList.isEmpty) {
|
if (optionList.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -306,7 +307,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
label: Text(
|
label: Text(
|
||||||
_mileageFirst ? 'Mileage first' : 'Had first',
|
_mileageFirst ? 'Mileage first' : 'Number order',
|
||||||
),
|
),
|
||||||
selected: _mileageFirst,
|
selected: _mileageFirst,
|
||||||
onSelected: (v) {
|
onSelected: (v) {
|
||||||
|
|||||||
@@ -46,11 +46,15 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Journeys',
|
Text(
|
||||||
style: Theme.of(context).textTheme.labelMedium),
|
'Journeys',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text('Trips',
|
Text(
|
||||||
style: Theme.of(context).textTheme.headlineSmall),
|
'Trips',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
@@ -81,10 +85,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'No trips yet',
|
'No trips yet',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.w700,
|
||||||
.titleMedium
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -101,8 +104,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
(trip) => Card(
|
(trip) => Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(trip.tripName),
|
title: Text(trip.tripName),
|
||||||
subtitle:
|
subtitle: Text(
|
||||||
Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
|
'${trip.tripMileage.toStringAsFixed(1)} mi',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -110,8 +114,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Column(
|
Column(
|
||||||
children:
|
children: tripDetails
|
||||||
tripDetails.map((trip) => _buildTripCard(context, trip, isMobile)).toList(),
|
.map((trip) => _buildTripCard(context, trip, isMobile))
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -134,10 +139,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
trip.name,
|
trip.name,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.w700,
|
||||||
.titleMedium
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs',
|
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs',
|
||||||
@@ -145,6 +149,14 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.train),
|
||||||
|
tooltip: 'Traction',
|
||||||
|
onPressed: () => _showTripWinners(context, trip),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
tooltip: 'Details',
|
tooltip: 'Details',
|
||||||
@@ -152,6 +164,8 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (legs.isNotEmpty)
|
if (legs.isNotEmpty)
|
||||||
Column(
|
Column(
|
||||||
@@ -168,10 +182,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.bold,
|
||||||
.labelLarge
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -215,10 +228,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
trip.name,
|
trip.name,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.bold,
|
||||||
.titleMedium
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||||
@@ -237,9 +249,63 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
subtitle: Text(_formatDate(leg.beginTime)),
|
subtitle: Text(_formatDate(leg.beginTime)),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.labelLarge
|
||||||
.textTheme
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
.labelLarge
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTripWinners(BuildContext context, TripDetail trip) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
trip.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: trip.legs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final leg = trip.legs[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.train),
|
||||||
|
title: Text('${leg.start} → ${leg.end}'),
|
||||||
|
subtitle: Text(_formatDate(leg.beginTime)),
|
||||||
|
trailing: Text(
|
||||||
|
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ void main() {
|
|||||||
providers: [
|
providers: [
|
||||||
Provider<ApiService>(
|
Provider<ApiService>(
|
||||||
create: (_) {
|
create: (_) {
|
||||||
api = ApiService(baseUrl: 'https://dev.mileograph.co.uk/api/v1');
|
api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1');
|
||||||
return api;
|
return api;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -37,6 +37,7 @@ void main() {
|
|||||||
ProxyProvider<AuthService, void>(
|
ProxyProvider<AuthService, void>(
|
||||||
update: (_, auth, __) {
|
update: (_, auth, __) {
|
||||||
api.setTokenProvider(() => auth.token);
|
api.setTokenProvider(() => auth.token);
|
||||||
|
api.setUnauthorizedHandler(() => auth.handleTokenExpired());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<ApiService, DataService>(
|
ChangeNotifierProxyProvider<ApiService, DataService>(
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
typedef TokenProvider = String? Function();
|
typedef TokenProvider = String? Function();
|
||||||
|
typedef UnauthorizedHandler = Future<void> Function();
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
TokenProvider? _getToken;
|
TokenProvider? _getToken;
|
||||||
|
UnauthorizedHandler? _onUnauthorized;
|
||||||
|
|
||||||
ApiService({required this.baseUrl});
|
ApiService({required this.baseUrl});
|
||||||
|
|
||||||
@@ -13,6 +15,10 @@ class ApiService {
|
|||||||
_getToken = provider;
|
_getToken = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUnauthorizedHandler(UnauthorizedHandler handler) {
|
||||||
|
_onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, String> _buildHeaders(Map<String, String>? extra) {
|
Map<String, String> _buildHeaders(Map<String, String>? extra) {
|
||||||
final token = _getToken?.call();
|
final token = _getToken?.call();
|
||||||
final headers = {'accept': 'application/json', ...?extra};
|
final headers = {'accept': 'application/json', ...?extra};
|
||||||
@@ -85,12 +91,19 @@ class ApiService {
|
|||||||
return {'Content-Type': 'application/json', if (extra != null) ...extra};
|
return {'Content-Type': 'application/json', if (extra != null) ...extra};
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic _processResponse(http.Response res) {
|
Future<dynamic> _processResponse(http.Response res) async {
|
||||||
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
|
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
return body;
|
return body;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (res.statusCode == 401 &&
|
||||||
|
body is Map<String, dynamic> &&
|
||||||
|
body['detail'] == 'Not authenticated' &&
|
||||||
|
_onUnauthorized != null) {
|
||||||
|
await _onUnauthorized!();
|
||||||
|
}
|
||||||
|
|
||||||
throw Exception('API error ${res.statusCode}: $body');
|
throw Exception('API error ${res.statusCode}: $body');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/apiService.dart';
|
import 'package:mileograph_flutter/services/apiService.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class AuthService extends ChangeNotifier {
|
class AuthService extends ChangeNotifier {
|
||||||
final ApiService api;
|
final ApiService api;
|
||||||
static const _tokenKey = 'auth_token';
|
static const _tokenKey = 'auth_token';
|
||||||
bool _restoring = false;
|
bool _restoring = false;
|
||||||
|
|
||||||
|
// secure storage instance
|
||||||
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||||
|
|
||||||
AuthService({required this.api});
|
AuthService({required this.api});
|
||||||
|
|
||||||
AuthenticatedUserData? _user;
|
AuthenticatedUserData? _user;
|
||||||
@@ -73,9 +76,10 @@ class AuthService extends ChangeNotifier {
|
|||||||
if (_restoring || _user != null) return;
|
if (_restoring || _user != null) return;
|
||||||
_restoring = true;
|
_restoring = true;
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
// read token from secure storage
|
||||||
final token = prefs.getString(_tokenKey);
|
final token = await _storage.read(key: _tokenKey);
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
final userResponse = await api.get(
|
final userResponse = await api.get(
|
||||||
'/users/me',
|
'/users/me',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -83,6 +87,7 @@ class AuthService extends ChangeNotifier {
|
|||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setLoginData(
|
setLoginData(
|
||||||
userId: userResponse['user_id'],
|
userId: userResponse['user_id'],
|
||||||
username: userResponse['username'],
|
username: userResponse['username'],
|
||||||
@@ -98,13 +103,11 @@ class AuthService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persistToken(String token) async {
|
Future<void> _persistToken(String token) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _storage.write(key: _tokenKey, value: token);
|
||||||
await prefs.setString(_tokenKey, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearToken() async {
|
Future<void> _clearToken() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _storage.delete(key: _tokenKey);
|
||||||
await prefs.remove(_tokenKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> register({
|
Future<void> register({
|
||||||
@@ -126,9 +129,13 @@ class AuthService extends ChangeNotifier {
|
|||||||
await api.postForm('/register', formData);
|
await api.postForm('/register', formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
void logout() {
|
Future<void> handleTokenExpired() async {
|
||||||
_user = null;
|
_user = null;
|
||||||
_clearToken();
|
await _clearToken();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void logout() {
|
||||||
|
handleTokenExpired(); // reuse
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ class DataService extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final buffer = StringBuffer(
|
final buffer = StringBuffer(
|
||||||
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit');
|
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit',
|
||||||
|
);
|
||||||
if (dateRangeStart != null && dateRangeStart.isNotEmpty) {
|
if (dateRangeStart != null && dateRangeStart.isNotEmpty) {
|
||||||
buffer.write('&date_range_start=$dateRangeStart');
|
buffer.write('&date_range_start=$dateRangeStart');
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,7 @@ class DataService extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final params = StringBuffer('?limit=$limit&offset=$offset');
|
final params = StringBuffer('?limit=$limit&offset=$offset');
|
||||||
if (hadOnly) params.write('&had_only=true');
|
if (hadOnly) params.write('&had_only=true');
|
||||||
if (mileageFirst) params.write('&mileage_first=true');
|
if (!mileageFirst) params.write('&mileage_first=false');
|
||||||
|
|
||||||
final payload = <String, dynamic>{};
|
final payload = <String, dynamic>{};
|
||||||
if (locoClass != null && locoClass.isNotEmpty) {
|
if (locoClass != null && locoClass.isNotEmpty) {
|
||||||
@@ -203,7 +204,7 @@ class DataService extends ChangeNotifier {
|
|||||||
if (json is List) {
|
if (json is List) {
|
||||||
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
|
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
|
||||||
_traction = append ? [..._traction, ...newItems] : newItems;
|
_traction = append ? [..._traction, ...newItems] : newItems;
|
||||||
_tractionHasMore = newItems.length >= limit;
|
_tractionHasMore = newItems.length >= limit - 1;
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Unexpected traction response: $json');
|
throw Exception('Unexpected traction response: $json');
|
||||||
}
|
}
|
||||||
@@ -245,12 +246,13 @@ class DataService extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/legs-and-stats');
|
final json = await api.get('/trips/legs-and-stats');
|
||||||
if (json is List) {
|
if (json is List) {
|
||||||
_tripDetails = json.map((e) => TripDetail.fromJson(e)).toList();
|
final trip_map = json.map((e) => TripDetail.fromJson(e)).toList();
|
||||||
|
_tripDetails = [...trip_map]..sort((a, b) => b.id.compareTo(a.id));
|
||||||
} else {
|
} else {
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch trips: $e');
|
debugPrint('Failed to fetch trip_map: $e');
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
} finally {
|
} finally {
|
||||||
_isTripDetailsLoading = false;
|
_isTripDetailsLoading = false;
|
||||||
@@ -260,7 +262,7 @@ class DataService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> fetchTrips() async {
|
Future<void> fetchTrips() async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips');
|
final json = await api.get('/trips/mileage');
|
||||||
Iterable<dynamic>? raw;
|
Iterable<dynamic>? raw;
|
||||||
if (json is List) {
|
if (json is List) {
|
||||||
raw = json;
|
raw = json;
|
||||||
@@ -274,10 +276,12 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (raw != null) {
|
if (raw != null) {
|
||||||
_tripList = raw
|
final trip_map = raw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map((e) => TripSummary.fromJson(e))
|
.map((e) => TripSummary.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
_tripList = [...trip_map]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||||
} else {
|
} else {
|
||||||
debugPrint('Unexpected trip list response: $json');
|
debugPrint('Unexpected trip list response: $json');
|
||||||
_tripList = [];
|
_tripList = [];
|
||||||
|
|||||||
36
lib/services/tokenStorageService.dart
Normal file
36
lib/services/tokenStorageService.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class TokenStorageService {
|
||||||
|
// Singleton pattern (optional but usually handy for services)
|
||||||
|
TokenStorageService._internal();
|
||||||
|
|
||||||
|
static final TokenStorageService _instance = TokenStorageService._internal();
|
||||||
|
|
||||||
|
factory TokenStorageService() => _instance;
|
||||||
|
|
||||||
|
static const _tokenKey = 'auth_token';
|
||||||
|
|
||||||
|
// Use const constructor for secure storage
|
||||||
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||||
|
|
||||||
|
/// Save or update the token
|
||||||
|
Future<void> setToken(String token) async {
|
||||||
|
await _storage.write(key: _tokenKey, value: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the stored token (null if none)
|
||||||
|
Future<String?> getToken() async {
|
||||||
|
return _storage.read(key: _tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the token
|
||||||
|
Future<void> clearToken() async {
|
||||||
|
await _storage.delete(key: _tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional: check quickly if a token exists
|
||||||
|
Future<bool> hasToken() async {
|
||||||
|
final token = await getToken();
|
||||||
|
return token != null && token.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <dynamic_color/dynamic_color_plugin.h>
|
#include <dynamic_color/dynamic_color_plugin.h>
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
|
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
|
||||||
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
|
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
dynamic_color
|
dynamic_color
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import dynamic_color
|
import dynamic_color
|
||||||
|
import flutter_secure_storage_darwin
|
||||||
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||||
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
80
pubspec.lock
80
pubspec.lock
@@ -94,6 +94,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.0"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.0"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -216,6 +264,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.19"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -405,6 +477,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.1.1+1
|
version: 0.1.2+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
@@ -35,6 +35,7 @@ dependencies:
|
|||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
provider: ^6.1.5
|
provider: ^6.1.5
|
||||||
dynamic_color: ^1.6.6
|
dynamic_color: ^1.6.6
|
||||||
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
DynamicColorPluginCApiRegisterWithRegistrar(
|
DynamicColorPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
dynamic_color
|
dynamic_color
|
||||||
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user