From c26b069137827665605d953dea9defd55473f4df Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 29 Jan 2026 13:55:57 -0500 Subject: [PATCH 001/141] Fixes scrollability bug --- lib/pages/loyalty_card_page.dart | 22 +++++++++++----------- pubspec.lock | 16 ++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 44b11e5c..3f03e84d 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -44,14 +44,13 @@ class LoyaltyCardPage extends State { } Widget _buildContent(BuildContext context) { - return SingleChildScrollView( - child: Column( + return Column( children: [ const SizedBox(height: 20), CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 50), + const SizedBox(height: 25), Text( - 'Current Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', + 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', textAlign: TextAlign.center, style: TextStyle( fontSize: 26, @@ -59,7 +58,7 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - const SizedBox(height: 25), + const SizedBox(height: 20), ElevatedButton( onPressed: () => _loadCard(), style: ElevatedButton.styleFrom( @@ -79,11 +78,10 @@ class LoyaltyCardPage extends State { ), ), ), - const SizedBox(height: 20), - _transactions(), + const SizedBox(height: 15), + Expanded(child: _transactions()) ], - ), - ); + ); } Widget _transactions() { @@ -126,9 +124,10 @@ class LoyaltyCardPage extends State { ], ), const SizedBox(height: 10), - ListView.builder( + Expanded( + child: ListView.builder( shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), itemCount: viewModel.recentTransactions.length, itemBuilder: (context, index) { final transaction = viewModel.recentTransactions[index]; @@ -155,6 +154,7 @@ class LoyaltyCardPage extends State { ); }, ), + ) ], ), ); diff --git a/pubspec.lock b/pubspec.lock index c17ea3e0..6d70d99e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -500,10 +500,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -961,26 +961,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" test_cov_console: dependency: "direct dev" description: From 00cf40062edd4695dcd942ff0980bc7b2b22c1d3 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 29 Jan 2026 14:16:53 -0500 Subject: [PATCH 002/141] Fixes tests --- test/pages/loyalty_card_page_test.dart | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 57b183f9..638b3a8a 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -129,7 +129,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$42.75'), findsOneWidget); + expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget); }); testWidgets('should display balance with two decimal places', ( @@ -140,7 +140,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$100.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$100.00'), findsOneWidget); }); testWidgets('should display default balance when userBalance is null', ( @@ -151,7 +151,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should display Load card button with correct styling', ( @@ -168,13 +168,6 @@ void main() { expect(button.onPressed, isNotNull); }); - - testWidgets('should have scrollable content', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); }); group('Transactions Display', () { @@ -792,7 +785,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should handle empty transaction list', (tester) async { @@ -811,7 +804,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$9999.99'), findsOneWidget); + expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget); }); testWidgets('should handle zero balance', (tester) async { @@ -820,7 +813,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); }); } From de44919641768b9c43abe6be41e7de237bcb4880 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 30 Jan 2026 18:00:30 -0500 Subject: [PATCH 003/141] Fixes tests --- test/pages/loyalty_card_page_test.dart | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 638b3a8a..5c7052ab 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -204,10 +204,28 @@ void main() { ]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Loaded \$10.00 on 01/10/2025'), findsOneWidget); + + await tester.scrollUntilVisible( + find.text('Used \$2.50 on 01/09/2025'), + 100, + scrollable: find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ), + ); expect(find.text('Used \$2.50 on 01/09/2025'), findsOneWidget); + + await tester.scrollUntilVisible( + find.text('Loaded \$25.00 on 01/08/2025'), + 100, + scrollable: find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ), + ); expect(find.text('Loaded \$25.00 on 01/08/2025'), findsOneWidget); }); From c24337dd65043cea46a5de6f31f51f747198e2c2 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 29 Jan 2026 13:55:57 -0500 Subject: [PATCH 004/141] Fixes scrollability bug # Conflicts: # pubspec.lock --- lib/pages/loyalty_card_page.dart | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 44b11e5c..3f03e84d 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -44,14 +44,13 @@ class LoyaltyCardPage extends State { } Widget _buildContent(BuildContext context) { - return SingleChildScrollView( - child: Column( + return Column( children: [ const SizedBox(height: 20), CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 50), + const SizedBox(height: 25), Text( - 'Current Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', + 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', textAlign: TextAlign.center, style: TextStyle( fontSize: 26, @@ -59,7 +58,7 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - const SizedBox(height: 25), + const SizedBox(height: 20), ElevatedButton( onPressed: () => _loadCard(), style: ElevatedButton.styleFrom( @@ -79,11 +78,10 @@ class LoyaltyCardPage extends State { ), ), ), - const SizedBox(height: 20), - _transactions(), + const SizedBox(height: 15), + Expanded(child: _transactions()) ], - ), - ); + ); } Widget _transactions() { @@ -126,9 +124,10 @@ class LoyaltyCardPage extends State { ], ), const SizedBox(height: 10), - ListView.builder( + Expanded( + child: ListView.builder( shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), itemCount: viewModel.recentTransactions.length, itemBuilder: (context, index) { final transaction = viewModel.recentTransactions[index]; @@ -155,6 +154,7 @@ class LoyaltyCardPage extends State { ); }, ), + ) ], ), ); From eeda81462995bb1ad23195b122fce3aa72396095 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 29 Jan 2026 14:16:53 -0500 Subject: [PATCH 005/141] Fixes tests --- test/pages/loyalty_card_page_test.dart | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 57b183f9..638b3a8a 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -129,7 +129,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$42.75'), findsOneWidget); + expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget); }); testWidgets('should display balance with two decimal places', ( @@ -140,7 +140,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$100.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$100.00'), findsOneWidget); }); testWidgets('should display default balance when userBalance is null', ( @@ -151,7 +151,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should display Load card button with correct styling', ( @@ -168,13 +168,6 @@ void main() { expect(button.onPressed, isNotNull); }); - - testWidgets('should have scrollable content', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); }); group('Transactions Display', () { @@ -792,7 +785,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should handle empty transaction list', (tester) async { @@ -811,7 +804,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$9999.99'), findsOneWidget); + expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget); }); testWidgets('should handle zero balance', (tester) async { @@ -820,7 +813,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); }); } From daf953df335f4d2ef5d8ff9d6dd13b5eb77d9f7d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 30 Jan 2026 18:00:30 -0500 Subject: [PATCH 006/141] Fixes tests --- test/pages/loyalty_card_page_test.dart | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 638b3a8a..5c7052ab 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -204,10 +204,28 @@ void main() { ]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Loaded \$10.00 on 01/10/2025'), findsOneWidget); + + await tester.scrollUntilVisible( + find.text('Used \$2.50 on 01/09/2025'), + 100, + scrollable: find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ), + ); expect(find.text('Used \$2.50 on 01/09/2025'), findsOneWidget); + + await tester.scrollUntilVisible( + find.text('Loaded \$25.00 on 01/08/2025'), + 100, + scrollable: find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ), + ); expect(find.text('Loaded \$25.00 on 01/08/2025'), findsOneWidget); }); From eee1d728aa32a17e3c08b30dfde34b5a13abb23a Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 5 Feb 2026 17:53:57 -0500 Subject: [PATCH 007/141] Adds delete account to edit profile --- lib/pages/edit_profile_page.dart | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index 29ee9181..87983017 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; @@ -26,6 +27,7 @@ class _EditProfilePageState extends State { ); final profileService = GetIt.instance(); final authService = GetIt.instance(); + final edgeFunctionService = GetIt.instance(); String currentName = ''; String currentEmail = ''; @@ -378,6 +380,31 @@ class _EditProfilePageState extends State { ), ), ), + Spacer(), + Center( + child: InkWell( + onTap: _isSaving ? null : _deleteAccount, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + 'Delete Account', + style: TextStyle( + color: Colors.red, + fontSize: 16, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ), ], ), ), @@ -385,6 +412,54 @@ class _EditProfilePageState extends State { ); } + void _deleteAccount() async { + bool confirm = await _confirmDeleteAccount(); + if (confirm) { + String? userId = authService.getCurrentUserId; + final response = await edgeFunctionService.runEdgeFunction(name: "delete-account", body: {"user_id": userId}); + if (response!.status == 200) { + statusDialog(context, title: "Account Deleted", message: "Your account has been deleted successfully.", isSuccess: true); + context.go("/login"); + } else { + statusDialog(context, title: "Error", message: "An error occurred, please try again later.", isSuccess: false); + return; + } + } else { + return; + } + } + + Future _confirmDeleteAccount() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + 'Delete your account?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + content: Text( + 'Are you sure you want to delete your account? Any money on your loyalty card will be lost.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Yes, Delete'), + ), + ], + ), + ) ?? + false; + } + Future _confirmationWindow() async { return await showDialog( context: context, From 55dac23f058bd52881810659514196dcf863236f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 5 Feb 2026 18:18:58 -0500 Subject: [PATCH 008/141] Refactor edit profile page --- lib/pages/edit_profile_page.dart | 627 ++++++++++++++++--------- test/pages/edit_profile_page_test.dart | 271 ++++++++++- 2 files changed, 660 insertions(+), 238 deletions(-) diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index 87983017..de8b2673 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -9,7 +9,6 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; - class EditProfilePage extends StatefulWidget { const EditProfilePage({super.key}); @@ -22,9 +21,7 @@ class _EditProfilePageState extends State { late final StreamSubscription _authSub; final TextEditingController _nameController = TextEditingController(text: ''); - final TextEditingController _emailController = TextEditingController( - text: '', - ); + final TextEditingController _emailController = TextEditingController(text: ''); final profileService = GetIt.instance(); final authService = GetIt.instance(); final edgeFunctionService = GetIt.instance(); @@ -107,18 +104,17 @@ class _EditProfilePageState extends State { final nameChanged = newName != currentName; final emailChanged = newEmail != currentEmail; - if (!nameChanged && !emailChanged) { statusDialog( context, title: "No Changes", - message: "You haven’t changed anything.", + message: "You haven't changed anything.", isSuccess: false, ); return; } - final confirmed = await _confirmationWindow(); + final confirmed = await _confirmSaveChanges(); if (!confirmed) return; if (!_formKey.currentState!.validate()) return; @@ -171,9 +167,13 @@ class _EditProfilePageState extends State { showDialog( context: context, builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: Text( 'Error', - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), ), content: Text( message, @@ -182,7 +182,10 @@ class _EditProfilePageState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), + child: Text( + 'OK', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), ), ], ), @@ -202,213 +205,364 @@ class _EditProfilePageState extends State { ), title: const Text( "Edit Profile", - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), ), centerTitle: true, elevation: 0, ), body: _isLoading ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ) - : Padding( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 24), - - // Current Name Display - if (currentName.isNotEmpty) - Text( - 'Current Name: $currentName', - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(height: 8), - - TextFormField( - controller: _nameController, - enabled: !_isSaving, - inputFormatters: [ - LengthLimitingTextInputFormatter(36), - FilteringTextInputFormatter.allow( - RegExp(r'[a-zA-Z0-9 ]'), - ), - ], - - maxLength: 36, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ) + : SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Name Section + _buildSectionHeader('Full Name'), + const SizedBox(height: 12), + + _buildInfoCard( + label: 'Current', + value: currentName.isNotEmpty ? currentName : 'Not set', + icon: Icons.badge_outlined, + ), + + const SizedBox(height: 16), + + TextFormField( + controller: _nameController, + enabled: !_isSaving, + inputFormatters: [ + LengthLimitingTextInputFormatter(36), + FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Z0-9 ]'), + ), + ], + maxLength: 36, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + decoration: InputDecoration( + labelText: 'New Full Name', + hintText: 'Enter your full name', + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + counterStyle: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.6), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, ), - decoration: InputDecoration( - labelText: 'Full Name', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - counterStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.person, - color: Theme.of(context).colorScheme.primary, - ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.2), ), - textInputAction: TextInputAction.next, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Name cannot be empty'; - } - return null; - }, + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.person_outline, + color: Theme.of(context).colorScheme.primary, ), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Name cannot be empty'; + } + return null; + }, + ), + + const SizedBox(height: 10), + + // Email Section + _buildSectionHeader('Email Address'), + const SizedBox(height: 12), - const SizedBox(height: 16), + _buildInfoCard( + label: 'Current', + value: currentEmail.isNotEmpty ? currentEmail : 'Not set', + icon: Icons.email_outlined, + ), + + const SizedBox(height: 16), + + TextFormField( + controller: _emailController, + enabled: !_isSaving, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + decoration: InputDecoration( + labelText: 'New Email', + hintText: 'Enter your email address', + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: Theme.of(context).colorScheme.primary, + ), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email cannot be empty'; + } + if (!value.trim().contains("@")) { + return 'Please enter a valid email'; + } + return null; + }, + ), - // Current Email Display - if (currentEmail.isNotEmpty) + const SizedBox(height: 20), + + // Save Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: _isSaving ? null : _onSavePressed, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 8), Text( - 'Current Email: $currentEmail', + 'Save Changes', style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 8), + ], + ), + ), - TextFormField( - controller: _emailController, - enabled: !_isSaving, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - decoration: InputDecoration( - labelText: 'Email', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, + const SizedBox(height: 30), + + // Delete Account Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.red, + size: 20, ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, + const SizedBox(width: 8), + Text( + 'Danger Zone', + style: TextStyle( + color: Colors.red, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.email, - color: Theme.of(context).colorScheme.primary, + ], + ), + const SizedBox(height: 12), + Text( + 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.7), + fontSize: 13, ), ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Email cannot be empty'; - } - if (!value.trim().contains("@")) { - return 'Please enter a valid email'; - } - return null; - }, - ), - - const SizedBox(height: 34), - Center( - child: SizedBox( - width: 200, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, + const SizedBox(height: 16), + OutlinedButton( + onPressed: _isSaving ? null : _deleteAccount, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - onPressed: _isSaving ? null : _onSavePressed, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text( - 'Save Changes', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), ), - ), - ), - Spacer(), - Center( - child: InkWell( - onTap: _isSaving ? null : _deleteAccount, child: _isSaving ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, + color: Colors.red, ), ) - : const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - 'Delete Account', - style: TextStyle( - color: Colors.red, - fontSize: 16, - decoration: TextDecoration.underline, + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.delete_outline, size: 18), + SizedBox(width: 8), + Text( + 'Delete Account', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), - ), + ], ), ), - ), - ], + ], + ), ), - ), + + const SizedBox(height: 24), + ], ), + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ); + } + + Widget _buildInfoCard({ + required String label, + required String value, + required IconData icon, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + icon, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), ); } @@ -416,12 +570,25 @@ class _EditProfilePageState extends State { bool confirm = await _confirmDeleteAccount(); if (confirm) { String? userId = authService.getCurrentUserId; - final response = await edgeFunctionService.runEdgeFunction(name: "delete-account", body: {"user_id": userId}); + final response = await edgeFunctionService.runEdgeFunction( + name: "delete-account", + body: {"user_id": userId}, + ); if (response!.status == 200) { - statusDialog(context, title: "Account Deleted", message: "Your account has been deleted successfully.", isSuccess: true); + statusDialog( + context, + title: "Account Deleted", + message: "Your account has been deleted successfully.", + isSuccess: true, + ); context.go("/login"); } else { - statusDialog(context, title: "Error", message: "An error occurred, please try again later.", isSuccess: false); + statusDialog( + context, + title: "Error", + message: "An error occurred, please try again later.", + isSuccess: false, + ); return; } } else { @@ -433,14 +600,24 @@ class _EditProfilePageState extends State { return await showDialog( context: context, builder: (context) => AlertDialog( - title: Text( - 'Delete your account?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Delete Account?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), content: Text( - 'Are you sure you want to delete your account? Any money on your loyalty card will be lost.', + 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', style: TextStyle( color: Theme.of(context).colorScheme.fontSecondary, ), @@ -448,11 +625,18 @@ class _EditProfilePageState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text( + 'Cancel', + style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + ), ), ElevatedButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('Yes, Delete'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), ), ], ), @@ -460,34 +644,43 @@ class _EditProfilePageState extends State { false; } - Future _confirmationWindow() async { + Future _confirmSaveChanges() async { return await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text( - 'Confirm Changes', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + 'Confirm Changes', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + 'Are you sure you want to save these changes to your profile?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), ), - content: Text( - 'Are you sure you want to change your information?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Yes, Save'), - ), - ], + child: const Text('Save'), ), - ) ?? + ], + ), + ) ?? false; } -} +} \ No newline at end of file diff --git a/test/pages/edit_profile_page_test.dart b/test/pages/edit_profile_page_test.dart index 0a1b3b68..3091168a 100644 --- a/test/pages/edit_profile_page_test.dart +++ b/test/pages/edit_profile_page_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,11 +13,13 @@ import 'mocks.dart'; void main() { late MockAuthService authService; late MockProfileService profileService; + late MockEdgeFunctionService edgeFunctionService; late StreamController authController; setUp(() { authService = MockAuthService(); profileService = MockProfileService(); + edgeFunctionService = MockEdgeFunctionService(); authController = StreamController.broadcast(); final getIt = GetIt.instance; @@ -24,15 +27,16 @@ void main() { getIt.registerSingleton(authService); getIt.registerSingleton(profileService); + getIt.registerSingleton(edgeFunctionService); when(() => authService.onAuthChange) .thenAnswer((_) => authController.stream); when(() => authService.getCurrentUserId) - .thenAnswer((_) => 'user-id'); + .thenAnswer((_) => 'user-id'); when(() => authService.getCurrentUserEmail()) - .thenAnswer((_) => 'test@example.com'); + .thenAnswer((_) => 'test@example.com'); when(() => profileService.getUserNameById('user-id')) .thenAnswer((_) async => 'John Doe'); @@ -65,18 +69,45 @@ void main() { builder: (_, __) => const Scaffold(body: Text('Verify Email')), ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), ], ), ); } - testWidgets('loads and displays user data', (tester) async { + testWidgets('displays page title', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - expect(find.text('Current Name: John Doe'), findsOneWidget); - expect(find.text('Current Email: test@example.com'), findsOneWidget); + expect(find.text('Edit Profile'), findsOneWidget); + }); + + testWidgets('loads and displays user data in info cards', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Full Name'), findsOneWidget); + expect(find.text('Email Address'), findsOneWidget); + + expect(find.text('Current'), findsNWidgets(2)); + expect(find.text('John Doe'), findsNWidgets(2)); + expect(find.text('test@example.com'), findsNWidgets(2)); + expect(find.text('Save Changes'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + }); + + testWidgets('displays danger zone section with delete account button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Danger Zone'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.text('Delete Account'), findsOneWidget); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); }); testWidgets('shows No Changes dialog if nothing changed', (tester) async { @@ -87,7 +118,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('No Changes'), findsOneWidget); - expect(find.text('You haven’t changed anything.'), findsOneWidget); + expect(find.text('You haven\'t changed anything.'), findsOneWidget); verifyNever(() => authService.updateUserAttributes( email: any(named: 'email'), @@ -99,27 +130,45 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), ''); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, ''); + await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(find.text('Name cannot be empty'), findsOneWidget); }); + testWidgets('validates invalid email', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final emailField = find.widgetWithText(TextFormField, 'New Email'); + await tester.enterText(emailField, 'invalid-email'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Please enter a valid email'), findsOneWidget); + }); + testWidgets('updates name only', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), 'Jane Smith'); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'Jane Smith'); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -134,12 +183,13 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Email'), 'new@email.com'); + final emailField = find.widgetWithText(TextFormField, 'New Email'); + await tester.enterText(emailField, 'new@email.com'); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -154,14 +204,16 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), ' Jane '); - await tester.enterText( - find.widgetWithText(TextFormField, 'Email'), ' jane@email.com '); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + final emailField = find.widgetWithText(TextFormField, 'New Email'); + + await tester.enterText(nameField, ' Jane '); + await tester.enterText(emailField, ' jane@email.com '); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -179,4 +231,181 @@ void main() { expect(find.text('Settings'), findsOneWidget); }); -} + + testWidgets('shows confirmation dialog before saving changes', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Changes'), findsOneWidget); + expect(find.text('Are you sure you want to save these changes to your profile?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('cancels save when user clicks cancel in confirmation', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )); + }); + + testWidgets('shows delete account confirmation dialog', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); + await tester.pumpAndSettle(); + + final deleteButton = find.byType(OutlinedButton); + await tester.tap(deleteButton); + await tester.pumpAndSettle(); + + expect(find.text('Delete Account?'), findsOneWidget); + expect(find.text('Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('cancels delete when user clicks cancel', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); + await tester.pumpAndSettle(); + + final deleteButton = find.byType(OutlinedButton); + await tester.tap(deleteButton); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )); + }); + + testWidgets('input fields have proper hint text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Enter your full name'), findsOneWidget); + expect(find.text('Enter your email address'), findsOneWidget); + }); + + testWidgets('displays loading indicator while fetching data', (tester) async { + final completer = Completer(); + + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete('John Doe'); + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('John Doe'), findsNWidgets(2)); + }); + + testWidgets('disables inputs while saving', (tester) async { + final completer = Completer(); + + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(); + + final saveButton = tester.widget( + find.ancestor( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ElevatedButton), + ).first, + ); + expect(saveButton.onPressed, isNull); + + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('enforces name character limit', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + + const longName = 'This is a very long name that exceeds the limit'; + await tester.enterText(nameField, longName); + await tester.pump(); + + final textField = tester.widget(nameField); + final controller = textField.controller!; + + expect(controller.text.length, lessThanOrEqualTo(36)); + }); + + testWidgets('name field only allows alphanumeric and spaces', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + + await tester.enterText(nameField, 'Test@#\$%'); + await tester.pump(); + + final textField = tester.widget(nameField); + final controller = textField.controller!; + + expect(controller.text, 'Test'); + }); + + testWidgets('displays icon buttons in danger zone', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + + testWidgets('page is scrollable', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); +} \ No newline at end of file From d9d44ceded395f8dc55930ea7d9d79d4846ad2b8 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 10:28:06 -0500 Subject: [PATCH 009/141] Adds reward calculation --- lib/logic/payment/process_payment.dart | 21 +++++++++++------ lib/main.dart | 4 +--- pubspec.lock | 24 +++++++++++++------- test/logic/payment/process_payment_test.dart | 6 +---- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index cf0016ac..56caf2a0 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -1,17 +1,14 @@ import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:get_it/get_it.dart'; class PaymentProcessor { - final PaymentService _paymentService; - final TransactionService _transactionService; + final PaymentService _paymentService = GetIt.instance(); + final TransactionService _transactionService = GetIt.instance(); - PaymentProcessor({ - required PaymentService paymentService, - required TransactionService transactionService, - }) : _paymentService = paymentService, - _transactionService = transactionService; Future processPayment( double amount, @@ -24,6 +21,7 @@ class PaymentProcessor { description: description, type: "Laundry", ); + processRewards(amount); return PaymentResult.success; } on StripeException { return PaymentResult.canceled; @@ -31,4 +29,13 @@ class PaymentProcessor { return PaymentResult.failed; } } + + void processRewards(double amount) { + double rewardAmount = amount * 0.01; + _transactionService.recordTransaction( + amount: rewardAmount, + description: "Reward from payment", + type: "Rewards", + ); + } } diff --git a/lib/main.dart b/lib/main.dart index a4a42aab..76d87776 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -101,9 +101,7 @@ Future setupDependencies() async { getIt.registerLazySingleton(() => LoyaltyViewModel()); - getIt.registerLazySingleton(() => PaymentProcessor( - paymentService: getIt(), - transactionService: getIt(),)); + getIt.registerLazySingleton(() => PaymentProcessor()); } class MyApp extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 31cdf559..49048951 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,10 +556,18 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -1025,26 +1033,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" test_cov_console: dependency: "direct dev" description: diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index eba1a76f..62ab7177 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -19,11 +19,7 @@ void main() { setUp(() { mockPaymentService = MockPaymentService(); mockTransactionService = MockTransactionService(); - - paymentProcessor = PaymentProcessor( - paymentService: mockPaymentService, - transactionService: mockTransactionService, - ); + paymentProcessor = PaymentProcessor(); }); group('PaymentProcessor.processPayment', () { From 3b8415bacbbdac54397828e8507de042a4193b90 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 10:36:19 -0500 Subject: [PATCH 010/141] Fixes query consistency --- lib/logic/services/profile_service.dart | 2 +- lib/logic/viewmodels/loyalty_view_model.dart | 3 ++- lib/pages/payment_page.dart | 3 ++- lib/services/supabase/supabase_profile_service.dart | 7 +------ 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/logic/services/profile_service.dart b/lib/logic/services/profile_service.dart index 33b2603b..a122b6dc 100644 --- a/lib/logic/services/profile_service.dart +++ b/lib/logic/services/profile_service.dart @@ -1,7 +1,7 @@ abstract class ProfileService { Future createAccount({required String id, required String name}); Future?> getUserBalanceById(String userId); - Future updateBalanceById(double balance); + Future updateBalanceById(String userId, double balance); Future getUserNameById(String userId); Future getUserRefundAttempts(String userId); Future updateName(String name); diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 1f1dc97c..17ca877a 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -75,6 +75,7 @@ class LoyaltyViewModel extends ChangeNotifier { } Future loadCard(double amount) async { + final userId = _authService.getCurrentUserId; final result = await _paymentProcessor.processPayment( amount, "Loyalty Card", @@ -82,7 +83,7 @@ class LoyaltyViewModel extends ChangeNotifier { if (result == PaymentResult.success) { final newBalance = (userBalance ?? 0) + amount; - await _profileService.updateBalanceById(newBalance); + await _profileService.updateBalanceById(userId!, newBalance); userBalance = newBalance; await _fetchTransactions(); } diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index eff22786..3be7a90d 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -315,7 +315,8 @@ class _PaymentPageState extends State { void _processLoyaltyPayment(BuildContext context) async { final updatedBalance = _userBalance! - _price!; - profileService.updateBalanceById(updatedBalance); + final userId = authService.getCurrentUserId; + profileService.updateBalanceById(userId!, updatedBalance); setState(() { _userBalance = updatedBalance; diff --git a/lib/services/supabase/supabase_profile_service.dart b/lib/services/supabase/supabase_profile_service.dart index 34919419..6ff82947 100644 --- a/lib/services/supabase/supabase_profile_service.dart +++ b/lib/services/supabase/supabase_profile_service.dart @@ -66,12 +66,7 @@ class SupabaseProfileService extends ProfileService { } @override - Future updateBalanceById(double balance) async { - final userId = _client.auth.currentUser?.id; - - if (userId == null) { - return; - } + Future updateBalanceById(String userId, double balance) async { try { await _client .from("profiles") From c5e8b8a3e1c8eb9a270b30ba04ed2e79e177a200 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 10:48:32 -0500 Subject: [PATCH 011/141] Implements recording rewards --- lib/logic/payment/process_payment.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 56caf2a0..645c2aac 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -1,3 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; @@ -8,6 +9,8 @@ import 'package:get_it/get_it.dart'; class PaymentProcessor { final PaymentService _paymentService = GetIt.instance(); final TransactionService _transactionService = GetIt.instance(); + final AuthService _authService = GetIt.instance(); + final ProfileService _profileService = GetIt.instance(); Future processPayment( @@ -30,12 +33,17 @@ class PaymentProcessor { } } - void processRewards(double amount) { + void processRewards(double amount) async { + final userId = _authService.getCurrentUserId; + final data = await _profileService.getUserBalanceById(userId!); double rewardAmount = amount * 0.01; + double? balance = data?['balance'].toDouble(); _transactionService.recordTransaction( amount: rewardAmount, description: "Reward from payment", type: "Rewards", ); + _profileService.updateBalanceById(userId, balance! + rewardAmount); + } } From 600297f227da59f2c6d294327dd1a303328fb587 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:02:33 -0500 Subject: [PATCH 012/141] Filters out reward transactions --- lib/logic/viewmodels/loyalty_view_model.dart | 7 ++++--- lib/services/supabase/supabase_transaction_service.dart | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 17ca877a..f3c1297d 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -55,9 +55,10 @@ class LoyaltyViewModel extends ChangeNotifier { final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); - final filtered = transactions.where((t) { - final createdAt = DateTime.parse(t['created_at'] as String); - return createdAt.isAfter(thirtyDaysAgo); + final filtered = transactions.where((transaction) { + final createdAt = DateTime.parse(transaction['created_at'] as String); + final type = transaction['type'] as String?; + return createdAt.isAfter(thirtyDaysAgo) && type != "Rewards"; }); recentTransactions = TransactionParser.formatTransactionsList( diff --git a/lib/services/supabase/supabase_transaction_service.dart b/lib/services/supabase/supabase_transaction_service.dart index 87669753..6f2441a7 100644 --- a/lib/services/supabase/supabase_transaction_service.dart +++ b/lib/services/supabase/supabase_transaction_service.dart @@ -17,7 +17,7 @@ class SupabaseTransactionService extends TransactionService{ final response = await _client .from('transactions') - .select('id, amount, description, created_at') + .select('id, amount, description, created_at, type') .eq('user_id', user.id) .order('created_at', ascending: false); From 1fb386421ca733be7c737ba3ebed72fb4bacf2c8 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:04:18 -0500 Subject: [PATCH 013/141] Adjust tests --- test/pages/payment_page_test.dart | 8 ++++---- test/services/supabase/profile/profile_service_test.dart | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index b5330cec..ad95f95b 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -239,7 +239,7 @@ void main() { () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); when( - () => mockProfileService.updateBalanceById(any()), + () => mockProfileService.updateBalanceById(any(), any()), ).thenAnswer((_) async => {}); when( () => mockMachineCommunicator.wakeDevice(any()), @@ -259,7 +259,7 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - verify(() => mockProfileService.updateBalanceById(6.5)).called(1); + verify(() => mockProfileService.updateBalanceById(any(), 6.5)).called(1); verify(() => mockMachineCommunicator.wakeDevice('machine123')).called(1); verify( () => mockTransactionService.recordTransaction( @@ -281,7 +281,7 @@ void main() { () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); when( - () => mockProfileService.updateBalanceById(any()), + () => mockProfileService.updateBalanceById(any(), any()), ).thenAnswer((_) async => {}); when( () => mockMachineCommunicator.wakeDevice(any()), @@ -352,7 +352,7 @@ void main() { 'balance': 10.0, }); - when(() => mockProfileService.updateBalanceById(any())).thenAnswer((_) async {}); + when(() => mockProfileService.updateBalanceById(any(), any())).thenAnswer((_) async {}); when(() => mockMachineCommunicator.wakeDevice(any())).thenAnswer((_) async => true); when(() => mockTransactionService.recordTransaction( diff --git a/test/services/supabase/profile/profile_service_test.dart b/test/services/supabase/profile/profile_service_test.dart index 9e6fbea2..c50ab9c0 100644 --- a/test/services/supabase/profile/profile_service_test.dart +++ b/test/services/supabase/profile/profile_service_test.dart @@ -81,7 +81,7 @@ void main() { }); test("Tests that the logic was called correctly to update account balance", () async { - await profileHandler.updateBalanceById(47.20); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); verify(() => supabaseMock.auth.currentUser).called(1); verify(() => supabaseMock.from("profiles")).called(1); verify(() => queryBuilderMock.update({"balance": 47.20})).called(1); @@ -89,13 +89,13 @@ void main() { test("Tests that updateBalanceID catches Postgrest exception", () async { when(() => supabaseMock.from('profiles')).thenThrow(PostgrestException(message: "Test exception")); - await profileHandler.updateBalanceById(47.20); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); //Test will fail if exception was not caught }); test("Tests that updateBalanceID catches unknown exception", () async { when(() => supabaseMock.from('profiles')).thenThrow(Exception("Test exception")); - await profileHandler.updateBalanceById(47.20); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); //Test will fail if exception was not caught }); From adebf5223b3bbe57e195c2a278a55b03e1494d76 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:07:08 -0500 Subject: [PATCH 014/141] Adds rewards to monthly history --- lib/pages/monthly_transaction_history.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 03fe0e43..62bd5a44 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -133,6 +133,15 @@ class MonthlyTransactionHistory extends StatelessWidget { data['loyaltyCard']!, Colors.black, ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Rewards Earned', + data['Reward from payment']!, + Theme + .of(context) + .colorScheme + .primary, + ), ], ), ), From 9688140ff31347f7e5230498760ba425005d939d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:22:42 -0500 Subject: [PATCH 015/141] Adds monthly rewards total to loyalty --- lib/logic/viewmodels/loyalty_view_model.dart | 18 +++++++++++++++++- lib/pages/loyalty_card_page.dart | 10 +++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index f3c1297d..84fd4761 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -16,6 +16,7 @@ class LoyaltyViewModel extends ChangeNotifier { double? userBalance; String? userName; String? errorMessage; + double? monthlyRewards; bool isLoading = true; bool showPastTransactions = false; @@ -23,7 +24,7 @@ class LoyaltyViewModel extends ChangeNotifier { // Call once from loyalty page Future initialize() async { - await Future.wait([_fetchBalance(), _fetchTransactions()]); + await Future.wait([_fetchBalance(), _fetchTransactions(), _fetchMonthlyRewards()]); } Future _fetchBalance() async { @@ -49,6 +50,21 @@ class LoyaltyViewModel extends ChangeNotifier { notifyListeners(); } + Future _fetchMonthlyRewards() async { + final transactions = await _transactionService.getTransactionsForUser(); + final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); + + final rewardTransactions = transactions.where((t) { + final createdAt = DateTime.parse(t['created_at'] as String); + final type = t['type'] as String?; + return createdAt.isAfter(thirtyDaysAgo) && type == 'Rewards'; + }); + + monthlyRewards = rewardTransactions.fold( + 0.0, (sum, transaction) => sum + (transaction['amount'] as num).toDouble(), + ); + } + Future _fetchTransactions() async { final transactions = await _transactionService.getTransactionsForUser(); final limit = showPastTransactions ? 100 : 3; diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 93fb1f6c..c3d701ee 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -59,7 +59,15 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - const SizedBox(height: 25), + Text( + 'Rewards earned this month: \$${viewModel.monthlyRewards?.toStringAsFixed(2) ?? '0.00'}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 10), ElevatedButton( onPressed: () => _loadCard(), style: ElevatedButton.styleFrom( From 0a7963c6a9b663c3d6ccdbcec1d2acc7741dc2d6 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:25:08 -0500 Subject: [PATCH 016/141] Fix monthly transaction history --- lib/logic/parsing/transaction_parser.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/logic/parsing/transaction_parser.dart b/lib/logic/parsing/transaction_parser.dart index a565b993..fd3b445f 100644 --- a/lib/logic/parsing/transaction_parser.dart +++ b/lib/logic/parsing/transaction_parser.dart @@ -68,6 +68,7 @@ class TransactionParser { 'directDryer': 0.0, 'loyaltyDryer': 0.0, 'loyaltyCard': 0.0, + 'Reward from payment': 0.0 }; } @@ -100,6 +101,9 @@ class TransactionParser { } else if (description == 'loyalty card') { result[monthKey]!['loyaltyCard'] = result[monthKey]!['loyaltyCard']! + amount; + } else if (description == 'Reward from payment') { + result[monthKey]!['Reward from payment'] = + result[monthKey]!['Reward from payment']! + amount; } } return result; From af89da477fe955f040ad0a7f8587e604f635dac0 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:34:08 -0500 Subject: [PATCH 017/141] Fix monthly transaction history --- lib/logic/parsing/transaction_parser.dart | 8 ++++---- lib/pages/monthly_transaction_history.dart | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/logic/parsing/transaction_parser.dart b/lib/logic/parsing/transaction_parser.dart index fd3b445f..90cb4d2a 100644 --- a/lib/logic/parsing/transaction_parser.dart +++ b/lib/logic/parsing/transaction_parser.dart @@ -68,7 +68,7 @@ class TransactionParser { 'directDryer': 0.0, 'loyaltyDryer': 0.0, 'loyaltyCard': 0.0, - 'Reward from payment': 0.0 + 'Rewards': 0.0 }; } @@ -101,9 +101,9 @@ class TransactionParser { } else if (description == 'loyalty card') { result[monthKey]!['loyaltyCard'] = result[monthKey]!['loyaltyCard']! + amount; - } else if (description == 'Reward from payment') { - result[monthKey]!['Reward from payment'] = - result[monthKey]!['Reward from payment']! + amount; + } else if (description == 'reward from payment') { + result[monthKey]!['Rewards'] = + result[monthKey]!['Rewards']! + amount; } } return result; diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 62bd5a44..71c8b67e 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -136,7 +136,7 @@ class MonthlyTransactionHistory extends StatelessWidget { const SizedBox(height: 8), _buildTransactionRow( 'Rewards Earned', - data['Reward from payment']!, + data['Rewards']!, Theme .of(context) .colorScheme From fd8c1cc99bdf7554840828a989fb7f0e412f86fa Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 11:47:38 -0500 Subject: [PATCH 018/141] Adds rewards info button and popup --- lib/pages/loyalty_card_page.dart | 73 +++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index c3d701ee..af4305a3 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -59,13 +59,73 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - Text( - 'Rewards earned this month: \$${viewModel.monthlyRewards?.toStringAsFixed(2) ?? '0.00'}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.7), + Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic, + child: Transform.translate( + offset: const Offset(-1, -1), + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Text( + 'Rewards Program', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + ), + ), + content: Text( + 'Earn 1% back on every purchase! Rewards are automatically added to your balance and can be used for future laundry services.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 0.0), + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.7), + ), + ), + ), + ), + ), + TextSpan( + text: + 'Rewards earned this month: \$${viewModel.monthlyRewards?.toStringAsFixed(2) ?? '0.00'}', + style: TextStyle( + fontSize: 16, + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.7), + ), + ), + ], ), + textAlign: TextAlign.center, ), const SizedBox(height: 10), ElevatedButton( @@ -427,7 +487,6 @@ class LoyaltyCardPage extends State { } if (result == PaymentResult.success) { - viewModel.fetchTransactions(); statusDialog( From d902cfc77653e2be6f93a5caa5591073dd0b16d0 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 12:00:42 -0500 Subject: [PATCH 019/141] Adds tests for loyalty_card_page.dart --- test/pages/loyalty_card_page_test.dart | 117 +++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 957ae99b..8fbe5e14 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -35,6 +35,7 @@ void main() { when(() => mockViewModel.userBalance).thenReturn(25.50); when(() => mockViewModel.recentTransactions).thenReturn([]); when(() => mockViewModel.showPastTransactions).thenReturn(false); + when(() => mockViewModel.monthlyRewards).thenReturn(0.0); // Setup default method behaviors when(() => mockViewModel.initialize()).thenAnswer((_) async => {}); @@ -177,6 +178,122 @@ void main() { }); }); + group('Rewards Display', () { + testWidgets('should display monthly rewards with correct formatting', ( + tester, + ) async { + when(() => mockViewModel.monthlyRewards).thenReturn(5.25); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect( + find.textContaining('Rewards earned this month: \$5.25'), + findsOneWidget, + ); + }); + + testWidgets('should display default rewards when monthlyRewards is null', ( + tester, + ) async { + when(() => mockViewModel.monthlyRewards).thenReturn(null); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect( + find.textContaining('Rewards earned this month: \$0.00'), + findsOneWidget, + ); + }); + + testWidgets('should display rewards with two decimal places', ( + tester, + ) async { + when(() => mockViewModel.monthlyRewards).thenReturn(10.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect( + find.textContaining('Rewards earned this month: \$10.00'), + findsOneWidget, + ); + }); + + testWidgets('should display info icon for rewards', (tester) async { + when(() => mockViewModel.monthlyRewards).thenReturn(5.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('should show rewards dialog when info icon is tapped', ( + tester, + ) async { + when(() => mockViewModel.monthlyRewards).thenReturn(5.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Rewards Program'), findsOneWidget); + expect( + find.text( + 'Earn 1% back on every purchase! Rewards are automatically added to your balance and can be used for future laundry services.', + ), + findsOneWidget, + ); + }); + + testWidgets('should close rewards dialog when Got it is tapped', ( + tester, + ) async { + when(() => mockViewModel.monthlyRewards).thenReturn(5.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Rewards Program'), findsOneWidget); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(find.text('Rewards Program'), findsNothing); + }); + + testWidgets('should handle very small reward amounts', (tester) async { + when(() => mockViewModel.monthlyRewards).thenReturn(0.01); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect( + find.textContaining('Rewards earned this month: \$0.01'), + findsOneWidget, + ); + }); + + testWidgets('should handle large reward amounts', (tester) async { + when(() => mockViewModel.monthlyRewards).thenReturn(999.99); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect( + find.textContaining('Rewards earned this month: \$999.99'), + findsOneWidget, + ); + }); + }); + group('Transactions Display', () { testWidgets('should show "No transactions found" when list is empty', ( tester, From 11c8ed4876e4c35f89dbace80e0fc5e4ab454a9d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 12:31:59 -0500 Subject: [PATCH 020/141] Adds tests for process_payment.dart --- lib/logic/payment/process_payment.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 645c2aac..bbc7b6ef 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -24,7 +24,7 @@ class PaymentProcessor { description: description, type: "Laundry", ); - processRewards(amount); + await processRewards(amount); return PaymentResult.success; } on StripeException { return PaymentResult.canceled; @@ -33,7 +33,7 @@ class PaymentProcessor { } } - void processRewards(double amount) async { + Future processRewards(double amount) async { final userId = _authService.getCurrentUserId; final data = await _profileService.getUserBalanceById(userId!); double rewardAmount = amount * 0.01; From cb3665b598391f29b1d05869ad6ecf2525547d1d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 12:32:27 -0500 Subject: [PATCH 021/141] Adds tests for process_payment.dart --- test/logic/payment/process_payment_test.dart | 201 +++++++++++++++++-- 1 file changed, 184 insertions(+), 17 deletions(-) diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index 62ab7177..c107bf22 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -1,3 +1,5 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; @@ -6,20 +8,38 @@ import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:clean_stream_laundry_app/logic/exceptions/platform_exception.dart'; +import 'package:get_it/get_it.dart'; class MockPaymentService extends Mock implements PaymentService {} class MockTransactionService extends Mock implements TransactionService {} +class MockAuthService extends Mock implements AuthService {} + +class MockProfileService extends Mock implements ProfileService {} + void main() { late MockPaymentService mockPaymentService; late MockTransactionService mockTransactionService; late PaymentProcessor paymentProcessor; + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; setUp(() { mockPaymentService = MockPaymentService(); mockTransactionService = MockTransactionService(); + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + + final getIt = GetIt.instance; + getIt.reset(); + getIt.registerSingleton(mockPaymentService); + getIt.registerSingleton(mockTransactionService); + getIt.registerSingleton(mockAuthService); + getIt.registerSingleton(mockProfileService); + paymentProcessor = PaymentProcessor(); + }); group('PaymentProcessor.processPayment', () { @@ -27,17 +47,21 @@ void main() { // Arrange const amount = 100.0; const description = 'Test payment'; + const userId = 'test-user-id'; + const currentBalance = 50.0; - when( - () => mockPaymentService.makePayment(amount), - ).thenAnswer((_) async => Future.value()); - when( - () => mockTransactionService.recordTransaction( - amount: amount, - description: description, - type: 'Laundry', - ), - ).thenAnswer((_) async => {}); + when(() => mockPaymentService.makePayment(amount)) + .thenAnswer((_) async => Future.value()); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); + when(() => mockAuthService.getCurrentUserId).thenReturn(userId); + when(() => mockProfileService.getUserBalanceById(userId)) + .thenAnswer((_) async => {'balance': currentBalance}); + when(() => mockProfileService.updateBalanceById(any(), any())) + .thenAnswer((_) async => {}); // Act final result = await paymentProcessor.processPayment(amount, description); @@ -45,13 +69,17 @@ void main() { // Assert expect(result, PaymentResult.success); verify(() => mockPaymentService.makePayment(amount)).called(1); - verify( - () => mockTransactionService.recordTransaction( - amount: amount, - description: description, - type: 'Laundry', - ), - ).called(1); + verify(() => mockTransactionService.recordTransaction( + amount: amount, + description: description, + type: 'Laundry', + )).called(1); + verify(() => mockTransactionService.recordTransaction( + amount: 1.0, // 1% of 100 + description: "Reward from payment", + type: "Rewards", + )).called(1); + verify(() => mockProfileService.updateBalanceById(userId, 51.0)).called(1); }); test( @@ -170,4 +198,143 @@ void main() { ); }); }); + + group('PaymentProcessor.processRewards', () { + test('should calculate 1% reward and update balance', () async { + // Arrange + const amount = 100.0; + const userId = 'test-user-id'; + const currentBalance = 50.0; + const expectedReward = 1.0; + const expectedNewBalance = 51.0; + + when(() => mockAuthService.getCurrentUserId).thenReturn(userId); + when(() => mockProfileService.getUserBalanceById(userId)) + .thenAnswer((_) async => {'balance': currentBalance}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); + when(() => mockProfileService.updateBalanceById(userId, any())) + .thenAnswer((_) async => {}); + + // Act + paymentProcessor.processRewards(amount); + await Future.delayed(Duration.zero); // Allow async to complete + + // Assert + verify(() => mockAuthService.getCurrentUserId).called(1); + verify(() => mockProfileService.getUserBalanceById(userId)).called(1); + verify(() => mockTransactionService.recordTransaction( + amount: expectedReward, + description: "Reward from payment", + type: "Rewards", + )).called(1); + verify(() => mockProfileService.updateBalanceById( + userId, + expectedNewBalance, + )).called(1); + }); + + test('should calculate correct reward for different amounts', () async { + // Arrange + const amount = 250.0; + const userId = 'test-user-id'; + const currentBalance = 100.0; + const expectedReward = 2.5; + const expectedNewBalance = 102.5; + + when(() => mockAuthService.getCurrentUserId).thenReturn(userId); + when(() => mockProfileService.getUserBalanceById(userId)) + .thenAnswer((_) async => {'balance': currentBalance}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); + when(() => mockProfileService.updateBalanceById(userId, any())) + .thenAnswer((_) async => {}); + + // Act + paymentProcessor.processRewards(amount); + await Future.delayed(Duration.zero); + + // Assert + verify(() => mockTransactionService.recordTransaction( + amount: expectedReward, + description: "Reward from payment", + type: "Rewards", + )).called(1); + verify(() => mockProfileService.updateBalanceById( + userId, + expectedNewBalance, + )).called(1); + }); + + test('should handle zero current balance', () async { + // Arrange + const amount = 50.0; + const userId = 'test-user-id'; + const currentBalance = 0.0; + const expectedReward = 0.5; + const expectedNewBalance = 0.5; + + when(() => mockAuthService.getCurrentUserId).thenReturn(userId); + when(() => mockProfileService.getUserBalanceById(userId)) + .thenAnswer((_) async => {'balance': currentBalance}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); + when(() => mockProfileService.updateBalanceById(userId, any())) + .thenAnswer((_) async => {}); + + // Act + paymentProcessor.processRewards(amount); + await Future.delayed(Duration.zero); + + // Assert + verify(() => mockProfileService.updateBalanceById( + userId, + expectedNewBalance, + )).called(1); + }); + + test('should handle small payment amounts', () async { + // Arrange + const amount = 1.0; + const userId = 'test-user-id'; + const currentBalance = 10.0; + const expectedReward = 0.01; + const expectedNewBalance = 10.01; + + when(() => mockAuthService.getCurrentUserId).thenReturn(userId); + when(() => mockProfileService.getUserBalanceById(userId)) + .thenAnswer((_) async => {'balance': currentBalance}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); + when(() => mockProfileService.updateBalanceById(userId, any())) + .thenAnswer((_) async => {}); + + // Act + paymentProcessor.processRewards(amount); + await Future.delayed(Duration.zero); + + // Assert + verify(() => mockTransactionService.recordTransaction( + amount: expectedReward, + description: "Reward from payment", + type: "Rewards", + )).called(1); + verify(() => mockProfileService.updateBalanceById( + userId, + expectedNewBalance, + )).called(1); + }); + }); } From cf736746a61ae664603575e4f6961a48f7cf6f68 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 12:41:13 -0500 Subject: [PATCH 022/141] Fixes tests --- test/logic/viewmodels/loyalty_view_model_test.dart | 2 +- test/pages/monthly_transaction_history_test.dart | 4 ++-- test/services/supabase/profile/profile_service_test.dart | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index a148bdd0..b7e4b9af 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -64,7 +64,7 @@ void main() { // Verify interactions verify(() => mockAuthService.getCurrentUserId).called(1); verify(() => mockProfileService.getUserBalanceById('user123')).called(1); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(2); }); test('should handle profile service error gracefully', () async { diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 465adb12..7c781cb1 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -220,7 +220,7 @@ void main() { await tester.pumpAndSettle(); final cardFinder = find.byType(Card); - expect(cardFinder, findsNWidgets(3)); + expect(cardFinder, findsNWidgets(2)); final firstCard = cardFinder.first; final firstCardTexts = find.descendant( @@ -285,7 +285,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); - expect(find.byType(Card), findsNWidgets(3)); + expect(find.byType(Card), findsNWidgets(2)); }); testWidgets('displays divider between month and transaction details', diff --git a/test/services/supabase/profile/profile_service_test.dart b/test/services/supabase/profile/profile_service_test.dart index c50ab9c0..20e7b0bb 100644 --- a/test/services/supabase/profile/profile_service_test.dart +++ b/test/services/supabase/profile/profile_service_test.dart @@ -82,7 +82,6 @@ void main() { test("Tests that the logic was called correctly to update account balance", () async { await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); - verify(() => supabaseMock.auth.currentUser).called(1); verify(() => supabaseMock.from("profiles")).called(1); verify(() => queryBuilderMock.update({"balance": 47.20})).called(1); }); From eef0d2c0511e440a0aee1b59646c413a08147fa5 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 12:48:41 -0500 Subject: [PATCH 023/141] Increase test coverage --- .../viewmodels/loyalty_view_model_test.dart | 144 ++++++++++++++++-- 1 file changed, 135 insertions(+), 9 deletions(-) diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index b7e4b9af..215d5f06 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -7,6 +7,7 @@ import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'mocks.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; void main() { late LoyaltyViewModel viewModel; @@ -85,6 +86,23 @@ void main() { expect(viewModel.isLoading, false); }); + test('initialize should handle null userId', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.errorMessage, 'User not known'); + expect(viewModel.isLoading, false); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + }); + + test('should default to 0.0 balance when null', () async { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); @@ -167,17 +185,125 @@ void main() { // Assert verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); + + test('fetchTransactions filters out Rewards and old transactions', () async { + // Arrange + final now = DateTime.now(); + when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( + (_) async => [ + { + 'created_at': now.toIso8601String(), + 'type': 'Laundry', + 'amount': 10, + 'description': 'Wash', + }, + { + 'created_at': now.toIso8601String(), + 'type': 'Rewards', + 'amount': 1, + 'description': 'Reward', + }, + { + 'created_at': + now.subtract(const Duration(days: 40)).toIso8601String(), + 'type': 'Laundry', + 'amount': 5, + 'description': 'Old wash', + }, + ], + ); + + // Act + await viewModel.toggleTransactionView(); + + // Assert + expect(viewModel.recentTransactions.length, 1); + }); + + + test('fetchMonthlyRewards sums only recent reward transactions', () async { + // Arrange + final now = DateTime.now(); + when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( + (_) async => [ + { + 'created_at': now.toIso8601String(), + 'type': 'Rewards', + 'amount': 2.0, + }, + { + 'created_at': now.toIso8601String(), + 'type': 'Rewards', + 'amount': 3.0, + }, + { + 'created_at': + now.subtract(const Duration(days: 40)).toIso8601String(), + 'type': 'Rewards', + 'amount': 10.0, + }, + ], + ); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.monthlyRewards, 5.0); + }); + }); group('loadCard', () { - // Note: This test is incomplete because processPayment is a top-level function - // See options below for how to handle this - test( - 'should update balance and fetch transactions on successful payment', - () async { - // You'll need to refactor processPayment to be testable - // See suggestions below - }, - ); + test('loadCard should update balance and fetch transactions on success', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.success); + + when(() => mockProfileService.updateBalanceById('user123', 30.0)) + .thenAnswer((_) async => {}); + + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.success); + expect(viewModel.userBalance, 30.0); + + verify(() => mockProfileService.updateBalanceById('user123', 30.0)).called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('loadCard should not update balance on failed payment', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.failed); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.failed); + expect(viewModel.userBalance, 20.0); + + verifyNever(() => mockProfileService.updateBalanceById(any(), any())); + }); + + }); } From aeb10426b104d995bef5b9734c10c44ec9ad9d9e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 7 Feb 2026 13:50:31 -0500 Subject: [PATCH 024/141] Fix reward being added --- lib/logic/payment/process_payment.dart | 13 +++++++------ lib/logic/viewmodels/loyalty_view_model.dart | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index bbc7b6ef..49539d72 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -24,7 +24,11 @@ class PaymentProcessor { description: description, type: "Laundry", ); - await processRewards(amount); + + final userId = _authService.getCurrentUserId; + final data = await _profileService.getUserBalanceById(userId!); + _profileService.updateBalanceById(userId, data?['balance'].toDouble() + processRewards(amount)); + return PaymentResult.success; } on StripeException { return PaymentResult.canceled; @@ -33,17 +37,14 @@ class PaymentProcessor { } } - Future processRewards(double amount) async { - final userId = _authService.getCurrentUserId; - final data = await _profileService.getUserBalanceById(userId!); + double processRewards(double amount) { double rewardAmount = amount * 0.01; - double? balance = data?['balance'].toDouble(); _transactionService.recordTransaction( amount: rewardAmount, description: "Reward from payment", type: "Rewards", ); - _profileService.updateBalanceById(userId, balance! + rewardAmount); + return (rewardAmount); } } diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 84fd4761..313de20e 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -7,6 +7,7 @@ import '../parsing/transaction_parser.dart'; import '../enums/payment_result_enum.dart'; import '../payment/process_payment.dart'; + class LoyaltyViewModel extends ChangeNotifier { final _authService = GetIt.instance(); final _profileService = GetIt.instance(); @@ -99,7 +100,7 @@ class LoyaltyViewModel extends ChangeNotifier { ); if (result == PaymentResult.success) { - final newBalance = (userBalance ?? 0) + amount; + final newBalance = (userBalance ?? 0) + amount + _paymentProcessor.processRewards(amount); await _profileService.updateBalanceById(userId!, newBalance); userBalance = newBalance; await _fetchTransactions(); From f4c47000f2afaa7809f931841bab31d07c861c7c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 7 Feb 2026 15:17:48 -0500 Subject: [PATCH 025/141] Fix tests for rewards --- lib/logic/payment/process_payment.dart | 3 +- test/logic/payment/process_payment_test.dart | 141 ++---------------- .../viewmodels/loyalty_view_model_test.dart | 13 +- 3 files changed, 23 insertions(+), 134 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 49539d72..4a9ea0ae 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -39,12 +39,13 @@ class PaymentProcessor { double processRewards(double amount) { double rewardAmount = amount * 0.01; + _transactionService.recordTransaction( amount: rewardAmount, description: "Reward from payment", type: "Rewards", ); - return (rewardAmount); + return (rewardAmount); } } diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index c107bf22..aa59c554 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -52,16 +52,16 @@ void main() { when(() => mockPaymentService.makePayment(amount)) .thenAnswer((_) async => Future.value()); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async => {}); when(() => mockAuthService.getCurrentUserId).thenReturn(userId); when(() => mockProfileService.getUserBalanceById(userId)) .thenAnswer((_) async => {'balance': currentBalance}); when(() => mockProfileService.updateBalanceById(any(), any())) .thenAnswer((_) async => {}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); // Act final result = await paymentProcessor.processPayment(amount, description); @@ -74,11 +74,6 @@ void main() { description: description, type: 'Laundry', )).called(1); - verify(() => mockTransactionService.recordTransaction( - amount: 1.0, // 1% of 100 - description: "Reward from payment", - type: "Rewards", - )).called(1); verify(() => mockProfileService.updateBalanceById(userId, 51.0)).called(1); }); @@ -199,142 +194,30 @@ void main() { }); }); - group('PaymentProcessor.processRewards', () { - test('should calculate 1% reward and update balance', () async { - // Arrange + group('processRewards', () { + test('should calculate 1% reward and update balance', () { const amount = 100.0; - const userId = 'test-user-id'; const currentBalance = 50.0; const expectedReward = 1.0; const expectedNewBalance = 51.0; - when(() => mockAuthService.getCurrentUserId).thenReturn(userId); - when(() => mockProfileService.getUserBalanceById(userId)) - .thenAnswer((_) async => {'balance': currentBalance}); when(() => mockTransactionService.recordTransaction( amount: any(named: 'amount'), description: any(named: 'description'), type: any(named: 'type'), )).thenAnswer((_) async => {}); - when(() => mockProfileService.updateBalanceById(userId, any())) - .thenAnswer((_) async => {}); - - // Act - paymentProcessor.processRewards(amount); - await Future.delayed(Duration.zero); // Allow async to complete - // Assert - verify(() => mockAuthService.getCurrentUserId).called(1); - verify(() => mockProfileService.getUserBalanceById(userId)).called(1); - verify(() => mockTransactionService.recordTransaction( - amount: expectedReward, - description: "Reward from payment", - type: "Rewards", - )).called(1); - verify(() => mockProfileService.updateBalanceById( - userId, - expectedNewBalance, - )).called(1); - }); - - test('should calculate correct reward for different amounts', () async { - // Arrange - const amount = 250.0; - const userId = 'test-user-id'; - const currentBalance = 100.0; - const expectedReward = 2.5; - const expectedNewBalance = 102.5; - - when(() => mockAuthService.getCurrentUserId).thenReturn(userId); - when(() => mockProfileService.getUserBalanceById(userId)) - .thenAnswer((_) async => {'balance': currentBalance}); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async => {}); - when(() => mockProfileService.updateBalanceById(userId, any())) - .thenAnswer((_) async => {}); + double rewardAmount = paymentProcessor.processRewards(amount); + double newBalance = currentBalance + rewardAmount; - // Act - paymentProcessor.processRewards(amount); - await Future.delayed(Duration.zero); + expect(rewardAmount, expectedReward); + expect(newBalance, expectedNewBalance); - // Assert verify(() => mockTransactionService.recordTransaction( - amount: expectedReward, + amount: 1.0, description: "Reward from payment", type: "Rewards", )).called(1); - verify(() => mockProfileService.updateBalanceById( - userId, - expectedNewBalance, - )).called(1); - }); - - test('should handle zero current balance', () async { - // Arrange - const amount = 50.0; - const userId = 'test-user-id'; - const currentBalance = 0.0; - const expectedReward = 0.5; - const expectedNewBalance = 0.5; - - when(() => mockAuthService.getCurrentUserId).thenReturn(userId); - when(() => mockProfileService.getUserBalanceById(userId)) - .thenAnswer((_) async => {'balance': currentBalance}); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async => {}); - when(() => mockProfileService.updateBalanceById(userId, any())) - .thenAnswer((_) async => {}); - - // Act - paymentProcessor.processRewards(amount); - await Future.delayed(Duration.zero); - - // Assert - verify(() => mockProfileService.updateBalanceById( - userId, - expectedNewBalance, - )).called(1); - }); - - test('should handle small payment amounts', () async { - // Arrange - const amount = 1.0; - const userId = 'test-user-id'; - const currentBalance = 10.0; - const expectedReward = 0.01; - const expectedNewBalance = 10.01; - - when(() => mockAuthService.getCurrentUserId).thenReturn(userId); - when(() => mockProfileService.getUserBalanceById(userId)) - .thenAnswer((_) async => {'balance': currentBalance}); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async => {}); - when(() => mockProfileService.updateBalanceById(userId, any())) - .thenAnswer((_) async => {}); - - // Act - paymentProcessor.processRewards(amount); - await Future.delayed(Duration.zero); - - // Assert - verify(() => mockTransactionService.recordTransaction( - amount: expectedReward, - description: "Reward from payment", - type: "Rewards", - )).called(1); - verify(() => mockProfileService.updateBalanceById( - userId, - expectedNewBalance, - )).called(1); }); }); } diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index 215d5f06..194a4104 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -266,8 +266,12 @@ void main() { 'Loyalty Card', )).thenAnswer((_) async => PaymentResult.success); - when(() => mockProfileService.updateBalanceById('user123', 30.0)) - .thenAnswer((_) async => {}); + when(() => mockPaymentProcessor.processRewards(10.0)) + .thenReturn(0.1); + + + when(() => mockProfileService.updateBalanceById('user123', 30.1)) + .thenAnswer((_) async => Future.value()); when(() => mockTransactionService.getTransactionsForUser()) .thenAnswer((_) async => []); @@ -277,9 +281,10 @@ void main() { // Assert expect(result, PaymentResult.success); - expect(viewModel.userBalance, 30.0); + expect(viewModel.userBalance, 30.1); - verify(() => mockProfileService.updateBalanceById('user123', 30.0)).called(1); + verify(() => mockPaymentProcessor.processRewards(10.0)).called(1); + verify(() => mockProfileService.updateBalanceById('user123', 30.1)).called(1); verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); From 765ad9ef3eea1a81acf95b081038cd4dcad511fd Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 7 Feb 2026 16:12:38 -0500 Subject: [PATCH 026/141] Fix duplicate rewards --- lib/logic/payment/process_payment.dart | 15 +++++++-------- test/logic/payment/process_payment_test.dart | 6 ------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 4a9ea0ae..1b97b15f 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -27,7 +27,13 @@ class PaymentProcessor { final userId = _authService.getCurrentUserId; final data = await _profileService.getUserBalanceById(userId!); - _profileService.updateBalanceById(userId, data?['balance'].toDouble() + processRewards(amount)); + final rewards = processRewards(amount); + _profileService.updateBalanceById(userId, data?['balance'].toDouble() + rewards); + _transactionService.recordTransaction( + amount: rewards, + description: "Reward from payment", + type: "Rewards", + ); return PaymentResult.success; } on StripeException { @@ -39,13 +45,6 @@ class PaymentProcessor { double processRewards(double amount) { double rewardAmount = amount * 0.01; - - _transactionService.recordTransaction( - amount: rewardAmount, - description: "Reward from payment", - type: "Rewards", - ); - return (rewardAmount); } } diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index aa59c554..9f469bca 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -212,12 +212,6 @@ void main() { expect(rewardAmount, expectedReward); expect(newBalance, expectedNewBalance); - - verify(() => mockTransactionService.recordTransaction( - amount: 1.0, - description: "Reward from payment", - type: "Rewards", - )).called(1); }); }); } From 8bbdd292afc8a76ddd0bc2d7f4b10b1ac80b0a2c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 7 Feb 2026 16:22:36 -0500 Subject: [PATCH 027/141] limit reward decimal place to 2 --- lib/logic/payment/process_payment.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 1b97b15f..9f9d2786 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -45,6 +45,6 @@ class PaymentProcessor { double processRewards(double amount) { double rewardAmount = amount * 0.01; - return (rewardAmount); + return (double.parse(rewardAmount.toStringAsFixed(2))); } } From cda578ffa90cc3b1678c52805dfc9e0f05a3dad9 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 7 Feb 2026 17:41:37 -0500 Subject: [PATCH 028/141] Make home page balance have 2 decimals --- lib/pages/home_page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8214392d..de58c8bf 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -32,6 +32,7 @@ class HomePageState extends State { late final MapController _mapController; + final authService = GetIt.instance(); final profileService = GetIt.instance(); @@ -107,7 +108,7 @@ class HomePageState extends State { ), const SizedBox(height: 2), Text( - "Current balance: \$${balance?["balance"] ?? 'Loading...'}", + "Current balance: \$${balance?["balance"].toStringAsFixed(2) ?? 'Loading...'}", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, From d4f92a55dd618e826958ad34b648d1bf0e51c750 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:04:28 -0500 Subject: [PATCH 029/141] Updated refund page to remove loyalty transactions --- lib/pages/refund_page.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index b29cb0aa..d5806dec 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -64,6 +64,11 @@ class RefundPageState extends State { } catch (e) { print(e.toString()); } + + //Filters out loyalty transactions + recentTransactions.removeWhere( + (transaction) => transaction.contains("Loyalty"), + ); } String getTransactionID() { From cc37fbfe10c5c08f2e220d2e4083396214a01bac Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:52:04 -0500 Subject: [PATCH 030/141] Added sign out before navigation to clear supabase cache. --- lib/pages/edit_profile_page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index de8b2673..a6640164 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -581,6 +581,7 @@ class _EditProfilePageState extends State { message: "Your account has been deleted successfully.", isSuccess: true, ); + await authService.logout(); context.go("/login"); } else { statusDialog( From 7cfad431d2708caae7419ea567b65cefd218ec1a Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:58:39 -0500 Subject: [PATCH 031/141] Updated filter --- lib/pages/refund_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index d5806dec..34c6953e 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -67,7 +67,7 @@ class RefundPageState extends State { //Filters out loyalty transactions recentTransactions.removeWhere( - (transaction) => transaction.contains("Loyalty"), + (transaction) => transaction.contains("added to Loyalty Card"), ); } From ae2b53cc331df244ac360c255d1e03bf54369f63 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 16:05:44 -0500 Subject: [PATCH 032/141] Add get location button and functions --- ios/Flutter/AppFrameworkInfo.plist | 4 + lib/logic/parsing/location_parser.dart | 69 +++++- lib/pages/home_page.dart | 200 +++++++++++------- macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 120 ++++++++++- pubspec.yaml | 2 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 319 insertions(+), 84 deletions(-) diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..d69d74da 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -22,5 +22,9 @@ 1.0 MinimumOSVersion 13.0 + UIBackgroundModes + + location + diff --git a/lib/logic/parsing/location_parser.dart b/lib/logic/parsing/location_parser.dart index b3bff27c..90a4a2fd 100644 --- a/lib/logic/parsing/location_parser.dart +++ b/lib/logic/parsing/location_parser.dart @@ -1,8 +1,13 @@ import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; class LocationParser { + final GeolocatorPlatform? geolocator; + + LocationParser({this.geolocator}); + static List parseLocations(List> locations) { List markers = []; @@ -24,4 +29,66 @@ class LocationParser { } return markers; } -} + + Future determinePosition() async { + LocationPermission permission; + + // Use injected geolocator if provided (for tests), otherwise use Geolocator + permission = geolocator != null + ? await geolocator!.checkPermission() + : await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = geolocator != null + ? await geolocator!.requestPermission() + : await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return Future.error('Location Permissions are denied'); + } + } + + Position position = geolocator != null + ? await geolocator!.getCurrentPosition() + : await Geolocator.getCurrentPosition(); + return position.toString(); + } + Future> parseCurrentLocation() async { + final positionString = await determinePosition(); + final parts = positionString.split(', '); + final List coords = []; + + coords.add(double.parse(parts[0].split(': ')[1])); + coords.add(double.parse(parts[1].split(': ')[1])); + return coords; + } + + Future?> getNearestLocation( + List> locations, + ) async { + final coords = await parseCurrentLocation(); + final userLat = coords[0]; + final userLng = coords[1]; + + Map? nearest; + double shortestDistance = double.infinity; + + for (var location in locations) { + if (location.containsKey('Latitude') && + location.containsKey('Longitude')) { + final distance = Geolocator.distanceBetween( + userLat, + userLng, + location['Latitude'].toDouble(), + location['Longitude'].toDouble(), + ); + + if (distance < shortestDistance) { + shortestDistance = distance; + nearest = location; + } + } + } + + return nearest; + } +} \ No newline at end of file diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8214392d..cf174c7c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -31,7 +32,6 @@ class HomePageState extends State { late StorageService storage; late final MapController _mapController; - final authService = GetIt.instance(); final profileService = GetIt.instance(); @@ -83,6 +83,7 @@ class HomePageState extends State { final machineService = GetIt.instance(); final locationService = GetIt.instance(); + final locationParser = LocationParser(); @override Widget build(BuildContext context) { @@ -95,9 +96,7 @@ class HomePageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - username == null - ? "Welcome!" - : "Welcome $username!", + username == null ? "Welcome!" : "Welcome $username!", style: TextStyle( fontWeight: FontWeight.bold, @@ -105,16 +104,56 @@ class HomePageState extends State { color: Theme.of(context).colorScheme.fontInverted, ), ), - const SizedBox(height: 2), - Text( - "Current balance: \$${balance?["balance"] ?? 'Loading...'}", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Current balance: \$${balance?["balance"] ?? 'Loading...'}", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final locations = await locationService.getLocations(); + final nearest = await locationParser.getNearestLocation( + locations, + ); + if (nearest != null) { + final address = nearest["Address"] as String; + setState(() { + selectedName = address; + locationSelected = true; + locationIDSelected = locationID[address]; + }); + storage.setValue("lastSelectedLocation", address); + _zoomToLocation(address); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: SvgPicture.asset( + "assets/locationPin.svg", + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), + ), + ], + ), const SizedBox(height: 10), FutureBuilder( @@ -166,6 +205,7 @@ class HomePageState extends State { initialCenter: initialCenter, initialZoom: initialZoom, keepAlive: true, + maxZoom: 15, ), children: [ TileLayer( @@ -280,7 +320,9 @@ class HomePageState extends State { ], ), ), + SizedBox(height: 10), + if (locationSelected) FutureBuilder( future: Future.wait([ @@ -339,78 +381,88 @@ class HomePageState extends State { ), ), ), - SizedBox( - height: 80, - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleWashers/$totalWashers Washers", - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, + SizedBox( + height: 80, + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "$idleWashers/$totalWashers Washers", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], ), - ], + ), ), - ), - ), - Container(width: 2, color: Colors.blue), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleDryers/$totalDryers Dryers", - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, + Container(width: 2, color: Colors.blue), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "$idleDryers/$totalDryers Dryers", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], ), - ], + ), ), - ), + ], ), - ], - ), + ), + ], ), - ], - ), - ); - }, - ), + ); + }, + ), ], ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d7f56dcf..e996361c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,9 @@ import Foundation import app_links import flutter_local_notifications +import geolocator_apple import mobile_scanner +import package_info_plus import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -15,7 +17,9 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 31cdf559..e59d7883 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -352,6 +352,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.4" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" get_it: dependency: "direct main" description: @@ -384,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.15.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" gtk: dependency: transitive description: @@ -556,10 +628,18 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -608,6 +688,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -1025,26 +1121,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" test_cov_console: dependency: "direct dev" description: @@ -1237,6 +1333,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d187fe77..3f60d83e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: get_it: 9.0.2 flutter_map: ^8.2.2 latlong2: ^0.9.1 - + geolocator: ^14.0.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1b9d289f..3e035bd8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ad82af28..d49f9206 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + geolocator_windows permission_handler_windows url_launcher_windows ) From 234799db7c327f679c45ada86782f3e5f51217de Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 16:07:21 -0500 Subject: [PATCH 033/141] Add tests to get locations --- test/logic/parsing/location_parser_test.dart | 445 +++++++++++++++++-- test/logic/parsing/mocks.dart | 5 + test/pages/home_page_test.dart | 106 ++++- 3 files changed, 505 insertions(+), 51 deletions(-) create mode 100644 test/logic/parsing/mocks.dart diff --git a/test/logic/parsing/location_parser_test.dart b/test/logic/parsing/location_parser_test.dart index 38d5dafc..0d035dee 100644 --- a/test/logic/parsing/location_parser_test.dart +++ b/test/logic/parsing/location_parser_test.dart @@ -3,12 +3,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:geolocator/geolocator.dart'; + +class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {} void main() { group('LocationParser', () { test('parseLocations returns empty list when input is empty', () { final result = LocationParser.parseLocations([]); - expect(result, isEmpty); }); @@ -98,57 +101,413 @@ void main() { expect(result[0].point.latitude, 40.7128); expect(result[1].point.latitude, 51.5074); }); - group('LocationParser', () { - // Your existing tests... - // Add this widget test - testWidgets('parseLocations creates fully initialized Marker objects', (WidgetTester tester) async { - final locations = [ - {'Latitude': 40.7128, 'Longitude': -74.0060}, - ]; + testWidgets('parseLocations creates fully initialized Marker objects', + (WidgetTester tester) async { + final locations = [ + {'Latitude': 40.7128, 'Longitude': -74.0060}, + ]; - final result = LocationParser.parseLocations(locations); + final result = LocationParser.parseLocations(locations); - expect(result.length, 1); + expect(result.length, 1); - final marker = result[0]; - expect(marker.point.latitude, 40.7128); - expect(marker.point.longitude, -74.0060); - expect(marker.width, 50); - expect(marker.height, 50); + final marker = result[0]; + expect(marker.point.latitude, 40.7128); + expect(marker.point.longitude, -74.0060); + expect(marker.width, 50); + expect(marker.height, 50); - // Actually build the widget to ensure it's instantiated - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: marker.child, + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: marker.child, + ), ), - ), + ); + + expect(find.byType(MapMarker), findsOneWidget); + }); + + test('parseLocations constructs complete Marker with all properties', () { + final locations = [ + {'Latitude': 40.7128, 'Longitude': -74.0060}, + ]; + + final result = LocationParser.parseLocations(locations); + final marker = result[0]; + + expect(marker.point, isA()); + expect(marker.point.latitude, 40.7128); + expect(marker.point.longitude, -74.0060); + expect(marker.width, 50.0); + expect(marker.height, 50.0); + expect(marker.child, isA()); + expect(marker.point.latitude, isA()); + expect(marker.point.longitude, isA()); + }); + }); + + group('LocationParser - Geolocator Methods', () { + late MockGeolocatorPlatform mockGeolocator; + late LocationParser locationParser; + + setUp(() { + mockGeolocator = MockGeolocatorPlatform(); + locationParser = LocationParser(geolocator: mockGeolocator); + }); + + group('determinePosition', () { + test('returns position string when permission is already granted', + () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 37.7749')); + expect(result, contains('Longitude: -122.4194')); + verify(() => mockGeolocator.checkPermission()).called(1); + verify(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).called(1); + verifyNever(() => mockGeolocator.requestPermission()); + }); + + test('requests and grants permission when initially denied', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, ); - expect(find.byType(MapMarker), findsOneWidget); + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.denied); + when(() => mockGeolocator.requestPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 37.7749')); + verify(() => mockGeolocator.checkPermission()).called(1); + verify(() => mockGeolocator.requestPermission()).called(1); + verify(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).called(1); + }); + + test('handles whileInUse permission', () async { + final mockPosition = Position( + latitude: 40.7128, + longitude: -74.0060, + timestamp: DateTime(2024, 1, 1), + accuracy: 15.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 40.7128')); + expect(result, contains('Longitude: -74.006')); }); }); - }); - test('parseLocations constructs complete Marker with all properties', () { - final locations = [ - {'Latitude': 40.7128, 'Longitude': -74.0060}, - ]; - - final result = LocationParser.parseLocations(locations); - final marker = result[0]; - - // Verify each property to ensure the constructor was called - expect(marker.point, isA()); - expect(marker.point.latitude, 40.7128); - expect(marker.point.longitude, -74.0060); - expect(marker.width, 50.0); - expect(marker.height, 50.0); - expect(marker.child, isA()); - - // Verify toDouble() conversion happened correctly - expect(marker.point.latitude, isA()); - expect(marker.point.longitude, isA()); + group('parseCurrentLocation', () { + test('correctly parses position string into coordinate list', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords.length, equals(2)); + expect(coords[0], equals(37.7749)); + expect(coords[1], equals(-122.4194)); + }); + + test('handles negative coordinates correctly', () async { + final mockPosition = Position( + latitude: -33.8688, + longitude: 151.2093, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords[0], equals(-33.8688)); + expect(coords[1], equals(151.2093)); + }); + + test('returns list of doubles', () async { + final mockPosition = Position( + latitude: 51.5074, + longitude: -0.1278, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords[0], isA()); + expect(coords[1], isA()); + }); + }); + + group('getNearestLocation', () { + test('returns nearest location from multiple options', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Far Location', + 'Latitude': 37.7849, + 'Longitude': -122.4094, + }, + { + 'Name': 'Nearest Location', + 'Latitude': 37.7759, + 'Longitude': -122.4184, + }, + { + 'Name': 'Distant Location', + 'Latitude': 37.8049, + 'Longitude': -122.3994, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Nearest Location')); + expect(nearest['Latitude'], equals(37.7759)); + expect(nearest['Longitude'], equals(-122.4184)); + }); + + test('returns null when locations list is empty', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final nearest = await locationParser.getNearestLocation([]); + + expect(nearest, isNull); + }); + + test('skips locations without valid coordinates', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Invalid - No coords', + }, + { + 'Name': 'Invalid - Only Lat', + 'Latitude': 37.7759, + }, + { + 'Name': 'Valid Location', + 'Latitude': 37.7759, + 'Longitude': -122.4184, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Valid Location')); + }); + + test('handles single location in list', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Only Location', + 'Latitude': 37.7849, + 'Longitude': -122.4094, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Only Location')); + }); + + test('returns null when all locations have invalid coordinates', + () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + {'Name': 'No coords'}, + {'Name': 'Only Lat', 'Latitude': 37.7759}, + {'Name': 'Only Lng', 'Longitude': -122.4184}, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNull); + }); + }); }); -} \ No newline at end of file +} diff --git a/test/logic/parsing/mocks.dart b/test/logic/parsing/mocks.dart new file mode 100644 index 00000000..d715ebbd --- /dev/null +++ b/test/logic/parsing/mocks.dart @@ -0,0 +1,5 @@ +// test/mocks/mock_geolocator.dart +import 'package:geolocator/geolocator.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {} \ No newline at end of file diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index dd0c7abc..acf6fd39 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -81,13 +81,6 @@ void main() { .thenAnswer((_) async => idleDryers); } - Future selectLocation(WidgetTester tester, String address) async { - await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); - await tester.tap(find.text(address).last); - await tester.pumpAndSettle(); - } - group('HomePage Widget Tests', () { test('should create HomePageState', () { const homePage = HomePage(); @@ -130,7 +123,7 @@ void main() { await tester.pumpAndSettle(); await tester.pumpAndSettle(const Duration(seconds: 1)); - + final dropdownFinder = find.descendant( of: find.byType(DropdownButtonHideUnderline), matching: find.byType(DropdownButton), @@ -177,6 +170,103 @@ void main() { }); }); + + group('Nearest Location Button', () { + testWidgets('should find and select nearest location when button is tapped', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + { + "id": 2, + "Address": "456 Oak Ave", + "Latitude": 40.5, + "Longitude": -86.5, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final nearestLocationButton = find.byType(ElevatedButton); + expect(nearestLocationButton, findsOneWidget); + + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + + verify(() => mockLocationService.getLocations()).called(greaterThan(1)); + }); + + testWidgets('should display nearest location button with correct styling', (tester) async { + mockLocations([{"id": 1, "Address": "123 Main St"}]); + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final button = find.byType(ElevatedButton); + expect(button, findsOneWidget); + + final elevatedButton = tester.widget(button); + expect(elevatedButton.onPressed, isNotNull); + }); + + testWidgets('should update selected location after finding nearest', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + { + "id": 2, + "Address": "456 Oak Ave", + "Latitude": 40.5, + "Longitude": -86.5, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + mockMachineCounts('2'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('Select Location'), findsOneWidget); + + final nearestLocationButton = find.byType(ElevatedButton); + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + + }); + + testWidgets('should save selected location to storage', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final nearestLocationButton = find.byType(ElevatedButton); + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + }); + }); }); } \ No newline at end of file From 4d3b5696c3265b59540ec013709135d7dc81c6b1 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 16:45:11 -0500 Subject: [PATCH 034/141] add image to button --- assets/locationPin.svg | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 assets/locationPin.svg diff --git a/assets/locationPin.svg b/assets/locationPin.svg new file mode 100644 index 00000000..911c34c4 --- /dev/null +++ b/assets/locationPin.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From d529c37bddb60a3026701c9b95263c85ab234a82 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 16:54:21 -0500 Subject: [PATCH 035/141] add android permission --- android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a817646c..a5c10710 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:tools="http://schemas.android.com/tools"> + From e556205a2250d7ea8908968ead177270dc7bd305 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 18:39:21 -0500 Subject: [PATCH 036/141] make button clickable text --- lib/pages/home_page.dart | 61 +++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index cf174c7c..34c59eb4 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -107,17 +107,20 @@ class HomePageState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - "Current balance: \$${balance?["balance"] ?? 'Loading...'}", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.fontInverted, + Flexible( + child: Text( + "Current balance: \$${balance?["balance"] ?? 'Loading...'}", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), - ElevatedButton( - onPressed: () async { + InkWell( + onTap: () async { final locations = await locationService.getLocations(); final nearest = await locationParser.getNearestLocation( locations, @@ -134,21 +137,33 @@ class HomePageState extends State { _zoomToLocation(address); } }, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 2, - ), - child: SvgPicture.asset( - "assets/locationPin.svg", - width: 24, - height: 24, - colorFilter: const ColorFilter.mode( - Colors.white, - BlendMode.srcIn, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Find Nearest Location", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary + ), + ), + const SizedBox(width: 6), + SvgPicture.asset( + "assets/locationPin.svg", + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ], ), ), ), From 5bb0d10dc5ed926ed2f4c1a6df166adc6e5ab8d8 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Feb 2026 18:40:28 -0500 Subject: [PATCH 037/141] fix tests for find location --- test/pages/home_page_test.dart | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index acf6fd39..ed09d17d 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -194,7 +194,10 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final nearestLocationButton = find.byType(ElevatedButton); + final nearestLocationButton = find.ancestor( + of: find.text('Find Nearest Location'), + matching: find.byType(InkWell), + ); expect(nearestLocationButton, findsOneWidget); await tester.tap(nearestLocationButton); @@ -208,11 +211,16 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final button = find.byType(ElevatedButton); + final button = find.ancestor( + of: find.text('Find Nearest Location'), + matching: find.byType(InkWell), + ); expect(button, findsOneWidget); - final elevatedButton = tester.widget(button); - expect(elevatedButton.onPressed, isNotNull); + final inkWell = tester.widget(button); + expect(inkWell.onTap, isNotNull); + + expect(find.text('Find Nearest Location'), findsOneWidget); }); testWidgets('should update selected location after finding nearest', (tester) async { @@ -240,7 +248,10 @@ void main() { expect(find.text('Select Location'), findsOneWidget); - final nearestLocationButton = find.byType(ElevatedButton); + final nearestLocationButton = find.ancestor( + of: find.text('Find Nearest Location'), + matching: find.byType(InkWell), + ); await tester.tap(nearestLocationButton); await tester.pumpAndSettle(); @@ -262,7 +273,10 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final nearestLocationButton = find.byType(ElevatedButton); + final nearestLocationButton = find.ancestor( + of: find.text('Find Nearest Location'), + matching: find.byType(InkWell), + ); await tester.tap(nearestLocationButton); await tester.pumpAndSettle(); }); From 6d206969c93d2dc281a61a89fee062b05da890a0 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:52:20 -0500 Subject: [PATCH 038/141] Fixed sign up Changed the deep linking process to help prevent race conditions. TEST AND PROFILE PAGE ARE STILL BORKEN. --- lib/pages/loading_page.dart | 157 +- .../supabase/supabase_auth_service.dart | 15 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 1386 ----------------- .../authentication/authenticator_test.dart | 2 +- 5 files changed, 95 insertions(+), 1467 deletions(-) delete mode 100644 pubspec.lock diff --git a/lib/pages/loading_page.dart b/lib/pages/loading_page.dart index 00a0a000..3ddbaf50 100644 --- a/lib/pages/loading_page.dart +++ b/lib/pages/loading_page.dart @@ -22,50 +22,68 @@ class _LoadingPageState extends State { @override void initState() { super.initState(); - _automaticLogIn(); - _coldStartRedirect(); + _initialize(); } - Future _coldStartRedirect() async { + Future _initialize() async { + final handledDeepLink = await _coldStartRedirect(); + + if (!handledDeepLink) { + await _automaticLogIn(); + } + } + + Future _coldStartRedirect() async { try { final AppLinks appLinks = AppLinks(); final Uri? initialUri = await appLinks.getInitialAppLink(); - if (initialUri != null && - initialUri.scheme == 'clean-stream' && + if (initialUri == null) return false; + + if (initialUri.scheme == 'clean-stream' && initialUri.host == 'reset-protected') { context.go('/reset-protected', extra: initialUri); + return true; } - if (initialUri != null && - initialUri.scheme == 'clean-stream' && + if (initialUri.scheme == 'clean-stream' && initialUri.host == 'email-verification') { context.go("/homePage"); - } else if (initialUri != null && initialUri.host == 'change-email') { + return true; + } + + if (initialUri.scheme == 'clean-stream' && + initialUri.host == 'change-email') { context.go("/email-verification"); - } else if (initialUri != null && - initialUri.scheme == 'clean-stream' && + return true; + } + + if (initialUri.scheme == 'clean-stream' && initialUri.host == 'oauth') { await authService.handleOAuthRedirect(initialUri); - if (await authService.isLoggedIn() == AuthenticationResponses.success) { + + if (await authService.isLoggedIn() == + AuthenticationResponses.success) { context.go("/homePage"); } else { context.go("/login"); } + + return true; } - } catch (e) {} - } - @override - void dispose() { - super.dispose(); + return false; + } catch (e) { + return false; + } } Future _automaticLogIn() async { await Future.delayed(Duration.zero); try { - if (await authService.isLoggedIn() == AuthenticationResponses.success) { + if (await authService.isLoggedIn() == + AuthenticationResponses.success) { if (!mounted) return; context.go("/homePage"); } else { @@ -83,58 +101,59 @@ class _LoadingPageState extends State { return Center( child: _error != null ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.redAccent, size: 80), - const SizedBox(height: 20), - Text( - 'Authentication Failed', - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - _error!, - style: TextStyle( - color: Colors.redAccent.withValues(alpha: 0.8), - fontSize: 14, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 26), - ElevatedButton.icon( - onPressed: () => context.go("/login"), - icon: const Icon(Icons.login), - label: const Text('Return to Login'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - ), - ), - ], - ) - : TweenAnimationBuilder( - tween: Tween(begin: begin, end: end), - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - builder: (context, scale, child) { - return Transform.scale(scale: scale, child: child); - }, - child: Image.asset("assets/Logo.png", height: 250), - onEnd: () { - setState(() { - double temp = begin; - begin = end; - end = temp; - }); - }, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + color: Colors.redAccent, size: 80), + const SizedBox(height: 20), + Text( + 'Authentication Failed', + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + _error!, + style: TextStyle( + color: Colors.redAccent.withValues(alpha: 0.8), + fontSize: 14, ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 26), + ElevatedButton.icon( + onPressed: () => context.go("/login"), + icon: const Icon(Icons.login), + label: const Text('Return to Login'), + ), + ], + ) + : TweenAnimationBuilder( + tween: Tween(begin: begin, end: end), + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + builder: (context, scale, child) { + return Transform.scale(scale: scale, child: child); + }, + child: Image.asset("assets/Logo.png", height: 250), + onEnd: () { + // Prevent infinite animation loop during widget tests + if (WidgetsBinding.instance.runtimeType.toString() == + 'TestWidgetsFlutterBinding') { + return; + } + + setState(() { + double temp = begin; + begin = end; + end = temp; + }); + }, + ), ); } -} +} \ No newline at end of file diff --git a/lib/services/supabase/supabase_auth_service.dart b/lib/services/supabase/supabase_auth_service.dart index 4272138e..49764fc8 100644 --- a/lib/services/supabase/supabase_auth_service.dart +++ b/lib/services/supabase/supabase_auth_service.dart @@ -23,16 +23,13 @@ class SupabaseAuthService implements AuthService { @override Future isLoggedIn() async { - AuthenticationResponses output = AuthenticationResponses.failure; - try { - await _client.auth.refreshSession(); - if (_client.auth.currentUser != null) { - output = AuthenticationResponses.success; - } - } catch (e) { - print(e); - return output; + AuthenticationResponses output = AuthenticationResponses.success; + final session = _client.auth.currentSession; + + if (!(session != null && session.user != null)) { + output = AuthenticationResponses.failure; } + return output; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e996361c..aea40d6d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,6 @@ import flutter_local_notifications import geolocator_apple import mobile_scanner import package_info_plus -import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index e59d7883..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,1386 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f - url: "https://pub.dev" - source: hosted - version: "85.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" - url: "https://pub.dev" - source: hosted - version: "7.7.1" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - app_links: - dependency: "direct main" - description: - name: app_links - sha256: "3ced568a5d9e309e99af71285666f1f3117bddd0bd5b3317979dccc1a40cada4" - url: "https://pub.dev" - source: hosted - version: "3.5.1" - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_earcut: - dependency: transitive - description: - name: dart_earcut - sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b - url: "https://pub.dev" - source: hosted - version: "1.2.0" - dart_polylabel2: - dependency: transitive - description: - name: dart_polylabel2 - sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - dbus: - dependency: transitive - description: - name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - dio: - dependency: "direct main" - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - 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" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_dotenv: - dependency: "direct main" - description: - name: flutter_dotenv - sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b - url: "https://pub.dev" - source: hosted - version: "5.2.1" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" - url: "https://pub.dev" - source: hosted - version: "0.13.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_local_notifications: - dependency: "direct main" - description: - name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" - url: "https://pub.dev" - source: hosted - version: "17.2.4" - flutter_local_notifications_linux: - dependency: transitive - description: - name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af - url: "https://pub.dev" - source: hosted - version: "4.0.1" - flutter_local_notifications_platform_interface: - dependency: transitive - description: - name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" - url: "https://pub.dev" - source: hosted - version: "8.2.2" - flutter_native_splash: - dependency: "direct dev" - description: - name: flutter_native_splash - sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" - url: "https://pub.dev" - source: hosted - version: "2.4.7" - flutter_stripe: - dependency: "direct main" - description: - name: flutter_stripe - sha256: "4a4e5524ec047a27e415f8efc7ea15d8182f586e295b804287000d9dab1ebfdc" - url: "https://pub.dev" - source: hosted - version: "12.1.0" - flutter_stripe_web: - dependency: "direct main" - description: - name: flutter_stripe_web - sha256: c0169ca042e902a7c5ed66a977333fccc196ae2a80505228495bc0b53a4dbe7c - url: "https://pub.dev" - source: hosted - version: "7.0.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 - url: "https://pub.dev" - source: hosted - version: "2.2.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - functions_client: - dependency: transitive - description: - name: functions_client - sha256: "38e5049d4ca5b3482c606d8bfe82183aa24c9650ef1fa0582ab5957a947b937f" - url: "https://pub.dev" - source: hosted - version: "2.4.4" - geoclue: - dependency: transitive - description: - name: geoclue - sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f - url: "https://pub.dev" - source: hosted - version: "0.1.1" - geolocator: - dependency: "direct main" - description: - name: geolocator - sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" - url: "https://pub.dev" - source: hosted - version: "14.0.2" - geolocator_android: - dependency: transitive - description: - name: geolocator_android - sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" - url: "https://pub.dev" - source: hosted - version: "5.0.2" - geolocator_apple: - dependency: transitive - description: - name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 - url: "https://pub.dev" - source: hosted - version: "2.3.13" - geolocator_linux: - dependency: transitive - description: - name: geolocator_linux - sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 - url: "https://pub.dev" - source: hosted - version: "0.2.4" - geolocator_platform_interface: - dependency: transitive - description: - name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" - url: "https://pub.dev" - source: hosted - version: "4.2.6" - geolocator_web: - dependency: transitive - description: - name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 - url: "https://pub.dev" - source: hosted - version: "4.1.3" - geolocator_windows: - dependency: transitive - description: - name: geolocator_windows - sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" - url: "https://pub.dev" - source: hosted - version: "0.2.5" - get_it: - dependency: "direct main" - description: - name: get_it - sha256: "5089fa71031d00d5bdae3ff52cf32640612c7b1dbe158d2306ef487d2d827f64" - url: "https://pub.dev" - source: hosted - version: "9.0.2" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c - url: "https://pub.dev" - source: hosted - version: "16.3.0" - gotrue: - dependency: transitive - description: - name: gotrue - sha256: "3a3c4b81d22145977251576a893d763aebc29f261e4c00a6eab904b38ba8ba37" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - gsettings: - dependency: transitive - description: - name: gsettings - sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" - url: "https://pub.dev" - source: hosted - version: "0.2.8" - gtk: - dependency: transitive - description: - name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c - url: "https://pub.dev" - source: hosted - version: "2.1.0" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http: - dependency: transitive - description: - name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 - url: "https://pub.dev" - source: hosted - version: "1.5.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" - url: "https://pub.dev" - source: hosted - version: "4.5.4" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" - url: "https://pub.dev" - source: hosted - version: "0.18.1" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - jwt_decode: - dependency: transitive - description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb - url: "https://pub.dev" - source: hosted - version: "0.3.1" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 - url: "https://pub.dev" - source: hosted - version: "6.0.0" - lists: - dependency: transitive - description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: "023a71afb4d7cfb5529d0f2636aa8b43db66257905b9486d702085989769c5f2" - url: "https://pub.dev" - source: hosted - version: "7.1.3" - mocktail: - dependency: "direct main" - description: - name: mocktail - sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: transitive - description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d - url: "https://pub.dev" - source: hosted - version: "9.0.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - 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: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 - url: "https://pub.dev" - source: hosted - version: "2.2.20" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - 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" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.dev" - source: hosted - version: "11.4.0" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.dev" - source: hosted - version: "12.1.0" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - 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" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" - postgrest: - dependency: transitive - description: - name: postgrest - sha256: "57637e331af3863fa1f555907ff24c30d69c3ad3ff127d89320e70e8d5e585f5" - url: "https://pub.dev" - source: hosted - version: "2.5.0" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - realtime_client: - dependency: transitive - description: - name: realtime_client - sha256: c0938faca85ff2bdcb8e97ebfca4ab1428661b441c1a414fb09c113e00cee2c6 - url: "https://pub.dev" - source: hosted - version: "2.5.3" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - 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: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" - url: "https://pub.dev" - source: hosted - version: "2.5.5" - 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" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - storage_client: - dependency: transitive - description: - name: storage_client - sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - stripe_android: - dependency: transitive - description: - name: stripe_android - sha256: f9127544327fe5bf23772a9899a46c4af1c2eccd128eb548c09b4ce9d3c99d7b - url: "https://pub.dev" - source: hosted - version: "12.1.0" - stripe_ios: - dependency: transitive - description: - name: stripe_ios - sha256: "82cd4c056730ce943a4f99d82433a358f8e09d871e6c71cb54bc053aa8f49a1d" - url: "https://pub.dev" - source: hosted - version: "12.1.0" - stripe_js: - dependency: transitive - description: - name: stripe_js - sha256: "76e692d187ce2f63be2ef237fe3749dfdba24af28eee7b84b4eec34e8c864cbe" - url: "https://pub.dev" - source: hosted - version: "7.1.0" - stripe_platform_interface: - dependency: transitive - description: - name: stripe_platform_interface - sha256: "1661f45a7fca7444f27ac9b85db98ba6cc2169e6999143e0364d9d93bc51d0ff" - url: "https://pub.dev" - source: hosted - version: "12.1.0" - supabase: - dependency: transitive - description: - name: supabase - sha256: b8991524ff1f4fcb50475847f100a399b96a7d347655bbbd1c7b51eea065f892 - url: "https://pub.dev" - source: hosted - version: "2.9.2" - supabase_flutter: - dependency: "direct main" - description: - name: supabase_flutter - sha256: "389eeb18d2a0773da61a157df6f35761e1855567271df12665bb7ddeb2dda0f7" - url: "https://pub.dev" - source: hosted - version: "2.10.2" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: "direct main" - description: - name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" - url: "https://pub.dev" - source: hosted - version: "1.26.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" - url: "https://pub.dev" - source: hosted - version: "0.7.6" - test_core: - dependency: transitive - description: - name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" - url: "https://pub.dev" - source: hosted - version: "0.6.11" - test_cov_console: - dependency: "direct dev" - description: - name: test_cov_console - sha256: "73519e8be3689d73f5cffb652c12c310acacf48379396d834da937094836e65e" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - timezone: - dependency: "direct main" - description: - name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" - url: "https://pub.dev" - source: hosted - version: "0.9.4" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - url_launcher: - dependency: transitive - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" - url: "https://pub.dev" - source: hosted - version: "6.3.24" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" - url: "https://pub.dev" - source: hosted - version: "6.3.5" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" - url: "https://pub.dev" - source: hosted - version: "3.2.4" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" - url: "https://pub.dev" - source: hosted - version: "3.1.4" - uuid: - dependency: transitive - description: - name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 - url: "https://pub.dev" - source: hosted - version: "4.5.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - watcher: - dependency: transitive - description: - name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" - url: "https://pub.dev" - source: hosted - version: "1.1.4" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - yet_another_json_isolate: - dependency: transitive - description: - name: yet_another_json_isolate - sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e - url: "https://pub.dev" - source: hosted - version: "2.1.0" -sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 6772f277..373d7412 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -626,7 +626,7 @@ void main(){ test("User is logged in",() async{ - when(() => supabaseAuth.refreshSession()).thenAnswer((_) async => AuthResponse()); + when(() => supabaseAuth.currentSession).thenReturn(Session(accessToken: 'test', tokenType: 'test', user: User(id: '', appMetadata: {}, userMetadata: {}, aud: '', createdAt: ''))); final response = await authenticator.isLoggedIn(); expect(response,AuthenticationResponses.success); From 150a2251ce0564a4448dad0c3f923c6be34b3644 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Feb 2026 21:15:51 -0500 Subject: [PATCH 039/141] Fixes signup session creation --- lib/middleware/app_router.dart | 4 ++++ lib/pages/email_verification_page.dart | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/middleware/app_router.dart b/lib/middleware/app_router.dart index d1c99c75..5410f25a 100644 --- a/lib/middleware/app_router.dart +++ b/lib/middleware/app_router.dart @@ -200,6 +200,10 @@ class RouterService { return query.isEmpty ? '/reset-protected' : '/reset-protected?$query'; } + if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { + return '/email-verification'; + } + // Handle clean-stream://change-email deep links if (uri.scheme == 'clean-stream' && uri.host == 'change-email') { // Optional: check type query param diff --git a/lib/pages/email_verification_page.dart b/lib/pages/email_verification_page.dart index 96b3a705..15aa7e69 100644 --- a/lib/pages/email_verification_page.dart +++ b/lib/pages/email_verification_page.dart @@ -33,10 +33,11 @@ class _EmailVerificationPageState extends State { }); // Handles app links - _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) { + _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) async { if (uri != null && uri.scheme == 'clean-stream' && uri.host == 'email-verification') { + await authService.handleOAuthRedirect(uri); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.go('/homePage'); From 0d1f22f4fc4c3f51cdc14cbf87614c6d3efec958 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Feb 2026 21:38:13 -0500 Subject: [PATCH 040/141] Fixes loading page --- lib/pages/loading_page.dart | 66 ++++++++++++++----------------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/lib/pages/loading_page.dart b/lib/pages/loading_page.dart index 3ddbaf50..e3a3324f 100644 --- a/lib/pages/loading_page.dart +++ b/lib/pages/loading_page.dart @@ -22,68 +22,51 @@ class _LoadingPageState extends State { @override void initState() { super.initState(); - _initialize(); + _automaticLogIn(); + _coldStartRedirect(); } - Future _initialize() async { - final handledDeepLink = await _coldStartRedirect(); - - if (!handledDeepLink) { - await _automaticLogIn(); - } - } - - Future _coldStartRedirect() async { + Future _coldStartRedirect() async { try { final AppLinks appLinks = AppLinks(); final Uri? initialUri = await appLinks.getInitialAppLink(); - if (initialUri == null) return false; - - if (initialUri.scheme == 'clean-stream' && + if (initialUri != null && + initialUri.scheme == 'clean-stream' && initialUri.host == 'reset-protected') { context.go('/reset-protected', extra: initialUri); - return true; } - if (initialUri.scheme == 'clean-stream' && + if (initialUri != null && + initialUri.scheme == 'clean-stream' && initialUri.host == 'email-verification') { context.go("/homePage"); - return true; - } + } else if (initialUri != null && initialUri.host == 'change-email') { - if (initialUri.scheme == 'clean-stream' && - initialUri.host == 'change-email') { context.go("/email-verification"); - return true; - } - - if (initialUri.scheme == 'clean-stream' && + } else if (initialUri != null && + initialUri.scheme == 'clean-stream' && initialUri.host == 'oauth') { await authService.handleOAuthRedirect(initialUri); - - if (await authService.isLoggedIn() == - AuthenticationResponses.success) { + if (await authService.isLoggedIn() == AuthenticationResponses.success) { context.go("/homePage"); } else { context.go("/login"); } - - return true; } + } catch (e) {} + } - return false; - } catch (e) { - return false; - } + @override + void dispose() { + super.dispose(); } Future _automaticLogIn() async { await Future.delayed(Duration.zero); try { - if (await authService.isLoggedIn() == - AuthenticationResponses.success) { + if (await authService.isLoggedIn() == AuthenticationResponses.success) { if (!mounted) return; context.go("/homePage"); } else { @@ -103,8 +86,7 @@ class _LoadingPageState extends State { ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, - color: Colors.redAccent, size: 80), + Icon(Icons.error_outline, color: Colors.redAccent, size: 80), const SizedBox(height: 20), Text( 'Authentication Failed', @@ -129,6 +111,12 @@ class _LoadingPageState extends State { onPressed: () => context.go("/login"), icon: const Icon(Icons.login), label: const Text('Return to Login'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), ), ], ) @@ -141,12 +129,6 @@ class _LoadingPageState extends State { }, child: Image.asset("assets/Logo.png", height: 250), onEnd: () { - // Prevent infinite animation loop during widget tests - if (WidgetsBinding.instance.runtimeType.toString() == - 'TestWidgetsFlutterBinding') { - return; - } - setState(() { double temp = begin; begin = end; From c89240ff99b1364b8371d3add893f249b170c30f Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:18:52 -0500 Subject: [PATCH 041/141] Refactored name for better clarity --- lib/logic/services/auth_service.dart | 2 +- lib/pages/email_verification_page.dart | 2 +- lib/pages/loading_page.dart | 2 +- lib/pages/login_page.dart | 2 +- lib/services/supabase/supabase_auth_service.dart | 2 +- test/pages/loading_page_test.dart | 2 +- test/pages/login_page_test.dart | 4 ++-- test/services/supabase/authentication/authenticator_test.dart | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/logic/services/auth_service.dart b/lib/logic/services/auth_service.dart index 120cae41..a5cc7da4 100644 --- a/lib/logic/services/auth_service.dart +++ b/lib/logic/services/auth_service.dart @@ -18,7 +18,7 @@ abstract class AuthService { Future resetPassword(String email); Future appleSignIn(); Future googleSignIn(); - Future handleOAuthRedirect(Uri uri); + Future getSessionFromURI(Uri uri); Future refreshSession(); User? getCurrentUser(); String? getCurrentUserEmail(); diff --git a/lib/pages/email_verification_page.dart b/lib/pages/email_verification_page.dart index 15aa7e69..407bb452 100644 --- a/lib/pages/email_verification_page.dart +++ b/lib/pages/email_verification_page.dart @@ -37,7 +37,7 @@ class _EmailVerificationPageState extends State { if (uri != null && uri.scheme == 'clean-stream' && uri.host == 'email-verification') { - await authService.handleOAuthRedirect(uri); + await authService.getSessionFromURI(uri); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.go('/homePage'); diff --git a/lib/pages/loading_page.dart b/lib/pages/loading_page.dart index e3a3324f..74d96a2c 100644 --- a/lib/pages/loading_page.dart +++ b/lib/pages/loading_page.dart @@ -47,7 +47,7 @@ class _LoadingPageState extends State { } else if (initialUri != null && initialUri.scheme == 'clean-stream' && initialUri.host == 'oauth') { - await authService.handleOAuthRedirect(initialUri); + await authService.getSessionFromURI(initialUri); if (await authService.isLoggedIn() == AuthenticationResponses.success) { context.go("/homePage"); } else { diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index fcbdc23c..0b3980e2 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -51,7 +51,7 @@ class _LoginScreenState extends State { if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { context.go("/homePage"); } else if (uri.scheme == 'clean-stream' && uri.host == 'oauth') { - await authService.handleOAuthRedirect(uri); + await authService.getSessionFromURI(uri); if (await authService.isLoggedIn() == AuthenticationResponses.success) { if (!mounted) return; diff --git a/lib/services/supabase/supabase_auth_service.dart b/lib/services/supabase/supabase_auth_service.dart index 49764fc8..ddce78d2 100644 --- a/lib/services/supabase/supabase_auth_service.dart +++ b/lib/services/supabase/supabase_auth_service.dart @@ -225,7 +225,7 @@ class SupabaseAuthService implements AuthService { } @override - Future handleOAuthRedirect(Uri uri) async { + Future getSessionFromURI(Uri uri) async { await _client.auth.getSessionFromUrl(uri); } diff --git a/test/pages/loading_page_test.dart b/test/pages/loading_page_test.dart index e9c227eb..3bee029f 100644 --- a/test/pages/loading_page_test.dart +++ b/test/pages/loading_page_test.dart @@ -251,7 +251,7 @@ void main() { name: any(named: 'name'), )).thenAnswer((_) async {}); - when(() => mockAuthService.handleOAuthRedirect(any())) + when(() => mockAuthService.getSessionFromURI(any())) .thenAnswer((_) async {}); await tester.pumpWidget(createTestWidget(LoadingPage())); diff --git a/test/pages/login_page_test.dart b/test/pages/login_page_test.dart index 1b9eceb1..d9c6469d 100644 --- a/test/pages/login_page_test.dart +++ b/test/pages/login_page_test.dart @@ -389,7 +389,7 @@ void main() { await tester.pumpAndSettle(); when( - () => mockAuthService.handleOAuthRedirect(any()), + () => mockAuthService.getSessionFromURI(any()), ).thenAnswer((_) async {}); when( () => mockAuthService.isLoggedIn(), @@ -414,7 +414,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.handleOAuthRedirect(any())).called(1); + verify(() => mockAuthService.getSessionFromURI(any())).called(1); verify(() => mockAuthService.isLoggedIn()).called(1); verify(() => mockAuthService.getCurrentUser()).called(1); verify( diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 373d7412..8cd2d6b7 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -898,7 +898,7 @@ void main(){ ); final testUri = Uri.parse('https://example.com/callback?code=test123'); - await authenticator.handleOAuthRedirect(testUri); + await authenticator.getSessionFromURI(testUri); verify(() => supabaseAuth.getSessionFromUrl(testUri)).called(1); }); @@ -917,7 +917,7 @@ void main(){ when(() => supabaseAuth.getSessionFromUrl(any())).thenAnswer( (_) async => AuthSessionUrlResponse(session: Session(accessToken: "test", tokenType: "test", user: User(id: "1234", appMetadata: {}, userMetadata: {}, aud: "test", createdAt: "test")), redirectType: "test")); - await authenticator.handleOAuthRedirect(Uri()); + await authenticator.getSessionFromURI(Uri()); verify(() => client.auth.getSessionFromUrl(any())); }); From 196177f13b4e229cb754d6feca9d5445f945915d Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:57:43 -0500 Subject: [PATCH 042/141] Updated reset password page Made it where you have to confirm and have password strength tests. TESTS ARE BROKEN IN THIS COMMIT --- ios/Podfile.lock | 13 + lib/pages/reset_protected_page.dart | 369 ++++++++++-------- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 104 ++++- 4 files changed, 317 insertions(+), 171 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f834c51c..baee355c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,9 +6,14 @@ PODS: - Flutter - flutter_native_splash (2.4.3): - Flutter + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -80,7 +85,9 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -107,8 +114,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: @@ -125,7 +136,9 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 25485b99..35fbd4e2 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; import 'package:get_it/get_it.dart'; class ResetProtectedPage extends StatefulWidget { @@ -21,8 +22,17 @@ class _ResetProtectedPageState extends State { bool valid = false; String? lastReceivedUri; Map? lastParams; + final _pwController = TextEditingController(); - final _formKey = GlobalKey(); + final _confirmController = TextEditingController(); + + bool _obscurePassword = true; + bool _obscureConfirm = true; + + String passwordText = "New Password"; + String confirmPasswordText = "Confirm Password"; + Color iconColor = Colors.blue; + Color labelColor = Colors.blue; @override void initState() { @@ -30,6 +40,24 @@ class _ResetProtectedPageState extends State { _initFromUri(widget.incomingUri); } + void _changeColorsToRed(String reason) { + setState(() { + passwordText = reason; + confirmPasswordText = reason; + iconColor = Colors.red; + labelColor = Colors.red; + }); + } + + void _changeColorsToDefault() { + setState(() { + passwordText = "New Password"; + confirmPasswordText = "Confirm Password"; + iconColor = Colors.blue; + labelColor = Colors.blue; + }); + } + Future _initFromUri(Uri? uri) async { setState(() { loading = true; @@ -38,13 +66,12 @@ class _ResetProtectedPageState extends State { Uri effective = uri ?? Uri.base; - // Accept app links and in-app routes for reset-protected final isResetUri = (effective.scheme == 'clean-stream' && (effective.host == 'reset-protected' || effective.path.contains('reset-protected'))) || - effective.path == '/reset-protected' || - effective.path.contains('reset-protected'); + effective.path == '/reset-protected' || + effective.path.contains('reset-protected'); if (!isResetUri) { setState(() { @@ -54,23 +81,24 @@ class _ResetProtectedPageState extends State { return; } - // Merge query parameters and fragment parameters (Supabase may use fragment) final Map queryParams = effective.queryParameters; - final Map fragmentParams = effective.fragment.isNotEmpty + + final Map fragmentParams = + effective.fragment.isNotEmpty ? Uri.splitQueryString(effective.fragment) - : {}; + : {}; - final params = {...queryParams, ...fragmentParams}; + final Map params = { + ...queryParams, + ...fragmentParams, + }; - // Common code param names: code, oobCode code = params['code'] ?? params['oobCode']; if (code == null) { setState(() { loading = false; valid = false; - - // store raw values for on-screen debugging lastReceivedUri = effective.toString(); lastParams = params; }); @@ -79,18 +107,11 @@ class _ResetProtectedPageState extends State { try { final response = await authService.exchangeCodeForSession(code!); - if (response == AuthenticationResponses.success) { - setState(() { - valid = true; - loading = false; - }); - } else { - setState(() { - valid = false; - loading = false; - }); - } - } catch (e) { + setState(() { + valid = response == AuthenticationResponses.success; + loading = false; + }); + } catch (_) { setState(() { valid = false; loading = false; @@ -101,70 +122,71 @@ class _ResetProtectedPageState extends State { @override void dispose() { _pwController.dispose(); + _confirmController.dispose(); super.dispose(); } Future _submit() async { - if (!_formKey.currentState!.validate() || code == null) return; + if (code == null) return; + + final password = _pwController.text.trim(); + final confirm = _confirmController.text.trim(); + + if (password.isEmpty || confirm.isEmpty) { + _changeColorsToRed("Please fill all fields"); + return; + } + + if (password != confirm) { + _changeColorsToRed("Passwords don't match"); + return; + } + setState(() => loading = true); try { - final response = await authService.updatePassword( - _pwController.text.trim(), - ); + final response = await authService.updatePassword(password); setState(() => loading = false); + + if (!mounted) return; + if (response == AuthenticationResponses.success) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset successful')), - ); - if (mounted) { - context.go('/login'); - } - } - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to reset password')), - ); - } - } - } catch (e) { - setState(() => loading = false); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to reset password')), + const SnackBar(content: Text('Password reset successful')), ); + context.go('/login'); + } else if (response == AuthenticationResponses.noDigit) { + _changeColorsToRed('Please include a digit'); + } else if (response == + AuthenticationResponses.lessThanMinLength) { + _changeColorsToRed("Password length is too short"); + } else if (response == + AuthenticationResponses.noSpecialCharacter) { + _changeColorsToRed("Please include a special character"); + } else if (response == + AuthenticationResponses.noUppercase) { + _changeColorsToRed("Please include an uppercase letter"); + } else if (response == + AuthenticationResponses.invalidSpecialCharacter) { + _changeColorsToRed("Please use a different special character"); + } else { + _changeColorsToRed("Failed to reset password"); } + } catch (_) { + setState(() => loading = false); + if (!mounted) return; + _changeColorsToRed("Failed to reset password"); } } - String? _validatePw(String? v) { - if (v == null || v.isEmpty) return 'Please enter a password'; - if (v.length < 8) return 'Password must be at least 8 characters'; - return null; - } - @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + if (loading) { return Scaffold( backgroundColor: scheme.surface, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - if (lastReceivedUri != null) - Text( - 'Received: $lastReceivedUri', - style: TextStyle(color: scheme.fontSecondary), - ), - ], - ), - ), + body: const Center(child: CircularProgressIndicator()), ); } @@ -175,34 +197,14 @@ class _ResetProtectedPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.lock_reset, size: 80, color: scheme.primary), - const SizedBox(height: 16), Text( 'Invalid or expired reset link', style: TextStyle(color: scheme.fontInverted), ), - const SizedBox(height: 8), - if (lastReceivedUri != null) - Text( - 'Received: $lastReceivedUri', - style: TextStyle(color: scheme.fontSecondary), - ), - if (lastParams != null) ...[ - const SizedBox(height: 8), - Text('Params:', style: TextStyle(color: scheme.fontSecondary)), - for (final e in lastParams!.entries) - Text( - '${e.key}: ${e.value}', - style: TextStyle(color: scheme.fontSecondary), - ), - ], const SizedBox(height: 16), TextButton( onPressed: () => context.go('/login'), - child: Text( - 'Back to Login', - style: TextStyle(color: scheme.primary), - ), + child: const Text('Back to Login'), ), ], ), @@ -216,92 +218,133 @@ class _ResetProtectedPageState extends State { backgroundColor: scheme.surface, foregroundColor: scheme.fontInverted, title: const Text('Reset Password'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), ), - body: Padding( + body: SingleChildScrollView( padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32), - Icon(Icons.lock_reset, size: 80, color: scheme.primary), - const SizedBox(height: 32), - Text( - 'Set a new password', - style: - Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: scheme.fontInverted, - ) ?? - TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: scheme.fontInverted, + child: Column( + children: [ + const SizedBox(height: 32), + + /// Password Requirements (Same as Sign Up) + ValueListenableBuilder( + valueListenable: _pwController, + builder: (context, value, _) { + final requirementText = + PasswordParser.process(value.text); + + if (requirementText == null) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric( + vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Text( + requirementText, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - 'Enter a new password for your account.', - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.fontSecondary, - ) ?? - TextStyle(color: scheme.fontSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 40), - TextFormField( - controller: _pwController, - obscureText: true, - style: TextStyle(color: scheme.fontInverted), - decoration: InputDecoration( - labelText: 'New password', - labelStyle: TextStyle(color: scheme.primary), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.primary, width: 2.0), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.fontSecondary), - borderRadius: BorderRadius.circular(12), + const SizedBox(height: 16), + ], + ); + }, + ), + + /// New Password + TextField( + controller: _pwController, + obscureText: _obscurePassword, + style: TextStyle(color: scheme.fontInverted), + decoration: InputDecoration( + labelText: passwordText, + labelStyle: TextStyle(color: labelColor), + prefixIcon: Icon(Icons.lock, color: iconColor), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + color: scheme.primary, ), - filled: true, - fillColor: scheme.surface, - prefixIcon: Icon(Icons.lock, color: scheme.primary), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, ), - validator: _validatePw, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: loading ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, + onChanged: (_) { + if (iconColor == Colors.red) { + _changeColorsToDefault(); + } + }, + ), + + const SizedBox(height: 16), + + /// Confirm Password + TextField( + controller: _confirmController, + obscureText: _obscureConfirm, + style: TextStyle(color: scheme.fontInverted), + decoration: InputDecoration( + labelText: confirmPasswordText, + labelStyle: TextStyle(color: labelColor), + prefixIcon: Icon(Icons.lock, color: iconColor), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm + ? Icons.visibility_off + : Icons.visibility, + color: scheme.primary, + ), + onPressed: () { + setState(() { + _obscureConfirm = !_obscureConfirm; + }); + }, ), - child: loading - ? const CircularProgressIndicator() - : const Text('Set Password'), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), ), - TextButton( - onPressed: loading ? null : () => context.go('/login'), - child: Text( - 'Back to Login', - style: TextStyle(color: scheme.primary), - ), + onChanged: (_) { + if (_pwController.text.trim() != + _confirmController.text.trim()) { + _changeColorsToRed("Passwords don't match"); + } else { + _changeColorsToDefault(); + } + }, + ), + + const SizedBox(height: 32), + + ElevatedButton( + onPressed: loading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, ), - ], - ), + child: loading + ? const CircularProgressIndicator() + : const Text('Set Password'), + ), + ], ), ), ); } -} +} \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index aea40d6d..e996361c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import flutter_local_notifications import geolocator_apple import mobile_scanner import package_info_plus +import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 1eb1fa6c..10f14d12 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dbus: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -254,6 +278,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + url: "https://pub.dev" + source: hosted + version: "8.2.2" flutter_native_splash: dependency: "direct dev" description: @@ -512,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -544,6 +584,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -572,10 +628,18 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -800,6 +864,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -1049,26 +1121,26 @@ packages: dependency: "direct main" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" test_cov_console: dependency: "direct dev" description: @@ -1093,6 +1165,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" universal_io: dependency: transitive description: @@ -1165,6 +1245,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_graphics: dependency: transitive description: From 53d889349d08c18a0569c02e390a1ace937ddb19 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:13:56 -0500 Subject: [PATCH 043/141] Fixed tests --- lib/pages/reset_protected_page.dart | 2 +- test/pages/reset_protected_page_test.dart | 33 ++++++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 35fbd4e2..a6837d0b 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -225,7 +225,7 @@ class _ResetProtectedPageState extends State { children: [ const SizedBox(height: 32), - /// Password Requirements (Same as Sign Up) + // Password Requirements (Same as Sign Up) ValueListenableBuilder( valueListenable: _pwController, builder: (context, value, _) { diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index af660ed2..7f2de5c1 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -1,4 +1,5 @@ import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; import 'package:flutter/material.dart'; @@ -91,9 +92,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Set a new password'), findsOneWidget); - expect(find.byType(TextFormField), findsOneWidget); - expect(find.text('Set Password'), findsOneWidget); + expect(find.text('New Password'), findsOneWidget); + expect(find.text('Confirm Password'), findsOneWidget); }); testWidgets('validates short password', (tester) async { @@ -108,11 +108,15 @@ void main() { ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'short'); + await tester.enterText(find.byType(TextField).at(0), 'short'); + await tester.enterText(find.byType(TextField).at(1), 'short'); await tester.tap(find.text('Set Password')); await tester.pumpAndSettle(); + String? validations = PasswordParser.process("short"); - expect(find.text('Password must be at least 8 characters'), findsOneWidget); + if (validations != null) { + expect(find.text(validations), findsOneWidget); + } }); testWidgets('submits new password and navigates to login', (tester) async { @@ -130,11 +134,12 @@ void main() { ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); + await tester.enterText(find.byType(TextField).at(0), 'password123&'); + await tester.enterText(find.byType(TextField).at(1), 'password123&'); await tester.tap(find.text('Set Password')); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); + verify(() => mockAuthService.updatePassword('password123&')).called(1); expect(find.text('Password reset successful'), findsOneWidget); expect(find.text('Login Page'), findsOneWidget); }); @@ -154,12 +159,13 @@ void main() { ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); + await tester.enterText(find.byType(TextField).at(0), 'password123&'); + await tester.enterText(find.byType(TextField).at(1), 'password123&'); await tester.tap(find.text('Set Password')); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); - expect(find.text('Failed to reset password'), findsOneWidget); + verify(() => mockAuthService.updatePassword('password123&')).called(1); + expect(find.text('Failed to reset password'), findsWidgets); }); testWidgets('shows failure message when update throws', (tester) async { @@ -177,11 +183,12 @@ void main() { ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); + await tester.enterText(find.byType(TextField).at(0), 'password123&'); + await tester.enterText(find.byType(TextField).at(1), 'password123&'); await tester.tap(find.text('Set Password')); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); - expect(find.text('Failed to reset password'), findsOneWidget); + verify(() => mockAuthService.updatePassword('password123&')).called(1); + expect(find.text('Failed to reset password'), findsWidgets); }); } From 3f2de0148727c29c5bfe83e0a6ca532b3db1a1d0 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:04:31 -0500 Subject: [PATCH 044/141] Added test to test back arrow --- test/pages/password_reset_test.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/pages/password_reset_test.dart b/test/pages/password_reset_test.dart index 3acceb5e..49ea85b3 100644 --- a/test/pages/password_reset_test.dart +++ b/test/pages/password_reset_test.dart @@ -123,4 +123,14 @@ void main() { expect(find.text('Login Page'), findsOneWidget); }); + + testWidgets('back arrow navigates to login page', (tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); } From 7314bda00282354ee4e4958a03d93f72534d9282 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 16 Feb 2026 15:34:50 -0500 Subject: [PATCH 045/141] Change deeplink to edit-profile --- lib/middleware/app_router.dart | 3 ++- lib/pages/change_email_verification.dart | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/middleware/app_router.dart b/lib/middleware/app_router.dart index 5410f25a..f474c0e6 100644 --- a/lib/middleware/app_router.dart +++ b/lib/middleware/app_router.dart @@ -200,6 +200,7 @@ class RouterService { return query.isEmpty ? '/reset-protected' : '/reset-protected?$query'; } + // Handle clean-stream://email-verification deep links if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { return '/email-verification'; } @@ -209,7 +210,7 @@ class RouterService { // Optional: check type query param final type = uri.queryParameters['type']; if (type == 'email_change' || type == null) { - return '/homePage'; + return '/editProfile'; } } diff --git a/lib/pages/change_email_verification.dart b/lib/pages/change_email_verification.dart index 247ece01..e357efcf 100644 --- a/lib/pages/change_email_verification.dart +++ b/lib/pages/change_email_verification.dart @@ -35,7 +35,7 @@ class _ChangeEmailVerificationPageState await authService.getCurrentUser(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.go('/homePage'); + context.go('/editProfile'); } }); } From 4aed54d361c6ca4a56501f30574a80cd3412e6d2 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:08:17 -0500 Subject: [PATCH 046/141] Updated reset password Made it where there is no more deep linking and only code can be entered. Updated UI and added a verify code page. Also added some new authentication methods --- lib/logic/services/auth_service.dart | 1 + lib/middleware/app_router.dart | 19 +- lib/pages/password_reset.dart | 5 +- lib/pages/reset_protected_page.dart | 489 ++++++++---------- lib/pages/verify_code_page.dart | 208 ++++++++ .../supabase/supabase_auth_service.dart | 25 +- test/pages/reset_protected_page_test.dart | 2 +- 7 files changed, 469 insertions(+), 280 deletions(-) create mode 100644 lib/pages/verify_code_page.dart diff --git a/lib/logic/services/auth_service.dart b/lib/logic/services/auth_service.dart index a5cc7da4..99146e30 100644 --- a/lib/logic/services/auth_service.dart +++ b/lib/logic/services/auth_service.dart @@ -28,4 +28,5 @@ abstract class AuthService { }); Future exchangeCodeForSession(String code); Future updatePassword(String newPassword); + Future verifyCode({required String email, required String code}); } diff --git a/lib/middleware/app_router.dart b/lib/middleware/app_router.dart index 5410f25a..69908558 100644 --- a/lib/middleware/app_router.dart +++ b/lib/middleware/app_router.dart @@ -1,6 +1,7 @@ import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/pages/change_email_verification.dart'; import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; +import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/pages/email_verification_page.dart'; @@ -173,21 +174,33 @@ class RouterService { GoRoute( path: '/reset-protected', pageBuilder: (context, state) { - final uri = (state.extra as Uri?) ?? state.uri; return CustomTransitionPage( key: state.pageKey, - child: ResetProtectedPage(incomingUri: uri), + child: ResetProtectedPage(), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, ); }, ), + GoRoute( + path: '/verify-code', + pageBuilder: (context, state) { + final email = state.extra as String; + return CustomTransitionPage( + key: state.pageKey, + child: CodeVerificationPage(email: email), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + transitionsBuilder: (_, _, _, child) => child, + ); + }, + ) ], errorBuilder: (context, state) { final uri = state.uri; if (uri.scheme == 'clean-stream' && uri.host == 'reset-protected') { - return ResetProtectedPage(incomingUri: uri); + return ResetProtectedPage(); } return const NotFoundScreen(); }, diff --git a/lib/pages/password_reset.dart b/lib/pages/password_reset.dart index fdc9f292..5a08f7c4 100644 --- a/lib/pages/password_reset.dart +++ b/lib/pages/password_reset.dart @@ -46,6 +46,7 @@ class _PasswordResetPageState extends State { if (response == AuthenticationResponses.success) { _showMessage('Password reset email sent! Check your email.'); + context.go("/verify-code", extra: _emailController.text.trim()); } else { _showMessage('Failed to send reset email.'); } @@ -151,7 +152,9 @@ class _PasswordResetPageState extends State { : SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: _sendResetEmail, + onPressed: () { + _sendResetEmail(); + }, style: ElevatedButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index a6837d0b..450cf86f 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -1,348 +1,291 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; -import 'package:get_it/get_it.dart'; + +import '../Logic/Theme/theme.dart'; class ResetProtectedPage extends StatefulWidget { - final Uri? incomingUri; - const ResetProtectedPage({this.incomingUri, super.key}); + const ResetProtectedPage({super.key}); @override State createState() => _ResetProtectedPageState(); } class _ResetProtectedPageState extends State { - final authService = GetIt.instance(); - - String? code; - bool loading = true; - bool valid = false; - String? lastReceivedUri; - Map? lastParams; + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); - final _pwController = TextEditingController(); - final _confirmController = TextEditingController(); + final authService = GetIt.instance(); bool _obscurePassword = true; bool _obscureConfirm = true; + bool _isLoading = false; - String passwordText = "New Password"; - String confirmPasswordText = "Confirm Password"; - Color iconColor = Colors.blue; - Color labelColor = Colors.blue; - - @override - void initState() { - super.initState(); - _initFromUri(widget.incomingUri); - } + var passwordText = "New Password"; + var confirmText = "Confirm Password"; + var iconColor = Colors.blue; + var labelColor = Colors.blue; void _changeColorsToRed(String reason) { setState(() { passwordText = reason; - confirmPasswordText = reason; + confirmText = reason; iconColor = Colors.red; labelColor = Colors.red; }); } - void _changeColorsToDefault() { + void _resetColors() { setState(() { passwordText = "New Password"; - confirmPasswordText = "Confirm Password"; + confirmText = "Confirm Password"; iconColor = Colors.blue; labelColor = Colors.blue; }); } - Future _initFromUri(Uri? uri) async { - setState(() { - loading = true; - valid = false; - }); - - Uri effective = uri ?? Uri.base; + void _showMessage(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } - final isResetUri = - (effective.scheme == 'clean-stream' && - (effective.host == 'reset-protected' || - effective.path.contains('reset-protected'))) || - effective.path == '/reset-protected' || - effective.path.contains('reset-protected'); + Future _submit() async { + final password = _passwordCtrl.text.trim(); + final confirm = _confirmCtrl.text.trim(); - if (!isResetUri) { - setState(() { - loading = false; - valid = false; - }); + if (password.isEmpty || confirm.isEmpty) { + _showMessage("Please fill in all fields"); return; } - final Map queryParams = effective.queryParameters; - - final Map fragmentParams = - effective.fragment.isNotEmpty - ? Uri.splitQueryString(effective.fragment) - : {}; - - final Map params = { - ...queryParams, - ...fragmentParams, - }; - - code = params['code'] ?? params['oobCode']; + if (password != confirm) { + _changeColorsToRed("Passwords don't match"); + return; + } - if (code == null) { - setState(() { - loading = false; - valid = false; - lastReceivedUri = effective.toString(); - lastParams = params; - }); + final requirementError = PasswordParser.process(password); + if (requirementError != null) { + _changeColorsToRed(requirementError); return; } + setState(() => _isLoading = true); + try { - final response = await authService.exchangeCodeForSession(code!); - setState(() { - valid = response == AuthenticationResponses.success; - loading = false; - }); - } catch (_) { - setState(() { - valid = false; - loading = false; - }); + await authService.resetPassword(password); + + _showMessage("Password reset successful"); + context.go("/login"); + } catch (e) { + _showMessage("Error: $e"); + } finally { + if (!mounted) return; + setState(() => _isLoading = false); } } @override void dispose() { - _pwController.dispose(); - _confirmController.dispose(); + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); super.dispose(); } - Future _submit() async { - if (code == null) return; - - final password = _pwController.text.trim(); - final confirm = _confirmController.text.trim(); + InputDecoration _inputDecoration({ + required String label, + required IconData icon, + required bool obscure, + required VoidCallback toggle, + }) { + return InputDecoration( + labelText: label, + labelStyle: TextStyle(color: labelColor), + + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), - if (password.isEmpty || confirm.isEmpty) { - _changeColorsToRed("Please fill all fields"); - return; - } + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), - if (password != confirm) { - _changeColorsToRed("Passwords don't match"); - return; - } + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.blue, width: 2.0), + borderRadius: BorderRadius.circular(12), + ), - setState(() => loading = true); + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary, + ), + borderRadius: BorderRadius.circular(12), + ), - try { - final response = await authService.updatePassword(password); - setState(() => loading = false); + prefixIcon: Icon(icon, color: iconColor), - if (!mounted) return; - - if (response == AuthenticationResponses.success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset successful')), - ); - context.go('/login'); - } else if (response == AuthenticationResponses.noDigit) { - _changeColorsToRed('Please include a digit'); - } else if (response == - AuthenticationResponses.lessThanMinLength) { - _changeColorsToRed("Password length is too short"); - } else if (response == - AuthenticationResponses.noSpecialCharacter) { - _changeColorsToRed("Please include a special character"); - } else if (response == - AuthenticationResponses.noUppercase) { - _changeColorsToRed("Please include an uppercase letter"); - } else if (response == - AuthenticationResponses.invalidSpecialCharacter) { - _changeColorsToRed("Please use a different special character"); - } else { - _changeColorsToRed("Failed to reset password"); - } - } catch (_) { - setState(() => loading = false); - if (!mounted) return; - _changeColorsToRed("Failed to reset password"); - } + suffixIcon: IconButton( + icon: Icon( + obscure ? Icons.visibility_off : Icons.visibility, + color: Colors.blue, + ), + onPressed: toggle, + ), + ); } @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); - if (loading) { - return Scaffold( - backgroundColor: scheme.surface, - body: const Center(child: CircularProgressIndicator()), - ); - } + return Scaffold( + backgroundColor: theme.colorScheme.surface, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + + /// Title + Text( + "Reset Password", + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), - if (!valid) { - return Scaffold( - backgroundColor: scheme.surface, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Invalid or expired reset link', - style: TextStyle(color: scheme.fontInverted), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/login'), - child: const Text('Back to Login'), - ), - ], - ), - ), - ); - } + const SizedBox(height: 8), - return Scaffold( - backgroundColor: scheme.surface, - appBar: AppBar( - backgroundColor: scheme.surface, - foregroundColor: scheme.fontInverted, - title: const Text('Reset Password'), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - children: [ - const SizedBox(height: 32), - - // Password Requirements (Same as Sign Up) - ValueListenableBuilder( - valueListenable: _pwController, - builder: (context, value, _) { - final requirementText = - PasswordParser.process(value.text); - - if (requirementText == null) { - return const SizedBox.shrink(); - } - - return Column( - children: [ - Container( - padding: const EdgeInsets.symmetric( - vertical: 6, horizontal: 12), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey), - ), - child: Text( - requirementText, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.grey, + Text( + "Enter your new password below", + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + + const SizedBox(height: 30), + + /// Password requirements (same style as signup) + ValueListenableBuilder( + valueListenable: _passwordCtrl, + builder: (context, value, _) { + final requirement = PasswordParser.process(value.text); + + if (requirement == null) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 10, ), - ), - ), - const SizedBox(height: 16), - ], - ); - }, - ), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Text( + requirement, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ); + }, + ), - /// New Password - TextField( - controller: _pwController, - obscureText: _obscurePassword, - style: TextStyle(color: scheme.fontInverted), - decoration: InputDecoration( - labelText: passwordText, - labelStyle: TextStyle(color: labelColor), - prefixIcon: Icon(Icons.lock, color: iconColor), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_off - : Icons.visibility, - color: scheme.primary, + /// Password field + TextField( + controller: _passwordCtrl, + obscureText: _obscurePassword, + decoration: _inputDecoration( + label: passwordText, + icon: Icons.lock, + obscure: _obscurePassword, + toggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + onChanged: (_) { + if (iconColor == Colors.red) _resetColors(); + }, ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12)), - ), - onChanged: (_) { - if (iconColor == Colors.red) { - _changeColorsToDefault(); - } - }, - ), - const SizedBox(height: 16), - - /// Confirm Password - TextField( - controller: _confirmController, - obscureText: _obscureConfirm, - style: TextStyle(color: scheme.fontInverted), - decoration: InputDecoration( - labelText: confirmPasswordText, - labelStyle: TextStyle(color: labelColor), - prefixIcon: Icon(Icons.lock, color: iconColor), - suffixIcon: IconButton( - icon: Icon( - _obscureConfirm - ? Icons.visibility_off - : Icons.visibility, - color: scheme.primary, + const SizedBox(height: 16), + + /// Confirm field + TextField( + controller: _confirmCtrl, + obscureText: _obscureConfirm, + decoration: _inputDecoration( + label: confirmText, + icon: Icons.lock, + obscure: _obscureConfirm, + toggle: () { + setState(() { + _obscureConfirm = !_obscureConfirm; + }); + }, + ), + onChanged: (_) { + if (_passwordCtrl.text != _confirmCtrl.text) { + _changeColorsToRed("Passwords don't match"); + } else { + _resetColors(); + } + }, ), - onPressed: () { - setState(() { - _obscureConfirm = !_obscureConfirm; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12)), - ), - onChanged: (_) { - if (_pwController.text.trim() != - _confirmController.text.trim()) { - _changeColorsToRed("Passwords don't match"); - } else { - _changeColorsToDefault(); - } - }, - ), - const SizedBox(height: 32), + const SizedBox(height: 24), + + /// Button (blue like signup) + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + "Reset Password", + style: TextStyle(fontSize: 16), + ), + ), + ), - ElevatedButton( - onPressed: loading ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, + const SizedBox(height: 20), + ], ), - child: loading - ? const CircularProgressIndicator() - : const Text('Set Password'), ), - ], + ), ), ), ); diff --git a/lib/pages/verify_code_page.dart b/lib/pages/verify_code_page.dart new file mode 100644 index 00000000..900a3121 --- /dev/null +++ b/lib/pages/verify_code_page.dart @@ -0,0 +1,208 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +import '../logic/services/auth_service.dart'; +import '../logic/theme/theme.dart'; + +class CodeVerificationPage extends StatefulWidget { + final String email; + + const CodeVerificationPage({Key? key, required this.email}) : super(key: key); + + @override + State createState() => _CodeVerificationPageState(); +} + +class _CodeVerificationPageState extends State { + final TextEditingController _codeController = TextEditingController(); + final authService = GetIt.instance(); + + bool _isLoading = false; + String? _error; + + void _verifyCode() async { + final code = _codeController.text.trim(); + + if (code.length != 6) { + setState(() { + _error = 'Please enter the 6-digit code'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await authService.verifyCode( + email: widget.email, + code: code, + ); + + if (!mounted) return; + + if (response == AuthenticationResponses.success) { + context.go('/reset-protected'); + return; + } else { + setState(() { + _error = 'Invalid or expired code'; + }); + } + } catch (_) { + if (!mounted) return; + + setState(() { + _error = 'Something went wrong. Try again'; + }); + } finally { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: scheme.surface, + appBar: AppBar( + backgroundColor: scheme.surface, + foregroundColor: scheme.fontInverted, + title: const Text('Verify Code'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const SizedBox(height: 40), + + /// Title + Text( + 'Enter Verification Code', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ), + ), + + const SizedBox(height: 12), + + /// Subtitle + Text( + 'We sent a 6-digit code to', + style: TextStyle(color: scheme.fontInverted.withOpacity(0.7)), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 6), + + /// Email + Text( + widget.email, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + /// Code Input + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + style: TextStyle( + color: scheme.fontInverted, + letterSpacing: 8, // 👈 nice spaced digits + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + decoration: InputDecoration( + labelText: _error ?? '6-digit code', + labelStyle: TextStyle( + color: _error != null ? Colors.red : scheme.primary, + ), + counterText: '', + prefixIcon: Icon( + Icons.lock, + color: _error != null ? Colors.red : scheme.primary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (_) { + if (_error != null) { + setState(() => _error = null); + } + }, + ), + + const SizedBox(height: 32), + + /// Verify Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _verifyCode, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Verify'), + ), + ), + + const SizedBox(height: 16), + + /// Error Message (optional extra under field) + if (_error != null) + Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + + const SizedBox(height: 16), + + /// Resend + TextButton( + onPressed: () async { + // TODO: implement resend + }, + child: Text( + 'Resend code', + style: TextStyle(color: scheme.primary), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/supabase/supabase_auth_service.dart b/lib/services/supabase/supabase_auth_service.dart index ddce78d2..c813fa82 100644 --- a/lib/services/supabase/supabase_auth_service.dart +++ b/lib/services/supabase/supabase_auth_service.dart @@ -267,8 +267,7 @@ class SupabaseAuthService implements AuthService { try { // Send password reset email and redirect back to the app via deep link. await _client.auth.resetPasswordForEmail( - email, - redirectTo: 'clean-stream://reset-protected', + email ); output = AuthenticationResponses.success; } catch (e) { @@ -305,4 +304,26 @@ class SupabaseAuthService implements AuthService { } return output; } + + @override + Future verifyCode({required String email, required String code}) async { + AuthenticationResponses output = AuthenticationResponses.success; + + try { + final response = await _client.auth.verifyOTP( + email: email, + token: code, + type: OtpType.recovery, + ); + + if (response.session == null) { + output = AuthenticationResponses.failure; + } + }catch (e){ + output = AuthenticationResponses.failure; + } + + return output; + } + } diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index 7f2de5c1..f68046ee 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -32,7 +32,7 @@ void main() { GoRoute( path: '/reset-protected', builder: (context, state) => - ResetProtectedPage(incomingUri: incomingUri), + ResetProtectedPage(), ), GoRoute( path: '/login', From 91a7ffa91a21cf8c32799dd31c9d175e1d43cb44 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 18:34:32 -0500 Subject: [PATCH 047/141] Refactors refund page --- lib/logic/services/transaction_service.dart | 2 +- lib/pages/refund_page.dart | 358 ++++++++++-------- .../supabase_transaction_service.dart | 17 +- lib/widgets/transactions_search_sheet.dart | 80 ++++ pubspec.lock | 88 +++++ 5 files changed, 386 insertions(+), 159 deletions(-) create mode 100644 lib/widgets/transactions_search_sheet.dart diff --git a/lib/logic/services/transaction_service.dart b/lib/logic/services/transaction_service.dart index c6caf5dc..57d5aa37 100644 --- a/lib/logic/services/transaction_service.dart +++ b/lib/logic/services/transaction_service.dart @@ -2,7 +2,7 @@ import 'dart:async'; abstract class TransactionService { Future recordTransaction({required double amount, required String description, required String type,}); Future>> getTransactionsForUser(); - Future>> getRefundableTransactionsForUser(); + Future<({List transactions, List ids})> getRefundableTransactionsForUser(); Future recordRefundRequest({required String transaction_id, required String description,}); Future subscribeForPaymentConfirmation( bool channelSubscribed,Completer? paymentCompleter ); } \ No newline at end of file diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index 34c6953e..7174197e 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -2,6 +2,7 @@ import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; @@ -30,6 +31,8 @@ class RefundPageState extends State { final authService = GetIt.instance(); bool _attemptedSubmit = false; final FocusNode _focusNode = FocusNode(); + bool _isLoading = false; + bool _isFetchingTransactions = true; @override void initState() { @@ -49,26 +52,16 @@ class RefundPageState extends State { Future _fetchTransactions() async { try { - final transactions = await transactionService.getTransactionsForUser(); + final result = await transactionService.getRefundableTransactionsForUser(); setState(() { - recentTransactions = TransactionParser.formatTransactionsList( - transactions.take(100), - "refundHistory", - ); - recentTransactions.removeWhere((e) => e.isEmpty); - recentTransactionIDs = TransactionParser.createTransactionIDList( - transactions.take(100), - ); - recentTransactionIDs.removeWhere((e) => e.isNegative); + recentTransactions = result.transactions; + recentTransactionIDs = result.ids; }); } catch (e) { print(e.toString()); + } finally { + if (mounted) setState(() => _isFetchingTransactions = false); } - - //Filters out loyalty transactions - recentTransactions.removeWhere( - (transaction) => transaction.contains("added to Loyalty Card"), - ); } String getTransactionID() { @@ -87,175 +80,232 @@ class RefundPageState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar( leading: IconButton( onPressed: () => context.pop(), - icon: Icon( - Icons.arrow_back, - color: Colors.white, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - "Request Refund", - style: TextStyle(color: Colors.white), + icon: const Icon(Icons.arrow_back, color: Colors.white), ), + backgroundColor: colorScheme.primary, + title: const Text("Request Refund", style: TextStyle(color: Colors.white)), centerTitle: true, ), - body: Scaffold( - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleRefund(); - } - }, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 24), - DropdownButtonFormField( - initialValue: selectedTransactionIndex, - hint: Text('Select a Transaction'), - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _handleRefund(); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.primary.withOpacity(0.2), ), - decoration: InputDecoration( - hintStyle: TextStyle(color: Colors.white), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontInverted, - width: 2, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), ), + child: Icon(Icons.receipt_long_rounded, + color: colorScheme.primary, size: 28), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - width: 2, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Submit a Refund Request", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.fontInverted, + )), + const SizedBox(height: 4), + Text( + "Select a transaction and describe your issue. Our team will review it shortly.", + style: TextStyle( + fontSize: 13, + color: colorScheme.fontSecondary, + ), + ), + ], ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - isExpanded: true, - menuMaxHeight: 250, - items: List.generate(recentTransactions.length, (index) { - return DropdownMenuItem( - value: index, - child: Text( - recentTransactions[index], - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - ); - }), - - onChanged: (int? newIndex) { - setState(() { - selectedTransactionIndex = newIndex; - selectedTransaction = newIndex != null - ? recentTransactions[newIndex] - : null; - }); - }, + ], ), + ), - const SizedBox(height: 20), + const SizedBox(height: 28), - TextField( - controller: descriptionController, - minLines: 3, - maxLines: null, - keyboardType: TextInputType.multiline, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - hintText: 'Please explain your reason for the refund...', - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontInverted, - width: 2, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - width: 2, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), + // Form card + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel("Transaction"), + const SizedBox(height: 8), + _isFetchingTransactions + ? const Center(child: CircularProgressIndicator()) + : GestureDetector( + onTap: () async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TransactionSearchSheet( + transactions: recentTransactions, + ), + ); - const SizedBox(height: 20), + if (selected != null) { + setState(() { + selectedTransaction = selected; + selectedTransactionIndex = + recentTransactions.indexOf(selected); + }); + } + }, + child: AbsorbPointer( + child: TextFormField( + decoration: _inputDecoration(context).copyWith( + hintText: 'Select a transaction', + ), + controller: TextEditingController( + text: selectedTransaction, + ), + style: TextStyle(color: colorScheme.fontInverted), + ), + ), + ), - Center( - child: ElevatedButton( - onPressed: () { - setState(() { - _attemptedSubmit = true; - }); + const SizedBox(height: 24), - if (!isFormValid()) return; + _buildLabel("Reason for Refund"), + const SizedBox(height: 8), + TextField( + controller: descriptionController, + minLines: 4, + maxLines: null, + keyboardType: TextInputType.multiline, + style: TextStyle(color: colorScheme.fontInverted), + decoration: _inputDecoration(context).copyWith( + hintText: 'Describe the issue with your transaction...', + hintStyle: TextStyle(color: colorScheme.fontSecondary), + ), + ), - _handleRefund(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: isFormValid() - ? Colors.blue - : Colors.grey, - foregroundColor: Colors.white, - ), - child: const Text("Submit Refund"), + if (_attemptedSubmit && !isFormValid()) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: const [ + Icon(Icons.info_outline, color: Colors.red, size: 16), + SizedBox(width: 6), + Text('Please fill in all fields', + style: TextStyle(color: Colors.red, fontSize: 13)), + ], + ), + ), + ], ), ), - if (_attemptedSubmit && !isFormValid()) - const Padding( - padding: EdgeInsets.only(top: 8), - child: Center( - child: Text( - 'Please fill in all fields', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.red, fontSize: 14), - ), + ), + + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isLoading + ? null + : () { + setState(() => _attemptedSubmit = true); + if (!isFormValid()) return; + _handleRefund(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: isFormValid() ? colorScheme.primary : Colors.grey, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), + elevation: isFormValid() ? 2 : 0, ), - ], - ), + child: _isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2.5), + ) + : const Text("Submit Refund Request", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ], ), ), ), ); } + Widget _buildLabel(String text) { + return Text( + text, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context).colorScheme.fontInverted, + ), + ); + } + + InputDecoration _inputDecoration(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); + } + void _handleRefund() async { + setState(() => _isLoading = true); final transactionId = getTransactionID(); final description = descriptionController.text; final userId = authService.getCurrentUserId; @@ -284,6 +334,7 @@ class RefundPageState extends State { ); _showRefundDialog(); + if (mounted) setState(() => _isLoading = false); } void _showRefundDialog() { @@ -293,6 +344,5 @@ class RefundPageState extends State { message: 'Your refund request has been submitted', isSuccess: true, ); - context.go("/settings"); } } diff --git a/lib/services/supabase/supabase_transaction_service.dart b/lib/services/supabase/supabase_transaction_service.dart index 87669753..80b5eeb6 100644 --- a/lib/services/supabase/supabase_transaction_service.dart +++ b/lib/services/supabase/supabase_transaction_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -25,18 +26,26 @@ class SupabaseTransactionService extends TransactionService{ } @override - Future>> getRefundableTransactionsForUser() async { + Future<({List transactions, List ids})> getRefundableTransactionsForUser() async { final user = _client.auth.currentUser; - if (user == null) return []; final response = await _client .from('transactions') .select('id, amount, description, created_at') - .eq('user_id', user.id) + .eq('user_id', user!.id) .neq('requested_refund', true) .order('created_at', ascending: false); - return List>.from(response); + final raw = List>.from(response).take(100); + print(raw); + + final transactions = TransactionParser.formatTransactionsList(raw, "refundHistory") + ..removeWhere((e) => e.isEmpty || e.contains("added to Loyalty Card")); + + final ids = TransactionParser.createTransactionIDList(raw) + ..removeWhere((e) => e.isNegative); + + return (transactions: transactions, ids: ids); } @override diff --git a/lib/widgets/transactions_search_sheet.dart b/lib/widgets/transactions_search_sheet.dart new file mode 100644 index 00000000..715fff74 --- /dev/null +++ b/lib/widgets/transactions_search_sheet.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +class TransactionSearchSheet extends StatefulWidget { + final List transactions; + + const TransactionSearchSheet({ + super.key, + required this.transactions, + }); + + @override + State createState() => + _TransactionSearchSheetState(); +} + +class _TransactionSearchSheetState + extends State { + late List filtered; + String query = ''; + + @override + void initState() { + super.initState(); + filtered = widget.transactions; + } + + void _filter(String value) { + setState(() { + query = value; + filtered = widget.transactions + .where((transaction) => + transaction.toLowerCase().contains(value.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: SizedBox( + height: 500, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + autofocus: true, + decoration: InputDecoration( + hintText: 'Search by date...', + prefixIcon: Icon(Icons.search), + ), + onChanged: _filter, + ), + ), + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, index) { + final transaction = filtered[index]; + + return ListTile( + title: Text(transaction), + onTap: () { + Navigator.pop(context, transaction); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1eb1fa6c..e59d7883 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dbus: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -254,6 +278,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + url: "https://pub.dev" + source: hosted + version: "8.2.2" flutter_native_splash: dependency: "direct dev" description: @@ -512,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -544,6 +584,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -576,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -800,6 +864,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -1093,6 +1165,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" universal_io: dependency: transitive description: @@ -1165,6 +1245,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_graphics: dependency: transitive description: From 54a0c4009fbd9cc50036e644a021d3d85567f3b3 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 18:35:56 -0500 Subject: [PATCH 048/141] Fix transaction flow bug --- lib/pages/payment_page.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index eff22786..4e6f0901 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -331,6 +331,13 @@ class _PaymentPageState extends State { ); Navigator.of(context, rootNavigator: true).pop(); + await transactionService.recordTransaction( + amount: _price!, + description: + "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", + type: "laundry", + ); + if (deviceAuthorized) { makeNotification(_machineName.toString()); setState(() { @@ -342,18 +349,11 @@ class _PaymentPageState extends State { message: "Machine $_machineName is now active.", isSuccess: true, ); - - await transactionService.recordTransaction( - amount: _price!, - description: - "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", - type: "laundry", - ); } else { statusDialog( context, title: "Machine Error", - message: "payment succeeded but machine did not wake up.", + message: "Payment succeeded but machine did not wake up. Please contact support", isSuccess: false, ); } From 40d53af1e2159acbafcb5c72b4ce439cb82eb022 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 18:39:05 -0500 Subject: [PATCH 049/141] Fix transaction format --- lib/logic/parsing/transaction_parser.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logic/parsing/transaction_parser.dart b/lib/logic/parsing/transaction_parser.dart index a565b993..4fff22ec 100644 --- a/lib/logic/parsing/transaction_parser.dart +++ b/lib/logic/parsing/transaction_parser.dart @@ -20,7 +20,7 @@ class TransactionParser { return ""; } - final action = description == "Loyalty Card" ? "added to" : "used on"; + final action = description == "Loyalty Card" ? "added to" : "-"; return '$formattedAmount $action $description on $formattedDate'; } From c1a218e0c6227cd18051962b435846ecfb5aebe0 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 18:48:35 -0500 Subject: [PATCH 050/141] Update transaction service tests --- .../supabase/supabase_transaction_service.dart | 1 - .../transaction/transaction_service_test.dart | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/services/supabase/supabase_transaction_service.dart b/lib/services/supabase/supabase_transaction_service.dart index 80b5eeb6..4c0d4ab9 100644 --- a/lib/services/supabase/supabase_transaction_service.dart +++ b/lib/services/supabase/supabase_transaction_service.dart @@ -37,7 +37,6 @@ class SupabaseTransactionService extends TransactionService{ .order('created_at', ascending: false); final raw = List>.from(response).take(100); - print(raw); final transactions = TransactionParser.formatTransactionsList(raw, "refundHistory") ..removeWhere((e) => e.isEmpty || e.contains("added to Loyalty Card")); diff --git a/test/services/supabase/transaction/transaction_service_test.dart b/test/services/supabase/transaction/transaction_service_test.dart index dfebf774..50f19a26 100644 --- a/test/services/supabase/transaction/transaction_service_test.dart +++ b/test/services/supabase/transaction/transaction_service_test.dart @@ -15,9 +15,19 @@ void main() { late RealtimeChannelMock channelMock; setUp(() { + final now = DateTime.now().toUtc(); + final fmt = (DateTime d) => d.toIso8601String(); + supabaseMock = SupabaseMock(); queryBuilderMock = QueryBuilderMock(); - fakeFilterBuilder = FakeFilterBuilder([{"amount": 2.75, "description": "machine", "created_at": "2025-11-02T16:24:51.685419+00:00", "requested_refund": true}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T15:13:24.87605+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T14:27:54.429939+00:00", "requested_refund": true}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T14:26:21.662999+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-27T18:06:40.987278+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-27T00:17:18.01511+00:00", "requested_refund": false}]); + fakeFilterBuilder = FakeFilterBuilder([ + {"id": 1, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 1))), "requested_refund": true}, + {"id": 2, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 2))), "requested_refund": false}, + {"id": 3, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 5))), "requested_refund": true}, + {"id": 4, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 7))), "requested_refund": false}, + {"id": 5, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 10))), "requested_refund": false}, + {"id": 6, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 13))), "requested_refund": false}, + ]); transactionHandler = SupabaseTransactionService(client: supabaseMock); supabaseAuth = GoTrueMock(); channelMock = RealtimeChannelMock(); @@ -69,7 +79,7 @@ void main() { test("Get refundable transaction history data",() async { final result = await transactionHandler.getRefundableTransactionsForUser(); - expect(result.length, 4); + expect(result.ids.length, 4); }); test("Tests if the user is null",() async{ From e7a6501151499b57594c24f3c47947cfdacd1f0c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 19:23:11 -0500 Subject: [PATCH 051/141] Adjusts tests for refactor --- lib/pages/refund_page.dart | 2 +- .../parsing/transaction_parser_test.dart | 4 +- test/pages/payment_page_test.dart | 72 ++-- test/pages/refund_page_test.dart | 401 +++++++++--------- .../transaction_search_sheet_test.dart | 106 +++++ 5 files changed, 346 insertions(+), 239 deletions(-) create mode 100644 test/widgets/transaction_search_sheet_test.dart diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index 7174197e..513c880f 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -167,7 +167,7 @@ class RefundPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildLabel("Transaction"), + _buildLabel("Select a Transaction"), const SizedBox(height: 8), _isFetchingTransactions ? const Center(child: CircularProgressIndicator()) diff --git a/test/logic/parsing/transaction_parser_test.dart b/test/logic/parsing/transaction_parser_test.dart index 02773b84..e269b3a2 100644 --- a/test/logic/parsing/transaction_parser_test.dart +++ b/test/logic/parsing/transaction_parser_test.dart @@ -10,7 +10,7 @@ void main(){ test("Test that transactions are parsed correctly",(){ final result = TransactionParser.formatTransaction(({"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()}), "transactionHistory"); - expect(result, "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); + expect(result, "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); }); test("Test that transactions are parsed correctly if description is Loyalty Card",(){ @@ -21,7 +21,7 @@ void main(){ test("Test that it can format a list of transactions",(){ final result = TransactionParser.formatTransactionsList([{"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()},{"amount": 4.75, "description": "Washer", "created_at": "2025-11-12T19:23:24.781326+00:00"}], "transactionHistory"); - expect(result[0], "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); + expect(result[0], "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); }); test("Test for monthly report",(){ diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index b5330cec..ae9f8630 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -54,11 +54,13 @@ void main() { getIt.registerSingleton(mockRouterService); getIt.registerSingleton(mockNotificationService); - when(() => mockNotificationService.scheduleEarlyMachineNotification( - id: any(named: 'id'), - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - )).thenAnswer((_) async {}); + when( + () => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + ), + ).thenAnswer((_) async {}); getIt.registerSingleton(mockPaymentProcessor); getIt.registerSingleton(mockLoyaltyViewModel); @@ -286,6 +288,13 @@ void main() { when( () => mockMachineCommunicator.wakeDevice(any()), ).thenAnswer((_) async => false); + when( + () => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => {}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); @@ -295,7 +304,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Machine Error'), findsWidgets); - verifyNever( + verify( () => mockTransactionService.recordTransaction( amount: any(named: 'amount'), description: any(named: 'description'), @@ -340,26 +349,33 @@ void main() { expect(find.text('Pay \$2.75'), findsOneWidget); }); - testWidgets('sends notification after successful loyalty payment', (tester) async { + testWidgets('sends notification after successful loyalty payment', ( + tester, + ) async { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when(() => mockMachineService.getMachineById(any())).thenAnswer((_) async => { - 'Name': 'Washer01', - 'Price': 3.50, - }); + when( + () => mockMachineService.getMachineById(any()), + ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); - when(() => mockProfileService.getUserBalanceById(any())).thenAnswer((_) async => { - 'balance': 10.0, - }); + when( + () => mockProfileService.getUserBalanceById(any()), + ).thenAnswer((_) async => {'balance': 10.0}); - when(() => mockProfileService.updateBalanceById(any())).thenAnswer((_) async {}); - when(() => mockMachineCommunicator.wakeDevice(any())).thenAnswer((_) async => true); + when( + () => mockProfileService.updateBalanceById(any()), + ).thenAnswer((_) async {}); + when( + () => mockMachineCommunicator.wakeDevice(any()), + ).thenAnswer((_) async => true); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async {}); + when( + () => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async {}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); @@ -368,11 +384,13 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - verify(() => mockNotificationService.scheduleEarlyMachineNotification( - id: 1, - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - )).called(1); + verify( + () => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + ), + ).called(1); }); }); -} \ No newline at end of file +} diff --git a/test/pages/refund_page_test.dart b/test/pages/refund_page_test.dart index b87cda2f..94995388 100644 --- a/test/pages/refund_page_test.dart +++ b/test/pages/refund_page_test.dart @@ -10,6 +10,7 @@ import 'mocks.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; void main() { late MockAuthService mockAuthService; @@ -62,12 +63,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Request Refund'), findsOneWidget); + expect(find.text('Select a transaction and describe your issue. Our team will review it shortly.'), findsOneWidget); + expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); + expect(find.text("Submit a Refund Request"), findsOneWidget); expect(find.text('Select a Transaction'), findsOneWidget); expect( - find.text('Please explain your reason for the refund...'), + find.text('Describe the issue with your transaction...'), findsOneWidget, ); - expect(find.text('Submit Refund'), findsOneWidget); + expect(find.text('Submit Refund Request'), findsOneWidget); }); testWidgets('submit button shows error when form is invalid', ( @@ -76,7 +80,7 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); + final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund Request'); expect(submitButton, findsOneWidget); // Tap the button @@ -88,42 +92,55 @@ void main() { }); testWidgets('loads transactions on init', (tester) async { - final mockTransactions = [ - {'id': 1, 'amount': 10.0, 'date': '2024-01-01'}, - {'id': 2, 'amount': 20.0, 'date': '2024-01-02'}, - ]; when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], + ids: [1, 2],)); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); + verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); }); testWidgets('can select transaction from dropdown', (tester) async { - final mockTransactions = [ - {'id': 1, 'amount': 10.0, 'date': '2024-01-01'}, - ]; - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byType(DropdownButtonFormField)); + // Tap the TextFormField to open the bottom sheet + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); + + // Bottom sheet is now open, tap the transaction in the list + expect(find.byType(TransactionSearchSheet), findsOneWidget); + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); + await tester.pumpAndSettle(); + + // Verify the selection appeared in the field + expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); }); testWidgets('can enter description text', (tester) async { + when( + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); + await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final textField = find.byType(TextField); + final textField = find.widgetWithText(TextField, "Describe the issue with your transaction..."); await tester.enterText(textField, 'Test refund reason'); await tester.pumpAndSettle(); @@ -131,19 +148,33 @@ void main() { }); testWidgets('submit button enabled when form is valid', (tester) async { - final mockTransactions = [ - {'id': 123, 'amount': 10.0, 'date': '2024-01-01'}, - ]; - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Select a transaction + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); + await tester.pumpAndSettle(); + + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Test refund reason', + ); await tester.pumpAndSettle(); + + // Verify button is enabled + final button = tester.widget(find.byType(ElevatedButton)); + expect(button.onPressed, isNotNull); + expect(button.style?.backgroundColor?.resolve({}), equals(Theme.of(tester.element(find.byType(ElevatedButton))).colorScheme.primary)); }); testWidgets('handles refund submission successfully', (tester) async { @@ -223,74 +254,54 @@ void main() { }); }); - testWidgets('onChanged callback updates selectedTransaction state', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 1, - 'amount': 10.0, - 'date': '2024-01-01', - 'description': 'Wash & Fold', - 'type': 'debit', - }, - { - 'id': 2, - 'amount': 20.0, - 'date': '2024-01-02', - 'description': 'Dry Cleaning', - 'type': 'debit', - }, - ]; - + testWidgets('selecting a transaction updates state', (tester) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], + ids: [1, 2], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final dropdown = find.byType(DropdownButtonFormField); - expect(dropdown, findsOneWidget); - - await tester.tap(dropdown); + // Open the search sheet + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); - final hasItems = find.byType(DropdownMenuItem).evaluate().isNotEmpty; + expect(find.byType(TransactionSearchSheet), findsOneWidget); - expect( - hasItems || find.text('Select a Transaction').evaluate().isNotEmpty, - true, - ); + // Select a transaction + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); + await tester.pumpAndSettle(); + + // Verify the TextFormField now shows the selected transaction + expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); }); testWidgets('_handleRefund calls all required services in correct order', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'date': '2024-01-01', - 'description': 'Test Transaction', - 'type': 'debit', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockAuthService.getCurrentUserId, + ).thenReturn('test-user-id'); + when( + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Test User'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "25.50"); + ).thenAnswer((_) async => '25.50'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: any(named: 'name'), body: any(named: 'body'), ), @@ -299,52 +310,34 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Select a transaction + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - testWidgets('_handleRefund completes full flow when form is submitted', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'date': '2024-01-01', - 'description': 'Test Transaction', - 'type': 'debit', - }, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => "25.50"); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Test refund reason', + ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Submit + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); - expect(submitButton, findsOneWidget); + // Verify services were called + verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); + verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'Test refund reason', + )).called(1); + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: any(named: 'body'), + )).called(1); }); testWidgets('_handleRefund early return when userId is null', (tester) async { @@ -368,32 +361,25 @@ void main() { }); testWidgets('verifies edge function is called with correct parameters', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 456, - 'amount': 50.00, - 'date': '2024-01-15', - 'description': 'Premium Service', - 'type': 'debit', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$50.00 - machine on Jan 15, 2024'], + ids: [456], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'John Doe'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "50.00"); + ).thenAnswer((_) async => '50.00'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -401,36 +387,55 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - testWidgets('complete refund submission flow with dialog and navigation', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 789, - 'amount': 100.00, - 'date': '2024-01-20', - 'description': 'Large Order', - 'type': 'debit', + // Select transaction + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$50.00 - machine on Jan 15, 2024')); + await tester.pumpAndSettle(); + + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Wrong charge', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'John Doe', + 'user_id': 'test-user-id', + 'transaction_id': '456', + 'amount': '50.00', + 'description': 'Wrong charge', }, - ]; + )).called(1); + }); + testWidgets('complete refund submission flow with dialog and navigation', ( + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$100.00 - machine on Jan 20, 2024'], + ids: [789], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Jane Smith'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "100.00"); + ).thenAnswer((_) async => '100.00'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -440,40 +445,29 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Request Refund'), findsOneWidget); - expect(find.text('Submit Refund'), findsOneWidget); + expect(find.text('Submit Refund Request'), findsOneWidget); }); testWidgets('_handleRefund executes all service calls correctly', ( - tester, - ) async { - final recentDate = DateTime.now().subtract(Duration(days: 5)); - final formattedDate = recentDate.toIso8601String(); - - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'created_at': formattedDate, - 'description': 'Wash & Fold', - 'type': 'debit', - 'user_id': 'test-user-id', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Test User'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "25.50"); + ).thenAnswer((_) async => '25.50'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -482,48 +476,37 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final RefundPageState state = tester.state(find.byType(RefundPage)); - - await tester.pump(const Duration(milliseconds: 100)); + // Select transaction + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); - - expect(state.recentTransactions.isNotEmpty, true); - expect(state.recentTransactionIDs.isNotEmpty, true); - - state.setState(() { - state.selectedTransactionIndex = 0; - state.selectedTransaction = state.recentTransactions[0]; - state.descriptionController.text = 'I want a refund please'; - }); + await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); - await tester.tap(submitButton); + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'I want a refund please', + ); + await tester.pumpAndSettle(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); - verify( - () => mockTransactionService.recordRefundRequest( - transaction_id: '123', - description: 'I want a refund please', - ), - ).called(1); - verify( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': 'Test User', - 'user_id': 'test-user-id', - 'transaction_id': '123', - 'amount': '25.50', - 'description': 'I want a refund please', - }, - ), - ).called(1); + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'I want a refund please', + )).called(1); + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'Test User', + 'user_id': 'test-user-id', + 'transaction_id': '123', + 'amount': '25.50', + 'description': 'I want a refund please', + }, + )).called(1); }); testWidgets( diff --git a/test/widgets/transaction_search_sheet_test.dart b/test/widgets/transaction_search_sheet_test.dart new file mode 100644 index 00000000..1a6da772 --- /dev/null +++ b/test/widgets/transaction_search_sheet_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; + +void main() { + const transactions = [ + '01/10/2026 - Coffee - \$5.00', + '02/14/2026 - Dinner - \$45.00', + '03/01/2026 - Books - \$30.00', + ]; + + Widget buildTestWidget() { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (_) => const TransactionSearchSheet( + transactions: transactions, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + } + + group('TransactionSearchSheet Widget Tests', () { + testWidgets('renders all transactions initially', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text(transactions[0]), findsOneWidget); + expect(find.text(transactions[1]), findsOneWidget); + expect(find.text(transactions[2]), findsOneWidget); + }); + + testWidgets('filters transactions based on search input', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '02/14'); + await tester.pumpAndSettle(); + + expect(find.text(transactions[1]), findsOneWidget); + expect(find.text(transactions[0]), findsNothing); + expect(find.text(transactions[2]), findsNothing); + }); + + testWidgets('is case insensitive', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'coffee'); + await tester.pumpAndSettle(); + + expect(find.text(transactions[0]), findsOneWidget); + }); + + testWidgets('returns selected transaction when tapped', + (WidgetTester tester) async { + String? selected; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + selected = await showModalBottomSheet( + context: context, + builder: (_) => const TransactionSearchSheet( + transactions: transactions, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text(transactions[1])); + await tester.pumpAndSettle(); + + expect(selected, transactions[1]); + }); + }); +} From 18984ea4afbcddb914ba95fff17da2f577449b07 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 19:35:50 -0500 Subject: [PATCH 052/141] Fixes popup and route on submit --- lib/pages/refund_page.dart | 4 +++- lib/widgets/status_dialog_box.dart | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index 513c880f..0158a363 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -343,6 +343,8 @@ class RefundPageState extends State { title: "Success", message: 'Your refund request has been submitted', isSuccess: true, - ); + ).then((_) { + context.go("/settings"); + }); } } diff --git a/lib/widgets/status_dialog_box.dart b/lib/widgets/status_dialog_box.dart index 26b39125..b5e779f3 100644 --- a/lib/widgets/status_dialog_box.dart +++ b/lib/widgets/status_dialog_box.dart @@ -1,13 +1,13 @@ import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:flutter/material.dart'; -void statusDialog( +Future statusDialog( BuildContext context, { required String title, required String message, required bool isSuccess, }) { - showDialog( + return showDialog( context: context, builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), From 2f5c4e99d0b439ba2f2635a5bc76fb42d2157227 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Feb 2026 19:52:53 -0500 Subject: [PATCH 053/141] Adjusts colors for dark mode --- lib/pages/refund_page.dart | 1 + lib/widgets/transactions_search_sheet.dart | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index 0158a363..08b7fc99 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -193,6 +193,7 @@ class RefundPageState extends State { child: TextFormField( decoration: _inputDecoration(context).copyWith( hintText: 'Select a transaction', + hintStyle: TextStyle(color: colorScheme.fontSecondary), ), controller: TextEditingController( text: selectedTransaction, diff --git a/lib/widgets/transactions_search_sheet.dart b/lib/widgets/transactions_search_sheet.dart index 715fff74..ac59376c 100644 --- a/lib/widgets/transactions_search_sheet.dart +++ b/lib/widgets/transactions_search_sheet.dart @@ -1,3 +1,4 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; import 'package:flutter/material.dart'; class TransactionSearchSheet extends StatefulWidget { @@ -50,8 +51,12 @@ class _TransactionSearchSheetState child: TextField( autofocus: true, decoration: InputDecoration( - hintText: 'Search by date...', - prefixIcon: Icon(Icons.search), + hintText: 'Search...', + hintStyle: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + prefixIcon: Icon( + Icons.search, + color: Theme.of(context).colorScheme.fontSecondary, + ), ), onChanged: _filter, ), @@ -63,6 +68,7 @@ class _TransactionSearchSheetState final transaction = filtered[index]; return ListTile( + textColor: Theme.of(context).colorScheme.fontInverted, title: Text(transaction), onTap: () { Navigator.pop(context, transaction); From 07266463422267c5582e5857aca152ccb621dedb Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 18 Feb 2026 10:58:05 -0500 Subject: [PATCH 054/141] Adds gradient extension --- lib/logic/theme/theme.dart | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lib/logic/theme/theme.dart b/lib/logic/theme/theme.dart index 4c636427..6451fa13 100644 --- a/lib/logic/theme/theme.dart +++ b/lib/logic/theme/theme.dart @@ -65,3 +65,41 @@ extension ModeChangerText on ColorScheme { : Color(0xEECFCFCD); } } + +extension GradientScheme on ColorScheme { + LinearGradient get primaryGradient { + return brightness == Brightness.dark + ? LinearGradient( + colors: [ + Color(0xFF2073A9), + Colors.deepPurple, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : LinearGradient( + colors: [ + Color(0xFF2073A9), + Color(0xFFf3c404), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + } + + LinearGradient get backgroundGradient { + return brightness == Brightness.dark + ? LinearGradient( + colors: [ + Colors.grey.shade900, + Colors.black, + ], + ) + : LinearGradient( + colors: [ + Colors.white, + Color(0xFFE3F2FD), + ], + ); + } +} From 171c86747c9f4e56f25a6afe1f818238f8234f2e Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:23:11 -0500 Subject: [PATCH 055/141] Tests --- lib/pages/reset_protected_page.dart | 11 +- test/pages/reset_protected_page_test.dart | 121 ++++-------------- .../authentication/authenticator_test.dart | 54 ++++++++ 3 files changed, 87 insertions(+), 99 deletions(-) diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 450cf86f..f9bb437c 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -76,12 +76,17 @@ class _ResetProtectedPageState extends State { setState(() => _isLoading = true); try { - await authService.resetPassword(password); + await authService.updatePassword(password); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password reset successful')), + ); - _showMessage("Password reset successful"); context.go("/login"); } catch (e) { - _showMessage("Error: $e"); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to reset password')), + ); } finally { if (!mounted) return; setState(() => _isLoading = false); diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index f68046ee..15420f8f 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -25,7 +25,7 @@ void main() { tearDown(() => GetIt.instance.reset()); - Widget createWidgetUnderTest({Uri? incomingUri}) { + Widget createWidgetUnderTest() { final router = GoRouter( initialLocation: '/reset-protected', routes: [ @@ -44,127 +44,58 @@ void main() { return MaterialApp.router(routerConfig: router); } - testWidgets('shows invalid link state for non-reset uri', (tester) async { - await tester.pumpWidget( - createWidgetUnderTest(incomingUri: Uri.parse('clean-stream://other')), - ); - await tester.pumpAndSettle(); - - expect(find.text('Invalid or expired reset link'), findsOneWidget); - expect(find.text('Back to Login'), findsOneWidget); - }); - - testWidgets('shows invalid link when code is missing', (tester) async { - await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Invalid or expired reset link'), findsOneWidget); - }); - - testWidgets('shows invalid link when code exchange fails', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Invalid or expired reset link'), findsOneWidget); - }); - - testWidgets('renders form when link is valid', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('New Password'), findsOneWidget); - expect(find.text('Confirm Password'), findsOneWidget); - }); - testWidgets('validates short password', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).at(0), 'short'); await tester.enterText(find.byType(TextField).at(1), 'short'); - await tester.tap(find.text('Set Password')); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); String? validations = PasswordParser.process("short"); if (validations != null) { - expect(find.text(validations), findsOneWidget); + expect(find.text(validations), findsWidgets); } }); testWidgets('submits new password and navigates to login', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - when( - () => mockAuthService.updatePassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.success); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); - await tester.enterText(find.byType(TextField).at(0), 'password123&'); - await tester.enterText(find.byType(TextField).at(1), 'password123&'); - await tester.tap(find.text('Set Password')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.updatePassword('password123&')).called(1); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); expect(find.text('Password reset successful'), findsOneWidget); + + await tester.pumpAndSettle(); expect(find.text('Login Page'), findsOneWidget); }); testWidgets('shows failure message when update fails', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - when( - () => mockAuthService.updatePassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.failure); + + when(() => mockAuthService.updatePassword(any())).thenThrow(Exception('network')); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField).at(0), 'password123&'); - await tester.enterText(find.byType(TextField).at(1), 'password123&'); - await tester.tap(find.text('Set Password')); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123&')).called(1); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); expect(find.text('Failed to reset password'), findsWidgets); }); @@ -177,18 +108,16 @@ void main() { ).thenThrow(Exception('network')); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField).at(0), 'password123&'); - await tester.enterText(find.byType(TextField).at(1), 'password123&'); - await tester.tap(find.text('Set Password')); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123&')).called(1); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); expect(find.text('Failed to reset password'), findsWidgets); }); } diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 8cd2d6b7..8abd1d6a 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -15,6 +15,7 @@ void main(){ registerFallbackValue(Uri()); registerFallbackValue(OAuthProvider.google); registerFallbackValue(UserAttributesFake()); + registerFallbackValue(OtpType.recovery); }); group("authentication Tests", (){ @@ -946,6 +947,59 @@ void main(){ expect(result,'testemail@test.com'); }); + + test("Tests that code is verified correctly",() async { + when(() => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + )).thenAnswer((_) async => AuthResponse( + session: Session( + accessToken: "test", + tokenType: "test", + user: User( + id: '', + appMetadata: {}, + userMetadata: {}, + aud: '', + createdAt: '', + ), + ), + )); + + AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + + expect(testResponse, AuthenticationResponses.success); + }); + + test("Tests that failure is returned when exception is thrown",() async { + when(() => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + )).thenThrow(Exception()); + + AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + + expect(testResponse, AuthenticationResponses.failure); + }); + + test("Tests that exchange code for session runs correctly",() async { + when(() => supabaseAuth.exchangeCodeForSession(any())).thenAnswer((_) async => AuthSessionUrlResponse(session: Session(accessToken: "", tokenType: "", user: User(id: "", appMetadata: {}, userMetadata: {}, aud: "", createdAt: "")), redirectType:"")); + AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + expect(response, AuthenticationResponses.success); + }); + + test("Tests that failure is sent with an exception",() async { + when(() => supabaseAuth.exchangeCodeForSession(any())).thenThrow(Exception()); + AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + expect(response, AuthenticationResponses.failure); + }); + + test("Tests that update password runs correctly",(){ + + }); + }); } \ No newline at end of file From 4ff00faf42fab7f63dcbe3ace5f72e5ae826ffb7 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 18 Feb 2026 16:13:50 -0500 Subject: [PATCH 056/141] Add ios location permissions --- ios/Flutter/AppFrameworkInfo.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index d69d74da..542193d4 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -26,5 +26,9 @@ location + NSLocationAlwaysUsageDescription + Your location is required to find the nearest location + NSLocationWhenInUseUsageDescription + Your location is required to find the nearest location From 317c6a0f7f175dd3570ea92cc48d4e39d2e8de09 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 18 Feb 2026 19:27:35 -0500 Subject: [PATCH 057/141] Fix ios location permissions --- ios/Flutter/AppFrameworkInfo.plist | 4 ---- ios/Runner/Info.plist | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 542193d4..d69d74da 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -26,9 +26,5 @@ location - NSLocationAlwaysUsageDescription - Your location is required to find the nearest location - NSLocationWhenInUseUsageDescription - Your location is required to find the nearest location diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3b03487e..3653ff74 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -64,5 +64,9 @@ UIStatusBarHidden + NSLocationAlwaysUsageDescription + Your location is required to find the nearest location + NSLocationWhenInUseUsageDescription + Your location is required to find the nearest location From 5040a9be141820ac5bf853e135064faef312c16f Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:36:25 -0500 Subject: [PATCH 058/141] Updated authentication tests --- .../authentication/authenticator_test.dart | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 8abd1d6a..1f5a00eb 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -18,7 +18,7 @@ void main(){ registerFallbackValue(OtpType.recovery); }); - group("authentication Tests", (){ + group("authentication Tests", () { setUp((){ client = SupabaseMock(); @@ -996,10 +996,45 @@ void main(){ expect(response, AuthenticationResponses.failure); }); - test("Tests that update password runs correctly",(){ + test("Tests that update password runs correctly",() async { + when(() => supabaseAuth.updateUser(any())).thenAnswer((_) async => UserResponse.fromJson({})); + + AuthenticationResponses response = await authenticator.updatePassword("password"); + + expect(response, AuthenticationResponses.success); }); - }); + test("Tests that update password handles errors",() async { + + when(() => supabaseAuth.updateUser(any())).thenThrow(Exception()); + + AuthenticationResponses response = await authenticator.updatePassword("password"); + + expect(response, AuthenticationResponses.failure); + }); + + test("Reset password runs correctly",() async { + + when(() => supabaseAuth.resetPasswordForEmail(any())) + .thenAnswer((_) async {}); + + AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + + expect(response, AuthenticationResponses.success); + }); + + test("Reset password handles errors",() async { + + when(() => supabaseAuth.resetPasswordForEmail(any())) + .thenThrow(Exception()); + + + AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + + expect(response, AuthenticationResponses.failure); + }); + + }); } \ No newline at end of file From d63bdd90b1c11ff62e5bc3c8102212ce82e1ba8e Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:00:55 -0500 Subject: [PATCH 059/141] Updated tests for rest protected --- lib/pages/reset_protected_page.dart | 6 +++--- test/pages/reset_protected_page_test.dart | 16 +++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index f9bb437c..03f22c2b 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -160,7 +160,7 @@ class _ResetProtectedPageState extends State { children: [ const SizedBox(height: 20), - /// Title + // Title Text( "Reset Password", style: theme.textTheme.headlineMedium?.copyWith( @@ -233,7 +233,7 @@ class _ResetProtectedPageState extends State { const SizedBox(height: 16), - /// Confirm field + // Confirm field TextField( controller: _confirmCtrl, obscureText: _obscureConfirm, @@ -258,7 +258,7 @@ class _ResetProtectedPageState extends State { const SizedBox(height: 24), - /// Button (blue like signup) + // Button SizedBox( height: 50, child: ElevatedButton( diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index 15420f8f..9565ee53 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -62,22 +62,24 @@ void main() { } }); - testWidgets('submits new password and navigates to login', (tester) async { + testWidgets('shows success message when update is successful', (tester) async { + + when(() => mockAuthService.updatePassword(any())).thenAnswer((_) async => AuthenticationResponses.success); await tester.pumpWidget( createWidgetUnderTest() ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); await tester.enterText(find.byType(TextField).at(1), 'Password123&'); await tester.tap(find.byType(ElevatedButton)); - await tester.pump(); + await tester.pumpAndSettle(); verify(() => mockAuthService.updatePassword('Password123&')).called(1); - expect(find.text('Password reset successful'), findsOneWidget); - - await tester.pumpAndSettle(); - expect(find.text('Login Page'), findsOneWidget); + expect(find.text("Password reset successful"), findsWidgets); + expect(find.text("Login Page"), findsOneWidget); }); testWidgets('shows failure message when update fails', (tester) async { @@ -99,7 +101,7 @@ void main() { expect(find.text('Failed to reset password'), findsWidgets); }); - testWidgets('shows failure message when update throws', (tester) async { + testWidgets('shows failure message when exchangeCodeForSession throws', (tester) async { when( () => mockAuthService.exchangeCodeForSession('abc'), ).thenAnswer((_) async => AuthenticationResponses.success); From 245d02244532c7081b28fa7f5544425d7611d5a8 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:34:33 -0500 Subject: [PATCH 060/141] Tested verify code page --- lib/pages/verify_code_page.dart | 16 +-- test/pages/verify_code_page_test.dart | 195 ++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 test/pages/verify_code_page_test.dart diff --git a/lib/pages/verify_code_page.dart b/lib/pages/verify_code_page.dart index 900a3121..a23db9d6 100644 --- a/lib/pages/verify_code_page.dart +++ b/lib/pages/verify_code_page.dart @@ -91,7 +91,7 @@ class _CodeVerificationPageState extends State { children: [ const SizedBox(height: 40), - /// Title + // Title Text( 'Enter Verification Code', style: TextStyle( @@ -103,7 +103,7 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 12), - /// Subtitle + // Subtitle Text( 'We sent a 6-digit code to', style: TextStyle(color: scheme.fontInverted.withOpacity(0.7)), @@ -112,7 +112,7 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 6), - /// Email + // Email Text( widget.email, style: TextStyle( @@ -124,14 +124,14 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 32), - /// Code Input + // Code Input TextField( controller: _codeController, keyboardType: TextInputType.number, maxLength: 6, style: TextStyle( color: scheme.fontInverted, - letterSpacing: 8, // 👈 nice spaced digits + letterSpacing: 8, fontSize: 20, fontWeight: FontWeight.bold, ), @@ -159,7 +159,7 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 32), - /// Verify Button + // Verify Button SizedBox( width: double.infinity, child: ElevatedButton( @@ -181,7 +181,7 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 16), - /// Error Message (optional extra under field) + // Error Message (optional extra under field) if (_error != null) Text( _error!, @@ -190,7 +190,7 @@ class _CodeVerificationPageState extends State { const SizedBox(height: 16), - /// Resend + // Resend TextButton( onPressed: () async { // TODO: implement resend diff --git a/test/pages/verify_code_page_test.dart b/test/pages/verify_code_page_test.dart new file mode 100644 index 00000000..c3ff0df1 --- /dev/null +++ b/test/pages/verify_code_page_test.dart @@ -0,0 +1,195 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; +import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../logic/viewmodels/mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + + getIt.registerSingleton(mockAuthService); + }); + + tearDown(() => GetIt.instance.reset()); + + Widget createWidgetUnderTest() { + final router = GoRouter( + initialLocation: '/verify-code', + routes: [ + GoRoute( + path: '/verify-code', + builder: (context, state) => + CodeVerificationPage(email: "testEmail"), + ), + GoRoute( + path: '/reset-protected', + builder: (context, state) => + ResetProtectedPage(), + ) + ], + ); + + return MaterialApp.router(routerConfig: router); + } + + group("UI elements render correctly", (){ + + testWidgets('validates title renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("Verify Code"), findsOneWidget); + + }); + + testWidgets('Subheading renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("Enter Verification Code"), findsOneWidget); + + }); + + testWidgets('Instructions render', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("We sent a 6-digit code to"), findsOneWidget); + + }); + + testWidgets('Text field renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + + }); + + testWidgets('Verify Button renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(ElevatedButton), findsOneWidget); + expect(find.text("Verify"), findsOneWidget); + }); + + testWidgets('Resend Button renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TextButton), findsOneWidget); + expect(find.text("Resend code"), findsOneWidget); + }); + + }); + + group("Logic tests", (){ + + testWidgets('Error is thrown if a code entered is too short', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(0), '1234'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Please enter the 6-digit code'), findsWidgets); + + }); + + testWidgets('Navigates correctly if a correct code was entered', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Reset Password'), findsWidgets); + + }); + + testWidgets('Shows error if the code verification was not correct', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text("Invalid or expired code"), findsWidgets); + + }); + + testWidgets('Shows error if exception was thrown', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenThrow(Exception()); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text("Something went wrong. Try again"), findsWidgets); + + }); + + }); + +} \ No newline at end of file From b183e316f4073ac4ccb80d56ca5210b425a1b99c Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:04:48 -0500 Subject: [PATCH 061/141] Added tests and updated theme --- lib/pages/reset_protected_page.dart | 14 +++++++++-- lib/pages/verify_code_page.dart | 35 ++++++++++++++++++++++++--- test/pages/verify_code_page_test.dart | 30 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 03f22c2b..5f95cad5 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -15,6 +15,7 @@ class ResetProtectedPage extends StatefulWidget { } class _ResetProtectedPageState extends State { + final _passwordCtrl = TextEditingController(); final _confirmCtrl = TextEditingController(); @@ -26,8 +27,17 @@ class _ResetProtectedPageState extends State { var passwordText = "New Password"; var confirmText = "Confirm Password"; - var iconColor = Colors.blue; - var labelColor = Colors.blue; + var iconColor; + var labelColor; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + iconColor = Theme.of(context).colorScheme.primary; + labelColor = Theme.of(context).colorScheme.primary; + + } void _changeColorsToRed(String reason) { setState(() { diff --git a/lib/pages/verify_code_page.dart b/lib/pages/verify_code_page.dart index a23db9d6..97fdf0f0 100644 --- a/lib/pages/verify_code_page.dart +++ b/lib/pages/verify_code_page.dart @@ -22,6 +22,36 @@ class _CodeVerificationPageState extends State { bool _isLoading = false; String? _error; + void _showMessage(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } + + Future _sendResetEmail() async { + + try { + final response = await authService.resetPassword( + widget.email, + ); + + setState(() { + _isLoading = false; + }); + + if (response == AuthenticationResponses.success) { + _showMessage('Password reset email sent! Check your email.'); + } else { + _showMessage('Failed to send reset email.'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showMessage('Error: $e'); + } + } + void _verifyCode() async { final code = _codeController.text.trim(); @@ -74,6 +104,7 @@ class _CodeVerificationPageState extends State { super.dispose(); } + @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; @@ -192,9 +223,7 @@ class _CodeVerificationPageState extends State { // Resend TextButton( - onPressed: () async { - // TODO: implement resend - }, + onPressed: _sendResetEmail, child: Text( 'Resend code', style: TextStyle(color: scheme.primary), diff --git a/test/pages/verify_code_page_test.dart b/test/pages/verify_code_page_test.dart index c3ff0df1..1ddeb326 100644 --- a/test/pages/verify_code_page_test.dart +++ b/test/pages/verify_code_page_test.dart @@ -190,6 +190,36 @@ void main() { }); + testWidgets('Shows message if email reset was successful', (tester) async { + + when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text("Password reset email sent! Check your email."), findsWidgets); + + }); + + testWidgets('Shows message if email reset was unsuccessful', (tester) async { + + when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text("Failed to send reset email."), findsWidgets); + + }); + }); } \ No newline at end of file From cd43d93e404e469e592042b95353205856eb39e8 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Feb 2026 15:32:24 -0500 Subject: [PATCH 062/141] Adds gradients to app bar, makes qr button subtle --- lib/logic/theme/theme.dart | 90 ++++++++----------- lib/pages/start_machine_page.dart | 15 ++-- lib/widgets/custom_app_bar.dart | 52 ++++++----- .../{large_button.dart => qr_button.dart} | 49 +++++----- test/pages/start_machine_page_test.dart | 13 +-- ...{large_button_test.dart => qr_button.dart} | 15 ++-- 6 files changed, 119 insertions(+), 115 deletions(-) rename lib/widgets/{large_button.dart => qr_button.dart} (53%) rename test/widgets/{large_button_test.dart => qr_button.dart} (85%) diff --git a/lib/logic/theme/theme.dart b/lib/logic/theme/theme.dart index 6451fa13..2af82548 100644 --- a/lib/logic/theme/theme.dart +++ b/lib/logic/theme/theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; ThemeData lightMode = ThemeData( + useMaterial3: true, brightness: Brightness.light, colorScheme: ColorScheme.light( brightness: Brightness.light, @@ -8,58 +9,57 @@ ThemeData lightMode = ThemeData( primary: Color(0xFF2073A9), secondary: Color(0xFFf3c404), tertiary: Colors.indigo[900], - ) + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), ); ThemeData darkMode = ThemeData( + useMaterial3: true, brightness: Brightness.dark, - colorScheme: ColorScheme.light( - brightness: Brightness.dark, - surface: Colors.grey.shade900, - primary: Color(0xFF2073A9), - secondary: Color(0xFFf3c404), - tertiary: Colors.deepPurple, - ) + colorScheme: ColorScheme.dark( + brightness: Brightness.dark, + surface: Colors.grey.shade900, + primary: Color(0xFF2073A9), + secondary: Color(0xFFf3c404), + tertiary: Colors.deepPurple, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), ); extension ModeChangerText on ColorScheme { String get modeChangerText { - return brightness == Brightness.dark - ? "Light Mode" - : "Dark Mode"; + return brightness == Brightness.dark ? "Light Mode" : "Dark Mode"; } Color get fontPrimary { - return brightness == Brightness.dark - ? Colors.black - : Colors.white; + return brightness == Brightness.dark ? Colors.black : Colors.white; } Color get fontInverted { - return brightness == Brightness.dark - ? Colors.white - : Colors.black; + return brightness == Brightness.dark ? Colors.white : Colors.black; } Color get fontSecondary { - return brightness == Brightness.dark - ? Colors.grey - : Colors.black87; + return brightness == Brightness.dark ? Colors.grey : Colors.black87; } Color get cardPrimary { - return brightness == Brightness.dark - ? Color(0xFFCFCFCD) - : Colors.white; + return brightness == Brightness.dark ? Color(0xFFCFCFCD) : Colors.white; } Color get cardSecondary { - return brightness == Brightness.dark - ? Color(0xFF2073A9) - : Colors.white; + return brightness == Brightness.dark ? Color(0xFF2073A9) : Colors.white; } - Color get greyCard{ + Color get greyCard { return brightness == Brightness.dark ? Color(0xFFCFCFCD) : Color(0xEECFCFCD); @@ -70,36 +70,24 @@ extension GradientScheme on ColorScheme { LinearGradient get primaryGradient { return brightness == Brightness.dark ? LinearGradient( - colors: [ - Color(0xFF2073A9), - Colors.deepPurple, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) + colors: [Color(0xFF2073A9), Colors.deepPurple], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) : LinearGradient( - colors: [ - Color(0xFF2073A9), - Color(0xFFf3c404), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); + colors: [Color(0xFF2073A9), Color(0xFF13BDFA)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ); } LinearGradient get backgroundGradient { return brightness == Brightness.dark ? LinearGradient( - colors: [ - Colors.grey.shade900, - Colors.black, - ], - ) + colors: [Color.fromARGB(255, 248, 248, 232), Color(0xFFE1E1E1)], + ) : LinearGradient( - colors: [ - Colors.white, - Color(0xFFE3F2FD), - ], - ); + colors: [Color.fromARGB(255, 245, 237, 226), Colors.white], + ); } } diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index fa0129f0..7b74e32d 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:go_router/go_router.dart'; @@ -19,7 +19,10 @@ class StartPage extends StatelessWidget { children: [ Container( height: 160, - margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), + margin: const EdgeInsets.symmetric( + horizontal: 23, + vertical: 10, + ), padding: const EdgeInsets.all(30), decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), @@ -45,7 +48,9 @@ class StartPage extends StatelessWidget { Text( "Tap phone to machine to pay", style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + color: Theme.of( + context, + ).colorScheme.fontSecondary, fontSize: 16, ), ), @@ -64,7 +69,7 @@ class StartPage extends StatelessWidget { SizedBox( height: 160, - child: LargeButton( + child: QRButton( headLineText: "Scan QR code", descriptionText: "Scan QR code on the machine", icon: Icons.qr_code_scanner, @@ -80,4 +85,4 @@ class StartPage extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart index dce7b52f..a6ec5b95 100644 --- a/lib/widgets/custom_app_bar.dart +++ b/lib/widgets/custom_app_bar.dart @@ -1,3 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -7,35 +8,42 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( - backgroundColor: Theme.of(context).colorScheme.primary, + toolbarHeight: 40, + backgroundColor: Colors.transparent, + elevation: 0, titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.only(left: 12), - child: GestureDetector( - onTap: () => context.go("/homePage"), - child: SizedBox( - width: 160, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/Icon.png", height: 26), - const SizedBox(width: 2), - Image.asset("assets/Slogan.png", height: 22), - ], - ), + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), + title: Padding( + padding: const EdgeInsets.only(left: 12), + child: GestureDetector( + onTap: () => context.go("/homePage"), + child: SizedBox( + width: 160, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.backgroundGradient, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/Icon.png", height: 26), + const SizedBox(width: 2), + Image.asset("assets/Slogan.png", height: 22), + ], ), ), ), ), + ), ); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} \ No newline at end of file +} diff --git a/lib/widgets/large_button.dart b/lib/widgets/qr_button.dart similarity index 53% rename from lib/widgets/large_button.dart rename to lib/widgets/qr_button.dart index 86cbe8e4..c52069ca 100644 --- a/lib/widgets/large_button.dart +++ b/lib/widgets/qr_button.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -class LargeButton extends StatelessWidget { +class QRButton extends StatelessWidget { final String headLineText; final String descriptionText; final IconData icon; final VoidCallback? onPressed; - const LargeButton({ + const QRButton({ super.key, required this.headLineText, required this.descriptionText, @@ -16,22 +16,25 @@ class LargeButton extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Center( child: SizedBox( width: double.infinity, - height: 170, + height: 160, // slightly tighter child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), + backgroundColor: colors.primary, + foregroundColor: Colors.white, + elevation: 8, // less aggressive + shadowColor: colors.primary.withOpacity(0.4), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), + borderRadius: BorderRadius.circular(22), ), - elevation: 8, - backgroundColor: Colors.blue.shade800, - shadowColor: Colors.blueAccent.shade400, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -43,27 +46,33 @@ class LargeButton extends StatelessWidget { children: [ Text( headLineText, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, color: Colors.white, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( descriptionText, - style: const TextStyle( - fontSize: 16, - color: Colors.white70, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.85), ), ), ], ), ), - Icon( - icon, - size: 48, - color: Colors.white, + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + icon, + size: 28, // reduced from 48 (too large before) + color: Colors.white, + ), ), ], ), @@ -72,4 +81,4 @@ class LargeButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index a3d4ef82..025596d3 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,5 +1,5 @@ import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -17,10 +17,7 @@ void main() { router = GoRouter( observers: [navigatorObserver], routes: [ - GoRoute( - path: '/', - builder: (_, __) => const StartPage(), - ), + GoRoute(path: '/', builder: (_, __) => const StartPage()), GoRoute( path: '/scanner', builder: (_, __) => const Scaffold(body: Text('Scanner Page')), @@ -30,16 +27,14 @@ void main() { }); Widget createTestApp() { - return MaterialApp.router( - routerConfig: router, - ); + return MaterialApp.router(routerConfig: router); } testWidgets('Tapping QR button navigates to /scanner', (tester) async { await tester.pumpWidget(createTestApp()); await tester.pumpAndSettle(); - final qrButton = find.widgetWithText(LargeButton, "Scan QR code"); + final qrButton = find.widgetWithText(QRButton, "Scan QR code"); expect(qrButton, findsOneWidget); diff --git a/test/widgets/large_button_test.dart b/test/widgets/qr_button.dart similarity index 85% rename from test/widgets/large_button_test.dart rename to test/widgets/qr_button.dart index 4c7509ec..a6d547e7 100644 --- a/test/widgets/large_button_test.dart +++ b/test/widgets/qr_button.dart @@ -1,23 +1,22 @@ -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group("Large Button Tests", () { - test('Large Button instantiates correctly', () { - const largeButton = LargeButton( + const largeButton = QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, ); - expect(largeButton, isA()); + expect(largeButton, isA()); }); testWidgets('Tests that the correct headline is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -31,7 +30,7 @@ void main() { testWidgets('Tests that the correct description is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -45,7 +44,7 @@ void main() { testWidgets('Tests that the correct icon is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -56,4 +55,4 @@ void main() { expect(find.byIcon(Icons.shield), findsOneWidget); }); }); -} \ No newline at end of file +} From 1930cd46de594517ed9e18bfbd3aa35d2cd183ae Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Feb 2026 15:42:10 -0500 Subject: [PATCH 063/141] Add gradient to wildcard appbars --- lib/pages/edit_profile_page.dart | 697 +++++++++++---------- lib/pages/loyalty_card_page.dart | 124 ++-- lib/pages/monthly_transaction_history.dart | 155 +++-- lib/pages/refund_page.dart | 19 +- 4 files changed, 516 insertions(+), 479 deletions(-) diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index a6640164..6359943b 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -21,7 +21,9 @@ class _EditProfilePageState extends State { late final StreamSubscription _authSub; final TextEditingController _nameController = TextEditingController(text: ''); - final TextEditingController _emailController = TextEditingController(text: ''); + final TextEditingController _emailController = TextEditingController( + text: '', + ); final profileService = GetIt.instance(); final authService = GetIt.instance(); final edgeFunctionService = GetIt.instance(); @@ -196,7 +198,12 @@ class _EditProfilePageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { @@ -216,290 +223,318 @@ class _EditProfilePageState extends State { ), body: _isLoading ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ) + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ) : SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Name Section - _buildSectionHeader('Full Name'), - const SizedBox(height: 12), - - _buildInfoCard( - label: 'Current', - value: currentName.isNotEmpty ? currentName : 'Not set', - icon: Icons.badge_outlined, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Name Section + _buildSectionHeader('Full Name'), + const SizedBox(height: 12), - const SizedBox(height: 16), - - TextFormField( - controller: _nameController, - enabled: !_isSaving, - inputFormatters: [ - LengthLimitingTextInputFormatter(36), - FilteringTextInputFormatter.allow( - RegExp(r'[a-zA-Z0-9 ]'), - ), - ], - maxLength: 36, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 16, - ), - decoration: InputDecoration( - labelText: 'New Full Name', - hintText: 'Enter your full name', - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.5), - ), - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - counterStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.6), - ), - filled: true, - fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, + _buildInfoCard( + label: 'Current', + value: currentName.isNotEmpty ? currentName : 'Not set', + icon: Icons.badge_outlined, ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.person_outline, - color: Theme.of(context).colorScheme.primary, - ), - ), - textInputAction: TextInputAction.next, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Name cannot be empty'; - } - return null; - }, - ), - const SizedBox(height: 10), + const SizedBox(height: 16), - // Email Section - _buildSectionHeader('Email Address'), - const SizedBox(height: 12), + TextFormField( + controller: _nameController, + enabled: !_isSaving, + inputFormatters: [ + LengthLimitingTextInputFormatter(36), + FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Z0-9 ]'), + ), + ], + maxLength: 36, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + decoration: InputDecoration( + labelText: 'New Full Name', + hintText: 'Enter your full name', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + counterStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.6), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.person_outline, + color: Theme.of(context).colorScheme.primary, + ), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Name cannot be empty'; + } + return null; + }, + ), - _buildInfoCard( - label: 'Current', - value: currentEmail.isNotEmpty ? currentEmail : 'Not set', - icon: Icons.email_outlined, - ), + const SizedBox(height: 10), - const SizedBox(height: 16), + // Email Section + _buildSectionHeader('Email Address'), + const SizedBox(height: 12), - TextFormField( - controller: _emailController, - enabled: !_isSaving, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 16, - ), - decoration: InputDecoration( - labelText: 'New Email', - hintText: 'Enter your email address', - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.5), - ), - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, + _buildInfoCard( + label: 'Current', + value: currentEmail.isNotEmpty + ? currentEmail + : 'Not set', + icon: Icons.email_outlined, ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.email_outlined, - color: Theme.of(context).colorScheme.primary, - ), - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Email cannot be empty'; - } - if (!value.trim().contains("@")) { - return 'Please enter a valid email'; - } - return null; - }, - ), - const SizedBox(height: 20), - - // Save Button - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: _isSaving ? null : _onSavePressed, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle_outline, size: 20), - SizedBox(width: 8), - Text( - 'Save Changes', + const SizedBox(height: 16), + + TextFormField( + controller: _emailController, + enabled: !_isSaving, style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, fontSize: 16, - fontWeight: FontWeight.w600, ), - ), - ], - ), - ), - - const SizedBox(height: 30), - - // Delete Account Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.red.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - children: [ - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - color: Colors.red, - size: 20, + decoration: InputDecoration( + labelText: 'New Email', + hintText: 'Enter your email address', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, ), - const SizedBox(width: 8), - Text( - 'Danger Zone', - style: TextStyle( - color: Colors.red, - fontSize: 16, - fontWeight: FontWeight.w600, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: Theme.of(context).colorScheme.primary, ), - ], - ), - const SizedBox(height: 12), - Text( - 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.7), - fontSize: 13, ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email cannot be empty'; + } + if (!value.trim().contains("@")) { + return 'Please enter a valid email'; + } + return null; + }, ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: _isSaving ? null : _deleteAccount, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red, width: 1.5), - padding: const EdgeInsets.symmetric(vertical: 12), + + const SizedBox(height: 20), + + // Save Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 2, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), ), + onPressed: _isSaving ? null : _onSavePressed, child: _isSaving ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.red, + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 8), + Text( + 'Save Changes', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const SizedBox(height: 30), + + // Delete Account Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.2), + width: 1, ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.delete_outline, size: 18), - SizedBox(width: 8), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Danger Zone', + style: TextStyle( + color: Colors.red, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), Text( - 'Delete Account', + 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.7), + fontSize: 13, ), ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _isSaving ? null : _deleteAccount, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide( + color: Colors.red, + width: 1.5, + ), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.red, + ), + ) + : Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: const [ + Icon(Icons.delete_outline, size: 18), + SizedBox(width: 8), + Text( + 'Delete Account', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ], ), ), + + const SizedBox(height: 24), ], ), ), - - const SizedBox(height: 24), - ], + ), ), - ), - ), - ), ); } @@ -531,11 +566,7 @@ class _EditProfilePageState extends State { ), child: Row( children: [ - Icon( - icon, - color: Theme.of(context).colorScheme.primary, - size: 20, - ), + Icon(icon, color: Theme.of(context).colorScheme.primary, size: 20), const SizedBox(width: 12), Expanded( child: Column( @@ -545,7 +576,9 @@ class _EditProfilePageState extends State { label, style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.fontSecondary.withValues(alpha: 0.6), + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.6), fontWeight: FontWeight.w500, ), ), @@ -599,89 +632,97 @@ class _EditProfilePageState extends State { Future _confirmDeleteAccount() async { return await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Row( - children: [ - Icon(Icons.warning_amber_rounded, color: Colors.red), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Delete Account?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.bold, - ), - ), + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ], - ), - content: Text( - 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Cancel', - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Delete Account?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, + content: Text( + 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), ), - child: const Text('Delete'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), + ), + ], ), - ], - ), - ) ?? + ) ?? false; } Future _confirmSaveChanges() async { return await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Text( - 'Confirm Changes', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.bold, - ), - ), - content: Text( - 'Are you sure you want to save these changes to your profile?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Cancel', - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, + title: Text( + 'Confirm Changes', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + 'Are you sure you want to save these changes to your profile?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), ), - child: const Text('Save'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: const Text('Save'), + ), + ], ), - ], - ), - ) ?? + ) ?? false; } -} \ No newline at end of file +} diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 45859d6b..9b1b4184 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -45,43 +45,43 @@ class LoyaltyCardPage extends State { Widget _buildContent(BuildContext context) { return Column( - children: [ - const SizedBox(height: 20), - CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 25), - Text( - 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.fontSecondary, - ), + children: [ + const SizedBox(height: 20), + CreditCard(username: viewModel.userName ?? 'John Doe'), + const SizedBox(height: 25), + Text( + 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () => _loadCard(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 2, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => _loadCard(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - child: const Text( - "Load card", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + elevation: 2, + ), + child: const Text( + "Load card", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), - const SizedBox(height: 15), - Expanded(child: _transactions()) - ], - ); + ), + const SizedBox(height: 15), + Expanded(child: _transactions()), + ], + ); } Widget _transactions() { @@ -125,36 +125,39 @@ class LoyaltyCardPage extends State { ), const SizedBox(height: 10), Expanded( - child: ListView.builder( - shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: viewModel.recentTransactions.length, - itemBuilder: (context, index) { - final transaction = viewModel.recentTransactions[index]; - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 4.0, - vertical: 6.0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 4, - color: Theme.of(context).colorScheme.cardPrimary, - child: ListTile( - leading: const Icon( - Icons.receipt_long, - color: Color(0xFF2073A9), + child: ListView.builder( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: viewModel.recentTransactions.length, + itemBuilder: (context, index) { + final transaction = viewModel.recentTransactions[index]; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 6.0, ), - title: Text( - transaction.toString(), - style: const TextStyle(fontSize: 14, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ), - ); - }, + elevation: 4, + color: Theme.of(context).colorScheme.cardPrimary, + child: ListTile( + leading: const Icon( + Icons.receipt_long, + color: Color(0xFF2073A9), + ), + title: Text( + transaction.toString(), + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + ), + ), + ), + ); + }, + ), ), - ) ], ), ); @@ -419,7 +422,6 @@ class LoyaltyCardPage extends State { } if (result == PaymentResult.success) { - viewModel.fetchTransactions(); statusDialog( diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 03fe0e43..b9c83c46 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -23,17 +23,19 @@ class MonthlyTransactionHistory extends StatelessWidget { return Scaffold( appBar: AppBar( leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: Colors.white, - ), + icon: Icon(Icons.arrow_back, color: Colors.white), onPressed: () => context.pop(), ), - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.transparent, title: Text( 'Monthly Transaction History', style: TextStyle(color: Colors.white), ), + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), elevation: 2, centerTitle: true, ), @@ -59,85 +61,78 @@ class MonthlyTransactionHistory extends StatelessWidget { final data = monthlySums[month]!; final total = data['directWasher']! + - data['directDryer']! + - data['loyaltyCard']!; - if (total == 0 && data['loyaltyWasher']==0 && data['loyaltyDryer']==0) { - return SizedBox(width: 0, height: 0,); + data['directDryer']! + + data['loyaltyCard']!; + if (total == 0 && + data['loyaltyWasher'] == 0 && + data['loyaltyDryer'] == 0) { + return SizedBox(width: 0, height: 0); } else { - return Card( - margin: const EdgeInsets.only(bottom: 16), - elevation: 2, - color: Theme - .of(context) - .colorScheme - .cardPrimary, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - month, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + color: Theme.of(context).colorScheme.cardPrimary, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + month, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - Text( - '\$${total.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, + Text( + '\$${total.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - ], - ), - const Divider(height: 24), - _buildTransactionRow( - 'Direct Washer Payments', - data['directWasher']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Washer Payments', - data['loyaltyWasher']!, - Theme - .of(context) - .colorScheme - .primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Direct Dryer Payments', - data['directDryer']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Dryer Payments', - data['loyaltyDryer']!, - Theme - .of(context) - .colorScheme - .primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Card Loads', - data['loyaltyCard']!, - Colors.black, - ), - ], + ], + ), + const Divider(height: 24), + _buildTransactionRow( + 'Direct Washer Payments', + data['directWasher']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Washer Payments', + data['loyaltyWasher']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Direct Dryer Payments', + data['directDryer']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Dryer Payments', + data['loyaltyDryer']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Card Loads', + data['loyaltyCard']!, + Colors.black, + ), + ], + ), ), - ), - ); - } + ); + } }, ), ), diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index 34c6953e..21474ddd 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -67,7 +67,7 @@ class RefundPageState extends State { //Filters out loyalty transactions recentTransactions.removeWhere( - (transaction) => transaction.contains("added to Loyalty Card"), + (transaction) => transaction.contains("added to Loyalty Card"), ); } @@ -91,17 +91,16 @@ class RefundPageState extends State { appBar: AppBar( leading: IconButton( onPressed: () => context.pop(), - icon: Icon( - Icons.arrow_back, - color: Colors.white, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - "Request Refund", - style: TextStyle(color: Colors.white), + icon: Icon(Icons.arrow_back, color: Colors.white), ), + backgroundColor: Colors.transparent, + title: Text("Request Refund", style: TextStyle(color: Colors.white)), centerTitle: true, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), ), body: Scaffold( body: KeyboardListener( From e9d1ad09352190db828b85c9faa9142adb7c806f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Feb 2026 16:22:00 -0500 Subject: [PATCH 064/141] Customzies stripe payment sheet --- lib/main.dart | 19 +++++++++--------- .../stripe/stripe_service_mobile.dart | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index a4a42aab..ddd55fa2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,6 +54,8 @@ Future setupDependencies() async { final supabase = Supabase.instance.client; Stripe.publishableKey = "${dotenv.env['STRIPE_PUBLISHABLE_KEY']}"; + Stripe.merchantIdentifier = "merchant.com.cleanstream.laundry"; + await Stripe.instance.applySettings(); getIt.registerLazySingleton( () => SupabaseTransactionService(client: supabase), @@ -87,13 +89,9 @@ Future setupDependencies() async { () => MachineCommunicator(), ); - getIt.registerLazySingleton( - () => RouterService() - ); + getIt.registerLazySingleton(() => RouterService()); - getIt.registerLazySingleton( - () => NotificationService(), - ); + getIt.registerLazySingleton(() => NotificationService()); GetIt.instance.registerSingleton( FlutterLocalNotificationsPlugin(), @@ -101,9 +99,12 @@ Future setupDependencies() async { getIt.registerLazySingleton(() => LoyaltyViewModel()); - getIt.registerLazySingleton(() => PaymentProcessor( - paymentService: getIt(), - transactionService: getIt(),)); + getIt.registerLazySingleton( + () => PaymentProcessor( + paymentService: getIt(), + transactionService: getIt(), + ), + ); } class MyApp extends StatelessWidget { diff --git a/lib/services/stripe/stripe_service_mobile.dart b/lib/services/stripe/stripe_service_mobile.dart index 92476d95..cb00196e 100644 --- a/lib/services/stripe/stripe_service_mobile.dart +++ b/lib/services/stripe/stripe_service_mobile.dart @@ -1,6 +1,7 @@ import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:get_it/get_it.dart'; @@ -9,6 +10,7 @@ class StripeService implements PaymentService { final _stripeInstance = GetIt.instance(); + @override Future makePayment(double amount) async { try { String? paymentIntentClientSecret = await createPaymentIntent( @@ -22,6 +24,24 @@ class StripeService implements PaymentService { paymentSheetParameters: SetupPaymentSheetParameters( paymentIntentClientSecret: paymentIntentClientSecret, merchantDisplayName: "Clean Stream Laundry Solutions", + appearance: PaymentSheetAppearance( + colors: PaymentSheetAppearanceColors( + primary: Color(0xFF2073A9), + background: CupertinoColors.systemBackground, + ), + shapes: const PaymentSheetShape(borderRadius: 12), + primaryButton: PaymentSheetPrimaryButtonAppearance( + colors: PaymentSheetPrimaryButtonTheme( + light: PaymentSheetPrimaryButtonThemeColors( + background: Color(0xFF2073A9), + text: CupertinoColors.white, + ), + ), + shapes: const PaymentSheetPrimaryButtonShape(blurRadius: 12), + ), + ), + applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'), + googlePay: const PaymentSheetGooglePay(merchantCountryCode: 'US'), ), ); await _stripeInstance.presentPaymentSheet(); From db6bb9bf7d7b87b3df7b2336f771d186b4ecaa26 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 22 Feb 2026 16:03:29 -0500 Subject: [PATCH 065/141] adjust IOS permissions --- ios/Runner/Info.plist | 14 ++++---------- macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3653ff74..21c3cc7a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,13 +5,9 @@ CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription - Scan your card to add it automatically - NSCameraUsageDescription - To scan cards + Camera permission is required for scanning QR codes and cards CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) - NSCameraUsageDescription - Camera permission is required for scanning QR codes CFBundleDisplayName Clean Stream Laundry App CFBundleExecutable @@ -64,9 +60,7 @@ UIStatusBarHidden - NSLocationAlwaysUsageDescription - Your location is required to find the nearest location - NSLocationWhenInUseUsageDescription - Your location is required to find the nearest location + NSLocationWhenInUseUsageDescription + Your location is required to find the nearest Clean Stream Laundry location - + \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index aea40d6d..e996361c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import flutter_local_notifications import geolocator_apple import mobile_scanner import package_info_plus +import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } From 52063bd58fe9185cd997fa0051ad81068888edc0 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:29:18 -0500 Subject: [PATCH 066/141] Added button to get navigation directions. Still will need some more testing. --- ios/Podfile.lock | 13 +++++++++ lib/pages/home_page.dart | 48 ++++++++++++++++++++++++++++++++++ test/pages/home_page_test.dart | 21 +++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f834c51c..baee355c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,9 +6,14 @@ PODS: - Flutter - flutter_native_splash (2.4.3): - Flutter + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -80,7 +85,9 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -107,8 +114,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: @@ -125,7 +136,9 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 34c59eb4..322997d9 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; @@ -7,11 +8,13 @@ import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:url_launcher/url_launcher.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -66,6 +69,41 @@ class HomePageState extends State { } } + Future _openDirectionsFromAddress(String? address) async { + if (address == null) return; + + final encodedAddress = Uri.encodeComponent(address); + Uri uri; + + if (kIsWeb) { + // Web fallback + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + uri = Uri.parse('google.navigation:q=$encodedAddress'); + break; + case TargetPlatform.iOS: + uri = Uri.parse( + 'http://maps.apple.com/?daddr=$encodedAddress&dirflg=d', + ); + break; + default: + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } + } + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw 'Could not open maps'; + } + } + void _loadUserData() async { final userId = authService.getCurrentUserId; if (userId == null) return; @@ -332,6 +370,16 @@ class HomePageState extends State { }, ), ), + IconButton( + onPressed: () async { + if(selectedName != null){ + await _openDirectionsFromAddress(selectedName); + }else{ + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please select a location to get directions!"))); + } + }, + icon: Icon(Icons.navigation,color:Theme.of(context).primaryColor) + ) ], ), ), diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index ed09d17d..51bbd643 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -280,7 +280,28 @@ void main() { await tester.tap(nearestLocationButton); await tester.pumpAndSettle(); }); + + testWidgets('Tests that icon button is visible', (tester) async { + + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.byType(IconButton), findsOneWidget); + }); }); + }); } \ No newline at end of file From 7b07a92d8d3d1258574f14faeae3667fd029594a Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:32:44 -0500 Subject: [PATCH 067/141] Refactored tests --- test/pages/home_page_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index 51bbd643..26785997 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -280,6 +280,9 @@ void main() { await tester.tap(nearestLocationButton); await tester.pumpAndSettle(); }); + }); + + group("Tests navigation button", (){ testWidgets('Tests that icon button is visible', (tester) async { From f36d867511f3cf6770ab8b4ee6839891f430431d Mon Sep 17 00:00:00 2001 From: karelinejones Date: Tue, 24 Feb 2026 11:09:34 -0500 Subject: [PATCH 068/141] transaction tabs and testing for it --- lib/pages/monthly_transaction_history.dart | 237 +++--- .../monthly_transaction_history_test.dart | 805 +++++++++--------- 2 files changed, 529 insertions(+), 513 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 03fe0e43..9699f22c 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -18,26 +18,39 @@ class MonthlyTransactionHistory extends StatelessWidget { return dateB.compareTo(dateA); }); - final ScrollController _scrollController = ScrollController(); + final now = DateTime.now(); + final currentYear = now.year; + final previousYear = currentYear - 1; + final twelveMonthCutoff = DateTime(now.year, now.month - 11, 1); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: Colors.white, + final previousYearMonths = sortedMonths.where((month) { + final date = DateFormat('MMM yyyy').parse(month); + return date.year == previousYear; + }).toList(); + + final currentYearMonths = sortedMonths.where((month) { + final date = DateFormat('MMM yyyy').parse(month); + return date.year == currentYear; + }).toList(); + + final lastTwelveMonths = sortedMonths.where((month) { + final date = DateFormat('MMM yyyy').parse(month); + return !date.isBefore(twelveMonthCutoff); + }).toList(); + + Widget buildMonthList(List visibleMonths) { + final ScrollController _scrollController = ScrollController(); + + if (visibleMonths.isEmpty) { + return const Center( + child: Text( + 'No transactions found for this time range.', + style: TextStyle(fontSize: 16), ), - onPressed: () => context.pop(), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - 'Monthly Transaction History', - style: TextStyle(color: Colors.white), - ), - elevation: 2, - centerTitle: true, - ), - body: Scaffold( + ); + } + + return Scaffold( body: Theme( data: Theme.of(context).copyWith( scrollbarTheme: ScrollbarThemeData( @@ -53,95 +66,129 @@ class MonthlyTransactionHistory extends StatelessWidget { child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), - itemCount: sortedMonths.length, + itemCount: visibleMonths.length, itemBuilder: (context, index) { - final month = sortedMonths[index]; + final month = visibleMonths[index]; final data = monthlySums[month]!; final total = data['directWasher']! + - data['directDryer']! + - data['loyaltyCard']!; - if (total == 0 && data['loyaltyWasher']==0 && data['loyaltyDryer']==0) { - return SizedBox(width: 0, height: 0,); + data['directDryer']! + + data['loyaltyCard']!; + if (total == 0 && + data['loyaltyWasher'] == 0 && + data['loyaltyDryer'] == 0) { + return const SizedBox(width: 0, height: 0); } else { - return Card( - margin: const EdgeInsets.only(bottom: 16), - elevation: 2, - color: Theme - .of(context) - .colorScheme - .cardPrimary, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - month, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + color: Theme.of(context).colorScheme.cardPrimary, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + month, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - Text( - '\$${total.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, + Text( + '\$${total.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - ], - ), - const Divider(height: 24), - _buildTransactionRow( - 'Direct Washer Payments', - data['directWasher']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Washer Payments', - data['loyaltyWasher']!, - Theme - .of(context) - .colorScheme - .primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Direct Dryer Payments', - data['directDryer']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Dryer Payments', - data['loyaltyDryer']!, - Theme - .of(context) - .colorScheme - .primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Card Loads', - data['loyaltyCard']!, - Colors.black, - ), - ], + ], + ), + const Divider(height: 24), + _buildTransactionRow( + 'Direct Washer Payments', + data['directWasher']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Washer Payments', + data['loyaltyWasher']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Direct Dryer Payments', + data['directDryer']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Dryer Payments', + data['loyaltyDryer']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Card Loads', + data['loyaltyCard']!, + Colors.black, + ), + ], + ), ), - ), - ); - } + ); + } }, ), ), ), + ); + } + + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.fontPrimary, + ), + onPressed: () => context.pop(), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + title: Text( + 'Monthly Transaction History', + style: TextStyle(color: Theme.of(context).colorScheme.fontPrimary), + ), + elevation: 2, + centerTitle: true, + bottom: TabBar( + labelColor: Theme.of(context).colorScheme.fontPrimary, + unselectedLabelColor: Theme.of( + context, + ).colorScheme.fontPrimary.withOpacity(0.7), + indicatorColor: Theme.of(context).colorScheme.fontPrimary, + tabs: [ + Tab(text: 'Year $previousYear'), + Tab(text: 'Year $currentYear'), + const Tab(text: 'Last 12 Months'), + ], + ), + ), + body: TabBarView( + children: [ + buildMonthList(previousYearMonths), + buildMonthList(currentYearMonths), + buildMonthList(lastTwelveMonths), + ], + ), ), ); } diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 465adb12..44ff135a 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'mocks.dart'; void main() { @@ -60,13 +61,14 @@ void main() { } group('MonthlyTransactionHistory Widget Tests', () { - testWidgets('renders AppBar title even when transactions empty', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); - await tester.pumpAndSettle(); + testWidgets('renders AppBar title even when transactions empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createTestWidget([])); + await tester.pumpAndSettle(); - expect(find.text('Monthly Transaction History'), findsOneWidget); - }); + expect(find.text('Monthly Transaction History'), findsOneWidget); + }); testWidgets('renders AppBar with back button', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget([])); @@ -85,41 +87,60 @@ void main() { expect(tester.takeException(), isNotNull); }); - testWidgets('displays no cards when transactions are empty', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); - await tester.pumpAndSettle(); + testWidgets('displays no cards when transactions are empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createTestWidget([])); + await tester.pumpAndSettle(); - expect(find.byType(Card), findsNothing); - }); + expect(find.byType(Card), findsNothing); + }); - testWidgets('displays card for month with transactions', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; + testWidgets('displays card for month with transactions', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); - expect(find.byType(Card), findsOneWidget); - }); + expect(find.byType(Card), findsOneWidget); + }); testWidgets('displays correct monthly total', (WidgetTester tester) async { final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.75), createTransaction( monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, + description: 'loyalty card', + amount: 10.00, + ), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$14.25'), findsOneWidget); + }); + + testWidgets('displays all transaction categories', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00, ), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), createTransaction( monthsAgo: 1, - description: 'Dryer #3', - amount: 1.75, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50, ), createTransaction( monthsAgo: 1, @@ -131,200 +152,267 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); - expect(find.text('\$14.25'), findsOneWidget); + expect(find.text('Direct Washer Payments'), findsOneWidget); + expect(find.text('Loyalty Washer Payments'), findsOneWidget); + expect(find.text('Direct Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Card Loads'), findsOneWidget); }); - testWidgets('displays all transaction categories', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.75, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('Direct Washer Payments'), findsOneWidget); - expect(find.text('Loyalty Washer Payments'), findsOneWidget); - expect(find.text('Direct Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Card Loads'), findsOneWidget); - }); - - testWidgets('displays correct amounts for each category', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #3', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('sorts months in descending order', - (WidgetTester tester) async { - final now = DateTime.now(); - final transactions = [ - createTransaction( - monthsAgo: 3, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.00, - ), - createTransaction( - monthsAgo: 2, - description: 'Washer #5', - amount: 2.75, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final cardFinder = find.byType(Card); - expect(cardFinder, findsNWidgets(3)); - - final firstCard = cardFinder.first; - final firstCardTexts = find.descendant( - of: firstCard, - matching: find.byType(Text), - ); - expect(firstCardTexts, findsAtLeastNWidgets(1)); - }); + testWidgets('displays correct amounts for each category', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 3.00), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$5.50'), findsWidgets); + }); + + testWidgets('sorts months in descending order', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final transactions = [ + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 2.75), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + final cardFinder = find.byType(Card); + expect(cardFinder, findsNWidgets(3)); + + final firstCard = cardFinder.first; + final firstCardTexts = find.descendant( + of: firstCard, + matching: find.byType(Text), + ); + expect(firstCardTexts, findsAtLeastNWidgets(1)); + }); testWidgets('displays scrollbar', (WidgetTester tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Scrollbar), findsOneWidget); + }); + + testWidgets('displays ListView with proper padding', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + final listView = tester.widget(find.byType(ListView)); + expect(listView.padding, const EdgeInsets.all(16)); + }); + + testWidgets('displays multiple months correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Card), findsNWidgets(3)); + }); + + testWidgets('displays divider between month and transaction details', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('displays zero amounts when no transactions of that type', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + final zeroAmountFinder = find.text('\$0.00'); + expect(zeroAmountFinder, findsWidgets); + }); + + testWidgets('card has proper margin', (WidgetTester tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + final card = tester.widget(find.byType(Card)); + expect(card.margin, const EdgeInsets.only(bottom: 16)); + }); + + testWidgets('handles loyalty washer payments correctly', ( + WidgetTester tester, + ) async { final transactions = [ createTransaction( monthsAgo: 1, - description: 'Washer #5', + description: 'Loyalty Payment on Washer #5', amount: 2.50, ), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on washer #3', + amount: 3.00, + ), ]; await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); - expect(find.byType(Scrollbar), findsOneWidget); + expect(find.text('\$5.50'), findsWidgets); }); - testWidgets('displays ListView with proper padding', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final listView = tester.widget(find.byType(ListView)); - expect(listView.padding, const EdgeInsets.all(16)); - }); - - testWidgets('displays multiple months correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 3, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 2, - description: 'Washer #5', - amount: 3.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsNWidgets(3)); - }); - - testWidgets('displays divider between month and transaction details', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Divider), findsOneWidget); - }); - - testWidgets('displays zero amounts when no transactions of that type', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final zeroAmountFinder = find.text('\$0.00'); - expect(zeroAmountFinder, findsWidgets); - }); + testWidgets('handles loyalty dryer payments correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #2', + amount: 1.50, + ), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on dryer #1', + amount: 1.25, + ), + ]; - testWidgets('card has proper margin', (WidgetTester tester) async { + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles direct dryer payments correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.50), + createTransaction(monthsAgo: 1, description: 'DRYER #1', amount: 1.25), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles loyalty card loads correctly', ( + WidgetTester tester, + ) async { final transactions = [ createTransaction( monthsAgo: 1, + description: 'loyalty card', + amount: 10.00, + ), + createTransaction( + monthsAgo: 1, + description: 'loyalty card', + amount: 20.00, + ), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$30.00'), findsWidgets); + }); + + testWidgets('displays formatted decimal amounts correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.5), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.76), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$1.76'), findsWidgets); + }); + + testWidgets('multiple transactions in same month aggregate correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #2', amount: 3.00), + createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 2.75), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$8.25'), findsWidgets); + expect(find.byType(Card), findsOneWidget); + }); + + testWidgets('ignores current month transactions', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Card), findsOneWidget); + expect(find.text('\$3.00'), findsExactly(2)); + }); + + testWidgets('handles transactions from exactly 11 months ago', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction( + monthsAgo: 11, description: 'Washer #5', amount: 2.50, ), @@ -333,221 +421,102 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); - final card = tester.widget(find.byType(Card)); - expect(card.margin, const EdgeInsets.only(bottom: 16)); + expect(find.byType(Card), findsOneWidget); }); - testWidgets('handles loyalty washer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on washer #3', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('handles loyalty dryer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #2', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on dryer #1', - amount: 1.25, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles direct dryer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'DRYER #1', - amount: 1.25, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles loyalty card loads correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 20.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$30.00'), findsWidgets); - }); - - testWidgets('displays formatted decimal amounts correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.5, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #3', - amount: 1.76, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$1.76'), findsWidgets); - }); - - testWidgets('multiple transactions in same month aggregate correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #1', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #2', - amount: 3.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #3', - amount: 2.75, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$8.25'), findsWidgets); - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('ignores current month transactions', - (WidgetTester tester) async { - final now = DateTime.now(); - final transactions = [ - { - 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), - 'description': 'Washer #5', - 'amount': 2.50, - }, - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsOneWidget); - expect(find.text('\$3.00'), findsExactly(2)); - }); - - testWidgets('handles transactions from exactly 11 months ago', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 11, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('handles mixed transaction types in same month', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.75, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$14.25'), findsOneWidget); - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$2.00'), findsWidgets); - expect(find.text('\$1.75'), findsWidgets); - expect(find.text('\$1.50'), findsWidgets); - expect(find.text('\$10.00'), findsWidgets); - }); + testWidgets('handles mixed transaction types in same month', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00, + ), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50, + ), + createTransaction( + monthsAgo: 1, + description: 'loyalty card', + amount: 10.00, + ), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$14.25'), findsOneWidget); + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$2.00'), findsWidgets); + expect(find.text('\$1.75'), findsWidgets); + expect(find.text('\$1.50'), findsWidgets); + expect(find.text('\$10.00'), findsWidgets); + }); + + testWidgets('shows last years, this years, and last 12 months tab', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + + await tester.pumpWidget(createTestWidget([])); + await tester.pumpAndSettle(); + + expect(find.text('Year ${now.year - 1}'), findsOneWidget); + expect(find.text('Year ${now.year}'), findsOneWidget); + expect(find.text('Last 12 Months'), findsOneWidget); + }); + + testWidgets('previous year tab shows previous year data', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final previousYearDate = DateTime(now.year - 1, now.month, 15); + final previousYearMonthLabel = + '${DateFormat('MMM').format(previousYearDate)} ${previousYearDate.year}'; + + final transactions = [ + createTransaction( + monthsAgo: 12, + description: 'Washer #5', + amount: 2.50, + ), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Year ${now.year - 1}')); + await tester.pumpAndSettle(); + + expect(find.text(previousYearMonthLabel), findsOneWidget); + }); + + testWidgets('can switch between tabs without errors', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + + final transactions = [ + createTransaction(monthsAgo: 12, description: 'Dryer #2', amount: 1.75), + ]; + + await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Year ${now.year}')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Last 12 Months')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); }); -} \ No newline at end of file +} From c8e0537c4ea9097787d8c62d267de72ad79cd338 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 18:14:34 -0500 Subject: [PATCH 069/141] Adds edge functions to repo --- .gitignore | 1 + package-lock.json | 324 +++++++++++++++ package.json | 28 ++ pubspec.lock | 88 ++++ scripts/update-functions.js | 29 ++ supabase/.gitignore | 8 + supabase/config.toml | 388 ++++++++++++++++++ supabase/functions/approveRefund/index.ts | 137 +++++++ supabase/functions/changePassword/index.ts | 33 ++ .../functions/checkPaymentResult/index.ts | 43 ++ .../functions/createCheckoutSession/index.ts | 80 ++++ supabase/functions/delete-account/index.ts | 52 +++ supabase/functions/denyRefund/index.ts | 117 ++++++ supabase/functions/paymentIntent/index.ts | 59 +++ supabase/functions/ping-device/pingDevice.ts | 103 +++++ supabase/functions/refund-email/index.ts | 128 ++++++ supabase/functions/resetToken/index.ts | 23 ++ supabase/functions/stripeWebhook/index.ts | 64 +++ supabase/functions/verifyPayment/index.ts | 18 + supabase/functions/wakeDevice/index.ts | 115 ++++++ 20 files changed, 1838 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/update-functions.js create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/functions/approveRefund/index.ts create mode 100644 supabase/functions/changePassword/index.ts create mode 100644 supabase/functions/checkPaymentResult/index.ts create mode 100644 supabase/functions/createCheckoutSession/index.ts create mode 100644 supabase/functions/delete-account/index.ts create mode 100644 supabase/functions/denyRefund/index.ts create mode 100644 supabase/functions/paymentIntent/index.ts create mode 100644 supabase/functions/ping-device/pingDevice.ts create mode 100644 supabase/functions/refund-email/index.ts create mode 100644 supabase/functions/resetToken/index.ts create mode 100644 supabase/functions/stripeWebhook/index.ts create mode 100644 supabase/functions/verifyPayment/index.ts create mode 100644 supabase/functions/wakeDevice/index.ts diff --git a/.gitignore b/.gitignore index a2123506..92bf8034 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .svn/ .swiftpm/ migrate_working_dir/ +node_modules/ # IntelliJ related *.iml diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..5d685939 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,324 @@ +{ + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "supabase": "^2.76.14" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/bin-links": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supabase": { + "version": "2.76.14", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.14.tgz", + "integrity": "sha512-2XmYs8+A4WXd+w/OND9u9qbSTnGdLCuddnii01H1LkmgwcZ9krXwxElE+YYmzhsEKCUHv5wVjAf5HTUwQ4PnVA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^6.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.5.9" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/write-file-atomic": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..589adf48 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "description": "Code repository for the Clean Stream Laundry Client mobile/web app.", + "homepage": "https://github.com/jamaki604/CleanStreamLaundryApp#readme", + "bugs": { + "url": "https://github.com/jamaki604/CleanStreamLaundryApp/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jamaki604/CleanStreamLaundryApp.git" + }, + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "supabase:update-functions": "node scripts/update-functions.js" + }, + "dependencies": { + "supabase": "^2.76.14" + } +} diff --git a/pubspec.lock b/pubspec.lock index 1eb1fa6c..e59d7883 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dbus: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -254,6 +278,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + url: "https://pub.dev" + source: hosted + version: "8.2.2" flutter_native_splash: dependency: "direct dev" description: @@ -512,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -544,6 +584,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -576,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -800,6 +864,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -1093,6 +1165,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" universal_io: dependency: transitive description: @@ -1165,6 +1245,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_graphics: dependency: transitive description: diff --git a/scripts/update-functions.js b/scripts/update-functions.js new file mode 100644 index 00000000..831afdaf --- /dev/null +++ b/scripts/update-functions.js @@ -0,0 +1,29 @@ +const { execSync } = require("child_process"); + +try { + console.log("Fetching function list..."); + + const output = execSync( + "npx supabase functions list --output json", + { encoding: "utf-8" } + ); + + const functions = JSON.parse(output); + + if (!functions.length) { + console.log("No functions found."); + process.exit(0); + } + + for (const fn of functions) { + console.log(`Downloading ${fn.name}...`); + execSync(`npx supabase functions download ${fn.slug}`, { + stdio: "inherit", + }); + } + + console.log("All functions updated successfully."); +} catch (err) { + console.error("Error updating functions:", err.message); + process.exit(1); +} \ No newline at end of file diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 00000000..ad9264f0 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..06284e21 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,388 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "CleanStreamLaundryApp" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/functions/approveRefund/index.ts b/supabase/functions/approveRefund/index.ts new file mode 100644 index 00000000..df35128e --- /dev/null +++ b/supabase/functions/approveRefund/index.ts @@ -0,0 +1,137 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey" +}; +serve(async (req)=>{ + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders + }); + } + try { + const url = new URL(req.url); + const userId = url.searchParams.get("user_id"); + const transactionId = url.searchParams.get("transaction_id"); + const amount = url.searchParams.get("amount"); + console.log("Received params:", { + userId, + transactionId, + amount + }); + if (!userId || !transactionId || !amount) { + return new Response("Missing params", { + status: 400, + headers: corsHeaders + }); + } + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const resendKey = Deno.env.get("RESEND_API_KEY"); + console.log("Environment check:", { + hasSupabaseUrl: !!supabaseUrl, + hasServiceKey: !!serviceKey, + hasResendKey: !!resendKey + }); + if (!supabaseUrl || !serviceKey) { + return new Response("Server configuration error", { + status: 500, + headers: corsHeaders + }); + } + // Create Supabase client with service role key + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }); + const { error: refundUpdateError } = await supabase.from("Refunds").update({ + status: "approved" + }).eq("transaction_id", transactionId); + if (refundUpdateError) { + console.error("Refund update error:", refundUpdateError); + return new Response(`Refund update error: ${refundUpdateError.message}`, { + status: 500, + headers: corsHeaders + }); + } + console.log("Fetching user email..."); + // Fetch user email + // Use auth.admin to access auth.users + const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userId); + if (userError || !user) { + console.error("User fetch error:", userError); + return new Response(`User not found: ${userError?.message || 'No data'}`, { + status: 404, + headers: corsHeaders + }); + } + const userEmail = user.email; + if (!userEmail) { + return new Response("User email not found", { + status: 404, + headers: corsHeaders + }); + } + console.log("Found user email:", userEmail); + // Continue with RPC call... + console.log("Calling increment_loyalty_balance RPC..."); + // Increment loyalty balance + const { data: rpcData, error: rpcError } = await supabase.rpc('increment_loyalty_balance', { + user_id: userId, + increment_amount: Number(amount) + }); + console.log("RPC result:", { + rpcData, + rpcError + }); + if (rpcError) { + return new Response(`RPC error: ${rpcError.message}`, { + status: 500, + headers: corsHeaders + }); + } + console.log("Sending confirmation email..."); + // Send confirmation email + const emailResponse = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${resendKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: userEmail, + subject: "Your Refund Was Approved", + html: ` +

Refund Approved

+

Your refund for transaction ${transactionId} was approved.

+

$${amount} has been added to your loyalty card.

+ ` + }) + }); + console.log("Email response status:", emailResponse.status); + return new Response(` + Refund Approved + The user's loyalty balance has been + updated and they've been notified via email. + Transaction: ${transactionId} + Amount:$${amount} + `, { + headers: { + "Content-Type": "text/html", + ...corsHeaders + } + }); + } catch (e) { + console.error("Caught error:", e); + return new Response(`Error: ${e.message}`, { + status: 500, + headers: corsHeaders + }); + } +}); diff --git a/supabase/functions/changePassword/index.ts b/supabase/functions/changePassword/index.ts new file mode 100644 index 00000000..85f740e7 --- /dev/null +++ b/supabase/functions/changePassword/index.ts @@ -0,0 +1,33 @@ +import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +serve(async (req) => { + try { + const { code, password } = await req.json(); + if (!code || !password) { + return new Response("Missing code or password", { status: 400 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + if (error || !data?.user) { + return new Response("Invalid or expired code", { status: 401 }); + } + + const { error: updateError } = await supabase.auth.admin.updateUserById( + data.user.id, + { password }, + ); + if (updateError) { + return new Response("Failed to update password", { status: 500 }); + } + + return new Response("Password updated", { status: 200 }); + } catch { + return new Response("Bad Request", { status: 400 }); + } +}); \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/index.ts b/supabase/functions/checkPaymentResult/index.ts new file mode 100644 index 00000000..e77294f9 --- /dev/null +++ b/supabase/functions/checkPaymentResult/index.ts @@ -0,0 +1,43 @@ +import Stripe from "npm:stripe@^14.0.0"; +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { + apiVersion: "2024-06-20" +}); +export default (async (req)=>{ + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Content-Type": "application/json" + }; + // Handle preflight requests + if (req.method === "OPTIONS") { + return new Response(null, { + headers + }); + } + try { + const { session_id } = await req.json(); + console.log(session_id); + if (!session_id) { + return new Response(JSON.stringify({ + error: "Missing session_id" + }), { + status: 400, + headers + }); + } + const session = await stripe.checkout.sessions.retrieve(session_id); + return new Response(JSON.stringify({ + status: session.payment_status + }), { + headers + }); + } catch (err) { + return new Response(JSON.stringify({ + error: err.message + }), { + status: 400, + headers + }); + } +}); diff --git a/supabase/functions/createCheckoutSession/index.ts b/supabase/functions/createCheckoutSession/index.ts new file mode 100644 index 00000000..5fd739ec --- /dev/null +++ b/supabase/functions/createCheckoutSession/index.ts @@ -0,0 +1,80 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { + apiVersion: "2024-06-20" +}); +serve(async (req)=>{ + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info" + }; + if (req.method === "OPTIONS") { + return new Response(null, { + headers + }); + } + try { + const { amount } = await req.json(); + // ✅ Proper token extraction for Edge Functions + const authHeader = req.headers.get("Authorization") || ""; + const token = authHeader.replace("Bearer ", ""); + const supabase = createClient(Deno.env.get("SUPABASE_URL"), Deno.env.get("SUPABASE_ANON_KEY"), { + global: { + headers: token ? { + Authorization: `Bearer ${token}` + } : {} + } + }); + // ✅ Correct Edge Function way to get current user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + console.error("Auth error:", userError); + return new Response("Unauthorized", { + status: 401 + }); + } + // --- Stripe Checkout --- + const session = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: [ + "card" + ], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: "Laundry Service" + }, + unit_amount: amount + }, + quantity: 1 + } + ], + metadata: { + user_id: user.id + }, + success_url: "http://localhost:8080/homaPage", + cancel_url: "http://localhost:8080/homePage" + }); + return new Response(JSON.stringify({ + url: session.url, + session_id: session.id + }), { + headers: { + ...headers, + "Content-Type": "application/json" + } + }); + } catch (err) { + console.error(err); + return new Response(JSON.stringify({ + error: err.message + }), { + status: 400, + headers + }); + } +}); diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 00000000..0f7cb7b6 --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,52 @@ +import { serve } from 'https://deno.land/std@0.182.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.14.0'; + +serve(async (req) => { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info" + }; + if (req.method === "OPTIONS") { + return new Response(null, { + headers + }); + } + + try { + const { user_id } = await req.json(); + + if (!user_id) { + return new Response( + JSON.stringify({ error: 'Missing user_id' }), + { status: 400, headers } + ); + } + + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ); + + const { data, error } = + await supabaseAdmin.auth.admin.deleteUser(user_id); + + if (error) { + console.error(error); + return new Response( + JSON.stringify({ error: error.message }), + { status: 500, headers } + ); + } + + return new Response( + JSON.stringify({ success: true, data }), + { status: 200, headers } + ); + } catch (err) { + return new Response( + JSON.stringify({ error: err.message }), + { status: 400, headers } + ); + } +}); diff --git a/supabase/functions/denyRefund/index.ts b/supabase/functions/denyRefund/index.ts new file mode 100644 index 00000000..0dfab798 --- /dev/null +++ b/supabase/functions/denyRefund/index.ts @@ -0,0 +1,117 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey" +}; +serve(async (req)=>{ + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders + }); + } + try { + const url = new URL(req.url); + const userId = url.searchParams.get("user_id"); + const transactionId = url.searchParams.get("transaction_id"); + const amount = url.searchParams.get("amount"); + console.log("Deny request - Received params:", { + userId, + transactionId, + amount + }); + if (!userId || !transactionId || !amount) { + return new Response("Missing params", { + status: 400, + headers: corsHeaders + }); + } + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const resendKey = Deno.env.get("RESEND_API_KEY"); + if (!supabaseUrl || !serviceKey) { + return new Response("Server configuration error", { + status: 500, + headers: corsHeaders + }); + } + // Create Supabase client with service role key + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }); + const { error: refundUpdateError } = await supabase.from("Refunds").update({ + status: "denied" + }).eq("transaction_id", transactionId); + if (refundUpdateError) { + console.error("Refund update error:", refundUpdateError); + return new Response(`Refund update error: ${refundUpdateError.message}`, { + status: 500, + headers: corsHeaders + }); + } + console.log("Fetching user email..."); + // Fetch user email from auth + const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userId); + if (userError || !user) { + console.error("User fetch error:", userError); + return new Response(`User not found: ${userError?.message || 'No data'}`, { + status: 404, + headers: corsHeaders + }); + } + const userEmail = user.email; + if (!userEmail) { + return new Response("User email not found", { + status: 404, + headers: corsHeaders + }); + } + console.log("Sending denial email to:", userEmail); + // Send denial email + const emailResponse = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${resendKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: userEmail, + subject: "Refund Request Denied", + html: ` +

Refund Request Denied

+

Unfortunately, your refund request for transaction ${transactionId} has been denied.

+

Amount: $${amount}

+

If you have questions about this decision, please contact support.

+ ` + }) + }); + console.log("Email response status:", emailResponse.status); + if (!emailResponse.ok) { + const errorText = await emailResponse.text(); + console.error("Email send failed:", errorText); + } + const html = `Refund Denied + The refund request has been denied and + the customer has been notified via email. + Transaction:${transactionId} + Amount:$${amount} + `; + return new Response(html, { + headers: { + "Content-Type": "text/html" + } + }); + } catch (e) { + console.error("Caught error:", e); + return new Response(`Error: ${e.message}`, { + status: 500, + headers: corsHeaders + }); + } +}); diff --git a/supabase/functions/paymentIntent/index.ts b/supabase/functions/paymentIntent/index.ts new file mode 100644 index 00000000..823ad202 --- /dev/null +++ b/supabase/functions/paymentIntent/index.ts @@ -0,0 +1,59 @@ +import Stripe from "npm:stripe"; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2024-06-20", +}); + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", + }, + }); + } + + if (req.method === "POST") { + try { + const { amount } = await req.json(); + + console.log("Creating PaymentIntent for:", amount); + + const intent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + payment_method_types: ["card"], + }); + + return new Response( + JSON.stringify({ clientSecret: intent.client_secret }), + { + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }, + } + ); + } catch (err) { + console.error("Stripe error:", err); + return new Response( + JSON.stringify({ error: err.message ?? "Unknown error" }), + { + status: 400, + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }, + } + ); + } + } + + return new Response("Method Not Allowed", { + status: 405, + headers: { "Access-Control-Allow-Origin": "*" }, + }); +}); diff --git a/supabase/functions/ping-device/pingDevice.ts b/supabase/functions/ping-device/pingDevice.ts new file mode 100644 index 00000000..42c27495 --- /dev/null +++ b/supabase/functions/ping-device/pingDevice.ts @@ -0,0 +1,103 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +const SUPABASE_URL = "https://dnuuhupoxjtwqzaqylvb.supabase.co"; +const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRudXVodXBveGp0d3F6YXF5bHZiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk3MDIzOTMsImV4cCI6MjA3NTI3ODM5M30.W6CvYhQlRcsKV6NJLU99aAI4-woHpYZ63hZbD4WeTW4"; +const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); +async function getMachineStatusFromSupabase(deviceId) { + try { + const { data, error } = await supabase.from('Machines').select('Status').eq('id', deviceId).single(); + console.log("Supabase data:", data); + console.log("Supabase error:", error); + if (error || !data) { + return 'error'; + } + const status = data.Status?.toLowerCase(); + console.log("status: ", status); + const validStatuses = [ + 'idle', + 'in-use', + 'offline', + 'error' + ]; + return validStatuses.includes(status) ? status : 'offline'; + } catch (e) { + console.error("Supabase fetch error:", e); + return 'error'; + } +} +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400" +}; +serve(async (req)=>{ + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders + }); + } + try { + const text = await req.text(); + const body = text ? JSON.parse(text) : {}; + const deviceId = body.deviceId; + if (!deviceId) { + return new Response(JSON.stringify({ + error: "deviceId is required", + receivedBody: body + }), { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } + const random = Math.random(); + const success = random < 0.95; + const delay = Math.floor(Math.random() * 150) + 50; + await new Promise((resolve)=>setTimeout(resolve, delay)); + const machineStatus = await getMachineStatusFromSupabase(deviceId); + if (success) { + return new Response(JSON.stringify({ + success: true, + deviceId, + message: machineStatus, + timestamp: new Date().toISOString(), + responseTime: `${delay}ms` + }), { + status: 200, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } else { + return new Response(JSON.stringify({ + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${delay}ms` + }), { + status: 503, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } + } catch (error) { + return new Response(JSON.stringify({ + success: false, + error: error.message || "Internal server error" + }), { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } +}); diff --git a/supabase/functions/refund-email/index.ts b/supabase/functions/refund-email/index.ts new file mode 100644 index 00000000..4eba9c57 --- /dev/null +++ b/supabase/functions/refund-email/index.ts @@ -0,0 +1,128 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400" +}; +serve(async (req)=>{ + // --- Handle CORS preflight --- + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders + }); + } + try { + // ------------------------------- + // Parse incoming JSON safely + // ------------------------------- + let body; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch (e) { + return new Response(JSON.stringify({ + error: "Invalid JSON body" + }), { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } + // -------------------------------------------------------- + // Extract expected fields from the body (sent by your app) + // -------------------------------------------------------- + const username = body.username; + const userId = body.user_id; + const transactionId = body.transaction_id; + const amount = body.amount; + const description = body.description; + const userAttempts = body.userAttempts; + // Validate required fields + if (!username || !userId || !transactionId || !amount) { + return new Response(JSON.stringify({ + error: "Missing required fields", + received: body + }), { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } + const approveLink = "https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/approveRefund" + `?user_id=${userId}&transaction_id=${transactionId}&amount=${amount}`; + const denyLink = "https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/denyRefund" + `?user_id=${userId}&transaction_id=${transactionId}&amount=${amount}`; + // -------------------------------------------------------- + // Build the email HTML + // -------------------------------------------------------- + const emailBody = ` +

Refund Request Received

+

Name: ${username}

+

User ID: ${userId}

+

Transaction ID: ${transactionId}

+

Amount: $${amount}

+

Reason: ${description}

+

Number of refund attempts: ${userAttempts}

+ +

 

+ `; + const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY"); + // -------------------------------------------------------- + // Send email via Resend + // -------------------------------------------------------- + const emailResponse = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: "yoder453@gmail.com", + subject: `New Refund Request - ${username}`, + html: emailBody + }) + }); + const emailData = await emailResponse.json(); + console.log("Resend API response:", emailData); + return new Response(JSON.stringify({ + success: true, + resend: emailData + }), { + status: 200, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } catch (error) { + console.error("Refund email error:", error); + return new Response(JSON.stringify({ + success: false, + error: error.message || "Internal server error" + }), { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders + } + }); + } +}); diff --git a/supabase/functions/resetToken/index.ts b/supabase/functions/resetToken/index.ts new file mode 100644 index 00000000..de8548f2 --- /dev/null +++ b/supabase/functions/resetToken/index.ts @@ -0,0 +1,23 @@ +import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +serve(async (req) => { + try { + const { code } = await req.json(); + if (!code) return new Response("Missing code", { status: 400 }); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + if (error || !data?.user) { + return new Response("Invalid or expired code", { status: 401 }); + } + + return new Response("OK", { status: 200 }); + } catch { + return new Response("Bad Request", { status: 400 }); + } +}); \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/index.ts b/supabase/functions/stripeWebhook/index.ts new file mode 100644 index 00000000..943cb42a --- /dev/null +++ b/supabase/functions/stripeWebhook/index.ts @@ -0,0 +1,64 @@ +import { serve } from "https://deno.land/std@0.223.0/http/server.ts"; +import Stripe from "npm:stripe@14"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2024-06-20", + httpClient: Stripe.createFetchHttpClient(), // Use fetch-based client for Deno +}); + +serve(async (req) => { + const signature = req.headers.get("stripe-signature"); + + if (!signature) { + console.error("❌ No signature header found"); + return new Response("No signature", { status: 400 }); + } + + const rawBody = await req.text(); // Try text() instead of arrayBuffer() + + let event; + try { + const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); + + if (!webhookSecret) { + console.error("❌ STRIPE_WEBHOOK_SECRET not set"); + return new Response("Configuration error", { status: 500 }); + } + + event = await stripe.webhooks.constructEventAsync( + rawBody, + signature, + webhookSecret, + undefined, + Stripe.createSubtleCryptoProvider() // Explicitly use async crypto + ); + + console.log("✅ Signature verified:", event.type); + } catch (err) { + console.error("❌ Webhook signature verification failed:", err.message); + return new Response(`Webhook Error: ${err.message}`, { status: 400 }); + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object; + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + await supabase + .channel("payments") + .send({ + type: "broadcast", + event: "payment_success", + payload: { + user_id: session.metadata?.user_id, + amount: session.amount_total + } + }); + } + + return new Response("OK", { status: 200 }); +}); \ No newline at end of file diff --git a/supabase/functions/verifyPayment/index.ts b/supabase/functions/verifyPayment/index.ts new file mode 100644 index 00000000..d8cd244f --- /dev/null +++ b/supabase/functions/verifyPayment/index.ts @@ -0,0 +1,18 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { serve } from "https://deno.land/std/http/server.ts"; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { + apiVersion: "2024-06-20", +}); + +serve(async (req) => { + const { session_id } = await req.json(); + + const session = await stripe.checkout.sessions.retrieve(session_id); + + return new Response(JSON.stringify({ + paid: session.payment_status === "paid", + }), { + headers: { "Content-Type": "application/json" } + }); +}); diff --git a/supabase/functions/wakeDevice/index.ts b/supabase/functions/wakeDevice/index.ts new file mode 100644 index 00000000..d4aed7d5 --- /dev/null +++ b/supabase/functions/wakeDevice/index.ts @@ -0,0 +1,115 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", +}; + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); + } + + try { + // Parse request body + let body; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch (e) { + return new Response( + JSON.stringify({ error: "Invalid JSON body" }), + { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } + + const deviceId = body.deviceId; + + // Validate deviceId + if (!deviceId) { + return new Response( + JSON.stringify({ + error: "deviceId is required", + receivedBody: body + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } + + // Simulate ping with 95% success rate + const random = Math.random(); + const success = random < 0.95; + + // Simulate network delay (50-200ms) + const delay = Math.floor(Math.random() * 150) + 50; + await new Promise((resolve) => setTimeout(resolve, delay)); + + if (success) { + return new Response( + JSON.stringify({ + success: true, + deviceId, + message: "Device wake signal sent successfully", + timestamp: new Date().toISOString(), + responseTime: `${delay}ms`, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } else { + return new Response( + JSON.stringify({ + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${delay}ms`, + }), + { + status: 503, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } + } catch (error) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file From fe97993290108329dceca71281a70395f6f3a422 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 18:39:39 -0500 Subject: [PATCH 070/141] Starts test suite --- .gitignore | 1 + deno.lock | 49 ++++ package.json | 5 +- .../functions/approveRefund/index.test.ts | 137 +++++++++++ supabase/functions/approveRefund/index.ts | 222 +++++++++--------- supabase/functions/approveRefund/logic.ts | 41 ++++ 6 files changed, 338 insertions(+), 117 deletions(-) create mode 100644 deno.lock create mode 100644 supabase/functions/approveRefund/index.test.ts create mode 100644 supabase/functions/approveRefund/logic.ts diff --git a/.gitignore b/.gitignore index 92bf8034..f8e55ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ .swiftpm/ migrate_working_dir/ node_modules/ +coverage_deno # IntelliJ related *.iml diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..5158cfd2 --- /dev/null +++ b/deno.lock @@ -0,0 +1,49 @@ +{ + "version": "5", + "redirects": { + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts" + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:supabase@^2.76.14" + ] + } + } +} diff --git a/package.json b/package.json index 589adf48..9a19d817 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "license": "ISC", "author": "", - "type": "commonjs", + "type": "module", "main": "index.js", "directories": { "lib": "lib", @@ -20,7 +20,8 @@ }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "supabase:update-functions": "node scripts/update-functions.js" + "supabase:update-functions": "node scripts/update-functions.js", + "test:coverage": "deno test --reload --coverage=coverage_deno supabase/functions" }, "dependencies": { "supabase": "^2.76.14" diff --git a/supabase/functions/approveRefund/index.test.ts b/supabase/functions/approveRefund/index.test.ts new file mode 100644 index 00000000..7c6b677d --- /dev/null +++ b/supabase/functions/approveRefund/index.test.ts @@ -0,0 +1,137 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std/testing/asserts.ts"; + + import { processRefund } from "./logic.ts"; + + function createMockDeps(overrides: Partial = {}) { + return { + updateRefund: async (_: string) => {}, + getUserEmail: async (_: string) => "test@example.com", + incrementLoyalty: async (_: string, __: number) => {}, + sendEmail: async (_: string, __: string, ___: string) => {}, + ...overrides, + }; + } + + Deno.test("processRefund succeeds with valid input", async () => { + const deps = createMockDeps(); + + const result = await processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ); + + assertEquals(result.success, true); + assertEquals(result.transactionId, "tx1"); + assertEquals(result.amount, "25"); + }); + + Deno.test("throws if params are missing", async () => { + const deps = createMockDeps(); + + await assertRejects( + () => + processRefund( + { + userId: "", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "Missing params" + ); + }); + + Deno.test("propagates updateRefund error", async () => { + const deps = createMockDeps({ + updateRefund: async () => { + throw new Error("DB failure"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "DB failure" + ); + }); + + Deno.test("throws if user email not found", async () => { + const deps = createMockDeps({ + getUserEmail: async () => "", + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "User email not found" + ); + }); + + Deno.test("propagates incrementLoyalty error", async () => { + const deps = createMockDeps({ + incrementLoyalty: async () => { + throw new Error("RPC failed"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "RPC failed" + ); + }); + + Deno.test("propagates sendEmail error", async () => { + const deps = createMockDeps({ + sendEmail: async () => { + throw new Error("Email failed"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "Email failed" + ); + }); \ No newline at end of file diff --git a/supabase/functions/approveRefund/index.ts b/supabase/functions/approveRefund/index.ts index df35128e..77848ef9 100644 --- a/supabase/functions/approveRefund/index.ts +++ b/supabase/functions/approveRefund/index.ts @@ -1,137 +1,129 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { processRefund } from "./logic.ts"; + const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey" + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", }; -serve(async (req)=>{ + +serve(async (req) => { if (req.method === "OPTIONS") { - return new Response(null, { - status: 200, - headers: corsHeaders - }); + return new Response(null, { status: 200, headers: corsHeaders }); } + try { const url = new URL(req.url); - const userId = url.searchParams.get("user_id"); - const transactionId = url.searchParams.get("transaction_id"); - const amount = url.searchParams.get("amount"); - console.log("Received params:", { - userId, - transactionId, - amount - }); - if (!userId || !transactionId || !amount) { - return new Response("Missing params", { - status: 400, - headers: corsHeaders - }); - } + const userId = url.searchParams.get("user_id") || ""; + const transactionId = url.searchParams.get("transaction_id") || ""; + const amount = url.searchParams.get("amount") || ""; + const supabaseUrl = Deno.env.get("SUPABASE_URL"); const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); const resendKey = Deno.env.get("RESEND_API_KEY"); - console.log("Environment check:", { - hasSupabaseUrl: !!supabaseUrl, - hasServiceKey: !!serviceKey, - hasResendKey: !!resendKey - }); - if (!supabaseUrl || !serviceKey) { + + if (!supabaseUrl || !serviceKey || !resendKey) { return new Response("Server configuration error", { status: 500, - headers: corsHeaders + headers: corsHeaders, }); } - // Create Supabase client with service role key + const supabase = createClient(supabaseUrl, serviceKey, { - auth: { - autoRefreshToken: false, - persistSession: false - } - }); - const { error: refundUpdateError } = await supabase.from("Refunds").update({ - status: "approved" - }).eq("transaction_id", transactionId); - if (refundUpdateError) { - console.error("Refund update error:", refundUpdateError); - return new Response(`Refund update error: ${refundUpdateError.message}`, { - status: 500, - headers: corsHeaders - }); - } - console.log("Fetching user email..."); - // Fetch user email - // Use auth.admin to access auth.users - const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userId); - if (userError || !user) { - console.error("User fetch error:", userError); - return new Response(`User not found: ${userError?.message || 'No data'}`, { - status: 404, - headers: corsHeaders - }); - } - const userEmail = user.email; - if (!userEmail) { - return new Response("User email not found", { - status: 404, - headers: corsHeaders - }); - } - console.log("Found user email:", userEmail); - // Continue with RPC call... - console.log("Calling increment_loyalty_balance RPC..."); - // Increment loyalty balance - const { data: rpcData, error: rpcError } = await supabase.rpc('increment_loyalty_balance', { - user_id: userId, - increment_amount: Number(amount) - }); - console.log("RPC result:", { - rpcData, - rpcError + auth: { autoRefreshToken: false, persistSession: false }, }); - if (rpcError) { - return new Response(`RPC error: ${rpcError.message}`, { - status: 500, - headers: corsHeaders - }); - } - console.log("Sending confirmation email..."); - // Send confirmation email - const emailResponse = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${resendKey}`, - "Content-Type": "application/json" + + const deps = { + updateRefund: async (transactionId: string) => { + const { error } = await supabase + .from("Refunds") + .update({ status: "approved" }) + .eq("transaction_id", transactionId); + + if (error) throw new Error(error.message); }, - body: JSON.stringify({ - from: "refund@updates.cleanstreamlaundry.com", - to: userEmail, - subject: "Your Refund Was Approved", - html: ` -

Refund Approved

-

Your refund for transaction ${transactionId} was approved.

-

$${amount} has been added to your loyalty card.

- ` - }) - }); - console.log("Email response status:", emailResponse.status); - return new Response(` - Refund Approved - The user's loyalty balance has been - updated and they've been notified via email. - Transaction: ${transactionId} - Amount:$${amount} - `, { - headers: { - "Content-Type": "text/html", - ...corsHeaders + + getUserEmail: async (userId: string) => { + const { data, error } = + await supabase.auth.admin.getUserById(userId); + + if (error || !data?.user?.email) { + throw new Error("User not found"); + } + + return data.user.email; + }, + + incrementLoyalty: async (userId: string, amount: number) => { + const { error } = await supabase.rpc( + "increment_loyalty_balance", + { + user_id: userId, + increment_amount: amount, + } + ); + + if (error) throw new Error(error.message); + }, + + sendEmail: async ( + email: string, + transactionId: string, + amount: string + ) => { + const response = await fetch( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${resendKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: email, + subject: "Your Refund Was Approved", + html: ` +

Refund Approved

+

Your refund for transaction + ${transactionId} was approved.

+

$${amount} has been added to your loyalty card.

+ `, + }), + } + ); + + if (!response.ok) { + throw new Error("Failed to send email"); + } + }, + }; + + const result = await processRefund( + { userId, transactionId, amount }, + deps + ); + + return new Response( + JSON.stringify(result), + { + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, } - }); - } catch (e) { - console.error("Caught error:", e); - return new Response(`Error: ${e.message}`, { - status: 500, - headers: corsHeaders - }); + ); + } catch (error) { + return new Response( + JSON.stringify({ error: error.message }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); } -}); +}); \ No newline at end of file diff --git a/supabase/functions/approveRefund/logic.ts b/supabase/functions/approveRefund/logic.ts new file mode 100644 index 00000000..40de0543 --- /dev/null +++ b/supabase/functions/approveRefund/logic.ts @@ -0,0 +1,41 @@ +export interface RefundDependencies { + updateRefund: (transactionId: string) => Promise; + getUserEmail: (userId: string) => Promise; + incrementLoyalty: (userId: string, amount: number) => Promise; + sendEmail: (email: string, transactionId: string, amount: string) => Promise; + } + + export interface RefundParams { + userId: string; + transactionId: string; + amount: string; + } + + export async function processRefund( + params: RefundParams, + deps: RefundDependencies + ) { + const { userId, transactionId, amount } = params; + + if (!userId || !transactionId || !amount) { + throw new Error("Missing params"); + } + + await deps.updateRefund(transactionId); + + const email = await deps.getUserEmail(userId); + + if (!email) { + throw new Error("User email not found"); + } + + await deps.incrementLoyalty(userId, Number(amount)); + + await deps.sendEmail(email, transactionId, amount); + + return { + success: true, + transactionId, + amount, + }; + } \ No newline at end of file From 5bc482063e91750879189daf08aaef094f0e3326 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 19:00:01 -0500 Subject: [PATCH 071/141] adds tests for reset password --- deno.lock | 1 + package-lock.json | 20 +++++++ package.json | 3 + supabase/functions/changePassword/index.ts | 32 +++++----- .../functions/changePassword/logic.test.ts | 58 +++++++++++++++++++ supabase/functions/changePassword/logic.ts | 30 ++++++++++ 6 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 supabase/functions/changePassword/logic.test.ts create mode 100644 supabase/functions/changePassword/logic.ts diff --git a/deno.lock b/deno.lock index 5158cfd2..f4cc9c58 100644 --- a/deno.lock +++ b/deno.lock @@ -42,6 +42,7 @@ "workspace": { "packageJson": { "dependencies": [ + "npm:@types/node@^25.3.0", "npm:supabase@^2.76.14" ] } diff --git a/package-lock.json b/package-lock.json index 5d685939..fbd3a17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "license": "ISC", "dependencies": { "supabase": "^2.76.14" + }, + "devDependencies": { + "@types/node": "^25.3.0" } }, "node_modules/@isaacs/fs-minipass": { @@ -24,6 +27,16 @@ "node": ">=18.0.0" } }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -289,6 +302,13 @@ "node": ">=18" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 9a19d817..3a025af2 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ }, "dependencies": { "supabase": "^2.76.14" + }, + "devDependencies": { + "@types/node": "^25.3.0" } } diff --git a/supabase/functions/changePassword/index.ts b/supabase/functions/changePassword/index.ts index 85f740e7..528989c8 100644 --- a/supabase/functions/changePassword/index.ts +++ b/supabase/functions/changePassword/index.ts @@ -1,33 +1,33 @@ import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { resetPassword } from "./logic.ts"; serve(async (req) => { try { const { code, password } = await req.json(); - if (!code || !password) { - return new Response("Missing code or password", { status: 400 }); - } const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, ); - const { data, error } = await supabase.auth.exchangeCodeForSession(code); - if (error || !data?.user) { - return new Response("Invalid or expired code", { status: 401 }); - } - - const { error: updateError } = await supabase.auth.admin.updateUserById( - data.user.id, - { password }, + const result = await resetPassword( + { code, password }, + { + exchangeCode: async (code) => { + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + if (error || !data?.user) return { userId: "" }; + return { userId: data.user.id }; + }, + updatePassword: async (userId, password) => { + const { error } = await supabase.auth.admin.updateUserById(userId, { password }); + if (error) throw error; + }, + } ); - if (updateError) { - return new Response("Failed to update password", { status: 500 }); - } return new Response("Password updated", { status: 200 }); - } catch { - return new Response("Bad Request", { status: 400 }); + } catch (e) { + return new Response(e.message || "Bad Request", { status: 400 }); } }); \ No newline at end of file diff --git a/supabase/functions/changePassword/logic.test.ts b/supabase/functions/changePassword/logic.test.ts new file mode 100644 index 00000000..d8a443ee --- /dev/null +++ b/supabase/functions/changePassword/logic.test.ts @@ -0,0 +1,58 @@ +import { assertEquals, assertRejects } from "https://deno.land/std/testing/asserts.ts"; +import { resetPassword } from "./logic.ts"; + +function createMockDeps(overrides: Partial = {}) { + return { + exchangeCode: async (code: string) => ({ userId: "user1" }), + updatePassword: async (userId: string, password: string) => {}, + ...overrides, + }; +} + +Deno.test("resetPassword succeeds with valid input", async () => { + const deps = createMockDeps(); + + const result = await resetPassword( + { code: "abc123", password: "newpass" }, + deps + ); + + assertEquals(result.success, true); + assertEquals(result.userId, "user1"); +}); + +Deno.test("throws if code or password is missing", async () => { + const deps = createMockDeps(); + + await assertRejects( + () => resetPassword({ code: "", password: "newpass" }, deps), + Error, + "Missing code or password" + ); +}); + +Deno.test("throws if exchangeCode returns no userId", async () => { + const deps = createMockDeps({ + exchangeCode: async () => ({ userId: "" }), + }); + + await assertRejects( + () => resetPassword({ code: "abc123", password: "newpass" }, deps), + Error, + "Invalid or expired code" + ); +}); + +Deno.test("propagates updatePassword error", async () => { + const deps = createMockDeps({ + updatePassword: async () => { + throw new Error("Update failed"); + }, + }); + + await assertRejects( + () => resetPassword({ code: "abc123", password: "newpass" }, deps), + Error, + "Update failed" + ); +}); \ No newline at end of file diff --git a/supabase/functions/changePassword/logic.ts b/supabase/functions/changePassword/logic.ts new file mode 100644 index 00000000..9144fa31 --- /dev/null +++ b/supabase/functions/changePassword/logic.ts @@ -0,0 +1,30 @@ +export interface ResetPasswordDeps { + exchangeCode: (code: string) => Promise<{ userId: string }>; + updatePassword: (userId: string, password: string) => Promise; +} + +export interface ResetPasswordParams { + code: string; + password: string; +} + +export async function resetPassword( + params: ResetPasswordParams, + deps: ResetPasswordDeps +) { + const { code, password } = params; + + if (!code || !password) { + throw new Error("Missing code or password"); + } + + const { userId } = await deps.exchangeCode(code); + + if (!userId) { + throw new Error("Invalid or expired code"); + } + + await deps.updatePassword(userId, password); + + return { success: true, userId }; +} \ No newline at end of file From 82286dae6123e4b9447f968eb5af315eafabac17 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 19:13:18 -0500 Subject: [PATCH 072/141] Adds checkPaymentResult tests --- deno.json | 10 + deno.lock | 269 ++++++++++++++++++ .../functions/checkPaymentResult/index.ts | 58 ++-- .../checkPaymentResult/logic.test.ts | 23 ++ .../functions/checkPaymentResult/logic.ts | 20 ++ 5 files changed, 346 insertions(+), 34 deletions(-) create mode 100644 deno.json create mode 100644 supabase/functions/checkPaymentResult/logic.test.ts create mode 100644 supabase/functions/checkPaymentResult/logic.ts diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..e1bf87f5 --- /dev/null +++ b/deno.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["deno.ns", "dom", "esnext"] + }, + "test": { + "include": ["supabase/functions/**/*.test.ts"], + "exclude": ["node_modules"] + }, + "nodeModulesDir": "auto" + } \ No newline at end of file diff --git a/deno.lock b/deno.lock index f4cc9c58..a4a5ca04 100644 --- a/deno.lock +++ b/deno.lock @@ -1,5 +1,274 @@ { "version": "5", + "specifiers": { + "npm:@types/node@^25.3.0": "25.3.0", + "npm:stripe@14": "14.25.0", + "npm:supabase@^2.76.14": "2.76.14" + }, + "npm": { + "@isaacs/fs-minipass@4.0.1": { + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": [ + "minipass" + ] + }, + "@types/node@25.3.0": { + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dependencies": [ + "undici-types" + ] + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "bin-links@6.0.0": { + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "dependencies": [ + "cmd-shim", + "npm-normalize-package-bin", + "proc-log", + "read-cmd-shim", + "write-file-atomic" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "chownr@3.0.0": { + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" + }, + "cmd-shim@8.0.0": { + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==" + }, + "data-uri-to-buffer@4.0.1": { + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "fetch-blob@3.2.0": { + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dependencies": [ + "node-domexception", + "web-streams-polyfill" + ] + }, + "formdata-polyfill@4.0.10": { + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": [ + "fetch-blob" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "minipass@7.1.3": { + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "minizlib@3.1.0": { + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dependencies": [ + "minipass" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-domexception@1.0.0": { + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": true + }, + "node-fetch@3.3.2": { + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": [ + "data-uri-to-buffer", + "fetch-blob", + "formdata-polyfill" + ] + }, + "npm-normalize-package-bin@5.0.0": { + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "proc-log@6.1.0": { + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==" + }, + "qs@6.15.0": { + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dependencies": [ + "side-channel" + ] + }, + "read-cmd-shim@6.0.0": { + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==" + }, + "side-channel-list@1.0.0": { + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.0": { + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "signal-exit@4.1.0": { + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + }, + "stripe@14.25.0": { + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "dependencies": [ + "@types/node", + "qs" + ] + }, + "supabase@2.76.14": { + "integrity": "sha512-2XmYs8+A4WXd+w/OND9u9qbSTnGdLCuddnii01H1LkmgwcZ9krXwxElE+YYmzhsEKCUHv5wVjAf5HTUwQ4PnVA==", + "dependencies": [ + "bin-links", + "https-proxy-agent", + "node-fetch", + "tar" + ], + "scripts": true, + "bin": true + }, + "tar@7.5.9": { + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "dependencies": [ + "@isaacs/fs-minipass", + "chownr", + "minipass", + "minizlib", + "yallist" + ] + }, + "undici-types@7.18.2": { + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + "web-streams-polyfill@3.3.3": { + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "write-file-atomic@7.0.0": { + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "dependencies": [ + "imurmurhash", + "signal-exit" + ] + }, + "yallist@5.0.0": { + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" + } + }, "redirects": { "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts" }, diff --git a/supabase/functions/checkPaymentResult/index.ts b/supabase/functions/checkPaymentResult/index.ts index e77294f9..80325601 100644 --- a/supabase/functions/checkPaymentResult/index.ts +++ b/supabase/functions/checkPaymentResult/index.ts @@ -1,43 +1,33 @@ +import { getPaymentStatusLogic, PaymentDeps, PaymentParams } from "./logic.ts"; import Stripe from "npm:stripe@^14.0.0"; -const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { - apiVersion: "2024-06-20" -}); -export default (async (req)=>{ + +const stripeKey = Deno.env.get("STRIPE_SECRET_KEY"); +if (!stripeKey) throw new Error("Missing STRIPE_SECRET_KEY env var"); + +const stripe = new Stripe(stripeKey, { apiVersion: "2023-10-16" }); + +export async function getPaymentStatus(req: Request) { const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Content-Type": "application/json" + "Content-Type": "application/json", }; - // Handle preflight requests - if (req.method === "OPTIONS") { - return new Response(null, { - headers - }); - } + + if (req.method === "OPTIONS") return new Response(null, { headers }); + try { const { session_id } = await req.json(); - console.log(session_id); - if (!session_id) { - return new Response(JSON.stringify({ - error: "Missing session_id" - }), { - status: 400, - headers - }); - } - const session = await stripe.checkout.sessions.retrieve(session_id); - return new Response(JSON.stringify({ - status: session.payment_status - }), { - headers - }); - } catch (err) { - return new Response(JSON.stringify({ - error: err.message - }), { - status: 400, - headers - }); + + const deps: PaymentDeps = { + retrieveSession: (id) => stripe.checkout.sessions.retrieve(id), + }; + + const result = await getPaymentStatusLogic({ sessionId: session_id }, deps); + + return new Response(JSON.stringify({ status: result }), { headers }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { status: 400, headers }); } -}); +} \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/logic.test.ts b/supabase/functions/checkPaymentResult/logic.test.ts new file mode 100644 index 00000000..0e0f08af --- /dev/null +++ b/supabase/functions/checkPaymentResult/logic.test.ts @@ -0,0 +1,23 @@ +import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/testing/asserts.ts"; +import { getPaymentStatusLogic, PaymentDeps } from "./logic.ts"; + +Deno.test("returns payment status for valid session", async () => { + const fakeDeps: PaymentDeps = { + retrieveSession: async (id) => ({ payment_status: "paid" }), + }; + + const result = await getPaymentStatusLogic({ sessionId: "sess_123" }, fakeDeps); + assertEquals(result, "paid"); +}); + +Deno.test("throws if sessionId is missing", async () => { + const fakeDeps: PaymentDeps = { + retrieveSession: async (id) => ({ payment_status: "paid" }), + }; + + await assertRejects( // await + assertRejects + () => getPaymentStatusLogic({ sessionId: "" }, fakeDeps), + Error, + "Missing sessionId" + ); + }); \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/logic.ts b/supabase/functions/checkPaymentResult/logic.ts new file mode 100644 index 00000000..887a2957 --- /dev/null +++ b/supabase/functions/checkPaymentResult/logic.ts @@ -0,0 +1,20 @@ +import Stripe from "npm:stripe@^14.0.0"; + +export interface PaymentDeps { + retrieveSession: (sessionId: string) => Promise<{ payment_status: string }>; +} + +export interface PaymentParams { + sessionId: string; +} + +export async function getPaymentStatusLogic( + params: PaymentParams, + deps: PaymentDeps +) { + const { sessionId } = params; + if (!sessionId) throw new Error("Missing sessionId"); + + const session = await deps.retrieveSession(sessionId); + return session.payment_status; +} \ No newline at end of file From e0b51ab30ffe5bf34c7ddf413b83b954cdfb5a27 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 19:54:50 -0500 Subject: [PATCH 073/141] Removed plaintext key --- supabase/functions/ping-device/pingDevice.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/supabase/functions/ping-device/pingDevice.ts b/supabase/functions/ping-device/pingDevice.ts index 42c27495..a11cf576 100644 --- a/supabase/functions/ping-device/pingDevice.ts +++ b/supabase/functions/ping-device/pingDevice.ts @@ -1,8 +1,10 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -const SUPABASE_URL = "https://dnuuhupoxjtwqzaqylvb.supabase.co"; -const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRudXVodXBveGp0d3F6YXF5bHZiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk3MDIzOTMsImV4cCI6MjA3NTI3ODM5M30.W6CvYhQlRcsKV6NJLU99aAI4-woHpYZ63hZbD4WeTW4"; -const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); + + +const supabaseUrl = Deno.env.get("SUPABASE_URL"); +const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); +const supabase = createClient(supabaseUrl, anonKey); async function getMachineStatusFromSupabase(deviceId) { try { const { data, error } = await supabase.from('Machines').select('Status').eq('id', deviceId).single(); From 668d5989a1757bf6bf56aaacdc5bd2e4f53d3039 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 20:05:47 -0500 Subject: [PATCH 074/141] Adds test for createCheckoutSession --- deno.lock | 20 ++- .../functions/createCheckoutSession/index.ts | 98 +++++--------- .../createCheckoutSession/logic.test.ts | 126 ++++++++++++++++++ .../functions/createCheckoutSession/logic.ts | 72 ++++++++++ 4 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 supabase/functions/createCheckoutSession/logic.test.ts create mode 100644 supabase/functions/createCheckoutSession/logic.ts diff --git a/deno.lock b/deno.lock index a4a5ca04..65246257 100644 --- a/deno.lock +++ b/deno.lock @@ -270,9 +270,15 @@ } }, "redirects": { - "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts" + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts", + "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.97.0", + "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext" }, "remote": { + "https://deno.land/std@0.168.0/fmt/colors.ts": "03ad95e543d2808bc43c17a3dd29d25b43d0f16287fe562a0be89bf632454a12", + "https://deno.land/std@0.168.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", + "https://deno.land/std@0.168.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.168.0/testing/asserts.ts": "51353e79437361d4b02d8e32f3fc83b22231bc8f8d4c841d86fd32b0b0afe940", "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", @@ -306,7 +312,17 @@ "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", - "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c" + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c", + "https://esm.sh/@supabase/auth-js@2.97.0/denonext/auth-js.mjs": "1647921d7d6c64ddb79cab6feefc5b8f373a97c6421695ae5af69514e42eacf3", + "https://esm.sh/@supabase/functions-js@2.97.0/denonext/functions-js.mjs": "89de9a4b9ff7b3d000c84989e586a53b3905f3e0bf3570b7595e24694b6d4e9e", + "https://esm.sh/@supabase/postgrest-js@2.97.0/denonext/postgrest-js.mjs": "7cdf9ffe7047f77421dad5af748d5e0e43b6253debdd85f3d71a9dec7bea39b6", + "https://esm.sh/@supabase/realtime-js@2.97.0/denonext/realtime-js.mjs": "4633785191d028eb751065b5f65443e982f14e68840f8c6559cdd300fa37bc4b", + "https://esm.sh/@supabase/storage-js@2.97.0/denonext/storage-js.mjs": "aed9af8fb795068fb6fb945f76bfe40a4dce0a84e22b60f333ee029b62c91f22", + "https://esm.sh/@supabase/supabase-js@2.97.0": "dcdf60d835a7a2edf75c186e8bb33fa5f43fec7dec96c93c89abfbb20b78245d", + "https://esm.sh/@supabase/supabase-js@2.97.0/denonext/supabase-js.mjs": "745f4cc1905e434b7a54fa2d48d875c920f1ef5d8ad9a964f8b079e3c7bd25ca", + "https://esm.sh/iceberg-js@0.8.1/denonext/iceberg-js.mjs": "d839d81a2e3966500ca2cdd0c1cb458e9608bacfc91f7bb67a69b2e878dcdb4f", + "https://esm.sh/iceberg-js@0.8.1?target=denonext": "58c849d7fe2bf4eca4a84eb501e83c161a7d8c34ca1bec15a962b3bec3062633", + "https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "c38da5dd6da6281964435002ce204cd586634fe3bdfb24a0ee116f48cf3292e9" }, "workspace": { "packageJson": { diff --git a/supabase/functions/createCheckoutSession/index.ts b/supabase/functions/createCheckoutSession/index.ts index 5fd739ec..9e772616 100644 --- a/supabase/functions/createCheckoutSession/index.ts +++ b/supabase/functions/createCheckoutSession/index.ts @@ -1,80 +1,44 @@ import Stripe from "npm:stripe@^14.0.0"; import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { - apiVersion: "2024-06-20" -}); -serve(async (req)=>{ - const headers = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info" - }; +import { handleCheckout } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; + +serve(async (req) => { if (req.method === "OPTIONS") { - return new Response(null, { - headers - }); + return new Response(null, { headers: CORS_HEADERS }); } + try { - const { amount } = await req.json(); - // ✅ Proper token extraction for Edge Functions const authHeader = req.headers.get("Authorization") || ""; const token = authHeader.replace("Bearer ", ""); - const supabase = createClient(Deno.env.get("SUPABASE_URL"), Deno.env.get("SUPABASE_ANON_KEY"), { - global: { - headers: token ? { - Authorization: `Bearer ${token}` - } : {} - } - }); - // ✅ Correct Edge Function way to get current user - const { data: { user }, error: userError } = await supabase.auth.getUser(); - if (userError || !user) { - console.error("Auth error:", userError); - return new Response("Unauthorized", { - status: 401 - }); - } - // --- Stripe Checkout --- - const session = await stripe.checkout.sessions.create({ - mode: "payment", - payment_method_types: [ - "card" - ], - line_items: [ - { - price_data: { - currency: "usd", - product_data: { - name: "Laundry Service" - }, - unit_amount: amount - }, - quantity: 1 - } - ], - metadata: { - user_id: user.id - }, - success_url: "http://localhost:8080/homaPage", - cancel_url: "http://localhost:8080/homePage" + + const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2024-06-20", }); - return new Response(JSON.stringify({ - url: session.url, - session_id: session.id - }), { - headers: { - ...headers, - "Content-Type": "application/json" - } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_ANON_KEY")!, + { global: { headers: token ? { Authorization: `Bearer ${token}` } : {} } } + ); + + const response = await handleCheckout(req, { stripe, supabase }); + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, }); } catch (err) { - console.error(err); - return new Response(JSON.stringify({ - error: err.message - }), { - status: 400, - headers + const status = err.message === "Unauthorized" ? 401 : 400; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: CORS_HEADERS, }); } -}); +}); \ No newline at end of file diff --git a/supabase/functions/createCheckoutSession/logic.test.ts b/supabase/functions/createCheckoutSession/logic.test.ts new file mode 100644 index 00000000..25a8314e --- /dev/null +++ b/supabase/functions/createCheckoutSession/logic.test.ts @@ -0,0 +1,126 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + getAuthenticatedUser, + createCheckoutSession, + } from "./logic.ts"; + + function makeSupabaseMock(user: object | null, error: object | null = null) { + return { + auth: { + getUser: () => Promise.resolve({ data: { user }, error }), + }, + } as any; + } + + function makeStripeMock(overrides?: Partial<{ url: string; id: string }>) { + return { + checkout: { + sessions: { + create: (_params: unknown) => + Promise.resolve({ + url: overrides?.url ?? "https://checkout.stripe.com/pay/test_session", + id: overrides?.id ?? "cs_test_abc123", + }), + }, + }, + } as any; + } + + Deno.test("getAuthenticatedUser — returns user when authenticated", async () => { + const mockUser = { id: "user-123", email: "test@example.com" }; + const supabase = makeSupabaseMock(mockUser); + + const user = await getAuthenticatedUser(supabase); + assertEquals(user.id, "user-123"); + }); + + Deno.test("getAuthenticatedUser — throws when user is null", async () => { + const supabase = makeSupabaseMock(null); + + await assertRejects( + () => getAuthenticatedUser(supabase), + Error, + "Unauthorized" + ); + }); + + Deno.test("getAuthenticatedUser — throws when Supabase returns an error", async () => { + const supabase = makeSupabaseMock(null, { message: "JWT expired" }); + + await assertRejects( + () => getAuthenticatedUser(supabase), + Error, + "Unauthorized" + ); + }); + + Deno.test("createCheckoutSession — returns url and session_id", async () => { + const stripe = makeStripeMock(); + + const result = await createCheckoutSession(stripe, 2500, "user-123"); + + assertEquals(result.session_id, "cs_test_abc123"); + assertEquals(result.url, "https://checkout.stripe.com/pay/test_session"); + }); + + Deno.test("createCheckoutSession — passes correct amount to Stripe", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + await createCheckoutSession(stripe, 4999, "user-456"); + + assertEquals(capturedParams.line_items[0].price_data.unit_amount, 4999); + assertEquals(capturedParams.metadata.user_id, "user-456"); + }); + + Deno.test("createCheckoutSession — throws on invalid amount (zero)", async () => { + const stripe = makeStripeMock(); + + await assertRejects( + () => createCheckoutSession(stripe, 0, "user-123"), + Error, + "Invalid amount" + ); + }); + + Deno.test("createCheckoutSession — throws on negative amount", async () => { + const stripe = makeStripeMock(); + + await assertRejects( + () => createCheckoutSession(stripe, -100, "user-123"), + Error, + "Invalid amount" + ); + }); + + Deno.test("createCheckoutSession — uses custom baseUrl for redirect URLs", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + await createCheckoutSession(stripe, 1000, "user-123", "https://myapp.com"); + + assertEquals(capturedParams.success_url, "https://myapp.com/homePage"); + assertEquals(capturedParams.cancel_url, "https://myapp.com/homePage"); + }); + \ No newline at end of file diff --git a/supabase/functions/createCheckoutSession/logic.ts b/supabase/functions/createCheckoutSession/logic.ts new file mode 100644 index 00000000..6fb58fb3 --- /dev/null +++ b/supabase/functions/createCheckoutSession/logic.ts @@ -0,0 +1,72 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export interface CheckoutDeps { + stripe: Stripe; + supabase: SupabaseClient; +} + +export interface CheckoutResult { + url: string | null; + session_id: string; +} + +export async function getAuthenticatedUser(supabase: SupabaseClient) { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + throw new Error("Unauthorized"); + } + + return user; +} + +export async function createCheckoutSession( + stripe: Stripe, + amount: number, + userId: string, + baseUrl = "http://localhost:8080" +): Promise { + if (!amount || typeof amount !== "number" || amount <= 0) { + throw new Error("Invalid amount"); + } + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card"], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { name: "Laundry Service" }, + unit_amount: amount, + }, + quantity: 1, + }, + ], + metadata: { user_id: userId }, + success_url: `${baseUrl}/homePage`, + cancel_url: `${baseUrl}/homePage`, + }); + + return { url: session.url, session_id: session.id }; +} + + +export async function handleCheckout( + req: Request, + deps: CheckoutDeps, + baseUrl?: string +): Promise { + const { amount } = await req.json(); + + const user = await getAuthenticatedUser(deps.supabase); + const result = await createCheckoutSession(deps.stripe, amount, user.id, baseUrl); + + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file From bbf76f1bad08ab82510131c811279c51dcdb1997 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 20:16:09 -0500 Subject: [PATCH 075/141] Adds tests for deleteAccount --- deno.lock | 34 +++++- supabase/functions/delete-account/index.ts | 63 ++++------- .../functions/delete-account/logic.test.ts | 107 ++++++++++++++++++ supabase/functions/delete-account/logic.ts | 45 ++++++++ 4 files changed, 207 insertions(+), 42 deletions(-) create mode 100644 supabase/functions/delete-account/logic.test.ts create mode 100644 supabase/functions/delete-account/logic.ts diff --git a/deno.lock b/deno.lock index 65246257..826d87c9 100644 --- a/deno.lock +++ b/deno.lock @@ -271,14 +271,27 @@ }, "redirects": { "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts", + "https://esm.sh/@supabase/functions-js@^2.1.0?target=denonext": "https://esm.sh/@supabase/functions-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/gotrue-js@^2.18.1?target=denonext": "https://esm.sh/@supabase/gotrue-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/postgrest-js@^1.1.1?target=denonext": "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext", + "https://esm.sh/@supabase/realtime-js@^2.7.1?target=denonext": "https://esm.sh/@supabase/realtime-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/storage-js@^2.3.1?target=denonext": "https://esm.sh/@supabase/storage-js@2.97.0?target=denonext", "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.97.0", - "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext" + "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext", + "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", + "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", + "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" }, "remote": { "https://deno.land/std@0.168.0/fmt/colors.ts": "03ad95e543d2808bc43c17a3dd29d25b43d0f16287fe562a0be89bf632454a12", "https://deno.land/std@0.168.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", "https://deno.land/std@0.168.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", "https://deno.land/std@0.168.0/testing/asserts.ts": "51353e79437361d4b02d8e32f3fc83b22231bc8f8d4c841d86fd32b0b0afe940", + "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.182.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.182.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.182.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", @@ -315,14 +328,31 @@ "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c", "https://esm.sh/@supabase/auth-js@2.97.0/denonext/auth-js.mjs": "1647921d7d6c64ddb79cab6feefc5b8f373a97c6421695ae5af69514e42eacf3", "https://esm.sh/@supabase/functions-js@2.97.0/denonext/functions-js.mjs": "89de9a4b9ff7b3d000c84989e586a53b3905f3e0bf3570b7595e24694b6d4e9e", + "https://esm.sh/@supabase/functions-js@2.97.0?target=denonext": "32cf95c9eb181b1c450e0bfb6e1cdc96bac8c770b02510a3c01be6687d7e764f", + "https://esm.sh/@supabase/gotrue-js@2.97.0/denonext/gotrue-js.mjs": "1e92f463966f625a3dc3b8936399480db099a3514c6df4ef861f23ed25d7ae7a", + "https://esm.sh/@supabase/gotrue-js@2.97.0?target=denonext": "a375a791f3e023b8a0ebea041c16c48bb8fd0e3ada1248ae9494f3b636ee4819", + "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", + "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext": "db2315bc0ff19690cd4239c5adb5f5787f5dea04955058ef1fca541d49555ed6", "https://esm.sh/@supabase/postgrest-js@2.97.0/denonext/postgrest-js.mjs": "7cdf9ffe7047f77421dad5af748d5e0e43b6253debdd85f3d71a9dec7bea39b6", "https://esm.sh/@supabase/realtime-js@2.97.0/denonext/realtime-js.mjs": "4633785191d028eb751065b5f65443e982f14e68840f8c6559cdd300fa37bc4b", + "https://esm.sh/@supabase/realtime-js@2.97.0?target=denonext": "b52f86d9d6b74c8c914ae9a77135db4d0b68f76081eb4414bfeb1815a78941d6", "https://esm.sh/@supabase/storage-js@2.97.0/denonext/storage-js.mjs": "aed9af8fb795068fb6fb945f76bfe40a4dce0a84e22b60f333ee029b62c91f22", + "https://esm.sh/@supabase/storage-js@2.97.0?target=denonext": "40761f5969795d9628f8a16712c50446018f6fb438512313d5be87b0c1874eb6", + "https://esm.sh/@supabase/supabase-js@2.14.0": "c277fa7166609cff04bb767940520ccbc912e801e24094e7175fcf83c8060d20", + "https://esm.sh/@supabase/supabase-js@2.14.0/denonext/supabase-js.mjs": "a12dd486a259c7b6f1054ff7fd19ec77024ad0168de22c7ce81ad5349d02fac0", "https://esm.sh/@supabase/supabase-js@2.97.0": "dcdf60d835a7a2edf75c186e8bb33fa5f43fec7dec96c93c89abfbb20b78245d", "https://esm.sh/@supabase/supabase-js@2.97.0/denonext/supabase-js.mjs": "745f4cc1905e434b7a54fa2d48d875c920f1ef5d8ad9a964f8b079e3c7bd25ca", "https://esm.sh/iceberg-js@0.8.1/denonext/iceberg-js.mjs": "d839d81a2e3966500ca2cdd0c1cb458e9608bacfc91f7bb67a69b2e878dcdb4f", "https://esm.sh/iceberg-js@0.8.1?target=denonext": "58c849d7fe2bf4eca4a84eb501e83c161a7d8c34ca1bec15a962b3bec3062633", - "https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "c38da5dd6da6281964435002ce204cd586634fe3bdfb24a0ee116f48cf3292e9" + "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", + "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "c38da5dd6da6281964435002ce204cd586634fe3bdfb24a0ee116f48cf3292e9", + "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "238cd0743827707cbc1c2d7c2cf1027dbc536fb53ec9a3fde0ff8026a3ac5385", + "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", + "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", + "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" }, "workspace": { "packageJson": { diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts index 0f7cb7b6..f3c5efdf 100644 --- a/supabase/functions/delete-account/index.ts +++ b/supabase/functions/delete-account/index.ts @@ -1,52 +1,35 @@ -import { serve } from 'https://deno.land/std@0.182.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.14.0'; +import { serve } from "https://deno.land/std@0.182.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.14.0"; +import { handleDeleteUser } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; serve(async (req) => { - const headers = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info" - }; if (req.method === "OPTIONS") { - return new Response(null, { - headers - }); + return new Response(null, { headers: CORS_HEADERS }); } try { - const { user_id } = await req.json(); - - if (!user_id) { - return new Response( - JSON.stringify({ error: 'Missing user_id' }), - { status: 400, headers } - ); - } - const supabaseAdmin = createClient( - Deno.env.get('SUPABASE_URL')!, - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); - const { data, error } = - await supabaseAdmin.auth.admin.deleteUser(user_id); - - if (error) { - console.error(error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers } - ); - } + const response = await handleDeleteUser(req, { supabaseAdmin }); - return new Response( - JSON.stringify({ success: true, data }), - { status: 200, headers } - ); + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); } catch (err) { - return new Response( - JSON.stringify({ error: err.message }), - { status: 400, headers } - ); + const status = err.message === "Missing user_id" ? 400 : 500; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: CORS_HEADERS, + }); } -}); +}); \ No newline at end of file diff --git a/supabase/functions/delete-account/logic.test.ts b/supabase/functions/delete-account/logic.test.ts new file mode 100644 index 00000000..3c7d35cf --- /dev/null +++ b/supabase/functions/delete-account/logic.test.ts @@ -0,0 +1,107 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.182.0/testing/asserts.ts"; + import { + validateUserId, + deleteUser, + } from "./logic.ts"; + + function makeAdminMock( + result: { data?: unknown; error?: { message: string } | null } = {} + ) { + return { + auth: { + admin: { + deleteUser: (_id: string) => + Promise.resolve({ + data: result.data ?? { user: { id: _id } }, + error: result.error ?? null, + }), + }, + }, + } as any; + } + + + Deno.test("validateUserId — accepts a valid UUID string", () => { + const id = "550e8400-e29b-41d4-a716-446655440000"; + assertEquals(validateUserId(id), id); + }); + + Deno.test("validateUserId — throws on null", () => { + try { + validateUserId(null); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on undefined", () => { + try { + validateUserId(undefined); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on empty string", () => { + try { + validateUserId(" "); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on non-string type", () => { + try { + validateUserId(12345); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + + Deno.test("deleteUser — returns success and data on valid user", async () => { + const mockData = { user: { id: "user-abc" } }; + const supabaseAdmin = makeAdminMock({ data: mockData }); + + const result = await deleteUser(supabaseAdmin, "user-abc"); + + assertEquals(result.success, true); + assertEquals(result.data, mockData); + }); + + Deno.test("deleteUser — throws when Supabase returns an error", async () => { + const supabaseAdmin = makeAdminMock({ + error: { message: "User not found" }, + }); + + await assertRejects( + () => deleteUser(supabaseAdmin, "ghost-user"), + Error, + "User not found" + ); + }); + + Deno.test("deleteUser — forwards the correct user_id to Supabase", async () => { + let capturedId: string | undefined; + const supabaseAdmin = { + auth: { + admin: { + deleteUser: (id: string) => { + capturedId = id; + return Promise.resolve({ data: {}, error: null }); + }, + }, + }, + } as any; + + await deleteUser(supabaseAdmin, "user-xyz"); + assertEquals(capturedId, "user-xyz"); + }); + \ No newline at end of file diff --git a/supabase/functions/delete-account/logic.ts b/supabase/functions/delete-account/logic.ts new file mode 100644 index 00000000..9ab1c7ce --- /dev/null +++ b/supabase/functions/delete-account/logic.ts @@ -0,0 +1,45 @@ +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.14.0"; + +export interface DeleteUserDeps { + supabaseAdmin: SupabaseClient; +} + +export interface DeleteUserResult { + success: true; + data: unknown; +} + +export function validateUserId(user_id: unknown): string { + if (!user_id || typeof user_id !== "string" || user_id.trim() === "") { + throw new Error("Missing user_id"); + } + return user_id; +} + + +export async function deleteUser( + supabaseAdmin: SupabaseClient, + user_id: string +): Promise { + const { data, error } = await supabaseAdmin.auth.admin.deleteUser(user_id); + + if (error) { + throw new Error(error.message); + } + + return { success: true, data }; +} + +export async function handleDeleteUser( + req: Request, + deps: DeleteUserDeps +): Promise { + const body = await req.json(); + const user_id = validateUserId(body?.user_id); + const result = await deleteUser(deps.supabaseAdmin, user_id); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file From f5c27c207b740d7c382ff4c5533c014183d919af Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 20:49:11 -0500 Subject: [PATCH 076/141] Adds tests for denyRefundEmail --- supabase/functions/denyRefund/index.ts | 129 +++------- supabase/functions/denyRefund/logic.test.ts | 256 ++++++++++++++++++++ supabase/functions/denyRefund/logic.ts | 115 +++++++++ 3 files changed, 405 insertions(+), 95 deletions(-) create mode 100644 supabase/functions/denyRefund/logic.test.ts create mode 100644 supabase/functions/denyRefund/logic.ts diff --git a/supabase/functions/denyRefund/index.ts b/supabase/functions/denyRefund/index.ts index 0dfab798..d296c0b5 100644 --- a/supabase/functions/denyRefund/index.ts +++ b/supabase/functions/denyRefund/index.ts @@ -1,117 +1,56 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -const corsHeaders = { +import { handleDenyRefund, sendDenialEmail } from "./logic.ts"; + +const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey" + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", }; -serve(async (req)=>{ + +serve(async (req) => { if (req.method === "OPTIONS") { - return new Response(null, { - status: 200, - headers: corsHeaders - }); + return new Response(null, { status: 200, headers: CORS_HEADERS }); } + try { - const url = new URL(req.url); - const userId = url.searchParams.get("user_id"); - const transactionId = url.searchParams.get("transaction_id"); - const amount = url.searchParams.get("amount"); - console.log("Deny request - Received params:", { - userId, - transactionId, - amount - }); - if (!userId || !transactionId || !amount) { - return new Response("Missing params", { - status: 400, - headers: corsHeaders - }); - } const supabaseUrl = Deno.env.get("SUPABASE_URL"); const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); const resendKey = Deno.env.get("RESEND_API_KEY"); + if (!supabaseUrl || !serviceKey) { return new Response("Server configuration error", { status: 500, - headers: corsHeaders + headers: CORS_HEADERS, }); } - // Create Supabase client with service role key + const supabase = createClient(supabaseUrl, serviceKey, { - auth: { - autoRefreshToken: false, - persistSession: false - } + auth: { autoRefreshToken: false, persistSession: false }, }); - const { error: refundUpdateError } = await supabase.from("Refunds").update({ - status: "denied" - }).eq("transaction_id", transactionId); - if (refundUpdateError) { - console.error("Refund update error:", refundUpdateError); - return new Response(`Refund update error: ${refundUpdateError.message}`, { - status: 500, - headers: corsHeaders - }); - } - console.log("Fetching user email..."); - // Fetch user email from auth - const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userId); - if (userError || !user) { - console.error("User fetch error:", userError); - return new Response(`User not found: ${userError?.message || 'No data'}`, { - status: 404, - headers: corsHeaders - }); - } - const userEmail = user.email; - if (!userEmail) { - return new Response("User email not found", { - status: 404, - headers: corsHeaders - }); - } - console.log("Sending denial email to:", userEmail); - // Send denial email - const emailResponse = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${resendKey}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - from: "refund@updates.cleanstreamlaundry.com", - to: userEmail, - subject: "Refund Request Denied", - html: ` -

Refund Request Denied

-

Unfortunately, your refund request for transaction ${transactionId} has been denied.

-

Amount: $${amount}

-

If you have questions about this decision, please contact support.

- ` - }) + + const response = await handleDenyRefund(req, { + supabase, + sendEmail: (to, transactionId, amount) => + sendDenialEmail(resendKey!, to, transactionId, amount), }); - console.log("Email response status:", emailResponse.status); - if (!emailResponse.ok) { - const errorText = await emailResponse.text(); - console.error("Email send failed:", errorText); - } - const html = `Refund Denied - The refund request has been denied and - the customer has been notified via email. - Transaction:${transactionId} - Amount:$${amount} - `; - return new Response(html, { - headers: { - "Content-Type": "text/html" - } + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, }); } catch (e) { - console.error("Caught error:", e); - return new Response(`Error: ${e.message}`, { - status: 500, - headers: corsHeaders - }); + const err = e instanceof Error ? e : new Error(String(e)); + + const statusMap: Record = { + "Missing params": 400, + "User email not found": 404, + }; + const status = + err.message.startsWith("User not found") ? 404 + : err.message.startsWith("Refund update error") ? 500 + : statusMap[err.message] ?? 500; + + return new Response(`Error: ${err.message}`, { status, headers: CORS_HEADERS }); } -}); +}); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.test.ts b/supabase/functions/denyRefund/logic.test.ts new file mode 100644 index 00000000..5100ce97 --- /dev/null +++ b/supabase/functions/denyRefund/logic.test.ts @@ -0,0 +1,256 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + extractParams, + denyRefundInDb, + getUserEmail, + handleDenyRefund, + } from "./logic.ts"; + + function makeUrl(params: Record) { + const url = new URL("http://localhost/deny-refund"); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + return url; + } + + function makeFullUrl(overrides: Partial> = {}) { + return makeUrl({ + user_id: "user-123", + transaction_id: "txn-abc", + amount: "25.00", + ...overrides, + }); + } + + function makeSupabaseMock(overrides: { + updateError?: { message: string } | null; + user?: { id: string; email?: string } | null; + userError?: { message: string } | null; + } = {}) { + return { + from: (_table: string) => ({ + update: (_data: unknown) => ({ + eq: (_col: string, _val: string) => + Promise.resolve({ error: overrides.updateError ?? null }), + }), + }), + auth: { + admin: { + getUserById: (_id: string) => + Promise.resolve({ + data: { user: overrides.user !== undefined ? overrides.user : { id: "user-123", email: "user@example.com" } }, + error: overrides.userError ?? null, + }), + }, + }, + } as any; + } + + function makeRequest(url: URL) { + return new Request(url.toString(), { method: "GET" }); + } + + Deno.test("extractParams — returns all three params when present", () => { + const url = makeFullUrl(); + const params = extractParams(url); + + assertEquals(params.userId, "user-123"); + assertEquals(params.transactionId, "txn-abc"); + assertEquals(params.amount, "25.00"); + }); + + Deno.test("extractParams — throws when user_id is missing", () => { + const url = makeUrl({ transaction_id: "txn-abc", amount: "25.00" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when transaction_id is missing", () => { + const url = makeUrl({ user_id: "user-123", amount: "25.00" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when amount is missing", () => { + const url = makeUrl({ user_id: "user-123", transaction_id: "txn-abc" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when all params are missing", () => { + const url = new URL("http://localhost/deny-refund"); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("denyRefundInDb — resolves without error on success", async () => { + const supabase = makeSupabaseMock(); + // should not throw + await denyRefundInDb(supabase, "txn-abc"); + }); + + Deno.test("denyRefundInDb — throws with prefixed message on Supabase error", async () => { + const supabase = makeSupabaseMock({ updateError: { message: "row not found" } }); + + await assertRejects( + () => denyRefundInDb(supabase, "txn-abc"), + Error, + "Refund update error: row not found" + ); + }); + + Deno.test("denyRefundInDb — passes correct transaction_id to Supabase", async () => { + let capturedVal: string | undefined; + const supabase = { + from: (_table: string) => ({ + update: (_data: unknown) => ({ + eq: (_col: string, val: string) => { + capturedVal = val; + return Promise.resolve({ error: null }); + }, + }), + }), + } as any; + + await denyRefundInDb(supabase, "txn-xyz"); + assertEquals(capturedVal, "txn-xyz"); + }); + + Deno.test("getUserEmail — returns email on valid user", async () => { + const supabase = makeSupabaseMock({ user: { id: "user-123", email: "user@example.com" } }); + + const email = await getUserEmail(supabase, "user-123"); + assertEquals(email, "user@example.com"); + }); + + Deno.test("getUserEmail — throws when user is null", async () => { + const supabase = makeSupabaseMock({ user: null }); + + await assertRejects( + () => getUserEmail(supabase, "ghost"), + Error, + "User not found" + ); + }); + + Deno.test("getUserEmail — throws when Supabase returns an error", async () => { + const supabase = makeSupabaseMock({ userError: { message: "JWT invalid" }, user: null }); + + await assertRejects( + () => getUserEmail(supabase, "user-123"), + Error, + "User not found: JWT invalid" + ); + }); + + Deno.test("getUserEmail — throws when user has no email", async () => { + const supabase = makeSupabaseMock({ user: { id: "user-123" } }); // no email field + + await assertRejects( + () => getUserEmail(supabase, "user-123"), + Error, + "User email not found" + ); + }); + + Deno.test("handleDenyRefund — returns 200 with confirmation HTML on success", async () => { + const req = makeRequest(makeFullUrl()); + const sendEmail = (_to: string, _txn: string, _amt: string) => Promise.resolve(); + + const res = await handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail, + }); + const body = await res.text(); + + assertEquals(res.status, 200); + assertEquals(body.includes("txn-abc"), true); + assertEquals(body.includes("25.00"), true); + }); + + Deno.test("handleDenyRefund — calls sendEmail with correct args", async () => { + const req = makeRequest(makeFullUrl()); + let capturedArgs: [string, string, string] | undefined; + + await handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: (to, txn, amt) => { + capturedArgs = [to, txn, amt]; + return Promise.resolve(); + }, + }); + + assertEquals(capturedArgs, ["user@example.com", "txn-abc", "25.00"]); + }); + + Deno.test("handleDenyRefund — throws Missing params when query params absent", async () => { + const req = new Request("http://localhost/deny-refund", { method: "GET" }); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: () => Promise.resolve(), + }), + Error, + "Missing params" + ); + }); + + Deno.test("handleDenyRefund — throws when DB update fails", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock({ updateError: { message: "constraint violation" } }), + sendEmail: () => Promise.resolve(), + }), + Error, + "Refund update error: constraint violation" + ); + }); + + Deno.test("handleDenyRefund — throws when user is not found", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock({ user: null }), + sendEmail: () => Promise.resolve(), + }), + Error, + "User not found" + ); + }); + + Deno.test("handleDenyRefund — throws when sendEmail fails", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: () => Promise.reject(new Error("Email send failed: 422")), + }), + Error, + "Email send failed" + ); + }); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.ts b/supabase/functions/denyRefund/logic.ts new file mode 100644 index 00000000..5020cadb --- /dev/null +++ b/supabase/functions/denyRefund/logic.ts @@ -0,0 +1,115 @@ +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export interface DenyRefundParams { + userId: string; + transactionId: string; + amount: string; +} + +export interface DenyRefundDeps { + supabase: SupabaseClient; + sendEmail: (to: string, transactionId: string, amount: string) => Promise; +} + +export function extractParams(url: URL): DenyRefundParams { + const userId = url.searchParams.get("user_id"); + const transactionId = url.searchParams.get("transaction_id"); + const amount = url.searchParams.get("amount"); + + if (!userId || !transactionId || !amount) { + throw new Error("Missing params"); + } + + return { userId, transactionId, amount }; +} + +export async function denyRefundInDb( + supabase: SupabaseClient, + transactionId: string +): Promise { + const { error } = await supabase + .from("Refunds") + .update({ status: "denied" }) + .eq("transaction_id", transactionId); + + if (error) { + throw new Error(`Refund update error: ${error.message}`); + } +} + +export async function getUserEmail( + supabase: SupabaseClient, + userId: string +): Promise { + const { + data: { user }, + error, + } = await supabase.auth.admin.getUserById(userId); + + if (error || !user) { + throw new Error(`User not found: ${error?.message ?? "No data"}`); + } + + if (!user.email) { + throw new Error("User email not found"); + } + + return user.email; +} + +export async function sendDenialEmail( + resendApiKey: string, + to: string, + transactionId: string, + amount: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to, + subject: "Refund Request Denied", + html: ` +

Refund Request Denied

+

Unfortunately, your refund request for transaction ${transactionId} has been denied.

+

Amount: $${amount}

+

If you have questions about this decision, please contact support.

+ `, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Email send failed: ${errorText}`); + } +} + +export async function handleDenyRefund( + req: Request, + deps: DenyRefundDeps +): Promise { + const url = new URL(req.url); + const { userId, transactionId, amount } = extractParams(url); + + await denyRefundInDb(deps.supabase, transactionId); + + const userEmail = await getUserEmail(deps.supabase, userId); + + await deps.sendEmail(userEmail, transactionId, amount); + + const html = `Refund Denied + The refund request has been denied and + the customer has been notified via email. + Transaction: ${transactionId} + Amount: $${amount} + `; + + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html" }, + }); +} \ No newline at end of file From 99e4dfe861fa3dd80348da7ac43de16551bcadec Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 20:54:49 -0500 Subject: [PATCH 077/141] Adds test for paymentIntent --- deno.lock | 1 + supabase/functions/paymentIntent/index.ts | 73 +++---- .../functions/paymentIntent/logic.test.ts | 189 ++++++++++++++++++ supabase/functions/paymentIntent/logic.ts | 46 +++++ 4 files changed, 263 insertions(+), 46 deletions(-) create mode 100644 supabase/functions/paymentIntent/logic.test.ts create mode 100644 supabase/functions/paymentIntent/logic.ts diff --git a/deno.lock b/deno.lock index 826d87c9..89be6f73 100644 --- a/deno.lock +++ b/deno.lock @@ -2,6 +2,7 @@ "version": "5", "specifiers": { "npm:@types/node@^25.3.0": "25.3.0", + "npm:stripe@*": "14.25.0", "npm:stripe@14": "14.25.0", "npm:supabase@^2.76.14": "2.76.14" }, diff --git a/supabase/functions/paymentIntent/index.ts b/supabase/functions/paymentIntent/index.ts index 823ad202..8b85ae0c 100644 --- a/supabase/functions/paymentIntent/index.ts +++ b/supabase/functions/paymentIntent/index.ts @@ -1,59 +1,40 @@ import Stripe from "npm:stripe"; +import { handleCreatePaymentIntent } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20", }); Deno.serve(async (req) => { - // Handle CORS preflight if (req.method === "OPTIONS") { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", - }, - }); + return new Response(null, { headers: CORS_HEADERS }); } - if (req.method === "POST") { - try { - const { amount } = await req.json(); - - console.log("Creating PaymentIntent for:", amount); + if (req.method !== "POST") { + return new Response("Method Not Allowed", { + status: 405, + headers: CORS_HEADERS, + }); + } - const intent = await stripe.paymentIntents.create({ - amount, - currency: "usd", - payment_method_types: ["card"], - }); + try { + const response = await handleCreatePaymentIntent(req, { stripe }); - return new Response( - JSON.stringify({ clientSecret: intent.client_secret }), - { - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - } - ); - } catch (err) { - console.error("Stripe error:", err); - return new Response( - JSON.stringify({ error: err.message ?? "Unknown error" }), - { - status: 400, - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - } - ); - } + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return new Response(JSON.stringify({ error: error.message ?? "Unknown error" }), { + status: 400, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + }); } - - return new Response("Method Not Allowed", { - status: 405, - headers: { "Access-Control-Allow-Origin": "*" }, - }); -}); +}); \ No newline at end of file diff --git a/supabase/functions/paymentIntent/logic.test.ts b/supabase/functions/paymentIntent/logic.test.ts new file mode 100644 index 00000000..93fc7fb1 --- /dev/null +++ b/supabase/functions/paymentIntent/logic.test.ts @@ -0,0 +1,189 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + validateAmount, + createPaymentIntent, + handleCreatePaymentIntent, + } from "./logic.ts"; + + function makeStripeMock(overrides: { + clientSecret?: string | null; + throwMessage?: string; + } = {}) { + return { + paymentIntents: { + create: (_params: unknown) => { + if (overrides.throwMessage) { + return Promise.reject(new Error(overrides.throwMessage)); + } + const clientSecret = "clientSecret" in overrides + ? overrides.clientSecret + : "pi_test_secret_abc123"; + return Promise.resolve({ client_secret: clientSecret }); + }, + }, + } as any; + } + + function makeRequest(body: unknown) { + return new Request("http://localhost/create-payment-intent", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } + + Deno.test("validateAmount — accepts a valid positive integer", () => { + assertEquals(validateAmount(2500), 2500); + }); + + Deno.test("validateAmount — accepts the minimum valid amount (1 cent)", () => { + assertEquals(validateAmount(1), 1); + }); + + Deno.test("validateAmount — throws on undefined", () => { + try { + validateAmount(undefined); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing amount"); + } + }); + + Deno.test("validateAmount — throws on null", () => { + try { + validateAmount(null); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing amount"); + } + }); + + Deno.test("validateAmount — throws on zero", () => { + try { + validateAmount(0); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on negative number", () => { + try { + validateAmount(-500); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on float (Stripe requires whole cents)", () => { + try { + validateAmount(24.99); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on string", () => { + try { + validateAmount("2500"); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("createPaymentIntent — returns clientSecret on success", async () => { + const stripe = makeStripeMock(); + const result = await createPaymentIntent(stripe, 2500); + + assertEquals(result.clientSecret, "pi_test_secret_abc123"); + }); + + Deno.test("createPaymentIntent — passes correct amount to Stripe", async () => { + let capturedParams: any; + const stripe = { + paymentIntents: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ client_secret: "pi_test_secret" }); + }, + }, + } as any; + + await createPaymentIntent(stripe, 4999); + + assertEquals(capturedParams.amount, 4999); + assertEquals(capturedParams.currency, "usd"); + assertEquals(capturedParams.payment_method_types, ["card"]); + }); + + Deno.test("createPaymentIntent — handles null client_secret from Stripe", async () => { + const stripe = makeStripeMock({ clientSecret: null }); + const result = await createPaymentIntent(stripe, 2500); + + assertEquals(result.clientSecret, null); + }); + + Deno.test("createPaymentIntent — throws when Stripe rejects", async () => { + const stripe = makeStripeMock({ throwMessage: "Your card was declined." }); + + await assertRejects( + () => createPaymentIntent(stripe, 2500), + Error, + "Your card was declined." + ); + }); + + + Deno.test("handleCreatePaymentIntent — returns 200 with clientSecret", async () => { + const req = makeRequest({ amount: 2500 }); + const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.clientSecret, "pi_test_secret_abc123"); + }); + + Deno.test("handleCreatePaymentIntent — throws Missing amount when body has no amount", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }), + Error, + "Missing amount" + ); + }); + + Deno.test("handleCreatePaymentIntent — throws Invalid amount for a float", async () => { + const req = makeRequest({ amount: 19.99 }); + + await assertRejects( + () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCreatePaymentIntent — throws when Stripe fails", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCreatePaymentIntent(req, { + stripe: makeStripeMock({ throwMessage: "Stripe API unavailable" }), + }), + Error, + "Stripe API unavailable" + ); + }); + + Deno.test("handleCreatePaymentIntent — response Content-Type is application/json", async () => { + const req = makeRequest({ amount: 1000 }); + const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); \ No newline at end of file diff --git a/supabase/functions/paymentIntent/logic.ts b/supabase/functions/paymentIntent/logic.ts new file mode 100644 index 00000000..a308762b --- /dev/null +++ b/supabase/functions/paymentIntent/logic.ts @@ -0,0 +1,46 @@ +import Stripe from "npm:stripe"; + +export interface PaymentIntentDeps { + stripe: Stripe; +} + +export interface PaymentIntentResult { + clientSecret: string | null; +} + +export function validateAmount(amount: unknown): number { + if (amount === undefined || amount === null) { + throw new Error("Missing amount"); + } + if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) { + throw new Error("Invalid amount: must be a positive integer (cents)"); + } + return amount; +} + +export async function createPaymentIntent( + stripe: Stripe, + amount: number +): Promise { + const intent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + payment_method_types: ["card"], + }); + + return { clientSecret: intent.client_secret }; +} + +export async function handleCreatePaymentIntent( + req: Request, + deps: PaymentIntentDeps +): Promise { + const body = await req.json(); + const amount = validateAmount(body?.amount); + const result = await createPaymentIntent(deps.stripe, amount); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file From f3e0eae1d1c59dcb73a557f496ea31cd2aaac28a Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 24 Feb 2026 21:02:07 -0500 Subject: [PATCH 078/141] Increases Coverage --- .../createCheckoutSession/logic.test.ts | 132 ++++++++++++++++++ .../functions/delete-account/logic.test.ts | 87 ++++++++++++ supabase/functions/denyRefund/logic.test.ts | 95 +++++++++++++ 3 files changed, 314 insertions(+) diff --git a/supabase/functions/createCheckoutSession/logic.test.ts b/supabase/functions/createCheckoutSession/logic.test.ts index 25a8314e..022daaf0 100644 --- a/supabase/functions/createCheckoutSession/logic.test.ts +++ b/supabase/functions/createCheckoutSession/logic.test.ts @@ -5,6 +5,7 @@ import { import { getAuthenticatedUser, createCheckoutSession, + handleCheckout } from "./logic.ts"; function makeSupabaseMock(user: object | null, error: object | null = null) { @@ -28,6 +29,14 @@ import { }, } as any; } + + function makeRequest(body: unknown) { + return new Request("http://localhost/checkout", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } Deno.test("getAuthenticatedUser — returns user when authenticated", async () => { const mockUser = { id: "user-123", email: "test@example.com" }; @@ -123,4 +132,127 @@ import { assertEquals(capturedParams.success_url, "https://myapp.com/homePage"); assertEquals(capturedParams.cancel_url, "https://myapp.com/homePage"); }); + + Deno.test("handleCheckout — returns 200 with url and session_id on success", async () => { + const req = makeRequest({ amount: 2500 }); + + const res = await handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.session_id, "cs_test_abc123"); + assertEquals(body.url, "https://checkout.stripe.com/pay/test_session"); + }); + + Deno.test("handleCheckout — response Content-Type is application/json", async () => { + const req = makeRequest({ amount: 2500 }); + + const res = await handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); + + Deno.test("handleCheckout — throws Unauthorized when user is null", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock(null), + }), + Error, + "Unauthorized" + ); + }); + + Deno.test("handleCheckout — throws Unauthorized when Supabase returns auth error", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock(null, { message: "JWT expired" }), + }), + Error, + "Unauthorized" + ); + }); + + Deno.test("handleCheckout — throws Invalid amount when amount is zero", async () => { + const req = makeRequest({ amount: 0 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCheckout — throws Invalid amount when amount is missing", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCheckout — forwards user id from auth to Stripe session metadata", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + const req = makeRequest({ amount: 1500 }); + await handleCheckout(req, { + stripe, + supabase: makeSupabaseMock({ id: "user-abc" }), + }); + + assertEquals(capturedParams.metadata.user_id, "user-abc"); + }); + + Deno.test("handleCheckout — uses provided baseUrl for redirect URLs", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + const req = makeRequest({ amount: 1500 }); + await handleCheckout( + req, + { stripe, supabase: makeSupabaseMock({ id: "user-123" }) }, + "https://myapp.com" + ); + + assertEquals(capturedParams.success_url, "https://myapp.com/homePage"); + assertEquals(capturedParams.cancel_url, "https://myapp.com/homePage"); + }); \ No newline at end of file diff --git a/supabase/functions/delete-account/logic.test.ts b/supabase/functions/delete-account/logic.test.ts index 3c7d35cf..83a894e8 100644 --- a/supabase/functions/delete-account/logic.test.ts +++ b/supabase/functions/delete-account/logic.test.ts @@ -5,6 +5,7 @@ import { import { validateUserId, deleteUser, + handleDeleteUser } from "./logic.ts"; function makeAdminMock( @@ -22,6 +23,14 @@ import { }, } as any; } + + function makeRequest(body: unknown) { + return new Request("http://localhost/delete-user", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } Deno.test("validateUserId — accepts a valid UUID string", () => { @@ -104,4 +113,82 @@ import { await deleteUser(supabaseAdmin, "user-xyz"); assertEquals(capturedId, "user-xyz"); }); + + Deno.test("handleDeleteUser — returns 200 with success true on valid request", async () => { + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.success, true); + }); + + Deno.test("handleDeleteUser — response body contains data field", async () => { + const mockData = { user: { id: "user-123", email: "test@example.com" } }; + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { + supabaseAdmin: makeAdminMock({ data: mockData }), + }); + const body = await res.json(); + + assertEquals(body.data, mockData); + }); + + Deno.test("handleDeleteUser — response Content-Type is application/json", async () => { + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); + + Deno.test("handleDeleteUser — throws Missing user_id when body has no user_id", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }), + Error, + "Missing user_id" + ); + }); + + Deno.test("handleDeleteUser — throws Missing user_id when user_id is empty string", async () => { + const req = makeRequest({ user_id: " " }); + + await assertRejects( + () => handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }), + Error, + "Missing user_id" + ); + }); + + Deno.test("handleDeleteUser — throws when Supabase admin delete fails", async () => { + const req = makeRequest({ user_id: "user-123" }); + + await assertRejects( + () => handleDeleteUser(req, { + supabaseAdmin: makeAdminMock({ error: { message: "User not found" } }), + }), + Error, + "User not found" + ); + }); + + Deno.test("handleDeleteUser — forwards the correct user_id to Supabase", async () => { + let capturedId: string | undefined; + const supabaseAdmin = { + auth: { + admin: { + deleteUser: (id: string) => { + capturedId = id; + return Promise.resolve({ data: {}, error: null }); + }, + }, + }, + } as any; + + const req = makeRequest({ user_id: "user-xyz" }); + await handleDeleteUser(req, { supabaseAdmin }); + + assertEquals(capturedId, "user-xyz"); + }); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.test.ts b/supabase/functions/denyRefund/logic.test.ts index 5100ce97..e66c93d8 100644 --- a/supabase/functions/denyRefund/logic.test.ts +++ b/supabase/functions/denyRefund/logic.test.ts @@ -7,6 +7,7 @@ import { denyRefundInDb, getUserEmail, handleDenyRefund, + sendDenialEmail } from "./logic.ts"; function makeUrl(params: Record) { @@ -53,6 +54,19 @@ import { function makeRequest(url: URL) { return new Request(url.toString(), { method: "GET" }); } + + function mockFetch(ok: boolean, responseText = "") { + globalThis.fetch = () => + Promise.resolve({ + ok, + text: () => Promise.resolve(responseText), + } as Response); + } + + function restoreFetch() { + // Deno's real fetch is on globalThis — reset after each test + globalThis.fetch = fetch; + } Deno.test("extractParams — returns all three params when present", () => { const url = makeFullUrl(); @@ -253,4 +267,85 @@ import { Error, "Email send failed" ); + }); + + Deno.test("sendDenialEmail — resolves without error on success", async () => { + mockFetch(true); + try { + await sendDenialEmail("test-api-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + // reaching here without throwing is the assertion + }); + + Deno.test("sendDenialEmail — throws with error text when response is not ok", async () => { + mockFetch(false, "Invalid API key"); + try { + await assertRejects( + () => sendDenialEmail("bad-key", "user@example.com", "txn-abc", "25.00"), + Error, + "Email send failed: Invalid API key" + ); + } finally { + restoreFetch(); + } + }); + + Deno.test("sendDenialEmail — sends POST to the correct Resend endpoint", async () => { + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + + globalThis.fetch = (url: string | URL | Request, init?: RequestInit) => { + capturedUrl = url.toString(); + capturedInit = init; + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("test-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + + assertEquals(capturedUrl, "https://api.resend.com/emails"); + assertEquals(capturedInit?.method, "POST"); + }); + + Deno.test("sendDenialEmail — sends correct Authorization header", async () => { + let capturedHeaders: Record | undefined; + + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record; + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("my-resend-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + + assertEquals(capturedHeaders?.["Authorization"], "Bearer my-resend-key"); + assertEquals(capturedHeaders?.["Content-Type"], "application/json"); + }); + + Deno.test("sendDenialEmail — sends correct recipient, transactionId, and amount in body", async () => { + let capturedBody: any; + + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string); + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("test-key", "customer@example.com", "txn-xyz", "49.99"); + } finally { + restoreFetch(); + } + + assertEquals(capturedBody.to, "customer@example.com"); + assertEquals(capturedBody.html.includes("txn-xyz"), true); + assertEquals(capturedBody.html.includes("49.99"), true); + assertEquals(capturedBody.subject, "Refund Request Denied"); }); \ No newline at end of file From f6cd104a30261be9a9862ad726fa50c15f34d129 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 08:53:55 -0500 Subject: [PATCH 079/141] Add pingDevice test --- supabase/functions/ping-device/index.ts | 79 ++++++++++++++ supabase/functions/ping-device/logic.test.ts | 61 +++++++++++ supabase/functions/ping-device/logic.ts | 61 +++++++++++ supabase/functions/ping-device/pingDevice.ts | 105 ------------------- 4 files changed, 201 insertions(+), 105 deletions(-) create mode 100644 supabase/functions/ping-device/index.ts create mode 100644 supabase/functions/ping-device/logic.test.ts create mode 100644 supabase/functions/ping-device/logic.ts delete mode 100644 supabase/functions/ping-device/pingDevice.ts diff --git a/supabase/functions/ping-device/index.ts b/supabase/functions/ping-device/index.ts new file mode 100644 index 00000000..2e418ae6 --- /dev/null +++ b/supabase/functions/ping-device/index.ts @@ -0,0 +1,79 @@ +// index.ts + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleMachineRequest } from "./logic.ts"; + +const supabaseUrl = Deno.env.get("SUPABASE_URL")!; +const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; +const supabase = createClient(supabaseUrl, anonKey); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", +}; + +async function getMachineStatusFromSupabase(deviceId: string) { + try { + const { data, error } = await supabase + .from("Machines") + .select("Status") + .eq("id", deviceId) + .single(); + + if (error || !data) return "error"; + + const status = data.Status?.toLowerCase(); + + const valid = ["idle", "in-use", "offline", "error"]; + + return valid.includes(status) ? status : "offline"; + } catch { + return "error"; + } +} + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); + } + + try { + const text = await req.text(); + const body = text ? JSON.parse(text) : {}; + + const result = await handleMachineRequest(body, { + getMachineStatus: getMachineStatusFromSupabase, + random: Math.random, + delay: (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)), + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + }); + } catch (error: any) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/ping-device/logic.test.ts b/supabase/functions/ping-device/logic.test.ts new file mode 100644 index 00000000..2c5b2fef --- /dev/null +++ b/supabase/functions/ping-device/logic.test.ts @@ -0,0 +1,61 @@ +export type MachineStatus = + | "idle" + | "in-use" + | "offline" + | "error"; + +export interface Dependencies { + getMachineStatus: (deviceId: string) => Promise; + random: () => number; + delay: (ms: number) => Promise; +} + +export async function handleMachineRequest( + body: any, + deps: Dependencies +) { + const { getMachineStatus, random, delay } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const machineStatus = await getMachineStatus(deviceId); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: machineStatus, + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; +} \ No newline at end of file diff --git a/supabase/functions/ping-device/logic.ts b/supabase/functions/ping-device/logic.ts new file mode 100644 index 00000000..2c5b2fef --- /dev/null +++ b/supabase/functions/ping-device/logic.ts @@ -0,0 +1,61 @@ +export type MachineStatus = + | "idle" + | "in-use" + | "offline" + | "error"; + +export interface Dependencies { + getMachineStatus: (deviceId: string) => Promise; + random: () => number; + delay: (ms: number) => Promise; +} + +export async function handleMachineRequest( + body: any, + deps: Dependencies +) { + const { getMachineStatus, random, delay } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const machineStatus = await getMachineStatus(deviceId); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: machineStatus, + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; +} \ No newline at end of file diff --git a/supabase/functions/ping-device/pingDevice.ts b/supabase/functions/ping-device/pingDevice.ts deleted file mode 100644 index a11cf576..00000000 --- a/supabase/functions/ping-device/pingDevice.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; - - -const supabaseUrl = Deno.env.get("SUPABASE_URL"); -const anonKey = Deno.env.get("SUPABASE_ANON_KEY"); -const supabase = createClient(supabaseUrl, anonKey); -async function getMachineStatusFromSupabase(deviceId) { - try { - const { data, error } = await supabase.from('Machines').select('Status').eq('id', deviceId).single(); - console.log("Supabase data:", data); - console.log("Supabase error:", error); - if (error || !data) { - return 'error'; - } - const status = data.Status?.toLowerCase(); - console.log("status: ", status); - const validStatuses = [ - 'idle', - 'in-use', - 'offline', - 'error' - ]; - return validStatuses.includes(status) ? status : 'offline'; - } catch (e) { - console.error("Supabase fetch error:", e); - return 'error'; - } -} -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "*", - "Access-Control-Max-Age": "86400" -}; -serve(async (req)=>{ - if (req.method === "OPTIONS") { - return new Response(null, { - status: 200, - headers: corsHeaders - }); - } - try { - const text = await req.text(); - const body = text ? JSON.parse(text) : {}; - const deviceId = body.deviceId; - if (!deviceId) { - return new Response(JSON.stringify({ - error: "deviceId is required", - receivedBody: body - }), { - status: 400, - headers: { - "Content-Type": "application/json", - ...corsHeaders - } - }); - } - const random = Math.random(); - const success = random < 0.95; - const delay = Math.floor(Math.random() * 150) + 50; - await new Promise((resolve)=>setTimeout(resolve, delay)); - const machineStatus = await getMachineStatusFromSupabase(deviceId); - if (success) { - return new Response(JSON.stringify({ - success: true, - deviceId, - message: machineStatus, - timestamp: new Date().toISOString(), - responseTime: `${delay}ms` - }), { - status: 200, - headers: { - "Content-Type": "application/json", - ...corsHeaders - } - }); - } else { - return new Response(JSON.stringify({ - success: false, - deviceId, - error: "Device unreachable or timeout", - timestamp: new Date().toISOString(), - responseTime: `${delay}ms` - }), { - status: 503, - headers: { - "Content-Type": "application/json", - ...corsHeaders - } - }); - } - } catch (error) { - return new Response(JSON.stringify({ - success: false, - error: error.message || "Internal server error" - }), { - status: 500, - headers: { - "Content-Type": "application/json", - ...corsHeaders - } - }); - } -}); From 06f219baaae140adb6c4a7f2c1fae0787883261e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 08:57:46 -0500 Subject: [PATCH 080/141] Adds refundEmail test --- supabase/functions/ping-device/index.ts | 2 - supabase/functions/refund-email/index.ts | 163 +++++++----------- supabase/functions/refund-email/logic.test.ts | 89 ++++++++++ supabase/functions/refund-email/logic.ts | 76 ++++++++ 4 files changed, 225 insertions(+), 105 deletions(-) create mode 100644 supabase/functions/refund-email/logic.test.ts create mode 100644 supabase/functions/refund-email/logic.ts diff --git a/supabase/functions/ping-device/index.ts b/supabase/functions/ping-device/index.ts index 2e418ae6..d62c2285 100644 --- a/supabase/functions/ping-device/index.ts +++ b/supabase/functions/ping-device/index.ts @@ -1,5 +1,3 @@ -// index.ts - import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { handleMachineRequest } from "./logic.ts"; diff --git a/supabase/functions/refund-email/index.ts b/supabase/functions/refund-email/index.ts index 4eba9c57..1fe7392f 100644 --- a/supabase/functions/refund-email/index.ts +++ b/supabase/functions/refund-email/index.ts @@ -1,128 +1,85 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { handleRefundRequest } from "./logic.ts"; + const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "*", - "Access-Control-Max-Age": "86400" + "Access-Control-Max-Age": "86400", }; -serve(async (req)=>{ - // --- Handle CORS preflight --- + +serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { status: 200, - headers: corsHeaders + headers: corsHeaders, }); } + try { - // ------------------------------- - // Parse incoming JSON safely - // ------------------------------- let body; + try { const text = await req.text(); body = text ? JSON.parse(text) : {}; - } catch (e) { - return new Response(JSON.stringify({ - error: "Invalid JSON body" - }), { - status: 400, - headers: { - "Content-Type": "application/json", - ...corsHeaders - } - }); - } - // -------------------------------------------------------- - // Extract expected fields from the body (sent by your app) - // -------------------------------------------------------- - const username = body.username; - const userId = body.user_id; - const transactionId = body.transaction_id; - const amount = body.amount; - const description = body.description; - const userAttempts = body.userAttempts; - // Validate required fields - if (!username || !userId || !transactionId || !amount) { - return new Response(JSON.stringify({ - error: "Missing required fields", - received: body - }), { - status: 400, - headers: { - "Content-Type": "application/json", - ...corsHeaders + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON body" }), + { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, } - }); + ); } - const approveLink = "https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/approveRefund" + `?user_id=${userId}&transaction_id=${transactionId}&amount=${amount}`; - const denyLink = "https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/denyRefund" + `?user_id=${userId}&transaction_id=${transactionId}&amount=${amount}`; - // -------------------------------------------------------- - // Build the email HTML - // -------------------------------------------------------- - const emailBody = ` -

Refund Request Received

-

Name: ${username}

-

User ID: ${userId}

-

Transaction ID: ${transactionId}

-

Amount: $${amount}

-

Reason: ${description}

-

Number of refund attempts: ${userAttempts}

- -

 

- `; - const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY"); - // -------------------------------------------------------- - // Send email via Resend - // -------------------------------------------------------- - const emailResponse = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - "Authorization": `Bearer ${RESEND_API_KEY}`, - "Content-Type": "application/json" + + const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!; + + const result = await handleRefundRequest(body, { + sendEmail: async ({ subject, html }) => { + const response = await fetch( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: "yoder453@gmail.com", + subject, + html, + }), + } + ); + + return response.json(); }, - body: JSON.stringify({ - from: "refund@updates.cleanstreamlaundry.com", - to: "yoder453@gmail.com", - subject: `New Refund Request - ${username}`, - html: emailBody - }) }); - const emailData = await emailResponse.json(); - console.log("Resend API response:", emailData); - return new Response(JSON.stringify({ - success: true, - resend: emailData - }), { - status: 200, + + return new Response(JSON.stringify(result.body), { + status: result.status, headers: { "Content-Type": "application/json", - ...corsHeaders - } + ...corsHeaders, + }, }); - } catch (error) { - console.error("Refund email error:", error); - return new Response(JSON.stringify({ - success: false, - error: error.message || "Internal server error" - }), { - status: 500, - headers: { - "Content-Type": "application/json", - ...corsHeaders + } catch (error: any) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, } - }); + ); } -}); +}); \ No newline at end of file diff --git a/supabase/functions/refund-email/logic.test.ts b/supabase/functions/refund-email/logic.test.ts new file mode 100644 index 00000000..5ecf6fc0 --- /dev/null +++ b/supabase/functions/refund-email/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleRefundRequest, + } from "./logic.ts"; + + import { + assertEquals, + assert, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + function createDeps() { + let capturedSubject = ""; + let capturedHtml = ""; + + return { + deps: { + sendEmail: async ({ subject, html }: any) => { + capturedSubject = subject; + capturedHtml = html; + return { id: "email_123" }; + }, + }, + getCaptured: () => ({ + subject: capturedSubject, + html: capturedHtml, + }), + }; + } + + const validBody = { + username: "John", + user_id: "u123", + transaction_id: "t456", + amount: 25, + description: "Machine ate my sock", + userAttempts: 2, + }; + + Deno.test("returns 400 if required fields missing", async () => { + const { deps } = createDeps(); + + const result = await handleRefundRequest({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Missing required fields"); + }); + + Deno.test("returns 200 on valid request", async () => { + const { deps } = createDeps(); + + const result = await handleRefundRequest(validBody, deps); + + assertEquals(result.status, 200); + assertEquals(result.body.success, true); + assertEquals(result.body.resend.id, "email_123"); + }); + + Deno.test("email subject contains username", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { subject } = getCaptured(); + + assert(subject.includes("John")); + }); + + Deno.test("email contains approve and deny links", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { html } = getCaptured(); + + assert(html.includes("approveRefund")); + assert(html.includes("denyRefund")); + assert(html.includes("u123")); + assert(html.includes("t456")); + }); + + Deno.test("email includes refund details", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { html } = getCaptured(); + + assert(html.includes("Machine ate my sock")); + assert(html.includes("$25")); + }); \ No newline at end of file diff --git a/supabase/functions/refund-email/logic.ts b/supabase/functions/refund-email/logic.ts new file mode 100644 index 00000000..9e9c8b7c --- /dev/null +++ b/supabase/functions/refund-email/logic.ts @@ -0,0 +1,76 @@ +export interface RefundRequestBody { + username?: string; + user_id?: string; + transaction_id?: string; + amount?: number; + description?: string; + userAttempts?: number; + } + + export interface Dependencies { + sendEmail: (params: { + subject: string; + html: string; + }) => Promise; + } + + export async function handleRefundRequest( + body: RefundRequestBody, + deps: Dependencies + ) { + const { sendEmail } = deps; + + const { + username, + user_id, + transaction_id, + amount, + description, + userAttempts, + } = body; + + if (!username || !user_id || !transaction_id || !amount) { + return { + status: 400, + body: { + error: "Missing required fields", + received: body, + }, + }; + } + + const approveLink = + `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/approveRefund` + + `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`; + + const denyLink = + `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/denyRefund` + + `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`; + + const emailBody = ` +

Refund Request Received

+

Name: ${username}

+

User ID: ${user_id}

+

Transaction ID: ${transaction_id}

+

Amount: $${amount}

+

Reason: ${description}

+

Number of refund attempts: ${userAttempts}

+ + `; + + const emailResult = await sendEmail({ + subject: `New Refund Request - ${username}`, + html: emailBody, + }); + + return { + status: 200, + body: { + success: true, + resend: emailResult, + }, + }; + } \ No newline at end of file From 2fac2d464a4b21337269299a548b268c6d7c55f3 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 09:00:24 -0500 Subject: [PATCH 081/141] Adds resetToken test --- supabase/functions/resetToken/index.ts | 20 ++++--- supabase/functions/resetToken/logic.test.ts | 61 +++++++++++++++++++++ supabase/functions/resetToken/logic.ts | 26 +++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 supabase/functions/resetToken/logic.test.ts create mode 100644 supabase/functions/resetToken/logic.ts diff --git a/supabase/functions/resetToken/index.ts b/supabase/functions/resetToken/index.ts index de8548f2..9ac75465 100644 --- a/supabase/functions/resetToken/index.ts +++ b/supabase/functions/resetToken/index.ts @@ -1,22 +1,28 @@ import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleExchangeCode } from "./logic.ts"; serve(async (req) => { try { - const { code } = await req.json(); - if (!code) return new Response("Missing code", { status: 400 }); + const body = await req.json(); const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, ); - const { data, error } = await supabase.auth.exchangeCodeForSession(code); - if (error || !data?.user) { - return new Response("Invalid or expired code", { status: 401 }); - } + const result = await handleExchangeCode(body, { + exchangeCodeForSession: async (code: string) => { + const { data, error } = + await supabase.auth.exchangeCodeForSession(code); + + if (error) return {}; + return { user: data?.user }; + }, + }); + + return new Response(result.body, { status: result.status }); - return new Response("OK", { status: 200 }); } catch { return new Response("Bad Request", { status: 400 }); } diff --git a/supabase/functions/resetToken/logic.test.ts b/supabase/functions/resetToken/logic.test.ts new file mode 100644 index 00000000..5ec3b9c0 --- /dev/null +++ b/supabase/functions/resetToken/logic.test.ts @@ -0,0 +1,61 @@ +import { + handleExchangeCode, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + user?: any; + }) { + return { + exchangeCodeForSession: async (_code: string) => { + return options?.user ? { user: options.user } : {}; + }, + }; + } + + + Deno.test("returns 400 if code missing", async () => { + const result = await handleExchangeCode({}, createDeps()); + + assertEquals(result.status, 400); + assertEquals(result.body, "Missing code"); + }); + + Deno.test("returns 401 if no user returned", async () => { + const result = await handleExchangeCode( + { code: "abc" }, + createDeps() + ); + + assertEquals(result.status, 401); + assertEquals(result.body, "Invalid or expired code"); + }); + + Deno.test("returns 200 if user exists", async () => { + const result = await handleExchangeCode( + { code: "abc" }, + createDeps({ user: { id: "123" } }) + ); + + assertEquals(result.status, 200); + assertEquals(result.body, "OK"); + }); + + Deno.test("calls dependency with correct code", async () => { + let capturedCode = ""; + + const deps = { + exchangeCodeForSession: async (code: string) => { + capturedCode = code; + return { user: { id: "123" } }; + }, + }; + + await handleExchangeCode({ code: "specialCode" }, deps); + + assertEquals(capturedCode, "specialCode"); + }); \ No newline at end of file diff --git a/supabase/functions/resetToken/logic.ts b/supabase/functions/resetToken/logic.ts new file mode 100644 index 00000000..6178d5ee --- /dev/null +++ b/supabase/functions/resetToken/logic.ts @@ -0,0 +1,26 @@ +export interface Dependencies { + exchangeCodeForSession: ( + code: string + ) => Promise<{ user?: any }>; + } + + export async function handleExchangeCode( + body: any, + deps: Dependencies + ) { + const { exchangeCodeForSession } = deps; + + const code = body?.code; + + if (!code) { + return { status: 400, body: "Missing code" }; + } + + const result = await exchangeCodeForSession(code); + + if (!result?.user) { + return { status: 401, body: "Invalid or expired code" }; + } + + return { status: 200, body: "OK" }; + } \ No newline at end of file From 5d0599857ca19dd8bcfde56c660ab06bb7464a2a Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 09:04:12 -0500 Subject: [PATCH 082/141] Adds stripeWebhook test --- supabase/functions/stripeWebhook/index.ts | 80 ++++++------- .../functions/stripeWebhook/logic.test.ts | 105 ++++++++++++++++++ supabase/functions/stripeWebhook/logic.ts | 51 +++++++++ 3 files changed, 187 insertions(+), 49 deletions(-) create mode 100644 supabase/functions/stripeWebhook/logic.test.ts create mode 100644 supabase/functions/stripeWebhook/logic.ts diff --git a/supabase/functions/stripeWebhook/index.ts b/supabase/functions/stripeWebhook/index.ts index 943cb42a..af9598e6 100644 --- a/supabase/functions/stripeWebhook/index.ts +++ b/supabase/functions/stripeWebhook/index.ts @@ -1,64 +1,46 @@ import { serve } from "https://deno.land/std@0.223.0/http/server.ts"; import Stripe from "npm:stripe@14"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleStripeWebhook } from "./logic.ts"; const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { - apiVersion: "2024-06-20", - httpClient: Stripe.createFetchHttpClient(), // Use fetch-based client for Deno + apiVersion: "2023-10-16", + httpClient: Stripe.createFetchHttpClient(), }); serve(async (req) => { const signature = req.headers.get("stripe-signature"); - - if (!signature) { - console.error("❌ No signature header found"); - return new Response("No signature", { status: 400 }); - } + const rawBody = await req.text(); - const rawBody = await req.text(); // Try text() instead of arrayBuffer() - - let event; - try { - const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); - - if (!webhookSecret) { - console.error("❌ STRIPE_WEBHOOK_SECRET not set"); - return new Response("Configuration error", { status: 500 }); - } + const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; - event = await stripe.webhooks.constructEventAsync( - rawBody, - signature, - webhookSecret, - undefined, - Stripe.createSubtleCryptoProvider() // Explicitly use async crypto - ); - - console.log("✅ Signature verified:", event.type); - } catch (err) { - console.error("❌ Webhook signature verification failed:", err.message); - return new Response(`Webhook Error: ${err.message}`, { status: 400 }); - } + const result = await handleStripeWebhook( + { rawBody, signature }, + { + verifyAndConstructEvent: async (body, sig) => { + return await stripe.webhooks.constructEventAsync( + body, + sig, + webhookSecret, + undefined, + Stripe.createSubtleCryptoProvider() + ); + }, - if (event.type === "checkout.session.completed") { - const session = event.data.object; - - const supabase = createClient( - Deno.env.get("SUPABASE_URL")!, - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! - ); + broadcastPaymentSuccess: async (payload) => { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); - await supabase - .channel("payments") - .send({ - type: "broadcast", - event: "payment_success", - payload: { - user_id: session.metadata?.user_id, - amount: session.amount_total - } - }); - } + await supabase.channel("payments").send({ + type: "broadcast", + event: "payment_success", + payload, + }); + }, + } + ); - return new Response("OK", { status: 200 }); + return new Response(result.body, { status: result.status }); }); \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/logic.test.ts b/supabase/functions/stripeWebhook/logic.test.ts new file mode 100644 index 00000000..29c673a5 --- /dev/null +++ b/supabase/functions/stripeWebhook/logic.test.ts @@ -0,0 +1,105 @@ +import { + handleStripeWebhook, + } from "./logic.ts"; + + import { + assertEquals, + assert, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + eventType?: string; + shouldThrow?: boolean; + }) { + let broadcastCalled = false; + let broadcastPayload: any = null; + + return { + deps: { + verifyAndConstructEvent: async () => { + if (options?.shouldThrow) { + throw new Error("Invalid signature"); + } + + return { + type: options?.eventType ?? "checkout.session.completed", + data: { + object: { + metadata: { user_id: "user123" }, + amount_total: 5000, + }, + }, + }; + }, + + broadcastPaymentSuccess: async (payload: any) => { + broadcastCalled = true; + broadcastPayload = payload; + }, + }, + + getBroadcastInfo: () => ({ + broadcastCalled, + broadcastPayload, + }), + }; + } + + + Deno.test("returns 400 if signature missing", async () => { + const { deps } = createDeps(); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: null }, + deps + ); + + assertEquals(result.status, 400); + assertEquals(result.body, "No signature"); + }); + + Deno.test("returns 400 if verification fails", async () => { + const { deps } = createDeps({ shouldThrow: true }); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + assertEquals(result.status, 400); + assert(result.body.includes("Invalid signature")); + }); + + Deno.test("broadcasts on checkout.session.completed", async () => { + const { deps, getBroadcastInfo } = createDeps(); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + const { broadcastCalled, broadcastPayload } = + getBroadcastInfo(); + + assertEquals(result.status, 200); + assertEquals(broadcastCalled, true); + assertEquals(broadcastPayload.user_id, "user123"); + assertEquals(broadcastPayload.amount, 5000); + }); + + Deno.test("does not broadcast for other event types", async () => { + const { deps, getBroadcastInfo } = createDeps({ + eventType: "payment.failed", + }); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + const { broadcastCalled } = getBroadcastInfo(); + + assertEquals(result.status, 200); + assertEquals(broadcastCalled, false); + }); \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/logic.ts b/supabase/functions/stripeWebhook/logic.ts new file mode 100644 index 00000000..ca5d2d07 --- /dev/null +++ b/supabase/functions/stripeWebhook/logic.ts @@ -0,0 +1,51 @@ +export interface StripeEvent { + type: string; + data: { + object: any; + }; + } + + export interface Dependencies { + verifyAndConstructEvent: (rawBody: string, signature: string) => Promise; + broadcastPaymentSuccess: (payload: { + user_id?: string; + amount?: number; + }) => Promise; + } + + export async function handleStripeWebhook( + params: { + rawBody: string; + signature: string | null; + }, + deps: Dependencies + ) { + const { rawBody, signature } = params; + const { verifyAndConstructEvent, broadcastPaymentSuccess } = deps; + + if (!signature) { + return { status: 400, body: "No signature" }; + } + + let event: StripeEvent; + + try { + event = await verifyAndConstructEvent(rawBody, signature); + } catch (err: any) { + return { + status: 400, + body: `Webhook Error: ${err.message}`, + }; + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object; + + await broadcastPaymentSuccess({ + user_id: session.metadata?.user_id, + amount: session.amount_total, + }); + } + + return { status: 200, body: "OK" }; + } \ No newline at end of file From 5e0182774ed84e58125327d06670c146221c0a06 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 09:08:03 -0500 Subject: [PATCH 083/141] Adds test for verifyPayment --- supabase/functions/verifyPayment/index.ts | 31 ++++--- .../functions/verifyPayment/logic.test.ts | 89 +++++++++++++++++++ supabase/functions/verifyPayment/logic.ts | 41 +++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 supabase/functions/verifyPayment/logic.test.ts create mode 100644 supabase/functions/verifyPayment/logic.ts diff --git a/supabase/functions/verifyPayment/index.ts b/supabase/functions/verifyPayment/index.ts index d8cd244f..273ccf59 100644 --- a/supabase/functions/verifyPayment/index.ts +++ b/supabase/functions/verifyPayment/index.ts @@ -1,18 +1,29 @@ import Stripe from "npm:stripe@^14.0.0"; import { serve } from "https://deno.land/std/http/server.ts"; +import { handleCheckPaymentResult } from "./logic.ts"; -const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"), { - apiVersion: "2024-06-20", +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2023-10-16", }); serve(async (req) => { - const { session_id } = await req.json(); + try { + const body = await req.json(); - const session = await stripe.checkout.sessions.retrieve(session_id); + const result = await handleCheckPaymentResult(body, { + retrieveSession: async (sessionId: string) => { + return await stripe.checkout.sessions.retrieve(sessionId); + }, + }); - return new Response(JSON.stringify({ - paid: session.payment_status === "paid", - }), { - headers: { "Content-Type": "application/json" } - }); -}); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); + } catch { + return new Response( + JSON.stringify({ error: "Bad Request" }), + { status: 400 } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/verifyPayment/logic.test.ts b/supabase/functions/verifyPayment/logic.test.ts new file mode 100644 index 00000000..4e8e7c01 --- /dev/null +++ b/supabase/functions/verifyPayment/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleCheckPaymentResult, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + paymentStatus?: string; + shouldThrow?: boolean; + }) { + let capturedSessionId = ""; + + return { + deps: { + retrieveSession: async (sessionId: string) => { + capturedSessionId = sessionId; + + if (options?.shouldThrow) { + throw new Error("Stripe error"); + } + + return { + payment_status: options?.paymentStatus ?? "unpaid", + }; + }, + }, + getCapturedSessionId: () => capturedSessionId, + }; + } + + + Deno.test("returns 400 if session_id missing", async () => { + const { deps } = createDeps(); + + const result = await handleCheckPaymentResult({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Missing session_id"); + }); + + Deno.test("returns paid: true when payment_status is paid", async () => { + const { deps } = createDeps({ paymentStatus: "paid" }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.paid, true); + }); + + Deno.test("returns paid: false when payment_status is not paid", async () => { + const { deps } = createDeps({ paymentStatus: "unpaid" }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.paid, false); + }); + + Deno.test("calls retrieveSession with correct session_id", async () => { + const { deps, getCapturedSessionId } = createDeps(); + + await handleCheckPaymentResult( + { session_id: "sess_abc" }, + deps + ); + + assertEquals(getCapturedSessionId(), "sess_abc"); + }); + + Deno.test("returns 400 if Stripe throws", async () => { + const { deps } = createDeps({ shouldThrow: true }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Stripe error"); + }); \ No newline at end of file diff --git a/supabase/functions/verifyPayment/logic.ts b/supabase/functions/verifyPayment/logic.ts new file mode 100644 index 00000000..216503f8 --- /dev/null +++ b/supabase/functions/verifyPayment/logic.ts @@ -0,0 +1,41 @@ +export interface StripeSession { + payment_status?: string; + } + + export interface Dependencies { + retrieveSession: (sessionId: string) => Promise; + } + + export async function handleCheckPaymentResult( + body: any, + deps: Dependencies + ) { + const { retrieveSession } = deps; + + const sessionId = body?.session_id; + + if (!sessionId) { + return { + status: 400, + body: { error: "Missing session_id" }, + }; + } + + try { + const session = await retrieveSession(sessionId); + + return { + status: 200, + body: { + paid: session.payment_status === "paid", + }, + }; + } catch (err: any) { + return { + status: 400, + body: { + error: err.message || "Failed to retrieve session", + }, + }; + } + } \ No newline at end of file From ab7de79988b8a365091d26a4b53ef24c17e820e4 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 09:10:18 -0500 Subject: [PATCH 084/141] Adds wakeDevice tests --- supabase/functions/wakeDevice/index.ts | 81 ++++--------------- supabase/functions/wakeDevice/logic.test.ts | 89 +++++++++++++++++++++ supabase/functions/wakeDevice/logic.ts | 55 +++++++++++++ 3 files changed, 160 insertions(+), 65 deletions(-) create mode 100644 supabase/functions/wakeDevice/logic.test.ts create mode 100644 supabase/functions/wakeDevice/logic.ts diff --git a/supabase/functions/wakeDevice/index.ts b/supabase/functions/wakeDevice/index.ts index d4aed7d5..52fe6d7b 100644 --- a/supabase/functions/wakeDevice/index.ts +++ b/supabase/functions/wakeDevice/index.ts @@ -1,4 +1,5 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { handleWakeDevice } from "./logic.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -8,7 +9,6 @@ const corsHeaders = { }; serve(async (req) => { - // Handle CORS preflight requests if (req.method === "OPTIONS") { return new Response(null, { status: 200, @@ -17,12 +17,11 @@ serve(async (req) => { } try { - // Parse request body let body; try { const text = await req.text(); body = text ? JSON.parse(text) : {}; - } catch (e) { + } catch { return new Response( JSON.stringify({ error: "Invalid JSON body" }), { @@ -35,69 +34,21 @@ serve(async (req) => { ); } - const deviceId = body.deviceId; - - // Validate deviceId - if (!deviceId) { - return new Response( - JSON.stringify({ - error: "deviceId is required", - receivedBody: body - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - ...corsHeaders, - }, - } - ); - } - - // Simulate ping with 95% success rate - const random = Math.random(); - const success = random < 0.95; - - // Simulate network delay (50-200ms) - const delay = Math.floor(Math.random() * 150) + 50; - await new Promise((resolve) => setTimeout(resolve, delay)); + const result = await handleWakeDevice(body, { + random: Math.random, + delay: (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)), + now: () => new Date(), + }); - if (success) { - return new Response( - JSON.stringify({ - success: true, - deviceId, - message: "Device wake signal sent successfully", - timestamp: new Date().toISOString(), - responseTime: `${delay}ms`, - }), - { - status: 200, - headers: { - "Content-Type": "application/json", - ...corsHeaders, - }, - } - ); - } else { - return new Response( - JSON.stringify({ - success: false, - deviceId, - error: "Device unreachable or timeout", - timestamp: new Date().toISOString(), - responseTime: `${delay}ms`, - }), - { - status: 503, - headers: { - "Content-Type": "application/json", - ...corsHeaders, - }, - } - ); - } - } catch (error) { + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + }); + } catch (error: any) { return new Response( JSON.stringify({ success: false, diff --git a/supabase/functions/wakeDevice/logic.test.ts b/supabase/functions/wakeDevice/logic.test.ts new file mode 100644 index 00000000..e0a42f01 --- /dev/null +++ b/supabase/functions/wakeDevice/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleWakeDevice, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + randomValues?: number[]; + }) { + let delayCalledWith = 0; + + const randomValues = options?.randomValues ?? [0.1, 0.5]; + let randomIndex = 0; + + return { + deps: { + random: () => randomValues[randomIndex++], + delay: async (ms: number) => { + delayCalledWith = ms; + }, + now: () => new Date("2024-01-01T00:00:00.000Z"), + }, + getDelay: () => delayCalledWith, + }; + } + + + Deno.test("returns 400 if deviceId missing", async () => { + const { deps } = createDeps(); + + const result = await handleWakeDevice({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "deviceId is required"); + }); + + Deno.test("returns success when random < 0.95", async () => { + // First random for success = 0.1 (success) + // Second random for delay = 0.5 + const { deps, getDelay } = createDeps({ + randomValues: [0.1, 0.5], + }); + + const result = await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.success, true); + assertEquals(result.body.deviceId, "abc123"); + assertEquals(result.body.timestamp, "2024-01-01T00:00:00.000Z"); + + // delay = floor(0.5 * 150) + 50 = 125 + assertEquals(getDelay(), 125); + assertEquals(result.body.responseTime, "125ms"); + }); + + Deno.test("returns 503 when random >= 0.95", async () => { + const { deps } = createDeps({ + randomValues: [0.99, 0.3], + }); + + const result = await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + assertEquals(result.status, 503); + assertEquals(result.body.success, false); + assertEquals(result.body.error, "Device unreachable or timeout"); + }); + + Deno.test("delay is awaited with correct ms", async () => { + const { deps, getDelay } = createDeps({ + randomValues: [0.1, 0.0], // minimum delay + }); + + await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + // floor(0 * 150) + 50 = 50 + assertEquals(getDelay(), 50); + }); \ No newline at end of file diff --git a/supabase/functions/wakeDevice/logic.ts b/supabase/functions/wakeDevice/logic.ts new file mode 100644 index 00000000..a82f1f56 --- /dev/null +++ b/supabase/functions/wakeDevice/logic.ts @@ -0,0 +1,55 @@ +export interface Dependencies { + random: () => number; + delay: (ms: number) => Promise; + now: () => Date; + } + + export async function handleWakeDevice( + body: any, + deps: Dependencies + ) { + const { random, delay, now } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const timestamp = now().toISOString(); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: "Device wake signal sent successfully", + timestamp, + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp, + responseTime: `${responseDelay}ms`, + }, + }; + } \ No newline at end of file From 36bd0a2493a8bf31e7011bd7fa26047f2f04b0d6 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 12:15:11 -0500 Subject: [PATCH 085/141] Remove unused script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 3a025af2..62686c83 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "test": "test" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", "supabase:update-functions": "node scripts/update-functions.js", "test:coverage": "deno test --reload --coverage=coverage_deno supabase/functions" }, From 6f476328a76fd67a9f15d4a93607c1a226ed397f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Wed, 25 Feb 2026 17:08:27 -0500 Subject: [PATCH 086/141] Fixes failing test --- test/widgets/custom_app_bar_test.dart | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/test/widgets/custom_app_bar_test.dart b/test/widgets/custom_app_bar_test.dart index 87edae26..7df3c940 100644 --- a/test/widgets/custom_app_bar_test.dart +++ b/test/widgets/custom_app_bar_test.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group("Custom App Bar Tests", () { - test('CustomAppBar instantiates correctly', () { const customAppBar = CustomAppBar(); expect(customAppBar, isA()); @@ -18,27 +16,21 @@ void main() { testWidgets('CustomAppBar builds an AppBar widget', (tester) async { await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: const CustomAppBar(), - ), - ), + MaterialApp(home: Scaffold(appBar: const CustomAppBar())), ); expect(find.byType(AppBar), findsOneWidget); }); - testWidgets('CustomAppBar uses theme primary color', (tester) async { - const testColor = Colors.green; + testWidgets('CustomAppBar has transparent background ', (tester) async { + const testColor = Colors.transparent; await tester.pumpWidget( MaterialApp( theme: ThemeData( colorScheme: const ColorScheme.light(primary: testColor), ), - home: Scaffold( - appBar: const CustomAppBar(), - ), + home: Scaffold(appBar: const CustomAppBar()), ), ); @@ -46,7 +38,9 @@ void main() { expect(appBar.backgroundColor, testColor); }); - testWidgets('CustomAppBar renders correctly inside Scaffold', (tester) async { + testWidgets('CustomAppBar renders correctly inside Scaffold', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( From 67de6155de882b0fbc084f3f19b2689d2d9d55fd Mon Sep 17 00:00:00 2001 From: karelinejones Date: Thu, 26 Feb 2026 23:32:54 -0500 Subject: [PATCH 087/141] fixed the tests and changed default to current year --- lib/pages/monthly_transaction_history.dart | 1 + .../monthly_transaction_history_test.dart | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 9699f22c..8667bd96 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -153,6 +153,7 @@ class MonthlyTransactionHistory extends StatelessWidget { return DefaultTabController( length: 3, + initialIndex: 1, child: Scaffold( appBar: AppBar( leading: IconButton( diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 44ff135a..3b1ab823 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -60,6 +60,11 @@ void main() { return MaterialApp.router(routerConfig: router); } + Future switchToLastTwelveMonthsTab(WidgetTester tester) async { + await tester.tap(find.text('Last 12 Months')); + await tester.pumpAndSettle(); + } + group('MonthlyTransactionHistory Widget Tests', () { testWidgets('renders AppBar title even when transactions empty', ( WidgetTester tester, @@ -105,6 +110,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.byType(Card), findsOneWidget); }); @@ -122,6 +128,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$14.25'), findsOneWidget); }); @@ -151,6 +158,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('Direct Washer Payments'), findsOneWidget); expect(find.text('Loyalty Washer Payments'), findsOneWidget); @@ -169,6 +177,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$5.50'), findsWidgets); }); @@ -176,7 +185,6 @@ void main() { testWidgets('sorts months in descending order', ( WidgetTester tester, ) async { - final now = DateTime.now(); final transactions = [ createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), @@ -185,9 +193,10 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); final cardFinder = find.byType(Card); - expect(cardFinder, findsNWidgets(3)); + expect(cardFinder, findsAtLeastNWidgets(2)); final firstCard = cardFinder.first; final firstCardTexts = find.descendant( @@ -195,6 +204,14 @@ void main() { matching: find.byType(Text), ); expect(firstCardTexts, findsAtLeastNWidgets(1)); + + final expectedMostRecent = DateFormat( + 'MMM yyyy', + ).format(DateTime(DateTime.now().year, DateTime.now().month - 1, 1)); + expect( + find.descendant(of: firstCard, matching: find.text(expectedMostRecent)), + findsOneWidget, + ); }); testWidgets('displays scrollbar', (WidgetTester tester) async { @@ -233,8 +250,9 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); - expect(find.byType(Card), findsNWidgets(3)); + expect(find.byType(Card), findsAtLeastNWidgets(2)); }); testWidgets('displays divider between month and transaction details', ( @@ -246,6 +264,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.byType(Divider), findsOneWidget); }); @@ -259,6 +278,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); final zeroAmountFinder = find.text('\$0.00'); expect(zeroAmountFinder, findsWidgets); @@ -271,6 +291,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); final card = tester.widget(find.byType(Card)); expect(card.margin, const EdgeInsets.only(bottom: 16)); @@ -294,6 +315,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$5.50'), findsWidgets); }); @@ -316,6 +338,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.75'), findsWidgets); }); @@ -330,6 +353,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.75'), findsWidgets); }); @@ -352,6 +376,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$30.00'), findsWidgets); }); @@ -366,6 +391,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.50'), findsWidgets); expect(find.text('\$1.76'), findsWidgets); @@ -382,6 +408,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$8.25'), findsWidgets); expect(find.byType(Card), findsOneWidget); @@ -402,6 +429,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.byType(Card), findsOneWidget); expect(find.text('\$3.00'), findsExactly(2)); @@ -420,6 +448,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.byType(Card), findsOneWidget); }); @@ -449,6 +478,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); + await switchToLastTwelveMonthsTab(tester); expect(find.text('\$14.25'), findsOneWidget); expect(find.text('\$2.50'), findsWidgets); @@ -475,13 +505,13 @@ void main() { WidgetTester tester, ) async { final now = DateTime.now(); - final previousYearDate = DateTime(now.year - 1, now.month, 15); + final previousYearDate = DateTime(now.year, now.month - 11, 15); final previousYearMonthLabel = '${DateFormat('MMM').format(previousYearDate)} ${previousYearDate.year}'; final transactions = [ createTransaction( - monthsAgo: 12, + monthsAgo: 11, description: 'Washer #5', amount: 2.50, ), From eb074544313aef759a7bd946c1df5b9bd508c0aa Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 16:48:49 -0400 Subject: [PATCH 088/141] Added Unlock Door button --- lib/pages/start_machine_page.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index fa0129f0..a343b231 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -60,7 +60,7 @@ class StartPage extends StatelessWidget { ), ), - const SizedBox(height: 30), + const SizedBox(height: 15), SizedBox( height: 160, @@ -73,6 +73,20 @@ class StartPage extends StatelessWidget { }, ), ), + + const SizedBox(height: 30), + + SizedBox( + height: 160, + child: LargeButton( + headLineText: "Unlock Door", + descriptionText: "Unlock doors after hours", + icon: Icons.lock_open_rounded, + onPressed: () { + context.go("/home"); + }, + ), + ), ], ), ), From 495f511ad4eac417b1b8f9a2166ea80c83a39e8b Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 17:38:43 -0400 Subject: [PATCH 089/141] Added section banners --- lib/pages/start_machine_page.dart | 7 +++-- lib/widgets/section_banner.dart | 43 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/section_banner.dart diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index a343b231..b393ebf3 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; class StartPage extends StatelessWidget { const StartPage({super.key}); @@ -17,6 +18,7 @@ class StartPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SectionHeader(title: "Payment Options"), Container( height: 160, margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), @@ -60,7 +62,7 @@ class StartPage extends StatelessWidget { ), ), - const SizedBox(height: 15), + const SizedBox(height: 10), SizedBox( height: 160, @@ -74,7 +76,8 @@ class StartPage extends StatelessWidget { ), ), - const SizedBox(height: 30), + const SizedBox(height: 10), + const SectionHeader(title: "After Hours"), SizedBox( height: 160, diff --git a/lib/widgets/section_banner.dart b/lib/widgets/section_banner.dart new file mode 100644 index 00000000..3d6698ea --- /dev/null +++ b/lib/widgets/section_banner.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart' show StatelessWidget, BuildContext, Widget, EdgeInsets, Expanded, FontWeight, TextStyle, Text, Padding, Row; +import 'package:flutter/material.dart'; + +class SectionHeader extends StatelessWidget { + final String title; + const SectionHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + children: [ + Expanded( + child: Divider( + thickness: 2, + color: color, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + title, + style: TextStyle( + color: color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Divider( + thickness: 2, + color: color, + ), + ), + ], + ), + ); + } +} \ No newline at end of file From a1ec0ce3f9dc4a3289ffc2ade6c4756f4b5e5bf6 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 19:28:16 -0400 Subject: [PATCH 090/141] Added simulated kisi services --- lib/logic/services/door_unlock_service.dart | 4 +++ lib/pages/start_machine_page.dart | 23 +++++++++++++--- lib/services/kisi/door_unlocker.dart | 29 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 lib/logic/services/door_unlock_service.dart create mode 100644 lib/services/kisi/door_unlocker.dart diff --git a/lib/logic/services/door_unlock_service.dart b/lib/logic/services/door_unlock_service.dart new file mode 100644 index 00000000..0908c1a9 --- /dev/null +++ b/lib/logic/services/door_unlock_service.dart @@ -0,0 +1,4 @@ +abstract class DoorUnlockService { + Future> getNearbyDoors(); + Future unlockDoor(String doorId); +} \ No newline at end of file diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index b393ebf3..2a728ccf 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -4,12 +4,15 @@ import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; class StartPage extends StatelessWidget { const StartPage({super.key}); @override Widget build(BuildContext context) { + final doorUnlocker = DoorUnlocker(); + return BasePage( body: Padding( padding: const EdgeInsets.all(20.0), @@ -85,10 +88,22 @@ class StartPage extends StatelessWidget { headLineText: "Unlock Door", descriptionText: "Unlock doors after hours", icon: Icons.lock_open_rounded, - onPressed: () { - context.go("/home"); - }, - ), + onPressed: () async { + final success = await doorUnlocker.unlockNearestDoor(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? "Nearest door unlocked!" + : "No access or no nearby doors found.", + ), + ), + ); + }; + }, + ), ), ], ), diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart new file mode 100644 index 00000000..1e727bf3 --- /dev/null +++ b/lib/services/kisi/door_unlocker.dart @@ -0,0 +1,29 @@ +import '../../logic/services/door_unlock_service.dart'; + +class DoorUnlocker implements DoorUnlockService { + final _readerToDoor = { + "Reader A": "Front Door", + "Reader B": "Broken Door", + }; + + @override + Future> getNearbyDoors() async { + await Future.delayed(const Duration(milliseconds: 500)); + return _readerToDoor.values.toList(); + } + + @override + Future unlockDoor(String doorId) async { + await Future.delayed(const Duration(seconds: 1)); + return doorId != "Broken Door"; // simulate access denied + } + + Future unlockNearestDoor() async { + final doors = await getNearbyDoors(); + if (doors.isEmpty) return false; + + final nearest = doors.first; + return await unlockDoor(nearest); + } + +} \ No newline at end of file From 770f1fb19fc3c1bd85c22e0b2b9ce0566c2c8157 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 20:10:10 -0400 Subject: [PATCH 091/141] Added door unlocking dialogue --- lib/pages/start_machine_page.dart | 49 +++++++++++++++------- lib/services/kisi/door_unlocker.dart | 22 +++++++++- lib/widgets/show_searching.dart | 63 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 lib/widgets/show_searching.dart diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 2a728ccf..61858690 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; +import '../widgets/status_dialog_box.dart'; class StartPage extends StatelessWidget { const StartPage({super.key}); @@ -88,22 +90,10 @@ class StartPage extends StatelessWidget { headLineText: "Unlock Door", descriptionText: "Unlock doors after hours", icon: Icons.lock_open_rounded, - onPressed: () async { - final success = await doorUnlocker.unlockNearestDoor(); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? "Nearest door unlocked!" - : "No access or no nearby doors found.", - ), - ), - ); - }; - }, - ), + onPressed: () async { + await _processUnlocking(context, doorUnlocker); + }, + ), ), ], ), @@ -112,4 +102,31 @@ class StartPage extends StatelessWidget { ), ); } +} + +Future _processUnlocking(BuildContext context, DoorUnlocker doorUnlocker,) +async { + cancelSearch = false; + + showSearchingDialog(context); + + final success = await doorUnlocker.unlockNearestDoor(); + + if (cancelSearch) { + doorUnlocker.cancelUnlockingDoor(); + return; + } + + if (context.mounted) Navigator.of(context).pop(); + + if (!context.mounted) return; + + statusDialog( + context, + title: success ? "Door Unlocked!" : "No Nearby Doors Found", + message: success + ? "The nearest door has been unlocked successfully" + : "We couldn't detect any nearby doors", + isSuccess: success, + ); } \ No newline at end of file diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart index 1e727bf3..f2dae809 100644 --- a/lib/services/kisi/door_unlocker.dart +++ b/lib/services/kisi/door_unlocker.dart @@ -1,9 +1,12 @@ import '../../logic/services/door_unlock_service.dart'; class DoorUnlocker implements DoorUnlockService { + bool cancelled = false; + final _readerToDoor = { "Reader A": "Front Door", - "Reader B": "Broken Door", + //"Reader A": "Broken Door", + "Reader B": "Back Door", }; @override @@ -18,12 +21,27 @@ class DoorUnlocker implements DoorUnlockService { return doorId != "Broken Door"; // simulate access denied } + void cancelUnlockingDoor() { + cancelled = true; + } + Future unlockNearestDoor() async { + cancelled = false; + final doors = await getNearbyDoors(); - if (doors.isEmpty) return false; + + if (cancelled) + return false; + if (doors.isEmpty) + return false; final nearest = doors.first; + + if (cancelled) + return false; + return await unlockDoor(nearest); } + } \ No newline at end of file diff --git a/lib/widgets/show_searching.dart b/lib/widgets/show_searching.dart new file mode 100644 index 00000000..635e86e5 --- /dev/null +++ b/lib/widgets/show_searching.dart @@ -0,0 +1,63 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +late bool cancelSearch = false; + +void showSearchingDialog(BuildContext context) { + cancelSearch = false; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + "Finding Nearby Doors...", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + const SizedBox(height: 10), + Text( + "Please wait while we search for the nearest door.", + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.fontInverted, + ) + ), + const SizedBox(height: 20), + TextButton( + onPressed: () { + cancelSearch = true; + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Text( + "Cancel", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ), + ); +} \ No newline at end of file From cf3f811baddf44b5b0c7dfcb27233ebf5091073a Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 20:15:34 -0400 Subject: [PATCH 092/141] Refactored door_unlocker --- lib/services/kisi/door_unlocker.dart | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart index f2dae809..704ec69a 100644 --- a/lib/services/kisi/door_unlocker.dart +++ b/lib/services/kisi/door_unlocker.dart @@ -11,7 +11,7 @@ class DoorUnlocker implements DoorUnlockService { @override Future> getNearbyDoors() async { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); return _readerToDoor.values.toList(); } @@ -30,16 +30,10 @@ class DoorUnlocker implements DoorUnlockService { final doors = await getNearbyDoors(); - if (cancelled) - return false; - if (doors.isEmpty) + if (cancelled || doors.isEmpty) return false; final nearest = doors.first; - - if (cancelled) - return false; - return await unlockDoor(nearest); } From 9a8896f5d8e5cbfcefceec00e5cebc29637ce08f Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 20:22:05 -0400 Subject: [PATCH 093/141] Added door_unlocker tests --- lib/services/kisi/door_unlocker.dart | 3 ++ test/services/kisi/door_unlocker_test.dart | 50 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 test/services/kisi/door_unlocker_test.dart diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart index 704ec69a..01f36e39 100644 --- a/lib/services/kisi/door_unlocker.dart +++ b/lib/services/kisi/door_unlocker.dart @@ -28,6 +28,9 @@ class DoorUnlocker implements DoorUnlockService { Future unlockNearestDoor() async { cancelled = false; + if (cancelled) + return false; + final doors = await getNearbyDoors(); if (cancelled || doors.isEmpty) diff --git a/test/services/kisi/door_unlocker_test.dart b/test/services/kisi/door_unlocker_test.dart new file mode 100644 index 00000000..c264d3c8 --- /dev/null +++ b/test/services/kisi/door_unlocker_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; + +void main() { + group('DoorUnlocker Tests', () { + late DoorUnlocker unlocker; + + setUp(() { + unlocker = DoorUnlocker(); + }); + + test('getNearbyDoors returns all mapped doors', () async { + final doors = await unlocker.getNearbyDoors(); + + expect(doors.length, 2); + expect(doors, contains("Front Door")); + expect(doors, contains("Back Door")); + }); + + test('unlockDoor returns true for normal doors', () async { + final result = await unlocker.unlockDoor("Front Door"); + expect(result, true); + }); + + test('unlockDoor returns false for Broken Door', () async { + final result = await unlocker.unlockDoor("Broken Door"); + expect(result, false); + }); + + test('unlockNearestDoor unlocks the first door', () async { + final result = await unlocker.unlockNearestDoor(); + expect(result, true); + }); + + test('unlockNearestDoor returns false when cancelled after fetching doors', () async { + final future = unlocker.unlockNearestDoor(); + + unlocker.cancelUnlockingDoor(); + + final result = await future; + expect(result, false); + }); + + test('cancelUnlockingDoor sets cancelled flag', () { + expect(unlocker.cancelled, false); + unlocker.cancelUnlockingDoor(); + expect(unlocker.cancelled, true); + }); + }); +} \ No newline at end of file From bf33d88fcf0c6383c19faacc3a38df7c3374c337 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 20:49:51 -0400 Subject: [PATCH 094/141] Added showSearching tests --- test/widgets/show_searching_test.dart | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/widgets/show_searching_test.dart diff --git a/test/widgets/show_searching_test.dart b/test/widgets/show_searching_test.dart new file mode 100644 index 00000000..a48e641a --- /dev/null +++ b/test/widgets/show_searching_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; + +void main() { + testWidgets('showSearchingDialog displays dialog with correct content', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog(context), + child: const Text('Open Dialog'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.text('Finding Nearby Doors...'), findsOneWidget); + expect(find.text('Please wait while we search for the nearest door.'), + findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel button sets cancelSearch = true and closes dialog', + (WidgetTester tester) async { + cancelSearch = false; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog(context), + child: const Text('Open Dialog'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Cancel')); + await tester.pump(const Duration(milliseconds: 100)); + + expect(cancelSearch, true); + expect(find.byType(Dialog), findsNothing); + }); +} \ No newline at end of file From 8fe6f64b46d862a4f9f554a209a19064584f29e8 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 21:41:19 -0400 Subject: [PATCH 095/141] Added tests for unlock button --- lib/pages/start_machine_page.dart | 7 +- test/pages/start_machine_page_test.dart | 107 ++++++++++++++++++++---- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 61858690..1ecacd47 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -9,12 +9,13 @@ import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import '../widgets/status_dialog_box.dart'; class StartPage extends StatelessWidget { - const StartPage({super.key}); + final DoorUnlocker doorUnlocker; + + StartPage({super.key, DoorUnlocker? doorUnlocker}) + : doorUnlocker = doorUnlocker ?? DoorUnlocker(); @override Widget build(BuildContext context) { - final doorUnlocker = DoorUnlocker(); - return BasePage( body: Padding( padding: const EdgeInsets.all(20.0), diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index a3d4ef82..cb3da82f 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,52 +1,125 @@ import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; import 'package:clean_stream_laundry_app/widgets/large_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; -class MockNavigatorObserver extends Mock implements NavigatorObserver {} +class MockDoorUnlocker extends Mock implements DoorUnlocker {} void main() { - late GoRouter router; - late MockNavigatorObserver navigatorObserver; + late MockDoorUnlocker mockUnlocker; setUp(() { - navigatorObserver = MockNavigatorObserver(); + mockUnlocker = MockDoorUnlocker(); + }); - router = GoRouter( - observers: [navigatorObserver], + Widget createStartPageTestApp(DoorUnlocker unlocker) { + final router = GoRouter( routes: [ GoRoute( path: '/', - builder: (_, __) => const StartPage(), - ), - GoRoute( - path: '/scanner', - builder: (_, __) => const Scaffold(body: Text('Scanner Page')), + builder: (_, _) => StartPage(doorUnlocker: unlocker), ), ], ); - }); - Widget createTestApp() { return MaterialApp.router( routerConfig: router, ); } + Widget createRouterTestApp() { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => StartPage(), + ), + GoRoute( + path: '/scanner', + builder: (_, _) => const Scaffold(body: Text('Scanner Page')), + ), + ], + ); + + return MaterialApp.router(routerConfig: router); + } + testWidgets('Tapping QR button navigates to /scanner', (tester) async { - await tester.pumpWidget(createTestApp()); + await tester.pumpWidget(createRouterTestApp()); await tester.pumpAndSettle(); final qrButton = find.widgetWithText(LargeButton, "Scan QR code"); - expect(qrButton, findsOneWidget); await tester.tap(qrButton); await tester.pumpAndSettle(); - // Verify we navigated to the scanner page expect(find.text("Scanner Page"), findsOneWidget); }); -} + + testWidgets('Unlock button shows searching dialog', (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return true; + }); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pump(); + + final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + await tester.ensureVisible(unlockButton); + await tester.pump(); + + await tester.tap(unlockButton); + await tester.pump(const Duration(milliseconds: 10)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.textContaining("Finding Nearby Doors"), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + }); + + + testWidgets('Successful unlock closes searching dialog and shows success dialog', + (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pump(); + + final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + await tester.ensureVisible(unlockButton); + await tester.pump(); + + await tester.tap(unlockButton); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.pump(const Duration(seconds: 3)); + + expect(find.text("Door Unlocked!"), findsOneWidget); + }); + + testWidgets('Failed unlock shows failure dialog', (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => false); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pump(); + + final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + await tester.ensureVisible(unlockButton); + await tester.pump(); + + await tester.tap(unlockButton); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.pump(const Duration(seconds: 2)); + + expect(find.text("No Nearby Doors Found"), findsOneWidget); + }); +} \ No newline at end of file From ffd47058714fabf7263869ded498025694b8c0fe Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Sun, 8 Mar 2026 21:41:39 -0400 Subject: [PATCH 096/141] Added show_searching tests --- lib/widgets/show_searching.dart | 10 +++++----- test/pages/mocks.dart | 3 +++ test/widgets/show_searching_test.dart | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/widgets/show_searching.dart b/lib/widgets/show_searching.dart index 635e86e5..b9549471 100644 --- a/lib/widgets/show_searching.dart +++ b/lib/widgets/show_searching.dart @@ -8,7 +8,7 @@ void showSearchingDialog(BuildContext context) { showDialog( context: context, barrierDismissible: false, - builder: (_) => Dialog( + builder: (dialogContext) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24), @@ -22,7 +22,7 @@ void showSearchingDialog(BuildContext context) { style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.fontInverted, + color: Theme.of(dialogContext).colorScheme.fontInverted, ), ), const SizedBox(height: 10), @@ -30,14 +30,14 @@ void showSearchingDialog(BuildContext context) { "Please wait while we search for the nearest door.", textAlign: TextAlign.center, style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ) + color: Theme.of(dialogContext).colorScheme.fontInverted, + ), ), const SizedBox(height: 20), TextButton( onPressed: () { cancelSearch = true; - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(); }, style: TextButton.styleFrom( backgroundColor: Colors.red, diff --git a/test/pages/mocks.dart b/test/pages/mocks.dart index afef57cd..a6dee910 100644 --- a/test/pages/mocks.dart +++ b/test/pages/mocks.dart @@ -14,6 +14,7 @@ import 'package:clean_stream_laundry_app/logic/services/edge_function_service.da import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; class MockAuthService extends Mock implements AuthService {} @@ -23,6 +24,8 @@ class MockLocationService extends Mock implements LocationService {} class MockMachineService extends Mock implements MachineService {} +class MockDoorUnlocker extends Mock implements DoorUnlocker {} + class MockThemeManager extends Mock implements ThemeManager {} class MockProfileService extends Mock implements ProfileService {} diff --git a/test/widgets/show_searching_test.dart b/test/widgets/show_searching_test.dart index a48e641a..694a7bdc 100644 --- a/test/widgets/show_searching_test.dart +++ b/test/widgets/show_searching_test.dart @@ -23,7 +23,7 @@ void main() { ); await tester.tap(find.text('Open Dialog')); - + await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(Dialog), findsOneWidget); From b5986413cdc6903a060ca38b810db418aece2330 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 9 Mar 2026 16:54:33 -0400 Subject: [PATCH 097/141] Fix script error --- package.json | 2 +- pubspec.lock | 44 ++++++++----------- ...date-functions.js => update-functions.cjs} | 0 .../functions/checkPaymentResult/logic.ts | 2 - .../functions/createCheckoutSession/index.ts | 2 +- 5 files changed, 20 insertions(+), 30 deletions(-) rename scripts/{update-functions.js => update-functions.cjs} (100%) diff --git a/package.json b/package.json index 62686c83..fd423542 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test": "test" }, "scripts": { - "supabase:update-functions": "node scripts/update-functions.js", + "supabase:update-functions": "node scripts/update-functions.cjs", "test:coverage": "deno test --reload --coverage=coverage_deno supabase/functions" }, "dependencies": { diff --git a/pubspec.lock b/pubspec.lock index e59d7883..bc6b3aad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "10.0.1" ansicolor: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -520,14 +520,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: @@ -612,26 +604,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -1121,26 +1113,26 @@ packages: dependency: "direct main" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.9" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.15" test_cov_console: dependency: "direct dev" description: diff --git a/scripts/update-functions.js b/scripts/update-functions.cjs similarity index 100% rename from scripts/update-functions.js rename to scripts/update-functions.cjs diff --git a/supabase/functions/checkPaymentResult/logic.ts b/supabase/functions/checkPaymentResult/logic.ts index 887a2957..b6d4faf3 100644 --- a/supabase/functions/checkPaymentResult/logic.ts +++ b/supabase/functions/checkPaymentResult/logic.ts @@ -1,5 +1,3 @@ -import Stripe from "npm:stripe@^14.0.0"; - export interface PaymentDeps { retrieveSession: (sessionId: string) => Promise<{ payment_status: string }>; } diff --git a/supabase/functions/createCheckoutSession/index.ts b/supabase/functions/createCheckoutSession/index.ts index 9e772616..0b39de8f 100644 --- a/supabase/functions/createCheckoutSession/index.ts +++ b/supabase/functions/createCheckoutSession/index.ts @@ -19,7 +19,7 @@ serve(async (req) => { const token = authHeader.replace("Bearer ", ""); const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { - apiVersion: "2024-06-20", + apiVersion: "2023-10-16", }); const supabase = createClient( From 340c225f3aa09b4d0a621066cef0011756a3914d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 9 Mar 2026 17:29:48 -0400 Subject: [PATCH 098/141] Fix Stripe function --- lib/services/stripe/stripe_service_mobile.dart | 13 ++++++++----- .../stripe/stripe_service_mobile_test.dart | 16 ++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/services/stripe/stripe_service_mobile.dart b/lib/services/stripe/stripe_service_mobile.dart index cb00196e..b658735d 100644 --- a/lib/services/stripe/stripe_service_mobile.dart +++ b/lib/services/stripe/stripe_service_mobile.dart @@ -28,8 +28,12 @@ class StripeService implements PaymentService { colors: PaymentSheetAppearanceColors( primary: Color(0xFF2073A9), background: CupertinoColors.systemBackground, + componentBackground: CupertinoColors.secondarySystemBackground, + componentBorder: CupertinoColors.separator, + componentText: CupertinoColors.label, + placeholderText: CupertinoColors.separator ), - shapes: const PaymentSheetShape(borderRadius: 12), + shapes: const PaymentSheetShape(borderRadius: 20), primaryButton: PaymentSheetPrimaryButtonAppearance( colors: PaymentSheetPrimaryButtonTheme( light: PaymentSheetPrimaryButtonThemeColors( @@ -37,7 +41,7 @@ class StripeService implements PaymentService { text: CupertinoColors.white, ), ), - shapes: const PaymentSheetPrimaryButtonShape(blurRadius: 12), + shapes: const PaymentSheetPrimaryButtonShape(blurRadius: 20), ), ), applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'), @@ -73,8 +77,7 @@ class StripeService implements PaymentService { } @protected - String convertDollarsToCents(double amount) { - final calculatedAmount = (amount * 100).toInt(); - return calculatedAmount.toString(); + int convertDollarsToCents(double amount) { + return (amount * 100).toInt(); } } diff --git a/test/services/stripe/stripe_service_mobile_test.dart b/test/services/stripe/stripe_service_mobile_test.dart index 18ffa116..757feaa5 100644 --- a/test/services/stripe/stripe_service_mobile_test.dart +++ b/test/services/stripe/stripe_service_mobile_test.dart @@ -58,7 +58,7 @@ void main() { // Assert (interaction) verify(() => mockEdgeFunctionService.runEdgeFunction( name: 'paymentIntent', - body: {'amount': '260', 'currency': 'usd'}, + body: {'amount': 260, 'currency': 'usd'}, )).called(1); }); @@ -191,7 +191,7 @@ void main() { expect(result, "testSecret123"); verify(() => mockEdgeFunctionService.runEdgeFunction( name: 'paymentIntent', - body: {'amount': '2570', 'currency': 'usd'}, + body: {'amount': 2570, 'currency': 'usd'}, )).called(1); }); @@ -261,27 +261,27 @@ void main() { group("convertDollarsToCents", () { test("converts dollars to cents correctly", () { - expect(stripeService.convertDollarsToCents(2.75), "275"); + expect(stripeService.convertDollarsToCents(2.75), 275); }); test("handles zero amount", () { - expect(stripeService.convertDollarsToCents(0), "0"); + expect(stripeService.convertDollarsToCents(0), 0); }); test("handles whole dollar amounts", () { - expect(stripeService.convertDollarsToCents(10.00), "1000"); + expect(stripeService.convertDollarsToCents(10.00), 1000); }); test("handles large amounts", () { - expect(stripeService.convertDollarsToCents(1234.56), "123456"); + expect(stripeService.convertDollarsToCents(1234.56), 123456); }); test("handles small decimal amounts", () { - expect(stripeService.convertDollarsToCents(0.01), "1"); + expect(stripeService.convertDollarsToCents(0.01), 1); }); test("rounds down fractional cents", () { - expect(stripeService.convertDollarsToCents(1.999), "199"); + expect(stripeService.convertDollarsToCents(1.999), 199); }); }); }); From b424a9ec2eea04fa19daeef3a261a71e1acc7bd4 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 11 Mar 2026 13:29:32 -0400 Subject: [PATCH 099/141] Made scrollbar interactive Made scrollbar interactive by removing the scrollbar from the unused scaffold --- lib/pages/monthly_transaction_history.dart | 183 ++++++++++----------- 1 file changed, 91 insertions(+), 92 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 8667bd96..45d8ccea 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -50,102 +50,101 @@ class MonthlyTransactionHistory extends StatelessWidget { ); } - return Scaffold( - body: Theme( - data: Theme.of(context).copyWith( - scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(Colors.lightBlue), - trackColor: WidgetStateProperty.all(Colors.transparent), - thickness: WidgetStateProperty.all(8), - radius: const Radius.circular(4), - ), + return Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all(Colors.lightBlue), + trackColor: WidgetStateProperty.all(Colors.transparent), + thickness: WidgetStateProperty.all(8), + radius: const Radius.circular(4), ), - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: visibleMonths.length, - itemBuilder: (context, index) { - final month = visibleMonths[index]; - final data = monthlySums[month]!; - final total = - data['directWasher']! + - data['directDryer']! + - data['loyaltyCard']!; - if (total == 0 && - data['loyaltyWasher'] == 0 && - data['loyaltyDryer'] == 0) { - return const SizedBox(width: 0, height: 0); - } else { - return Card( - margin: const EdgeInsets.only(bottom: 16), - elevation: 2, - color: Theme.of(context).colorScheme.cardPrimary, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - month, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, - ), + ), + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + interactive: true, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: visibleMonths.length, + itemBuilder: (context, index) { + final month = visibleMonths[index]; + final data = monthlySums[month]!; + final total = + data['directWasher']! + + data['directDryer']! + + data['loyaltyCard']!; + if (total == 0 && + data['loyaltyWasher'] == 0 && + data['loyaltyDryer'] == 0) { + return const SizedBox(width: 0, height: 0); + } else { + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + color: Theme.of(context).colorScheme.cardPrimary, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + month, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, ), - Text( - '\$${total.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black, - ), + ), + Text( + '\$${total.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, ), - ], - ), - const Divider(height: 24), - _buildTransactionRow( - 'Direct Washer Payments', - data['directWasher']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Washer Payments', - data['loyaltyWasher']!, - Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Direct Dryer Payments', - data['directDryer']!, - Colors.black, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Dryer Payments', - data['loyaltyDryer']!, - Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Card Loads', - data['loyaltyCard']!, - Colors.black, - ), - ], - ), + ), + ], + ), + const Divider(height: 24), + _buildTransactionRow( + 'Direct Washer Payments', + data['directWasher']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Washer Payments', + data['loyaltyWasher']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Direct Dryer Payments', + data['directDryer']!, + Colors.black, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Dryer Payments', + data['loyaltyDryer']!, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + _buildTransactionRow( + 'Loyalty Card Loads', + data['loyaltyCard']!, + Colors.black, + ), + ], ), - ); - } - }, - ), + ), + ); + } + }, ), ), ); From 4ccd49c22f4412cc35524746b70379b222070781 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:39:27 -0400 Subject: [PATCH 100/141] Fixed file import issue --- lib/pages/start_machine_page.dart | 2 +- test/pages/start_machine_page_test.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 6c347761..302e49ad 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -92,7 +92,7 @@ class StartPage extends StatelessWidget { SizedBox( height: 160, - child: LargeButton( + child: QRButton( headLineText: "Unlock Door", descriptionText: "Unlock doors after hours", icon: Icons.lock_open_rounded, diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index f2926b25..c75945b0 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -52,7 +52,7 @@ void main() { await tester.pumpWidget(createRouterTestApp()); await tester.pumpAndSettle(); - final qrButton = find.widgetWithText(LargeButton, "Scan QR code"); + final qrButton = find.widgetWithText(QRButton, "Scan QR code"); expect(qrButton, findsOneWidget); await tester.tap(qrButton); @@ -71,7 +71,7 @@ void main() { await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pump(); - final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); await tester.ensureVisible(unlockButton); await tester.pump(); @@ -93,7 +93,7 @@ void main() { await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pump(); - final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); await tester.ensureVisible(unlockButton); await tester.pump(); @@ -112,7 +112,7 @@ void main() { await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pump(); - final unlockButton = find.widgetWithText(LargeButton, "Unlock Door"); + final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); await tester.ensureVisible(unlockButton); await tester.pump(); From 5e60a5dec3dafa5de4d39f401c193d5d71b87276 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:43:51 -0400 Subject: [PATCH 101/141] Updated permissions --- ios/Runner/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 21c3cc7a..5f3d730c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -62,5 +62,7 @@ NSLocationWhenInUseUsageDescription Your location is required to find the nearest Clean Stream Laundry location + NSLocationAlwaysAndWhenInUseUsageDescription + Your location is used to find nearby Clean Stream Laundry locations and improve location-based services in the app. \ No newline at end of file From ed5cb649964d303923c7edc70d3fb12c6cf4c119 Mon Sep 17 00:00:00 2001 From: nolan-meyer1 <145584308+nolan-meyer1@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:05:37 -0400 Subject: [PATCH 102/141] Added logo to reset protected page --- lib/pages/reset_protected_page.dart | 12 ++++++++++-- test/pages/reset_protected_page_test.dart | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 5f95cad5..8c947974 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -170,6 +170,14 @@ class _ResetProtectedPageState extends State { children: [ const SizedBox(height: 20), + //Clean Stream Logo + Image.asset( + "assets/Logo.png", + height: 250, + width: 250, + key: const Key('app_logo'), + ), + // Title Text( "Reset Password", @@ -189,7 +197,7 @@ class _ResetProtectedPageState extends State { const SizedBox(height: 30), - /// Password requirements (same style as signup) + // Password requirements (same style as signup) ValueListenableBuilder( valueListenable: _passwordCtrl, builder: (context, value, _) { @@ -222,7 +230,7 @@ class _ResetProtectedPageState extends State { }, ), - /// Password field + // Password field TextField( controller: _passwordCtrl, obscureText: _obscurePassword, diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index 9565ee53..f8f664e9 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -122,4 +122,15 @@ void main() { verify(() => mockAuthService.updatePassword('Password123&')).called(1); expect(find.text('Failed to reset password'), findsWidgets); }); + + testWidgets('Verifies that the image is present', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(Image),findsOneWidget); + }); } From 17433af69494163bf3b8cf8a6134ef6f590ac41d Mon Sep 17 00:00:00 2001 From: karelinejones Date: Thu, 12 Mar 2026 11:52:54 -0400 Subject: [PATCH 103/141] refactored navbar --- lib/widgets/navigation_bar.dart | 91 ++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/lib/widgets/navigation_bar.dart b/lib/widgets/navigation_bar.dart index 7e4d672b..50d67c2e 100644 --- a/lib/widgets/navigation_bar.dart +++ b/lib/widgets/navigation_bar.dart @@ -2,16 +2,42 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class NavBar extends StatelessWidget { + static const List<_NavDestination> _destinations = [ + _NavDestination( + route: '/homePage', + label: 'Home', + icon: Icons.home, + routePrefixes: ['/homePage'], + ), + _NavDestination( + route: '/startPage', + label: 'Start', + icon: Icons.local_laundry_service_sharp, + routePrefixes: ['/start', '/startPage'], + ), + _NavDestination( + route: '/loyalty', + label: 'Wallet', + icon: Icons.wallet, + routePrefixes: ['/loyalty'], + ), + _NavDestination( + route: '/settings', + label: 'Settings', + icon: Icons.settings, + routePrefixes: ['/settings', '/monthlyTransactionHistory', '/refundPage'], + ), + ]; - const NavBar({super.key,}); + const NavBar({super.key}); int _getIndex(String location) { - if (location.startsWith('/homePage')) return 0; - if (location.startsWith('/start')) return 1; - if (location.startsWith('/loyalty')) return 2; - if (location.startsWith('/settings')) return 3; - if (location.startsWith('/monthlyTransactionHistory')) return 3; - if (location.startsWith('/refundPage')) return 3; + for (var i = 0; i < _destinations.length; i++) { + final destination = _destinations[i]; + if (destination.routePrefixes.any(location.startsWith)) { + return i; + } + } return 0; } @@ -20,35 +46,42 @@ class NavBar extends StatelessWidget { final router = GoRouter.of(context); final location = router.routeInformationProvider.value.uri.toString(); final currentIndex = _getIndex(location); + final colorScheme = Theme.of(context).colorScheme; return BottomNavigationBar( currentIndex: currentIndex, - backgroundColor: Theme.of(context).colorScheme.surface, - selectedItemColor: Theme.of(context).colorScheme.primary, + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, unselectedItemColor: Colors.grey, type: BottomNavigationBarType.fixed, onTap: (index) { - switch (index) { - case 0: - context.go("/homePage"); - break; - case 1: - context.go("/startPage"); - break; - case 2: - context.go("/loyalty"); - break; - case 3: - context.go("/settings"); - break; + final route = _destinations[index].route; + if (!location.startsWith(route)) { + context.go(route); } }, - items: const [ - BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), - BottomNavigationBarItem(icon: Icon(Icons.local_laundry_service_sharp), label: 'Start'), - BottomNavigationBarItem(icon: Icon(Icons.wallet), label: 'Wallet'), - BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), - ], + items: _destinations + .map( + (destination) => BottomNavigationBarItem( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), ); } -} \ No newline at end of file +} + +class _NavDestination { + final String route; + final String label; + final IconData icon; + final List routePrefixes; + + const _NavDestination({ + required this.route, + required this.label, + required this.icon, + required this.routePrefixes, + }); +} From ccf79f47caca2cde0b7d0f5af698b758f83dabb5 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:09:19 -0400 Subject: [PATCH 104/141] Adds trailing decimal points to balance --- lib/pages/home_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 322997d9..d71fde75 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -147,7 +147,7 @@ class HomePageState extends State { children: [ Flexible( child: Text( - "Current balance: \$${balance?["balance"] ?? 'Loading...'}", + 'Current balance: \$${balance?["balance"] != null ? (balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, From 188c7b0ef2df856e34ae0ef148ad8a2180a41022 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:11:46 -0400 Subject: [PATCH 105/141] Ensures all border radii are 14 px --- lib/pages/home_page.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index d71fde75..515fc05c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -215,9 +215,9 @@ class HomePageState extends State { if (snapshot.connectionState == ConnectionState.waiting) { return Container( height: 400, - width: 400, + width: 300, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all( color: Colors.grey.shade400, width: 1, @@ -245,7 +245,7 @@ class HomePageState extends State { double initialZoom = 7.2; return Container( - height: 400, + height: 300, width: 400, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), @@ -278,7 +278,7 @@ class HomePageState extends State { margin: EdgeInsets.only(top: 20), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), color: Theme.of(context).colorScheme.cardSecondary, ), @@ -416,7 +416,7 @@ class HomePageState extends State { width: 520, decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), color: Colors.transparent, ), child: Column( From 67390577e91c521d97e376644e33a076618b2039 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:22:05 -0400 Subject: [PATCH 106/141] Makes dropdown a BottomPopupSheet --- lib/pages/home_page.dart | 138 +++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 65 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 515fc05c..f32989a7 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -177,7 +177,10 @@ class HomePageState extends State { }, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -188,7 +191,9 @@ class HomePageState extends State { fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, - decorationColor: Theme.of(context).colorScheme.primary + decorationColor: Theme.of( + context, + ).colorScheme.primary, ), ), const SizedBox(width: 6), @@ -276,7 +281,7 @@ class HomePageState extends State { ), Container( margin: EdgeInsets.only(top: 20), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), @@ -284,20 +289,20 @@ class HomePageState extends State { ), child: Row( children: [ - Icon(Icons.location_on, color: Colors.blue, size: 28), + Icon(Icons.location_on, color: Colors.blue, size: 24), SizedBox(width: 8), Expanded( child: FutureBuilder( - future: Future.wait([locationService.getLocations()]), + future: locationService.getLocations(), builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator(), + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 24, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } - final data = snapshot.data![0]; + final data = snapshot.data!; for (var item in data) { locationID[item["Address"]] = item["id"]; } @@ -314,57 +319,56 @@ class HomePageState extends State { }); } - return DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: locationID.containsKey(selectedName) - ? selectedName - : null, - hint: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - "Select Location", - style: TextStyle( - fontSize: 18, - color: Theme.of( - context, - ).colorScheme.fontInverted, - ), + return GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - ), - onChanged: (String? newValue) { - if (newValue != null) { - storage.setValue( - "lastSelectedLocation", - newValue, - ); - _zoomToLocation(newValue); - } - setState(() { - selectedName = newValue; - locationSelected = true; - locationIDSelected = locationID[newValue]; - }); - }, - items: locationID.entries.map((entry) { - return DropdownMenuItem( - value: entry.key, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - entry.key, - style: TextStyle( - fontSize: 18, - color: Theme.of( - context, - ).colorScheme.fontInverted, + builder: (_) => ListView.separated( + padding: EdgeInsets.symmetric(vertical: 12), + itemCount: data.length, + separatorBuilder: (_, __) => Divider(height: 1), + itemBuilder: (_, index) { + final item = data[index]; + return ListTile( + title: Text( + item["Address"], + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontInverted, + ), ), - ), - ), - ); - }).toList(), + onTap: () { + setState(() { + selectedName = item["Address"]; + locationSelected = true; + locationIDSelected = item["id"]; + }); + storage.setValue("lastSelectedLocation", selectedName!); + _zoomToLocation(selectedName!); + Navigator.pop(context); + }, + ); + }, + ), + ); + }, + child: Container( + padding: EdgeInsets.symmetric(vertical: 4), + child: Text( + selectedName ?? "Select Location", + style: TextStyle( + fontSize: 16, + color: selectedName == null + ? Colors.grey + : Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), ), ); }, @@ -372,19 +376,23 @@ class HomePageState extends State { ), IconButton( onPressed: () async { - if(selectedName != null){ + if (selectedName != null) { await _openDirectionsFromAddress(selectedName); - }else{ - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please select a location to get directions!"))); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Please select a location to get directions!")), + ); } }, - icon: Icon(Icons.navigation,color:Theme.of(context).primaryColor) - ) + icon: Icon(Icons.navigation, color: Theme.of(context).primaryColor, size: 24), + padding: EdgeInsets.zero, // remove extra padding + constraints: BoxConstraints(), + ), ], ), ), - SizedBox(height: 10), + SizedBox(height: 18), if (locationSelected) FutureBuilder( From a7e94d178f38bdf39e68a5ab3b4e2159b53276cb Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:32:08 -0400 Subject: [PATCH 107/141] Makes border radii throughout app 14 px --- lib/pages/edit_profile_page.dart | 26 +++++++++++----------- lib/pages/home_page.dart | 2 +- lib/pages/loyalty_card_page.dart | 4 ++-- lib/pages/monthly_transaction_history.dart | 3 +++ lib/pages/refund_page.dart | 8 +++---- lib/pages/start_machine_page.dart | 2 +- lib/widgets/credit_card.dart | 2 +- lib/widgets/custom_app_bar.dart | 2 +- lib/widgets/qr_button.dart | 2 +- lib/widgets/settings_card.dart | 2 ++ 10 files changed, 29 insertions(+), 24 deletions(-) diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index 6359943b..959fd78b 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -169,7 +169,7 @@ class _EditProfilePageState extends State { showDialog( context: context, builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), title: Text( 'Error', style: TextStyle( @@ -290,7 +290,7 @@ class _EditProfilePageState extends State { horizontal: 16, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( @@ -298,14 +298,14 @@ class _EditProfilePageState extends State { color: Theme.of(context).colorScheme.primary, width: 2.0, ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.fontSecondary .withValues(alpha: 0.2), ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), prefixIcon: Icon( Icons.person_outline, @@ -365,7 +365,7 @@ class _EditProfilePageState extends State { horizontal: 16, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( @@ -373,14 +373,14 @@ class _EditProfilePageState extends State { color: Theme.of(context).colorScheme.primary, width: 2.0, ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.fontSecondary .withValues(alpha: 0.2), ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), prefixIcon: Icon( Icons.email_outlined, @@ -411,7 +411,7 @@ class _EditProfilePageState extends State { padding: const EdgeInsets.symmetric(vertical: 16), elevation: 2, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), onPressed: _isSaving ? null : _onSavePressed, @@ -447,7 +447,7 @@ class _EditProfilePageState extends State { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all( color: Colors.red.withValues(alpha: 0.2), width: 1, @@ -497,7 +497,7 @@ class _EditProfilePageState extends State { vertical: 12, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), ), ), child: _isSaving @@ -558,7 +558,7 @@ class _EditProfilePageState extends State { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), width: 1, @@ -635,7 +635,7 @@ class _EditProfilePageState extends State { context: context, builder: (context) => AlertDialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(14), ), title: Row( children: [ @@ -687,7 +687,7 @@ class _EditProfilePageState extends State { context: context, builder: (context) => AlertDialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(14), ), title: Text( 'Confirm Changes', diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index f32989a7..f0574175 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -253,7 +253,7 @@ class HomePageState extends State { height: 300, width: 400, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), ), clipBehavior: Clip.antiAlias, diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 9b1b4184..25913f78 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -65,7 +65,7 @@ class LoyaltyCardPage extends State { backgroundColor: Colors.blue, disabledBackgroundColor: Colors.grey, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, ), @@ -137,7 +137,7 @@ class LoyaltyCardPage extends State { vertical: 6.0, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), ), elevation: 4, color: Theme.of(context).colorScheme.cardPrimary, diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index b9c83c46..6b0801f1 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -70,6 +70,9 @@ class MonthlyTransactionHistory extends StatelessWidget { } else { return Card( margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), elevation: 2, color: Theme.of(context).colorScheme.cardPrimary, child: Padding( diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index f4dca64a..26bb3215 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -123,7 +123,7 @@ class RefundPageState extends State { padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(14), border: Border.all( color: colorScheme.primary.withOpacity(0.2), ), @@ -134,7 +134,7 @@ class RefundPageState extends State { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), child: Icon( Icons.receipt_long_rounded, @@ -176,7 +176,7 @@ class RefundPageState extends State { Card( elevation: 2, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(14), ), child: Padding( padding: const EdgeInsets.all(20), @@ -289,7 +289,7 @@ class RefundPageState extends State { : Colors.grey, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: isFormValid() ? 2 : 0, ), diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 302e49ad..d95d6567 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -34,7 +34,7 @@ class StartPage extends StatelessWidget { padding: const EdgeInsets.all(30), decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), color: Colors.transparent, ), child: Row( diff --git a/lib/widgets/credit_card.dart b/lib/widgets/credit_card.dart index e5b2803c..7e13e5be 100644 --- a/lib/widgets/credit_card.dart +++ b/lib/widgets/credit_card.dart @@ -19,7 +19,7 @@ class CreditCard extends StatelessWidget { color: Theme.of(context).colorScheme.cardPrimary, elevation: 10, margin: const EdgeInsets.symmetric(horizontal: 24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), child: Padding( padding: EdgeInsets.only(top: 10, bottom: 10), child: SizedBox( diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart index a6ec5b95..fbeaac65 100644 --- a/lib/widgets/custom_app_bar.dart +++ b/lib/widgets/custom_app_bar.dart @@ -27,7 +27,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), decoration: BoxDecoration( gradient: Theme.of(context).colorScheme.backgroundGradient, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/widgets/qr_button.dart b/lib/widgets/qr_button.dart index c52069ca..bddbc92b 100644 --- a/lib/widgets/qr_button.dart +++ b/lib/widgets/qr_button.dart @@ -33,7 +33,7 @@ class QRButton extends StatelessWidget { shadowColor: colors.primary.withOpacity(0.4), padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(14), ), ), child: Row( diff --git a/lib/widgets/settings_card.dart b/lib/widgets/settings_card.dart index 5da68567..9cf98da2 100644 --- a/lib/widgets/settings_card.dart +++ b/lib/widgets/settings_card.dart @@ -21,6 +21,8 @@ class SettingsCard extends StatelessWidget { Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 6, child: ListTile( leading: Icon( icon, From 2ba7d4dd75a0894e089a54bcd5490f34ac97ba0e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:37:22 -0400 Subject: [PATCH 108/141] Fixes edge inset going to edge of screen --- lib/pages/home_page.dart | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index f0574175..8874ff9e 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; @@ -128,7 +127,7 @@ class HomePageState extends State { return BasePage( key: HomePage.pageKey, body: Padding( - padding: const EdgeInsets.all(4.0), + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 4.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -185,7 +184,7 @@ class HomePageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - "Find Nearest Location", + "Nearest Location", style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -251,7 +250,7 @@ class HomePageState extends State { return Container( height: 300, - width: 400, + width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), @@ -268,9 +267,9 @@ class HomePageState extends State { children: [ TileLayer( urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: - 'https://cleanstreamlaundry.com/', + 'https://cleanstreamlaundry.com/', tileProvider: NetworkTileProvider(), ), MarkerLayer(markers: markers), @@ -421,7 +420,7 @@ class HomePageState extends State { final idleDryers = snapshot.data![3]; return Container( - width: 520, + width: double.infinity, decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), borderRadius: BorderRadius.circular(14), @@ -463,7 +462,7 @@ class HomePageState extends State { ), child: Row( mainAxisAlignment: - MainAxisAlignment.center, + MainAxisAlignment.center, children: [ Flexible( child: FittedBox( @@ -499,7 +498,7 @@ class HomePageState extends State { ), child: Row( mainAxisAlignment: - MainAxisAlignment.center, + MainAxisAlignment.center, children: [ Flexible( child: FittedBox( @@ -540,4 +539,4 @@ class HomePageState extends State { ), ); } -} +} \ No newline at end of file From ccf4bdcd667794d7c53436306c98a2a5f9ad0e71 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Thu, 12 Mar 2026 21:48:30 -0400 Subject: [PATCH 109/141] Adjusts tests --- lib/pages/home_page.dart | 2 +- test/pages/home_page_test.dart | 32 +++++++++++++++----------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8874ff9e..66a3fe0f 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -127,7 +127,7 @@ class HomePageState extends State { return BasePage( key: HomePage.pageKey, body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index 26785997..88a0bee2 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -122,21 +122,19 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 1)); + // Find and tap the button that opens the BottomSheet + final openSheetButton = find.text('Select Location'); + expect(openSheetButton, findsOneWidget); - final dropdownFinder = find.descendant( - of: find.byType(DropdownButtonHideUnderline), - matching: find.byType(DropdownButton), - ); - - expect(dropdownFinder, findsOneWidget); + await tester.tap(openSheetButton); + await tester.pumpAndSettle(); - final dropdown = tester.widget>(dropdownFinder); - expect(dropdown.items, isNotNull); - expect(dropdown.items!.length, equals(2)); + // Now the BottomSheet should be visible with your location items + final location1 = find.text('123 Main St'); + final location2 = find.text('456 Oak Ave'); - expect(dropdown.items![0].value, equals('123 Main St')); - expect(dropdown.items![1].value, equals('456 Oak Ave')); + expect(location1, findsOneWidget); + expect(location2, findsOneWidget); }); testWidgets('should restore last selected location from storage', (tester) async { @@ -195,7 +193,7 @@ void main() { await tester.pumpAndSettle(); final nearestLocationButton = find.ancestor( - of: find.text('Find Nearest Location'), + of: find.text('Nearest Location'), matching: find.byType(InkWell), ); expect(nearestLocationButton, findsOneWidget); @@ -212,7 +210,7 @@ void main() { await tester.pumpAndSettle(); final button = find.ancestor( - of: find.text('Find Nearest Location'), + of: find.text('Nearest Location'), matching: find.byType(InkWell), ); expect(button, findsOneWidget); @@ -220,7 +218,7 @@ void main() { final inkWell = tester.widget(button); expect(inkWell.onTap, isNotNull); - expect(find.text('Find Nearest Location'), findsOneWidget); + expect(find.text('Nearest Location'), findsOneWidget); }); testWidgets('should update selected location after finding nearest', (tester) async { @@ -249,7 +247,7 @@ void main() { expect(find.text('Select Location'), findsOneWidget); final nearestLocationButton = find.ancestor( - of: find.text('Find Nearest Location'), + of: find.text('Nearest Location'), matching: find.byType(InkWell), ); await tester.tap(nearestLocationButton); @@ -274,7 +272,7 @@ void main() { await tester.pumpAndSettle(); final nearestLocationButton = find.ancestor( - of: find.text('Find Nearest Location'), + of: find.text('Nearest Location'), matching: find.byType(InkWell), ); await tester.tap(nearestLocationButton); From 8fdcb3718e9cbd112c0deb14dd4cfcbea8d5864e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 13 Mar 2026 08:59:04 -0400 Subject: [PATCH 110/141] Adds disclosure for refund --- lib/pages/refund_page.dart | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index f4dca64a..be9571b9 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -311,6 +311,41 @@ class RefundPageState extends State { ), ), ), + SizedBox(height: 12,), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFFFDE7).withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFFF9A825).withOpacity(0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Colors.black, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + "Refund requests are reviewed within 3–5 business days. " + "Approved refunds will be returned to your loyalty card balance. " + "We reserve the right to deny requests that do not meet our refund policy criteria.", + style: TextStyle( + fontSize: 12, + color: Colors.black, + height: 1.5, + ), + ), + ), + ], + ), + ), + SizedBox(height: 4,) ], ), ), From 22031d2534e524939a879af959b559643856ec21 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 13 Mar 2026 09:02:37 -0400 Subject: [PATCH 111/141] Adds test for disclosure widget --- test/pages/refund_page_test.dart | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/pages/refund_page_test.dart b/test/pages/refund_page_test.dart index 94995388..2689bedd 100644 --- a/test/pages/refund_page_test.dart +++ b/test/pages/refund_page_test.dart @@ -693,5 +693,40 @@ void main() { }, ), ).called(1); + + }); + + testWidgets('disclosure widget displays correctly', (tester) async { + when( + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => (transactions: [], ids: [])); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Refund requests are reviewed within 3–5 business days.'), + findsOneWidget, + ); + expect( + find.textContaining('Approved refunds will be returned to your loyalty card balance.'), + findsOneWidget, + ); + expect( + find.textContaining('We reserve the right to deny requests that do not meet our refund policy criteria.'), + findsOneWidget, + ); + + expect(find.byIcon(Icons.info_outline_rounded), findsOneWidget); + + final container = tester.widget( + find.ancestor( + of: find.byIcon(Icons.info_outline_rounded), + matching: find.byType(Container), + ).first, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, const Color(0xFFFFFDE7).withOpacity(0.8)); + expect(decoration.borderRadius, BorderRadius.circular(14)); }); } From 562c0aebce1ee07d7e702324f33864160ba22488 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 13 Mar 2026 09:19:22 -0400 Subject: [PATCH 112/141] Fixes spacing --- lib/pages/home_page.dart | 15 +++++++++------ lib/widgets/custom_app_bar.dart | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 66a3fe0f..e3a59eb8 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -127,7 +127,7 @@ class HomePageState extends State { return BasePage( key: HomePage.pageKey, body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -174,7 +174,7 @@ class HomePageState extends State { _zoomToLocation(address); } }, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -218,8 +218,8 @@ class HomePageState extends State { builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( - height: 400, - width: 300, + height: 300, + width: 400, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all( @@ -279,7 +279,7 @@ class HomePageState extends State { }, ), Container( - margin: EdgeInsets.only(top: 20), + margin: EdgeInsets.only(top: 12), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), @@ -391,7 +391,7 @@ class HomePageState extends State { ), ), - SizedBox(height: 18), + SizedBox(height: 14), if (locationSelected) FutureBuilder( @@ -533,6 +533,9 @@ class HomePageState extends State { ); }, ), + + SizedBox(height: 12,) + ], ), ), diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart index fbeaac65..6d757082 100644 --- a/lib/widgets/custom_app_bar.dart +++ b/lib/widgets/custom_app_bar.dart @@ -11,6 +11,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { toolbarHeight: 40, backgroundColor: Colors.transparent, elevation: 0, + centerTitle: false, titleSpacing: 0, flexibleSpace: Container( decoration: BoxDecoration( From 9de584857102d2bceb47c9e2b993a86d37ebe46c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 13 Mar 2026 14:50:49 -0400 Subject: [PATCH 113/141] Adds machine control cards --- lib/pages/payment_page.dart | 200 ++++++++++++++------------ lib/widgets/dryer_controls_card.dart | 108 ++++++++++++++ lib/widgets/washer_controls_card.dart | 30 ++++ 3 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 lib/widgets/dryer_controls_card.dart create mode 100644 lib/widgets/washer_controls_card.dart diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 4e6f0901..75ee4c67 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -13,6 +13,8 @@ import 'package:clean_stream_laundry_app/logic/services/machine_communication_se import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:go_router/go_router.dart'; +import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; class PaymentPage extends StatefulWidget { final String machineId; @@ -31,6 +33,8 @@ class _PaymentPageState extends State { double? _userBalance; bool _isLoading = true; + int _dryerMinutes = 5; + final machineService = GetIt.instance(); final profileService = GetIt.instance(); final authService = GetIt.instance(); @@ -39,6 +43,10 @@ class _PaymentPageState extends State { final notificationService = GetIt.instance(); final paymentProcessor = GetIt.instance(); + bool get _isDryer => + _machineName != null && + _machineName!.toLowerCase().contains('dryer'); + @override void initState() { super.initState(); @@ -49,20 +57,24 @@ class _PaymentPageState extends State { final data = await machineService.getMachineById(widget.machineId); final userId = authService.getCurrentUserId; - if (userId == null) { - return; - } + if (userId == null) return; + final balance = await profileService.getUserBalanceById(userId); if (data != null && balance != null) { + final name = data['Name'] as String?; + final isThisDryer = + name != null && name.toLowerCase().contains('dryer'); + setState(() { _userBalance = (balance['balance'] as num).toDouble(); - _machineName = data['Name']; - _price = (data['Price'] as num).toDouble(); + _machineName = name; + _price = isThisDryer + ? (_dryerMinutes / 5) * 0.25 + : (data['Price'] as num).toDouble(); _isLoading = false; }); } else { - // handle error / machine not found setState(() { _userBalance = 0; _machineName = 'Unknown'; @@ -72,13 +84,20 @@ class _PaymentPageState extends State { } } + void _onDryerChanged(double price, int minutes) { + setState(() { + _price = price; + _dryerMinutes = minutes; + }); + } + Future makeNotification(String name) async { final notificationService = GetIt.instance(); await notificationService.scheduleEarlyMachineNotification( id: 1, - //This is where we would add code to get the machine finish time - //Use the machine finish time instead of hardcoding 5 mins - machineTime: const Duration(minutes: 5, seconds: 5), + machineTime: _isDryer + ? Duration(minutes: _dryerMinutes) + : const Duration(minutes: 5, seconds: 5), machineName: name, ); } @@ -89,44 +108,53 @@ class _PaymentPageState extends State { body: _isLoading ? const Center(child: CircularProgressIndicator()) : Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - const SizedBox(height: 40), - _buildAmountCard(), - const SizedBox(height: 30), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: _paymentCompleted - ? _buildBackToHomeButton( - context, - ) // Show this when payment is complete - : _buildPaymentButtons(context), // Show this otherwise - ), - ], + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAmountCard(), + + const SizedBox(height: 20), + + if (_isDryer) + DryerControlsCard(onChanged: _onDryerChanged) + else + const WasherControlsCard(), + + const SizedBox(height: 30), + ], + ), ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: _paymentCompleted + ? _buildBackToHomeButton(context) + : _buildPaymentButtons(context), + ), + ], + ), ); } + Widget _buildAmountCard() { return Container( padding: const EdgeInsets.all(30), decoration: BoxDecoration( color: Theme.of(context).colorScheme.greyCard, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), ), child: Column( children: [ - Icon(Icons.local_laundry_service, size: 80, color: Color(0xFF2073A9)), + Icon( + Icons.local_laundry_service, + size: 80, + color: Color(0xFF2073A9), + ), const SizedBox(height: 20), Text( 'Machine $_machineName', @@ -157,13 +185,11 @@ class _PaymentPageState extends State { return SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - context.go('/homePage'); - }, + onPressed: () => context.go('/homePage'), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue[700], shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), @@ -183,7 +209,6 @@ class _PaymentPageState extends State { Widget _buildPaymentButtons(BuildContext context) { return Row( children: [ - // Stripe payment button Expanded( child: ElevatedButton( onPressed: (_isConfirmed || _price == null || _price == 0) @@ -192,8 +217,7 @@ class _PaymentPageState extends State { final success = await paymentProcessor.processPayment( _price!, MachineFormatter.formatMachineType( - _machineName.toString(), - ), + _machineName.toString()), ); if (success == PaymentResult.success) { @@ -205,17 +229,14 @@ class _PaymentPageState extends State { ); final deviceAuthorized = - await machineCommunicator.wakeDevice(widget.machineId); + await machineCommunicator.wakeDevice( + widget.machineId); Navigator.of(context, rootNavigator: true).pop(); if (deviceAuthorized) { - setState(() { - _paymentCompleted = true; - }); - + setState(() => _paymentCompleted = true); makeNotification(_machineName.toString()); - statusDialog( context, title: "Payment Processed! Machine Ready!", @@ -226,7 +247,8 @@ class _PaymentPageState extends State { statusDialog( context, title: "Machine Error", - message: "Payment succeeded but machine did not wake up.", + message: + "Payment succeeded but machine did not wake up.", isSuccess: false, ); } @@ -240,61 +262,59 @@ class _PaymentPageState extends State { } }, style: ElevatedButton.styleFrom( - backgroundColor: (_isConfirmed || _price == null || _price == 0) + backgroundColor: + (_isConfirmed || _price == null || _price == 0) ? Colors.grey : Colors.blue[700], disabledBackgroundColor: Colors.grey, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), ), child: _isConfirmed ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) : Text( - _price != null && _price! > 0 - ? 'Pay \$${_price!.toStringAsFixed(2)}' - : 'Pay', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + _price != null && _price! > 0 + ? 'Pay \$${_price!.toStringAsFixed(2)}' + : 'Pay', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ), ), const SizedBox(width: 16), - // Loyalty payment button Expanded( child: ElevatedButton( - onPressed: - (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) + onPressed: (_isConfirmed || + _price == null || + _price == 0 || + (_userBalance ?? 0) < (_price ?? 0)) ? null : () => _processLoyaltyPayment(context), style: ElevatedButton.styleFrom( - backgroundColor: - (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) + backgroundColor: (_isConfirmed || + _price == null || + _price == 0 || + (_userBalance ?? 0) < (_price ?? 0)) ? Colors.grey : Colors.green[700], disabledBackgroundColor: Colors.grey, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), @@ -316,19 +336,18 @@ class _PaymentPageState extends State { void _processLoyaltyPayment(BuildContext context) async { final updatedBalance = _userBalance! - _price!; profileService.updateBalanceById(updatedBalance); + setState(() => _userBalance = updatedBalance); - setState(() { - _userBalance = updatedBalance; - }); showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) => - const Center(child: CircularProgressIndicator()), - ); - final deviceAuthorized = await machineCommunicator.wakeDevice( - widget.machineId, + const Center(child: CircularProgressIndicator()), ); + + final deviceAuthorized = + await machineCommunicator.wakeDevice(widget.machineId); + Navigator.of(context, rootNavigator: true).pop(); await transactionService.recordTransaction( @@ -340,9 +359,7 @@ class _PaymentPageState extends State { if (deviceAuthorized) { makeNotification(_machineName.toString()); - setState(() { - _paymentCompleted = true; - }); + setState(() => _paymentCompleted = true); statusDialog( context, title: "Machine Ready!", @@ -353,7 +370,8 @@ class _PaymentPageState extends State { statusDialog( context, title: "Machine Error", - message: "Payment succeeded but machine did not wake up. Please contact support", + message: + "Payment succeeded but machine did not wake up. Please contact support", isSuccess: false, ); } @@ -366,4 +384,4 @@ class _PaymentPageState extends State { isSuccess: true, ); } -} +} \ No newline at end of file diff --git a/lib/widgets/dryer_controls_card.dart b/lib/widgets/dryer_controls_card.dart new file mode 100644 index 00000000..4b8bd3f7 --- /dev/null +++ b/lib/widgets/dryer_controls_card.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + +class DryerControlsCard extends StatefulWidget { + final void Function(double price, int minutes) onChanged; + + const DryerControlsCard({super.key, required this.onChanged}); + + @override + State createState() => _DryerControlsCardState(); +} + +class _DryerControlsCardState extends State { + int _selectedMinutes = 30; + + double get _calculatedPrice => (_selectedMinutes / 5) * 0.25; + + @override + void initState() { + super.initState(); + // Notify parent of the initial default values after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onChanged(_calculatedPrice, _selectedMinutes); + }); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + color: Theme.of(context).colorScheme.greyCard, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + children: [ + Text( + 'Set Dry Time', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + + Text( + '$_selectedMinutes min', + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Color(0xFF2073A9), + ), + ), + + const SizedBox(height: 4), + + Text( + '\$0.25 per 5 minutes', + style: TextStyle(fontSize: 13, color: Colors.black), + ), + + const SizedBox(height: 8), + + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: const Color(0xFF2073A9), + inactiveTrackColor: const Color(0xFF2073A9).withOpacity(0.2), + thumbColor: const Color(0xFF2073A9), + overlayColor: const Color(0xFF2073A9).withOpacity(0.12), + trackHeight: 4, + ), + child: Slider( + value: _selectedMinutes.toDouble(), + min: 5, + max: 90, + divisions: 17, + onChanged: (value) { + final snapped = (value / 5).round() * 5; + setState(() => _selectedMinutes = snapped); + widget.onChanged(_calculatedPrice, snapped); + }, + ), + ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '5 min', + style: TextStyle(fontSize: 12, color: Colors.black), + ), + Text( + '90 min', + style: TextStyle(fontSize: 12, color: Colors.black), + ), + ], + ), + ), + ], + ), + ) + ); + } +} diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart new file mode 100644 index 00000000..7e5453bc --- /dev/null +++ b/lib/widgets/washer_controls_card.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + +class WasherControlsCard extends StatelessWidget { + const WasherControlsCard({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + color: Theme.of(context).colorScheme.greyCard, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + children: [ + Text( + 'Washer Card', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + ), + ); + } +} From 94f2dd6f37bd1bf6456949039f61e7990ec01836 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 13 Mar 2026 15:03:52 -0400 Subject: [PATCH 114/141] Adjusts tests --- lib/pages/home_page.dart | 1 - test/pages/payment_page_test.dart | 4 +- test/widgets/dryer_controls_card_test.dart | 209 +++++++++++++++++++++ 3 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 test/widgets/dryer_controls_card_test.dart diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 322997d9..4ad96f8f 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index ae9f8630..0d034741 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -345,8 +345,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Machine Dryer05'), findsOneWidget); - expect(find.text('\$2.75'), findsOneWidget); - expect(find.text('Pay \$2.75'), findsOneWidget); + expect(find.text('\$1.50'), findsOneWidget); + expect(find.text('Pay \$1.50'), findsOneWidget); }); testWidgets('sends notification after successful loyalty payment', ( diff --git a/test/widgets/dryer_controls_card_test.dart b/test/widgets/dryer_controls_card_test.dart new file mode 100644 index 00000000..74b53f80 --- /dev/null +++ b/test/widgets/dryer_controls_card_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; + +ThemeData _testTheme() => ThemeData.light(); + +Widget _wrap({required void Function(double, int) onChanged}) { + return MaterialApp( + theme: _testTheme(), + home: Scaffold( + body: DryerControlsCard(onChanged: onChanged), + ), + ); +} + +void main() { + group('DryerControlsCard', () { + + testWidgets('renders title text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('Set Dry Time'), findsOneWidget); + }); + + testWidgets('renders default 30 min label', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('30 min'), findsOneWidget); + }); + + testWidgets('renders pricing hint text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('\$0.25 per 5 minutes'), findsOneWidget); + }); + + testWidgets('renders min and max labels', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('5 min'), findsOneWidget); + expect(find.text('90 min'), findsOneWidget); + }); + + testWidgets('renders a Slider', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets('renders as a Card with elevation', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + final card = tester.widget(find.byType(Card)); + expect(card.elevation, 4); + }); + + + testWidgets('fires onChanged after first frame with default values', + (tester) async { + double? receivedPrice; + int? receivedMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + receivedPrice = price; + receivedMinutes = minutes; + })); + + await tester.pump(); + + expect(receivedMinutes, 30); + expect(receivedPrice, closeTo(1.50, 0.001)); + }); + + + group('price calculation', () { + Future dragToMinutes(WidgetTester tester, int minutes) async { + final slider = find.byType(Slider); + final sliderRect = tester.getRect(slider); + + const thumbPadding = 24.0; + final trackLeft = sliderRect.left + thumbPadding; + final trackWidth = sliderRect.width - thumbPadding * 2; + + final fraction = (minutes - 5) / 85.0; + final x = trackLeft + fraction * trackWidth; + + await tester.tapAt(Offset(x, sliderRect.center.dy)); + await tester.pump(); + } + + testWidgets('5 min → \$0.25', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 5); + expect(price, closeTo(0.25, 0.001)); + }); + + testWidgets('30 min → \$1.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 30); + expect(price, closeTo(1.50, 0.001)); + }); + + testWidgets('60 min → \$3.00', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 60); + expect(price, closeTo(3.00, 0.001)); + }); + + testWidgets('90 min → \$4.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 90); + expect(price, closeTo(4.50, 0.001)); + }); + }); + + + testWidgets('slider value snaps to multiples of 5', (tester) async { + final capturedMinutes = []; + + await tester.pumpWidget( + _wrap(onChanged: (_, minutes) => capturedMinutes.add(minutes))); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.timedDragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + const Duration(milliseconds: 500), + ); + await tester.pump(); + + for (final m in capturedMinutes) { + expect(m % 5, 0, + reason: '$m is not a multiple of 5'); + } + }); + + testWidgets('minute label updates when slider moves', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + ); + await tester.pump(); + + expect(find.text('90 min'), findsWidgets); + }); + + + testWidgets('onChanged is called when slider moves', (tester) async { + int callCount = 0; + await tester.pumpWidget( + _wrap(onChanged: (_, __) => callCount++)); + + await tester.pump(); + callCount = 0; + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.5, 0), + ); + await tester.pump(); + + expect(callCount, greaterThan(0)); + }); + + testWidgets('onChanged price and minutes are consistent', (tester) async { + double? lastPrice; + int? lastMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + lastPrice = price; + lastMinutes = minutes; + })); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.35, 0), // somewhere in the middle + ); + await tester.pump(); + + expect(lastMinutes, isNotNull); + expect(lastPrice, isNotNull); + final expectedPrice = (lastMinutes! / 5) * 0.25; + expect(lastPrice, closeTo(expectedPrice, 0.001)); + }); + }); +} \ No newline at end of file From ae9438d5a73cb1ef5b5b9cf3357c83d9a65f28bc Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Fri, 13 Mar 2026 17:20:26 -0400 Subject: [PATCH 115/141] Added washer card buttons --- lib/widgets/washer_controls_card.dart | 111 +++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart index 7e5453bc..51e2bb56 100644 --- a/lib/widgets/washer_controls_card.dart +++ b/lib/widgets/washer_controls_card.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -class WasherControlsCard extends StatelessWidget { +class WasherControlsCard extends StatefulWidget { const WasherControlsCard({super.key}); + @override + State createState() => _WasherControlsCardState(); +} + +class _WasherControlsCardState extends State { + String? selectedCycle = "Cold Normal"; + @override Widget build(BuildContext context) { return Card( @@ -15,16 +22,114 @@ class WasherControlsCard extends StatelessWidget { child: Column( children: [ Text( - 'Washer Card', + 'Select Your Cycle', style: TextStyle( - fontSize: 26, + fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87, ), ), + const SizedBox(height: 1), + Text( + 'Please make sure the selected cycle is the cycle on your machine', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.black87, + ), + ), + const SizedBox(height: 10), + + Column( + children: [ + Row( + children: [ + Expanded( + child: _WasherButton( + label: "Hot Heavy", + selected: selectedCycle == "Hot Heavy", + onTap: () { + setState(() => selectedCycle = "Hot Heavy"); + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: _WasherButton( + label: "Hot Normal", + selected: selectedCycle == "Hot Normal", + onTap: () { + setState(() => selectedCycle = "Hot Normal"); + }, + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _WasherButton( + label: "Cold Heavy", + selected: selectedCycle == "Cold Heavy", + onTap: () { + setState(() => selectedCycle = "Cold Heavy"); + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: _WasherButton( + label: "Cold Normal", + selected: selectedCycle == "Cold Normal", + onTap: () { + setState(() => selectedCycle = "Cold Normal"); + }, + ), + ), + ], + ), + ], + ), ], ), ), ); } } + +class _WasherButton extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _WasherButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: selected + ? Colors.green + : Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} \ No newline at end of file From cafbe11699c35ca018bc190f198c6f83e9953308 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Fri, 13 Mar 2026 17:32:25 -0400 Subject: [PATCH 116/141] Added cost values Added cost values for each button --- lib/widgets/washer_controls_card.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart index 51e2bb56..d2e2d375 100644 --- a/lib/widgets/washer_controls_card.dart +++ b/lib/widgets/washer_controls_card.dart @@ -10,6 +10,7 @@ class WasherControlsCard extends StatefulWidget { class _WasherControlsCardState extends State { String? selectedCycle = "Cold Normal"; + double addedCost = 0; @override Widget build(BuildContext context) { @@ -50,6 +51,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Hot Heavy", onTap: () { setState(() => selectedCycle = "Hot Heavy"); + addedCost = .5; }, ), ), @@ -60,6 +62,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Hot Normal", onTap: () { setState(() => selectedCycle = "Hot Normal"); + addedCost = .25; }, ), ), @@ -74,6 +77,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Cold Heavy", onTap: () { setState(() => selectedCycle = "Cold Heavy"); + addedCost = .25; }, ), ), @@ -84,6 +88,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Cold Normal", onTap: () { setState(() => selectedCycle = "Cold Normal"); + addedCost = 0; }, ), ), From 37f2598de1127c247ea882617294ebf6af348089 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Fri, 13 Mar 2026 18:07:48 -0400 Subject: [PATCH 117/141] Added variable washer price Washer controls card and payment page now communicate to change price --- lib/pages/payment_page.dart | 20 ++++++++++++++++++-- lib/widgets/washer_controls_card.dart | 17 +++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 75ee4c67..8a870649 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -33,6 +33,8 @@ class _PaymentPageState extends State { double? _userBalance; bool _isLoading = true; + double _addedWasherCost = 0; + double _baseWasherPrice = 0; int _dryerMinutes = 5; final machineService = GetIt.instance(); @@ -65,6 +67,9 @@ class _PaymentPageState extends State { final name = data['Name'] as String?; final isThisDryer = name != null && name.toLowerCase().contains('dryer'); + final isThisWasher = + name != null && name.toLowerCase().contains('washer'); + _baseWasherPrice = (data['Price'] as num).toDouble(); setState(() { _userBalance = (balance['balance'] as num).toDouble(); @@ -72,6 +77,9 @@ class _PaymentPageState extends State { _price = isThisDryer ? (_dryerMinutes / 5) * 0.25 : (data['Price'] as num).toDouble(); + _price = isThisWasher + ? (data['Price'] as num).toDouble() + : (data['Price'] as num).toDouble(); _isLoading = false; }); } else { @@ -91,6 +99,13 @@ class _PaymentPageState extends State { }); } + void _onWasherCycleChanged(double addedCost) { + setState(() { + _addedWasherCost = addedCost; + _price = _baseWasherPrice + _addedWasherCost; + }); + } + Future makeNotification(String name) async { final notificationService = GetIt.instance(); await notificationService.scheduleEarlyMachineNotification( @@ -122,8 +137,9 @@ class _PaymentPageState extends State { if (_isDryer) DryerControlsCard(onChanged: _onDryerChanged) else - const WasherControlsCard(), - + WasherControlsCard( + onCycleChanged: _onWasherCycleChanged, + ), const SizedBox(height: 30), ], ), diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart index d2e2d375..a64664de 100644 --- a/lib/widgets/washer_controls_card.dart +++ b/lib/widgets/washer_controls_card.dart @@ -2,7 +2,13 @@ import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; class WasherControlsCard extends StatefulWidget { - const WasherControlsCard({super.key}); + final void Function(double addedCost) onCycleChanged; + + WasherControlsCard({ + super.key, + required this.onCycleChanged, + }); + @override State createState() => _WasherControlsCardState(); @@ -10,7 +16,6 @@ class WasherControlsCard extends StatefulWidget { class _WasherControlsCardState extends State { String? selectedCycle = "Cold Normal"; - double addedCost = 0; @override Widget build(BuildContext context) { @@ -51,7 +56,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Hot Heavy", onTap: () { setState(() => selectedCycle = "Hot Heavy"); - addedCost = .5; + widget.onCycleChanged(0.5); }, ), ), @@ -62,7 +67,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Hot Normal", onTap: () { setState(() => selectedCycle = "Hot Normal"); - addedCost = .25; + widget.onCycleChanged(0.25); }, ), ), @@ -77,7 +82,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Cold Heavy", onTap: () { setState(() => selectedCycle = "Cold Heavy"); - addedCost = .25; + widget.onCycleChanged(0.25); }, ), ), @@ -88,7 +93,7 @@ class _WasherControlsCardState extends State { selected: selectedCycle == "Cold Normal", onTap: () { setState(() => selectedCycle = "Cold Normal"); - addedCost = 0; + widget.onCycleChanged(0); }, ), ), From f0d21482206b2c925c43434b1aacaeb717d3afbd Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Fri, 13 Mar 2026 18:49:31 -0400 Subject: [PATCH 118/141] Refactored payment_page logic --- lib/pages/payment_page.dart | 16 +++------------- lib/widgets/washer_controls_card.dart | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 8a870649..20eb6960 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -33,8 +33,8 @@ class _PaymentPageState extends State { double? _userBalance; bool _isLoading = true; + double _basePrice = 0; double _addedWasherCost = 0; - double _baseWasherPrice = 0; int _dryerMinutes = 5; final machineService = GetIt.instance(); @@ -65,21 +65,11 @@ class _PaymentPageState extends State { if (data != null && balance != null) { final name = data['Name'] as String?; - final isThisDryer = - name != null && name.toLowerCase().contains('dryer'); - final isThisWasher = - name != null && name.toLowerCase().contains('washer'); - _baseWasherPrice = (data['Price'] as num).toDouble(); + _basePrice = (data['Price'] as num).toDouble(); setState(() { _userBalance = (balance['balance'] as num).toDouble(); _machineName = name; - _price = isThisDryer - ? (_dryerMinutes / 5) * 0.25 - : (data['Price'] as num).toDouble(); - _price = isThisWasher - ? (data['Price'] as num).toDouble() - : (data['Price'] as num).toDouble(); _isLoading = false; }); } else { @@ -102,7 +92,7 @@ class _PaymentPageState extends State { void _onWasherCycleChanged(double addedCost) { setState(() { _addedWasherCost = addedCost; - _price = _baseWasherPrice + _addedWasherCost; + _price = _basePrice + _addedWasherCost; }); } diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart index a64664de..775d4103 100644 --- a/lib/widgets/washer_controls_card.dart +++ b/lib/widgets/washer_controls_card.dart @@ -15,7 +15,7 @@ class WasherControlsCard extends StatefulWidget { } class _WasherControlsCardState extends State { - String? selectedCycle = "Cold Normal"; + String? selectedCycle; @override Widget build(BuildContext context) { From 57f9f7c78cc50e77b554e765a955f0807ac5d7dc Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Fri, 13 Mar 2026 19:00:10 -0400 Subject: [PATCH 119/141] Corrected 4 tests Fixed 4 tests because the new UI messed them up --- test/pages/payment_page_test.dart | 49 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index 0d034741..9b10473f 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -54,13 +54,11 @@ void main() { getIt.registerSingleton(mockRouterService); getIt.registerSingleton(mockNotificationService); - when( - () => mockNotificationService.scheduleEarlyMachineNotification( - id: any(named: 'id'), - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - ), - ).thenAnswer((_) async {}); + when(() => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).thenAnswer((_) async {}); getIt.registerSingleton(mockPaymentProcessor); getIt.registerSingleton(mockLoyaltyViewModel); @@ -120,7 +118,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById('machine123'), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Washer01'}); when( () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 10.0}); @@ -128,8 +126,7 @@ void main() { await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); - expect(find.text('Machine Washer01'), findsOneWidget); - expect(find.text('\$3.50'), findsOneWidget); + expect(find.text('Machine Washer 1'), findsOneWidget); expect(find.text('Amount Due'), findsOneWidget); }); @@ -168,7 +165,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -176,7 +173,7 @@ void main() { await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); - expect(find.text('Pay \$3.50'), findsOneWidget); + expect(find.text('Pay \$1.50'), findsOneWidget); expect(find.text('Pay with Loyalty'), findsOneWidget); }); @@ -186,10 +183,10 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when(() => mockProfileService.getUserBalanceById(any())).thenAnswer( (_) async => { - 'balance': 2.0, // Insufficient balance + 'balance': 1.0, // Insufficient balance }, ); @@ -211,7 +208,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -236,7 +233,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -261,11 +258,11 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - verify(() => mockProfileService.updateBalanceById(6.5)).called(1); + verify(() => mockProfileService.updateBalanceById(8.5)).called(1); verify(() => mockMachineCommunicator.wakeDevice('machine123')).called(1); verify( () => mockTransactionService.recordTransaction( - amount: 3.50, + amount: 1.50, description: any(named: 'description'), type: 'laundry', ), @@ -278,7 +275,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -319,7 +316,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -384,13 +381,11 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - verify( - () => mockNotificationService.scheduleEarlyMachineNotification( - id: 1, - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - ), - ).called(1); + verify(() => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).called(1); }); }); } From e6279e01dc3b53fe2b022059b2f349f05ad7e5da Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 14 Mar 2026 13:05:55 -0400 Subject: [PATCH 120/141] Update docker config --- Dockerfile | 46 ++++++++++++++++++++++++++++++++++------------ docker-compose.yml | 9 +++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5abe270f..6de3e322 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,52 @@ -# Base image with Flutter SDK and Dart preinstalled +# Base image with Flutter SDK and Dart FROM ghcr.io/cirruslabs/flutter:3.35.3 +# Install dependencies +RUN apt-get update && apt-get install -y \ + curl \ + unzip \ + git \ + ca-certificates \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js (includes npm) +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs + +# Install Supabase CLI +RUN npm install -g supabase + +# Install Deno +RUN curl -fsSL https://deno.land/install.sh | sh + +# Add Deno to PATH +ENV DENO_INSTALL="/root/.deno" +ENV PATH="$DENO_INSTALL/bin:$PATH" # Set working directory WORKDIR /app -# Copy pubspec first to cache dependencies +# Copy dependency files first (for caching) COPY pubspec.* ./ -# Get Flutter dependencies +# Install Flutter dependencies RUN flutter pub get -# Copy rest of the source code +# Copy rest of project COPY . . -# Create .env file placeholder (mounted at runtime) -RUN touch .env - -# Enable web support just in case +# Enable web support RUN flutter config --enable-web -# Run Flutter tests to verify setup (optional) +# Optional: run tests RUN flutter test -# Expose web dev port +# Expose dev server port EXPOSE 8080 -# Default command for development (serves on web) -CMD ["flutter", "run", "-d", "web-server", "--web-port=8080", "--web-hostname=0.0.0.0"] +# Create .env placeholder (mounted at runtime) +RUN touch .env + +# Default command +CMD ["flutter", "run", "-d", "web-server", "--web-port=8080", "--web-hostname=0.0.0.0"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 77478ee0..d14c099c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,14 @@ services: - .env command: flutter run -d web-server --web-port=8080 --web-hostname=0.0.0.0 + dev: + build: . + volumes: + - .:/app + working_dir: /app + command: bash + stdin_open: true + tty: true + volumes: flutter_cache: From 22aede7ee563129149bc8c974972a7a1852e0c25 Mon Sep 17 00:00:00 2001 From: JamesR367 Date: Sat, 14 Mar 2026 15:04:32 -0400 Subject: [PATCH 121/141] Add rewards feature --- lib/logic/payment/process_payment.dart | 19 --- lib/logic/services/profile_service.dart | 1 + lib/logic/viewmodels/loyalty_view_model.dart | 37 +++--- lib/pages/loyalty_card_page.dart | 11 +- .../supabase/supabase_profile_service.dart | 16 ++- pubspec.lock | 44 ++++--- test/logic/payment/process_payment_test.dart | 21 ---- .../viewmodels/loyalty_view_model_test.dart | 46 +------ test/pages/loyalty_card_page_test.dart | 117 ------------------ .../monthly_transaction_history_test.dart | 2 +- 10 files changed, 75 insertions(+), 239 deletions(-) diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index 9f9d2786..7a1634d9 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -1,6 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; @@ -9,8 +7,6 @@ import 'package:get_it/get_it.dart'; class PaymentProcessor { final PaymentService _paymentService = GetIt.instance(); final TransactionService _transactionService = GetIt.instance(); - final AuthService _authService = GetIt.instance(); - final ProfileService _profileService = GetIt.instance(); Future processPayment( @@ -25,16 +21,6 @@ class PaymentProcessor { type: "Laundry", ); - final userId = _authService.getCurrentUserId; - final data = await _profileService.getUserBalanceById(userId!); - final rewards = processRewards(amount); - _profileService.updateBalanceById(userId, data?['balance'].toDouble() + rewards); - _transactionService.recordTransaction( - amount: rewards, - description: "Reward from payment", - type: "Rewards", - ); - return PaymentResult.success; } on StripeException { return PaymentResult.canceled; @@ -42,9 +28,4 @@ class PaymentProcessor { return PaymentResult.failed; } } - - double processRewards(double amount) { - double rewardAmount = amount * 0.01; - return (double.parse(rewardAmount.toStringAsFixed(2))); - } } diff --git a/lib/logic/services/profile_service.dart b/lib/logic/services/profile_service.dart index a122b6dc..05284c6c 100644 --- a/lib/logic/services/profile_service.dart +++ b/lib/logic/services/profile_service.dart @@ -7,4 +7,5 @@ abstract class ProfileService { Future updateName(String name); Future getNotificationLeadTime(); Future setNotificationLeadTime(int value); + Future updateRewardsById(String userId, double amount); } diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 313de20e..93c15c20 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -15,9 +15,9 @@ class LoyaltyViewModel extends ChangeNotifier { final _paymentProcessor = GetIt.instance(); double? userBalance; + double? userReward; String? userName; String? errorMessage; - double? monthlyRewards; bool isLoading = true; bool showPastTransactions = false; @@ -25,7 +25,7 @@ class LoyaltyViewModel extends ChangeNotifier { // Call once from loyalty page Future initialize() async { - await Future.wait([_fetchBalance(), _fetchTransactions(), _fetchMonthlyRewards()]); + await Future.wait([_fetchBalance(), _fetchTransactions()]); } Future _fetchBalance() async { @@ -43,6 +43,7 @@ class LoyaltyViewModel extends ChangeNotifier { userBalance = (data?['balance'] as num?)?.toDouble() ?? 0.0; userName = data?['full_name'] ?? 'John Doe'; + userReward = (data?["reward_tracker"] as num?)?.toDouble() ?? 0.0; } catch (_) { errorMessage = 'Failed to fetch balance'; } @@ -51,21 +52,6 @@ class LoyaltyViewModel extends ChangeNotifier { notifyListeners(); } - Future _fetchMonthlyRewards() async { - final transactions = await _transactionService.getTransactionsForUser(); - final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); - - final rewardTransactions = transactions.where((t) { - final createdAt = DateTime.parse(t['created_at'] as String); - final type = t['type'] as String?; - return createdAt.isAfter(thirtyDaysAgo) && type == 'Rewards'; - }); - - monthlyRewards = rewardTransactions.fold( - 0.0, (sum, transaction) => sum + (transaction['amount'] as num).toDouble(), - ); - } - Future _fetchTransactions() async { final transactions = await _transactionService.getTransactionsForUser(); final limit = showPastTransactions ? 100 : 3; @@ -100,7 +86,8 @@ class LoyaltyViewModel extends ChangeNotifier { ); if (result == PaymentResult.success) { - final newBalance = (userBalance ?? 0) + amount + _paymentProcessor.processRewards(amount); + amount = checkRewards(amount); + final newBalance = (userBalance ?? 0) + amount; await _profileService.updateBalanceById(userId!, newBalance); userBalance = newBalance; await _fetchTransactions(); @@ -113,4 +100,16 @@ class LoyaltyViewModel extends ChangeNotifier { Future fetchTransactions() async { await _transactionService.getTransactionsForUser(); } -} + + double checkRewards(double amount) { + double combined = (userReward ?? 0) + amount; + double remainder = combined % 20; + int rewardsEarned = combined ~/ 20; + + if (remainder != (userReward ?? 0)) { + _profileService.updateRewardsById(_authService.getCurrentUserId!, remainder); + } + + return amount + (rewardsEarned * 5); + } +} \ No newline at end of file diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 9b1b4184..40496f52 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -58,7 +58,16 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - const SizedBox(height: 20), + Text( + '\$${(20 - (viewModel.userReward ?? 0)).toStringAsFixed(2)} until next reward', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + const SizedBox(height: 15), ElevatedButton( onPressed: () => _loadCard(), style: ElevatedButton.styleFrom( diff --git a/lib/services/supabase/supabase_profile_service.dart b/lib/services/supabase/supabase_profile_service.dart index 6ff82947..f310e226 100644 --- a/lib/services/supabase/supabase_profile_service.dart +++ b/lib/services/supabase/supabase_profile_service.dart @@ -36,7 +36,7 @@ class SupabaseProfileService extends ProfileService { try { final response = await _client .from('profiles') - .select("full_name, balance") + .select("full_name, balance, reward_tracker") .eq('id', userId) .single(); return response; @@ -79,6 +79,20 @@ class SupabaseProfileService extends ProfileService { } } + @override + Future updateRewardsById(String userId, double amount) async { + try { + await _client + .from("profiles") + .update({"reward_tracker": amount}) + .eq("id", userId); + } on PostgrestException { + return; + } catch (e) { + return; + } + } + @override Future getUserNameById(String userId) async { try { diff --git a/pubspec.lock b/pubspec.lock index 18dea148..e59d7883 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "7.7.1" ansicolor: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -520,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -604,26 +612,26 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mgrs_dart: dependency: transitive description: @@ -1113,26 +1121,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" test_cov_console: dependency: "direct dev" description: diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index 9f469bca..b3886ddc 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -193,25 +193,4 @@ void main() { ); }); }); - - group('processRewards', () { - test('should calculate 1% reward and update balance', () { - const amount = 100.0; - const currentBalance = 50.0; - const expectedReward = 1.0; - const expectedNewBalance = 51.0; - - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async => {}); - - double rewardAmount = paymentProcessor.processRewards(amount); - double newBalance = currentBalance + rewardAmount; - - expect(rewardAmount, expectedReward); - expect(newBalance, expectedNewBalance); - }); - }); } diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index 194a4104..006a1b78 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -65,7 +65,7 @@ void main() { // Verify interactions verify(() => mockAuthService.getCurrentUserId).called(1); verify(() => mockProfileService.getUserBalanceById('user123')).called(1); - verify(() => mockTransactionService.getTransactionsForUser()).called(2); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); test('should handle profile service error gracefully', () async { @@ -219,39 +219,6 @@ void main() { // Assert expect(viewModel.recentTransactions.length, 1); }); - - - test('fetchMonthlyRewards sums only recent reward transactions', () async { - // Arrange - final now = DateTime.now(); - when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( - (_) async => [ - { - 'created_at': now.toIso8601String(), - 'type': 'Rewards', - 'amount': 2.0, - }, - { - 'created_at': now.toIso8601String(), - 'type': 'Rewards', - 'amount': 3.0, - }, - { - 'created_at': - now.subtract(const Duration(days: 40)).toIso8601String(), - 'type': 'Rewards', - 'amount': 10.0, - }, - ], - ); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.monthlyRewards, 5.0); - }); - }); group('loadCard', () { @@ -266,11 +233,7 @@ void main() { 'Loyalty Card', )).thenAnswer((_) async => PaymentResult.success); - when(() => mockPaymentProcessor.processRewards(10.0)) - .thenReturn(0.1); - - - when(() => mockProfileService.updateBalanceById('user123', 30.1)) + when(() => mockProfileService.updateBalanceById('user123', 30)) .thenAnswer((_) async => Future.value()); when(() => mockTransactionService.getTransactionsForUser()) @@ -281,10 +244,9 @@ void main() { // Assert expect(result, PaymentResult.success); - expect(viewModel.userBalance, 30.1); + expect(viewModel.userBalance, 30); - verify(() => mockPaymentProcessor.processRewards(10.0)).called(1); - verify(() => mockProfileService.updateBalanceById('user123', 30.1)).called(1); + verify(() => mockProfileService.updateBalanceById('user123', 30)).called(1); verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 33c8a951..18160d30 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -35,7 +35,6 @@ void main() { when(() => mockViewModel.userBalance).thenReturn(25.50); when(() => mockViewModel.recentTransactions).thenReturn([]); when(() => mockViewModel.showPastTransactions).thenReturn(false); - when(() => mockViewModel.monthlyRewards).thenReturn(0.0); // Setup default method behaviors when(() => mockViewModel.initialize()).thenAnswer((_) async => {}); @@ -171,122 +170,6 @@ void main() { }); }); - group('Rewards Display', () { - testWidgets('should display monthly rewards with correct formatting', ( - tester, - ) async { - when(() => mockViewModel.monthlyRewards).thenReturn(5.25); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect( - find.textContaining('Rewards earned this month: \$5.25'), - findsOneWidget, - ); - }); - - testWidgets('should display default rewards when monthlyRewards is null', ( - tester, - ) async { - when(() => mockViewModel.monthlyRewards).thenReturn(null); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect( - find.textContaining('Rewards earned this month: \$0.00'), - findsOneWidget, - ); - }); - - testWidgets('should display rewards with two decimal places', ( - tester, - ) async { - when(() => mockViewModel.monthlyRewards).thenReturn(10.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect( - find.textContaining('Rewards earned this month: \$10.00'), - findsOneWidget, - ); - }); - - testWidgets('should display info icon for rewards', (tester) async { - when(() => mockViewModel.monthlyRewards).thenReturn(5.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byIcon(Icons.info_outline), findsOneWidget); - }); - - testWidgets('should show rewards dialog when info icon is tapped', ( - tester, - ) async { - when(() => mockViewModel.monthlyRewards).thenReturn(5.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.byIcon(Icons.info_outline)); - await tester.pumpAndSettle(); - - expect(find.text('Rewards Program'), findsOneWidget); - expect( - find.text( - 'Earn 1% back on every purchase! Rewards are automatically added to your balance and can be used for future laundry services.', - ), - findsOneWidget, - ); - }); - - testWidgets('should close rewards dialog when Got it is tapped', ( - tester, - ) async { - when(() => mockViewModel.monthlyRewards).thenReturn(5.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.byIcon(Icons.info_outline)); - await tester.pumpAndSettle(); - - expect(find.text('Rewards Program'), findsOneWidget); - - await tester.tap(find.text('Got it')); - await tester.pumpAndSettle(); - - expect(find.text('Rewards Program'), findsNothing); - }); - - testWidgets('should handle very small reward amounts', (tester) async { - when(() => mockViewModel.monthlyRewards).thenReturn(0.01); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect( - find.textContaining('Rewards earned this month: \$0.01'), - findsOneWidget, - ); - }); - - testWidgets('should handle large reward amounts', (tester) async { - when(() => mockViewModel.monthlyRewards).thenReturn(999.99); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect( - find.textContaining('Rewards earned this month: \$999.99'), - findsOneWidget, - ); - }); - }); - group('Transactions Display', () { testWidgets('should show "No transactions found" when list is empty', ( tester, diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 7c781cb1..be110881 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -285,7 +285,7 @@ void main() { await tester.pumpWidget(createTestWidget(transactions)); await tester.pumpAndSettle(); - expect(find.byType(Card), findsNWidgets(2)); + expect(find.byType(Card), findsNWidgets(3)); }); testWidgets('displays divider between month and transaction details', From 77273991c23ab92cb9a378c547658ebfce390f34 Mon Sep 17 00:00:00 2001 From: JamesR367 Date: Sat, 14 Mar 2026 15:34:09 -0400 Subject: [PATCH 122/141] Fix broken tests --- test/logic/payment/process_payment_test.dart | 9 --- .../viewmodels/loyalty_view_model_test.dart | 73 ++++++++++--------- .../monthly_transaction_history_test.dart | 3 +- test/pages/payment_page_test.dart | 24 ++++-- 4 files changed, 56 insertions(+), 53 deletions(-) diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index b3886ddc..fbe7ce24 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -44,19 +44,11 @@ void main() { group('PaymentProcessor.processPayment', () { test('should complete payment and record transaction on success', () async { - // Arrange const amount = 100.0; const description = 'Test payment'; - const userId = 'test-user-id'; - const currentBalance = 50.0; when(() => mockPaymentService.makePayment(amount)) .thenAnswer((_) async => Future.value()); - when(() => mockAuthService.getCurrentUserId).thenReturn(userId); - when(() => mockProfileService.getUserBalanceById(userId)) - .thenAnswer((_) async => {'balance': currentBalance}); - when(() => mockProfileService.updateBalanceById(any(), any())) - .thenAnswer((_) async => {}); when(() => mockTransactionService.recordTransaction( amount: any(named: 'amount'), description: any(named: 'description'), @@ -74,7 +66,6 @@ void main() { description: description, type: 'Laundry', )).called(1); - verify(() => mockProfileService.updateBalanceById(userId, 51.0)).called(1); }); test( diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index 006a1b78..d5ea4845 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -47,10 +47,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 100.0, 'full_name': 'Jane Doe'}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -72,10 +72,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenThrow(Exception('Network error')); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -102,15 +102,14 @@ void main() { verifyNever(() => mockProfileService.getUserBalanceById(any())); }); - test('should default to 0.0 balance when null', () async { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': null, 'full_name': 'Jane Doe'}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -125,10 +124,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 100.0, 'full_name': null}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -143,7 +142,7 @@ void main() { test('should toggle showPastTransactions from false to true', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); expect(viewModel.showPastTransactions, false); @@ -159,7 +158,7 @@ void main() { test('should toggle showPastTransactions from true to false', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); viewModel.showPastTransactions = true; @@ -176,7 +175,7 @@ void main() { test('should call transaction service', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -222,33 +221,39 @@ void main() { }); group('loadCard', () { - test('loadCard should update balance and fetch transactions on success', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + test('loadCard should update balance and fetch transactions on success', + () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - viewModel.userBalance = 20.0; + viewModel.userBalance = 20.0; - when(() => mockPaymentProcessor.processPayment( - 10.0, - 'Loyalty Card', - )).thenAnswer((_) async => PaymentResult.success); + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.success); - when(() => mockProfileService.updateBalanceById('user123', 30)) - .thenAnswer((_) async => Future.value()); + when(() => mockProfileService.updateBalanceById('user123', 30)) + .thenAnswer((_) async => Future.value()); - when(() => mockTransactionService.getTransactionsForUser()) - .thenAnswer((_) async => []); + // Stub for checkRewards -> updateRewardsById called internally + when(() => mockProfileService.updateRewardsById(any(), any())) + .thenAnswer((_) async => Future.value()); - // Act - final result = await viewModel.loadCard(10.0); + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); - // Assert - expect(result, PaymentResult.success); - expect(viewModel.userBalance, 30); + // Act + final result = await viewModel.loadCard(10.0); - verify(() => mockProfileService.updateBalanceById('user123', 30)).called(1); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); + // Assert + expect(result, PaymentResult.success); + expect(viewModel.userBalance, 30); + + verify(() => mockProfileService.updateBalanceById('user123', 30)) + .called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); test('loadCard should not update balance on failed payment', () async { // Arrange @@ -270,7 +275,5 @@ void main() { verifyNever(() => mockProfileService.updateBalanceById(any(), any())); }); - - }); -} +} \ No newline at end of file diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index be110881..16eb0dad 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -197,7 +197,6 @@ void main() { testWidgets('sorts months in descending order', (WidgetTester tester) async { - final now = DateTime.now(); final transactions = [ createTransaction( monthsAgo: 3, @@ -220,7 +219,7 @@ void main() { await tester.pumpAndSettle(); final cardFinder = find.byType(Card); - expect(cardFinder, findsNWidgets(2)); + expect(cardFinder, findsNWidgets(3)); final firstCard = cardFinder.first; final firstCardTexts = find.descendant( diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index ad95f95b..938c646f 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -271,22 +271,32 @@ void main() { }); testWidgets('handles machine wake failure in loyalty payment', ( - WidgetTester tester, - ) async { + WidgetTester tester, + ) async { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockMachineService.getMachineById(any()), + () => mockMachineService.getMachineById(any()), ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); when( - () => mockProfileService.getUserBalanceById(any()), + () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); when( - () => mockProfileService.updateBalanceById(any(), any()), + () => mockProfileService.updateBalanceById(any(), any()), ).thenAnswer((_) async => {}); when( - () => mockMachineCommunicator.wakeDevice(any()), + () => mockMachineCommunicator.wakeDevice(any()), ).thenAnswer((_) async => false); + // ADD THIS: mocktail returns null by default for unstubbed Future methods, + // which causes a TypeError. Stub it even though it should never be called. + when( + () => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async {}); + await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); @@ -296,7 +306,7 @@ void main() { expect(find.text('Machine Error'), findsWidgets); verifyNever( - () => mockTransactionService.recordTransaction( + () => mockTransactionService.recordTransaction( amount: any(named: 'amount'), description: any(named: 'description'), type: any(named: 'type'), From a36e1f7e98676abbaf644a17389e1d7750ff021d Mon Sep 17 00:00:00 2001 From: JamesR367 Date: Sun, 15 Mar 2026 13:16:46 -0400 Subject: [PATCH 123/141] Add missing test and fix broken test --- lib/pages/payment_page.dart | 55 +++++++++---------- .../profile/profile_service_test.dart | 16 ++++++ 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index ef495390..0af13099 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -314,56 +314,51 @@ class _PaymentPageState extends State { } void _processLoyaltyPayment(BuildContext context) async { - final updatedBalance = _userBalance! - _price!; - final userId = authService.getCurrentUserId; - profileService.updateBalanceById(userId!, updatedBalance); - - setState(() { - _userBalance = updatedBalance; - }); showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) => - const Center(child: CircularProgressIndicator()), + const Center(child: CircularProgressIndicator()), ); + final deviceAuthorized = await machineCommunicator.wakeDevice( widget.machineId, ); + Navigator.of(context, rootNavigator: true).pop(); - await transactionService.recordTransaction( - amount: _price!, - description: - "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", - type: "laundry", - ); - if (deviceAuthorized) { - makeNotification(_machineName.toString()); - setState(() { - _paymentCompleted = true; - }); - statusDialog( - context, - title: "Machine Ready!", - message: "Machine $_machineName is now active.", - isSuccess: true, - ); - } else { + if (!deviceAuthorized) { statusDialog( context, title: "Machine Error", - message: "Payment succeeded but machine did not wake up. Please contact support", + message: "Machine did not respond. Your balance has not been charged. Please contact support.", isSuccess: false, ); + return; } + + final userId = authService.getCurrentUserId; + final updatedBalance = _userBalance! - _price!; + await profileService.updateBalanceById(userId!, updatedBalance); + + setState(() { + _userBalance = updatedBalance; + _paymentCompleted = true; + }); + + await transactionService.recordTransaction( + amount: _price!, + description: "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", + type: "laundry", + ); + + makeNotification(_machineName.toString()); statusDialog( context, - title: "Payment Successful!", - message: - "Thank you! \$${_price?.toStringAsFixed(2)} was taken from your Loyalty Card.", + title: "Payment Successful! Machine Ready!", + message: "Machine $_machineName is now active. \$${_price?.toStringAsFixed(2)} was taken from your Loyalty Card.", isSuccess: true, ); } diff --git a/test/services/supabase/profile/profile_service_test.dart b/test/services/supabase/profile/profile_service_test.dart index 20e7b0bb..f0fef761 100644 --- a/test/services/supabase/profile/profile_service_test.dart +++ b/test/services/supabase/profile/profile_service_test.dart @@ -150,4 +150,20 @@ void main() { expect(() async => await profileHandler.updateName("testName"), throwsA(isA())); }); + test("Tests that the logic was called correctly to update account rewards", () async { + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + verify(() => supabaseMock.from("profiles")).called(1); + verify(() => queryBuilderMock.update({"reward_tracker": 10.50})).called(1); + }); + + test("Tests that updateRewardsById catches Postgrest exception", () async { + when(() => supabaseMock.from('profiles')).thenThrow(PostgrestException(message: "Test exception")); + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + }); + + test("Tests that updateRewardsById catches unknown exception", () async { + when(() => supabaseMock.from('profiles')).thenThrow(Exception("Test exception")); + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + }); + } \ No newline at end of file From bd46882951149e83b37b894c27fbccd2e7248b95 Mon Sep 17 00:00:00 2001 From: JamesR367 Date: Sun, 15 Mar 2026 13:44:43 -0400 Subject: [PATCH 124/141] Add rewards info pop up --- lib/pages/loyalty_card_page.dart | 62 +++++++++++++++++++++----- test/pages/loyalty_card_page_test.dart | 43 ++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 40496f52..1faa2d6a 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -46,9 +46,9 @@ class LoyaltyCardPage extends State { Widget _buildContent(BuildContext context) { return Column( children: [ - const SizedBox(height: 20), + const SizedBox(height: 10), CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 25), + const SizedBox(height: 17), Text( 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', textAlign: TextAlign.center, @@ -58,16 +58,31 @@ class LoyaltyCardPage extends State { color: Theme.of(context).colorScheme.fontSecondary, ), ), - Text( - '\$${(20 - (viewModel.userReward ?? 0)).toStringAsFixed(2)} until next reward', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.fontSecondary, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '\$${(20 - (viewModel.userReward ?? 0)).toStringAsFixed(2)} until next reward', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showRewardInfoDialog(context), + icon: const Icon(Icons.info_outline), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: Colors.blue, + ), + ], ), - const SizedBox(height: 15), + const SizedBox(height: 7), ElevatedButton( onPressed: () => _loadCard(), style: ElevatedButton.styleFrom( @@ -132,7 +147,7 @@ class LoyaltyCardPage extends State { ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 9), Expanded( child: ListView.builder( shrinkWrap: true, @@ -457,4 +472,27 @@ class LoyaltyCardPage extends State { ); } } + + void _showRewardInfoDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Rewards program'), + content: const Text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Got it'), + ), + ], + ); + }, + ); + } } diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 18160d30..00deef62 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -834,4 +834,47 @@ void main() { expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); }); + group('Reward Info Dialog', () { + testWidgets('should display info button next to reward text', (tester) async { + when(() => mockViewModel.userReward).thenReturn(5.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('should open reward info dialog when info button is tapped', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsOneWidget); + expect( + find.text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + findsOneWidget, + ); + }); + + testWidgets('should close reward info dialog when Got it is tapped', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsNothing); + }); + }); } From 96feeb9f317b9486b44f577e4b349052e5425070 Mon Sep 17 00:00:00 2001 From: JamesR367 Date: Sun, 15 Mar 2026 15:24:50 -0400 Subject: [PATCH 125/141] Rewards display updates on payment --- lib/logic/viewmodels/loyalty_view_model.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 93c15c20..a0f3c143 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -110,6 +110,8 @@ class LoyaltyViewModel extends ChangeNotifier { _profileService.updateRewardsById(_authService.getCurrentUserId!, remainder); } + userReward = remainder; + return amount + (rewardsEarned * 5); } } \ No newline at end of file From b6bb4ff9a044cf21e162676661e91318f94c51c9 Mon Sep 17 00:00:00 2001 From: karelinejones Date: Sun, 15 Mar 2026 15:30:07 -0400 Subject: [PATCH 126/141] changed the tabs to be a selection filter and changed tests --- lib/pages/monthly_transaction_history.dart | 159 ++++++++---- .../monthly_transaction_history_test.dart | 234 ++++++++++++------ 2 files changed, 265 insertions(+), 128 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 8667bd96..c1fe0fc4 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -4,13 +4,21 @@ import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:go_router/go_router.dart'; -class MonthlyTransactionHistory extends StatelessWidget { +class MonthlyTransactionHistory extends StatefulWidget { final List> transactions; const MonthlyTransactionHistory({super.key, required this.transactions}); + @override + State createState() => + _MonthlyTransactionHistoryState(); +} + +class _MonthlyTransactionHistoryState extends State { + int? _selectedYear; + @override Widget build(BuildContext context) { - final monthlySums = TransactionParser.getMonthlySums(transactions); + final monthlySums = TransactionParser.getMonthlySums(widget.transactions); final sortedMonths = monthlySums.keys.toList() ..sort((a, b) { final dateA = DateFormat('MMM yyyy').parse(a); @@ -18,26 +26,75 @@ class MonthlyTransactionHistory extends StatelessWidget { return dateB.compareTo(dateA); }); - final now = DateTime.now(); - final currentYear = now.year; - final previousYear = currentYear - 1; - final twelveMonthCutoff = DateTime(now.year, now.month - 11, 1); + final availableYears = + sortedMonths + .map((month) => DateFormat('MMM yyyy').parse(month).year) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); - final previousYearMonths = sortedMonths.where((month) { - final date = DateFormat('MMM yyyy').parse(month); - return date.year == previousYear; - }).toList(); + if (availableYears.isEmpty) { + availableYears.add(DateTime.now().year); + } - final currentYearMonths = sortedMonths.where((month) { - final date = DateFormat('MMM yyyy').parse(month); - return date.year == currentYear; - }).toList(); + final selectedYear = + (_selectedYear != null && availableYears.contains(_selectedYear)) + ? _selectedYear! + : availableYears.first; - final lastTwelveMonths = sortedMonths.where((month) { + final filteredMonths = sortedMonths.where((month) { final date = DateFormat('MMM yyyy').parse(month); - return !date.isBefore(twelveMonthCutoff); + return date.year == selectedYear; }).toList(); + Future showYearPickerSheet() async { + await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.cardPrimary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Year: $selectedYear', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const Divider(height: 1), + ...availableYears.map( + (year) => ListTile( + key: ValueKey('year-option-$year'), + title: Text(year.toString()), + trailing: year == selectedYear + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + if (year == selectedYear) return; + setState(() { + _selectedYear = year; + }); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + Widget buildMonthList(List visibleMonths) { final ScrollController _scrollController = ScrollController(); @@ -151,46 +208,44 @@ class MonthlyTransactionHistory extends StatelessWidget { ); } - return DefaultTabController( - length: 3, - initialIndex: 1, - child: Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.fontPrimary, - ), - onPressed: () => context.pop(), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - 'Monthly Transaction History', - style: TextStyle(color: Theme.of(context).colorScheme.fontPrimary), - ), - elevation: 2, - centerTitle: true, - bottom: TabBar( - labelColor: Theme.of(context).colorScheme.fontPrimary, - unselectedLabelColor: Theme.of( - context, - ).colorScheme.fontPrimary.withOpacity(0.7), - indicatorColor: Theme.of(context).colorScheme.fontPrimary, - tabs: [ - Tab(text: 'Year $previousYear'), - Tab(text: 'Year $currentYear'), - const Tab(text: 'Last 12 Months'), - ], + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.fontPrimary, ), + onPressed: () => context.pop(), ), - body: TabBarView( - children: [ - buildMonthList(previousYearMonths), - buildMonthList(currentYearMonths), - buildMonthList(lastTwelveMonths), - ], + backgroundColor: Theme.of(context).colorScheme.primary, + title: Text( + 'Monthly Transaction History', + style: TextStyle(color: Theme.of(context).colorScheme.fontPrimary), ), + elevation: 2, + centerTitle: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton.icon( + key: const ValueKey('year-filter-button'), + onPressed: showYearPickerSheet, + icon: Icon( + Icons.arrow_drop_down, + color: Theme.of(context).colorScheme.fontPrimary, + ), + label: Text( + 'Year', + style: TextStyle( + color: Theme.of(context).colorScheme.fontPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], ), + body: buildMonthList(filteredMonths), ); } diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 3b1ab823..371e0b72 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -28,9 +28,7 @@ void main() { getIt.registerSingleton(mockTransactionService); }); - tearDown(() { - GetIt.instance.reset(); - }); + tearDown(() => GetIt.instance.reset()); /// Helper function to create a transaction in the past month Map createTransaction({ @@ -47,7 +45,7 @@ void main() { }; } - Widget createTestWidget(List> transactions) { + Widget createWidgetUnderTest(List> transactions) { router = GoRouter( routes: [ GoRoute( @@ -60,8 +58,10 @@ void main() { return MaterialApp.router(routerConfig: router); } - Future switchToLastTwelveMonthsTab(WidgetTester tester) async { - await tester.tap(find.text('Last 12 Months')); + Future selectYearFilter(WidgetTester tester, int year) async { + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(ValueKey('year-option-$year'))); await tester.pumpAndSettle(); } @@ -69,21 +69,21 @@ void main() { testWidgets('renders AppBar title even when transactions empty', ( WidgetTester tester, ) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); expect(find.text('Monthly Transaction History'), findsOneWidget); }); testWidgets('renders AppBar with back button', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); expect(find.byIcon(Icons.arrow_back), findsOneWidget); }); testWidgets('back button pops the route', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.arrow_back)); @@ -95,7 +95,7 @@ void main() { testWidgets('displays no cards when transactions are empty', ( WidgetTester tester, ) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); expect(find.byType(Card), findsNothing); @@ -108,9 +108,13 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); + + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); expect(find.byType(Card), findsOneWidget); }); @@ -126,9 +130,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$14.25'), findsOneWidget); }); @@ -156,9 +159,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('Direct Washer Payments'), findsOneWidget); expect(find.text('Loyalty Washer Payments'), findsOneWidget); @@ -175,9 +177,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 3.00), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$5.50'), findsWidgets); }); @@ -191,9 +192,8 @@ void main() { createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 2.75), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); final cardFinder = find.byType(Card); expect(cardFinder, findsAtLeastNWidgets(2)); @@ -219,7 +219,7 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); expect(find.byType(Scrollbar), findsOneWidget); @@ -232,7 +232,7 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); final listView = tester.widget(find.byType(ListView)); @@ -248,9 +248,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.byType(Card), findsAtLeastNWidgets(2)); }); @@ -262,9 +261,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.byType(Divider), findsOneWidget); }); @@ -276,9 +274,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); final zeroAmountFinder = find.text('\$0.00'); expect(zeroAmountFinder, findsWidgets); @@ -289,9 +286,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); final card = tester.widget(find.byType(Card)); expect(card.margin, const EdgeInsets.only(bottom: 16)); @@ -313,9 +309,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$5.50'), findsWidgets); }); @@ -336,9 +331,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.75'), findsWidgets); }); @@ -351,9 +345,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'DRYER #1', amount: 1.25), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.75'), findsWidgets); }); @@ -374,9 +367,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$30.00'), findsWidgets); }); @@ -389,9 +381,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.76), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$2.50'), findsWidgets); expect(find.text('\$1.76'), findsWidgets); @@ -406,9 +397,8 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 2.75), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$8.25'), findsWidgets); expect(find.byType(Card), findsOneWidget); @@ -427,9 +417,13 @@ void main() { createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); + + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); expect(find.byType(Card), findsOneWidget); expect(find.text('\$3.00'), findsExactly(2)); @@ -446,9 +440,13 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); + + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); expect(find.byType(Card), findsOneWidget); }); @@ -476,9 +474,8 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await switchToLastTwelveMonthsTab(tester); expect(find.text('\$14.25'), findsOneWidget); expect(find.text('\$2.50'), findsWidgets); @@ -488,63 +485,148 @@ void main() { expect(find.text('\$10.00'), findsWidgets); }); - testWidgets('shows last years, this years, and last 12 months tab', ( + testWidgets('shows year filter options from transaction years', ( WidgetTester tester, ) async { - final now = DateTime.now(); + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + + final expectedYears = {newerDate.year, olderDate.year}.toList() + ..sort((a, b) => b.compareTo(a)); + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #1', + 'amount': 1.75, + }, + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('year-filter-button')), findsOneWidget); - await tester.pumpWidget(createTestWidget([])); + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); await tester.pumpAndSettle(); - expect(find.text('Year ${now.year - 1}'), findsOneWidget); - expect(find.text('Year ${now.year}'), findsOneWidget); - expect(find.text('Last 12 Months'), findsOneWidget); + expect(find.text('Year: ${expectedYears.first}'), findsOneWidget); + + for (final year in expectedYears) { + expect(find.byKey(ValueKey('year-option-$year')), findsOneWidget); + } + + await tester.tap( + find.byKey(ValueKey('year-option-${expectedYears.first}')), + ); + await tester.pumpAndSettle(); }); - testWidgets('previous year tab shows previous year data', ( + testWidgets('selecting a year filter shows that year data', ( WidgetTester tester, ) async { - final now = DateTime.now(); - final previousYearDate = DateTime(now.year, now.month - 11, 15); - final previousYearMonthLabel = - '${DateFormat('MMM').format(previousYearDate)} ${previousYearDate.year}'; + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + final newerYear = newerDate.year; + final olderYear = olderDate.year; + final olderMonthLabel = DateFormat( + 'MMM yyyy', + ).format(DateTime(olderDate.year, olderDate.month, 1)); + final newerMonthLabel = DateFormat( + 'MMM yyyy', + ).format(DateTime(newerDate.year, newerDate.month, 1)); final transactions = [ - createTransaction( - monthsAgo: 11, - description: 'Washer #5', - amount: 2.50, - ), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 3.00, + }, ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await tester.tap(find.text('Year ${now.year - 1}')); - await tester.pumpAndSettle(); + if (olderYear == newerYear) { + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsOneWidget); + return; + } + + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsNothing); + + await selectYearFilter(tester, olderYear); - expect(find.text(previousYearMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsOneWidget); + expect(find.text(newerMonthLabel), findsNothing); }); - testWidgets('can switch between tabs without errors', ( + testWidgets('can switch between year filters without errors', ( WidgetTester tester, ) async { - final now = DateTime.now(); + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + final olderYear = olderDate.year; + final newerYear = newerDate.year; final transactions = [ - createTransaction(monthsAgo: 12, description: 'Dryer #2', amount: 1.75), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 1.75, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 2.25, + }, ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - await tester.tap(find.text('Year ${now.year}')); - await tester.pumpAndSettle(); + await selectYearFilter(tester, olderYear); expect(tester.takeException(), isNull); - await tester.tap(find.text('Last 12 Months')); - await tester.pumpAndSettle(); + if (olderYear != newerYear) { + await selectYearFilter(tester, newerYear); + } expect(tester.takeException(), isNull); }); From 0884c3264636336e6de26f184e4eb4cdb38d5f9b Mon Sep 17 00:00:00 2001 From: karelinejones Date: Sun, 15 Mar 2026 15:44:44 -0400 Subject: [PATCH 127/141] fixed the theme --- lib/pages/monthly_transaction_history.dart | 79 ++++++++++++---------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 243a5734..077400c9 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -18,6 +18,14 @@ class _MonthlyTransactionHistoryState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final cardBackgroundColor = colorScheme.cardPrimary; + final cardTextColor = + ThemeData.estimateBrightnessForColor(cardBackgroundColor) == + Brightness.dark + ? Colors.white + : Colors.black; + final monthlySums = TransactionParser.getMonthlySums(widget.transactions); final sortedMonths = monthlySums.keys.toList() ..sort((a, b) { @@ -50,7 +58,7 @@ class _MonthlyTransactionHistoryState extends State { Future showYearPickerSheet() async { await showModalBottomSheet( context: context, - backgroundColor: Theme.of(context).colorScheme.cardPrimary, + backgroundColor: cardBackgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), @@ -64,19 +72,22 @@ class _MonthlyTransactionHistoryState extends State { ListTile( title: Text( 'Year: $selectedYear', - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: cardTextColor, + ), ), ), const Divider(height: 1), ...availableYears.map( (year) => ListTile( key: ValueKey('year-option-$year'), - title: Text(year.toString()), + title: Text( + year.toString(), + style: TextStyle(color: cardTextColor), + ), trailing: year == selectedYear - ? Icon( - Icons.check, - color: Theme.of(context).colorScheme.primary, - ) + ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { Navigator.of(sheetContext).pop(); @@ -110,22 +121,22 @@ class _MonthlyTransactionHistoryState extends State { return Theme( data: Theme.of(context).copyWith( scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(Colors.lightBlue), + thumbColor: WidgetStateProperty.all(colorScheme.primary), trackColor: WidgetStateProperty.all(Colors.transparent), thickness: WidgetStateProperty.all(8), radius: const Radius.circular(4), ), ), child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - interactive: true, - child: ListView.builder( controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: visibleMonths.length, - itemBuilder: (context, index) { - final month = visibleMonths[index]; + thumbVisibility: true, + interactive: true, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: visibleMonths.length, + itemBuilder: (context, index) { + final month = visibleMonths[index]; final data = monthlySums[month]!; final total = data['directWasher']! + @@ -139,7 +150,7 @@ class _MonthlyTransactionHistoryState extends State { return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, - color: Theme.of(context).colorScheme.cardPrimary, + color: cardBackgroundColor, child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -150,18 +161,18 @@ class _MonthlyTransactionHistoryState extends State { children: [ Text( month, - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Colors.black, + color: cardTextColor, ), ), Text( '\$${total.toStringAsFixed(2)}', - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Colors.black, + color: cardTextColor, ), ), ], @@ -170,31 +181,31 @@ class _MonthlyTransactionHistoryState extends State { _buildTransactionRow( 'Direct Washer Payments', data['directWasher']!, - Colors.black, + cardTextColor, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Washer Payments', data['loyaltyWasher']!, - Theme.of(context).colorScheme.primary, + colorScheme.primary, ), const SizedBox(height: 8), _buildTransactionRow( 'Direct Dryer Payments', data['directDryer']!, - Colors.black, + cardTextColor, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Dryer Payments', data['loyaltyDryer']!, - Theme.of(context).colorScheme.primary, + colorScheme.primary, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Card Loads', data['loyaltyCard']!, - Colors.black, + cardTextColor, ), ], ), @@ -210,16 +221,13 @@ class _MonthlyTransactionHistoryState extends State { return Scaffold( appBar: AppBar( leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.fontPrimary, - ), + icon: Icon(Icons.arrow_back, color: colorScheme.fontPrimary), onPressed: () => context.pop(), ), - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: colorScheme.primary, title: Text( 'Monthly Transaction History', - style: TextStyle(color: Theme.of(context).colorScheme.fontPrimary), + style: TextStyle(color: colorScheme.fontPrimary), ), elevation: 2, centerTitle: true, @@ -229,14 +237,11 @@ class _MonthlyTransactionHistoryState extends State { child: TextButton.icon( key: const ValueKey('year-filter-button'), onPressed: showYearPickerSheet, - icon: Icon( - Icons.arrow_drop_down, - color: Theme.of(context).colorScheme.fontPrimary, - ), + icon: Icon(Icons.arrow_drop_down, color: colorScheme.fontPrimary), label: Text( 'Year', style: TextStyle( - color: Theme.of(context).colorScheme.fontPrimary, + color: colorScheme.fontPrimary, fontWeight: FontWeight.w600, ), ), From 1b19ec53250e78aa63aa29487c8e31f25f6a5c7f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 15 Mar 2026 16:31:07 -0400 Subject: [PATCH 128/141] Comments out apple pay for testing --- lib/services/stripe/stripe_service_mobile.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/services/stripe/stripe_service_mobile.dart b/lib/services/stripe/stripe_service_mobile.dart index b658735d..4f4302d4 100644 --- a/lib/services/stripe/stripe_service_mobile.dart +++ b/lib/services/stripe/stripe_service_mobile.dart @@ -44,7 +44,8 @@ class StripeService implements PaymentService { shapes: const PaymentSheetPrimaryButtonShape(blurRadius: 20), ), ), - applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'), + // Commented out for testing until we get a merchant id from Apple Developer + //applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'), googlePay: const PaymentSheetGooglePay(merchantCountryCode: 'US'), ), ); From af7c07c809445b59f80029a06b0a3b5c16bf806f Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 00:23:32 -0400 Subject: [PATCH 129/141] Added balance check --- lib/pages/start_machine_page.dart | 86 +++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 302e49ad..dd1d2896 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -1,14 +1,20 @@ import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import '../widgets/status_dialog_box.dart'; +bool cancelSearch = false; +const double minimumBalance = 20.0; + class StartPage extends StatelessWidget { + final viewModel = GetIt.instance(); final DoorUnlocker doorUnlocker; StartPage({super.key, DoorUnlocker? doorUnlocker}) @@ -27,10 +33,7 @@ class StartPage extends StatelessWidget { const SectionHeader(title: "Payment Options"), Container( height: 160, - margin: const EdgeInsets.symmetric( - horizontal: 23, - vertical: 10, - ), + margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), padding: const EdgeInsets.all(30), decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), @@ -56,9 +59,7 @@ class StartPage extends StatelessWidget { Text( "Tap phone to machine to pay", style: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary, + color: Theme.of(context).colorScheme.fontSecondary, fontSize: 16, ), ), @@ -97,7 +98,11 @@ class StartPage extends StatelessWidget { descriptionText: "Unlock doors after hours", icon: Icons.lock_open_rounded, onPressed: () async { - await _processUnlocking(context, doorUnlocker); + if (viewModel.userBalance! < minimumBalance) { + _showLowBalanceDialog(context); + } else { + await _processUnlocking(context); + } }, ), ), @@ -108,31 +113,56 @@ class StartPage extends StatelessWidget { ), ); } -} -Future _processUnlocking(BuildContext context, DoorUnlocker doorUnlocker,) -async { - cancelSearch = false; + Future _processUnlocking(BuildContext context) async { + cancelSearch = false; - showSearchingDialog(context); + showSearchingDialog(context); - final success = await doorUnlocker.unlockNearestDoor(); + final success = await doorUnlocker.unlockNearestDoor(); - if (cancelSearch) { - doorUnlocker.cancelUnlockingDoor(); - return; - } + if (cancelSearch) { + doorUnlocker.cancelUnlockingDoor(); + return; + } - if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) Navigator.of(context).pop(); - if (!context.mounted) return; + if (!context.mounted) return; - statusDialog( - context, - title: success ? "Door Unlocked!" : "No Nearby Doors Found", - message: success - ? "The nearest door has been unlocked successfully" - : "We couldn't detect any nearby doors", - isSuccess: success, - ); + statusDialog( + context, + title: success ? "Door Unlocked!" : "No Nearby Doors Found", + message: success + ? "The nearest door has been unlocked successfully" + : "We couldn't detect any nearby doors", + isSuccess: success, + ); + } } + +void _showLowBalanceDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Low Balance'), + content: Text('You need at least \$' + minimumBalance.toStringAsFixed(2) + + ' to unlock a door'), + icon: const Icon(Icons.error), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go("/startPage"); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); +} \ No newline at end of file From 950ea2a86e9b4cc71834e103ff21833dd4e900d2 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 00:34:33 -0400 Subject: [PATCH 130/141] Added tests for balance check --- lib/pages/start_machine_page.dart | 7 +- test/pages/start_machine_page_test.dart | 117 +++++++++++++++++------- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index dd1d2896..7abea54b 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -149,15 +149,14 @@ void _showLowBalanceDialog(BuildContext context) { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: const Text('Low Balance'), - content: Text('You need at least \$' + minimumBalance.toStringAsFixed(2) - + ' to unlock a door'), + title: const Text('Error'), + content: const Text('You need at least \$20 to unlock a door'), icon: const Icon(Icons.error), actions: [ TextButton( onPressed: () { Navigator.of(dialogContext).pop(); - context.go("/startPage"); + context.go("/start"); }, child: const Text('OK'), ), diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index c75945b0..f7a40f1b 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,19 +1,29 @@ +import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; -import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; class MockDoorUnlocker extends Mock implements DoorUnlocker {} +class MockLoyaltyViewModel extends Mock implements LoyaltyViewModel {} void main() { + late MockLoyaltyViewModel mockViewModel; late MockDoorUnlocker mockUnlocker; setUp(() { mockUnlocker = MockDoorUnlocker(); + mockViewModel = MockLoyaltyViewModel(); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(mockViewModel); }); Widget createStartPageTestApp(DoorUnlocker unlocker) { @@ -21,14 +31,12 @@ void main() { routes: [ GoRoute( path: '/', - builder: (_, _) => StartPage(doorUnlocker: unlocker), + builder: (_, __) => StartPage(doorUnlocker: unlocker), ), ], ); - return MaterialApp.router( - routerConfig: router, - ); + return MaterialApp.router(routerConfig: router); } Widget createRouterTestApp() { @@ -36,11 +44,11 @@ void main() { routes: [ GoRoute( path: '/', - builder: (_, _) => StartPage(), + builder: (_, __) => StartPage(), ), GoRoute( path: '/scanner', - builder: (_, _) => const Scaffold(body: Text('Scanner Page')), + builder: (_, __) => const Scaffold(body: Text('Scanner Page')), ), ], ); @@ -48,7 +56,14 @@ void main() { return MaterialApp.router(routerConfig: router); } + Future scrollToUnlockButton(WidgetTester tester) async { + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); + await tester.pumpAndSettle(); + } + testWidgets('Tapping QR button navigates to /scanner', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + await tester.pumpWidget(createRouterTestApp()); await tester.pumpAndSettle(); @@ -62,6 +77,8 @@ void main() { }); testWidgets('Unlock button shows searching dialog', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()) .thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 50)); @@ -69,14 +86,12 @@ void main() { }); await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pump(); + await tester.pumpAndSettle(); - final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); - await tester.ensureVisible(unlockButton); - await tester.pump(); + await scrollToUnlockButton(tester); - await tester.tap(unlockButton); - await tester.pump(const Duration(milliseconds: 10)); + await tester.tap(find.text("Unlock Door")); + await tester.pump(const Duration(milliseconds: 20)); expect(find.byType(Dialog), findsOneWidget); expect(find.textContaining("Finding Nearby Doors"), findsOneWidget); @@ -84,43 +99,81 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); }); - testWidgets('Successful unlock closes searching dialog and shows success dialog', - (tester) async { - when(() => mockUnlocker.unlockNearestDoor()) - .thenAnswer((_) async => true); + (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pump(); + await tester.pumpAndSettle(); - final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); - await tester.ensureVisible(unlockButton); - await tester.pump(); + await scrollToUnlockButton(tester); - await tester.tap(unlockButton); + await tester.tap(find.text("Unlock Door")); await tester.pump(const Duration(milliseconds: 100)); - - await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 2)); expect(find.text("Door Unlocked!"), findsOneWidget); }); testWidgets('Failed unlock shows failure dialog', (tester) async { - when(() => mockUnlocker.unlockNearestDoor()) - .thenAnswer((_) async => false); + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => false); await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pump(); + await tester.pumpAndSettle(); - final unlockButton = find.widgetWithText(QRButton, "Unlock Door"); - await tester.ensureVisible(unlockButton); - await tester.pump(); + await scrollToUnlockButton(tester); - await tester.tap(unlockButton); + await tester.tap(find.text("Unlock Door")); await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(seconds: 2)); expect(find.text("No Nearby Doors Found"), findsOneWidget); }); + + testWidgets('shows low balance dialog when balance is below 20', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(19.99); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pumpAndSettle(); + + expect(find.text('You need at least \$20 to unlock a door'), findsOneWidget); + verifyNever(() => mockUnlocker.unlockNearestDoor()); + }); + + testWidgets('allows unlocking when balance is exactly 20', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(20.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); + + testWidgets('allows unlocking when balance is above 20', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(25.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); } \ No newline at end of file From 0222b61a43cd22fc9e3cebece9a4358cd5bfd39a Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 00:38:39 -0400 Subject: [PATCH 131/141] Added washer card tests --- test/widgets/washer_controls_card_test.dart | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 test/widgets/washer_controls_card_test.dart diff --git a/test/widgets/washer_controls_card_test.dart b/test/widgets/washer_controls_card_test.dart new file mode 100644 index 00000000..e4d75f8d --- /dev/null +++ b/test/widgets/washer_controls_card_test.dart @@ -0,0 +1,126 @@ +import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late double? receivedCost; + + Widget createTestWidget() { + return MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (cost) => receivedCost = cost, + ), + ), + ); + } + + setUp(() { + receivedCost = null; + }); + + testWidgets('renders all four washer cycle buttons', (tester) async { + await tester.pumpWidget(createTestWidget()); + + expect(find.text('Hot Heavy'), findsOneWidget); + expect(find.text('Hot Normal'), findsOneWidget); + expect(find.text('Cold Heavy'), findsOneWidget); + expect(find.text('Cold Normal'), findsOneWidget); + }); + + testWidgets('selecting Hot Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(receivedCost, 0.5); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + expect((button.style?.backgroundColor?.resolve({})) , Colors.green); + }); + + testWidgets('selecting Hot Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Heavy')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Heavy')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(receivedCost, 0); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('only one button is selected at a time', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Select Hot Heavy + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + ElevatedButton hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + ElevatedButton hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), Colors.green); + expect(hotNormal.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + + // Select Hot Normal + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + expect(hotNormal.style?.backgroundColor?.resolve({}), Colors.green); + }); + + testWidgets('callback fires exactly once per tap', (tester) async { + int callCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (_) => callCount++, + ), + ), + ), + ); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(callCount, 1); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(callCount, 2); + }); +} \ No newline at end of file From 6221710fa8e724203cfd2c5ab232619f76463b97 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 00:51:32 -0400 Subject: [PATCH 132/141] Fixed test Fixed 'displays machine information after loading' test --- test/pages/payment_page_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index 9b10473f..5bd143f0 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -113,20 +113,20 @@ void main() { }); testWidgets('displays machine information after loading', ( - WidgetTester tester, - ) async { + WidgetTester tester, + ) async { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockMachineService.getMachineById('machine123'), - ).thenAnswer((_) async => {'Name': 'Washer01'}); + () => mockMachineService.getMachineById('machine123'), + ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 10.0}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); - expect(find.text('Machine Washer 1'), findsOneWidget); + expect(find.text('Machine Washer01'), findsOneWidget); expect(find.text('Amount Due'), findsOneWidget); }); From 9bc1e303c02a5c67ac8dfa0bd83fcda3ca8164d7 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 16 Mar 2026 08:14:54 -0400 Subject: [PATCH 133/141] Sets basePrice in fetchMachineInfo() so price is visible --- lib/pages/payment_page.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 20eb6960..e2f24578 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -70,6 +70,7 @@ class _PaymentPageState extends State { setState(() { _userBalance = (balance['balance'] as num).toDouble(); _machineName = name; + _price = _basePrice; _isLoading = false; }); } else { @@ -242,7 +243,7 @@ class _PaymentPageState extends State { if (deviceAuthorized) { setState(() => _paymentCompleted = true); - makeNotification(_machineName.toString()); + await makeNotification(_machineName.toString()); statusDialog( context, title: "Payment Processed! Machine Ready!", @@ -364,7 +365,7 @@ class _PaymentPageState extends State { ); if (deviceAuthorized) { - makeNotification(_machineName.toString()); + await makeNotification(_machineName.toString()); setState(() => _paymentCompleted = true); statusDialog( context, From 6c17599b46ebaa357c2c2cc4028c72ac53407060 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 11:46:01 -0400 Subject: [PATCH 134/141] Updated balance check Balance check is now more accurate, updates when the page loads, and now lets $20 work --- lib/pages/start_machine_page.dart | 59 ++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 7abea54b..10c1be0b 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -6,20 +6,49 @@ import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import '../widgets/status_dialog_box.dart'; bool cancelSearch = false; -const double minimumBalance = 20.0; +const double minimumBalance = 20; -class StartPage extends StatelessWidget { - final viewModel = GetIt.instance(); +class StartPage extends StatefulWidget { final DoorUnlocker doorUnlocker; StartPage({super.key, DoorUnlocker? doorUnlocker}) : doorUnlocker = doorUnlocker ?? DoorUnlocker(); + @override + State createState() => _StartPageState(); +} + +class _StartPageState extends State { + final profileService = GetIt.instance(); + final authService = GetIt.instance(); + + Map? balance; // <-- MATCHES HomePage + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + final userId = authService.getCurrentUserId; + if (userId == null) return; + + final fetchedBalance = await profileService.getUserBalanceById(userId); + + if (mounted) { + setState(() { + balance = fetchedBalance; // <-- MAP, not double + }); + } + } + @override Widget build(BuildContext context) { return BasePage( @@ -31,6 +60,7 @@ class StartPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SectionHeader(title: "Payment Options"), + Container( height: 160, margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), @@ -98,11 +128,14 @@ class StartPage extends StatelessWidget { descriptionText: "Unlock doors after hours", icon: Icons.lock_open_rounded, onPressed: () async { - if (viewModel.userBalance! < minimumBalance) { + final bal = balance?["balance"]; + + if (bal == null || bal < minimumBalance) { _showLowBalanceDialog(context); - } else { - await _processUnlocking(context); + return; } + + await _processUnlocking(context); }, ), ), @@ -119,10 +152,10 @@ class StartPage extends StatelessWidget { showSearchingDialog(context); - final success = await doorUnlocker.unlockNearestDoor(); + final success = await widget.doorUnlocker.unlockNearestDoor(); if (cancelSearch) { - doorUnlocker.cancelUnlockingDoor(); + widget.doorUnlocker.cancelUnlockingDoor(); return; } @@ -149,14 +182,16 @@ void _showLowBalanceDialog(BuildContext context) { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: const Text('Error'), - content: const Text('You need at least \$20 to unlock a door'), + title: const Text('Low Balance'), + content: Text( + 'You need at least ${minimumBalance.toStringAsFixed(2)} to unlock a door', + ), icon: const Icon(Icons.error), actions: [ TextButton( onPressed: () { Navigator.of(dialogContext).pop(); - context.go("/start"); + context.go("/startPage"); }, child: const Text('OK'), ), From cab5f5c688ceb5a1715c201415a0835f46597caf Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 13:30:13 -0400 Subject: [PATCH 135/141] Fixed dialog crashes --- lib/pages/start_machine_page.dart | 37 +++++++++++++++++----------- lib/services/kisi/door_unlocker.dart | 22 +++++++++++------ lib/widgets/show_searching.dart | 7 +++++- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 10c1be0b..876bc005 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -6,12 +6,12 @@ import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import '../widgets/status_dialog_box.dart'; -bool cancelSearch = false; const double minimumBalance = 20; class StartPage extends StatefulWidget { @@ -28,7 +28,7 @@ class _StartPageState extends State { final profileService = GetIt.instance(); final authService = GetIt.instance(); - Map? balance; // <-- MATCHES HomePage + Map? balance; @override void initState() { @@ -44,7 +44,7 @@ class _StartPageState extends State { if (mounted) { setState(() { - balance = fetchedBalance; // <-- MAP, not double + balance = fetchedBalance; }); } } @@ -63,7 +63,8 @@ class _StartPageState extends State { Container( height: 160, - margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), + margin: const EdgeInsets.symmetric( + horizontal: 23, vertical: 10), padding: const EdgeInsets.all(30), decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), @@ -81,7 +82,10 @@ class _StartPageState extends State { Text( "Tap To Pay", style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, + color: Theme + .of(context) + .colorScheme + .fontInverted, fontSize: 28, fontWeight: FontWeight.bold, ), @@ -89,7 +93,10 @@ class _StartPageState extends State { Text( "Tap phone to machine to pay", style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + color: Theme + .of(context) + .colorScheme + .fontSecondary, fontSize: 16, ), ), @@ -150,19 +157,21 @@ class _StartPageState extends State { Future _processUnlocking(BuildContext context) async { cancelSearch = false; - showSearchingDialog(context); + showSearchingDialog( + context, + () => widget.doorUnlocker.cancelUnlockingDoor(), + ); final success = await widget.doorUnlocker.unlockNearestDoor(); - if (cancelSearch) { - widget.doorUnlocker.cancelUnlockingDoor(); - return; - } - - if (context.mounted) Navigator.of(context).pop(); - if (!context.mounted) return; + if (cancelSearch) return; + + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + statusDialog( context, title: success ? "Door Unlocked!" : "No Nearby Doors Found", diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart index 01f36e39..755ecfdf 100644 --- a/lib/services/kisi/door_unlocker.dart +++ b/lib/services/kisi/door_unlocker.dart @@ -12,12 +12,17 @@ class DoorUnlocker implements DoorUnlockService { @override Future> getNearbyDoors() async { await Future.delayed(const Duration(seconds: 1)); + + if (cancelled) return []; + return _readerToDoor.values.toList(); } - @override Future unlockDoor(String doorId) async { await Future.delayed(const Duration(seconds: 1)); + + if (cancelled) return false; + return doorId != "Broken Door"; // simulate access denied } @@ -28,17 +33,20 @@ class DoorUnlocker implements DoorUnlockService { Future unlockNearestDoor() async { cancelled = false; - if (cancelled) - return false; - final doors = await getNearbyDoors(); - if (cancelled || doors.isEmpty) + if (cancelled || doors.isEmpty) { return false; + } final nearest = doors.first; - return await unlockDoor(nearest); - } + final success = await unlockDoor(nearest); + if (cancelled) { + return false; + } + + return success; + } } \ No newline at end of file diff --git a/lib/widgets/show_searching.dart b/lib/widgets/show_searching.dart index b9549471..070b231f 100644 --- a/lib/widgets/show_searching.dart +++ b/lib/widgets/show_searching.dart @@ -3,8 +3,12 @@ import 'package:flutter/material.dart'; late bool cancelSearch = false; -void showSearchingDialog(BuildContext context) { +void showSearchingDialog( + BuildContext context, + VoidCallback onCancel, + ) { cancelSearch = false; + showDialog( context: context, barrierDismissible: false, @@ -37,6 +41,7 @@ void showSearchingDialog(BuildContext context) { TextButton( onPressed: () { cancelSearch = true; + onCancel(); Navigator.of(dialogContext).pop(); }, style: TextButton.styleFrom( From eacda408b80319bb3752f2735b7c497d81976b01 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 13:38:50 -0400 Subject: [PATCH 136/141] Reworked show_searching tests --- lib/pages/start_machine_page.dart | 2 +- test/widgets/show_searching_test.dart | 113 +++++++++++++++----------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index 876bc005..e20fd683 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -171,7 +171,7 @@ class _StartPageState extends State { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } - + statusDialog( context, title: success ? "Door Unlocked!" : "No Nearby Doors Found", diff --git a/test/widgets/show_searching_test.dart b/test/widgets/show_searching_test.dart index 694a7bdc..78762f21 100644 --- a/test/widgets/show_searching_test.dart +++ b/test/widgets/show_searching_test.dart @@ -3,64 +3,83 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; void main() { - testWidgets('showSearchingDialog displays dialog with correct content', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => showSearchingDialog(context), - child: const Text('Open Dialog'), + + testWidgets('showSearchingDialog displays dialog', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog( + context, + () {}, ), + child: const Text('Open Dialog'), ), - ); - }, - ), + ), + ); + }, ), - ); - - await tester.tap(find.text('Open Dialog')); + ), + ); - await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Open Dialog')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); - expect(find.byType(Dialog), findsOneWidget); - expect(find.text('Finding Nearby Doors...'), findsOneWidget); - expect(find.text('Please wait while we search for the nearest door.'), - findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - }); + expect(find.byType(Dialog), findsOneWidget); + expect(find.text('Finding Nearby Doors...'), findsOneWidget); + expect( + find.text('Please wait while we search for the nearest door.'), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + }, + ); - testWidgets('Cancel button sets cancelSearch = true and closes dialog', - (WidgetTester tester) async { - cancelSearch = false; + testWidgets('Cancel button sets cancelSearch and closes dialog', + (WidgetTester tester) async { + cancelSearch = false; + bool cancelCalled = false; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => showSearchingDialog(context), - child: const Text('Open Dialog'), + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog( + context, + () { + cancelCalled = true; + }, ), + child: const Text('Open Dialog'), ), - ); - }, - ), + ), + ); + }, ), - ); + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(find.text('Open Dialog')); - await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Cancel')); - await tester.tap(find.text('Cancel')); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); - expect(cancelSearch, true); - expect(find.byType(Dialog), findsNothing); - }); + expect(cancelSearch, true); + expect(cancelCalled, true); + expect(find.byType(Dialog), findsNothing); + }, + ); } \ No newline at end of file From 2c795f1c1c5be0db0939295152c0cb8835023670 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 16 Mar 2026 13:59:50 -0400 Subject: [PATCH 137/141] Fixed start_machine_tests Fixed start_machine_tests to align with new code --- test/pages/start_machine_page_test.dart | 75 +++++++++++++++---------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index f7a40f1b..a97cf349 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,3 +1,5 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; @@ -10,20 +12,34 @@ import 'package:mocktail/mocktail.dart'; class MockDoorUnlocker extends Mock implements DoorUnlocker {} class MockLoyaltyViewModel extends Mock implements LoyaltyViewModel {} +class MockProfileService extends Mock implements ProfileService {} +class MockAuthService extends Mock implements AuthService {} void main() { - late MockLoyaltyViewModel mockViewModel; late MockDoorUnlocker mockUnlocker; + late MockLoyaltyViewModel mockViewModel; + late MockProfileService mockProfileService; + late MockAuthService mockAuthService; setUp(() { mockUnlocker = MockDoorUnlocker(); mockViewModel = MockLoyaltyViewModel(); + mockProfileService = MockProfileService(); + mockAuthService = MockAuthService(); final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } + + if (getIt.isRegistered()) getIt.unregister(); + if (getIt.isRegistered()) getIt.unregister(); + if (getIt.isRegistered()) getIt.unregister(); + getIt.registerSingleton(mockViewModel); + getIt.registerSingleton(mockProfileService); + getIt.registerSingleton(mockAuthService); + + when(() => mockAuthService.getCurrentUserId).thenReturn("user123"); + when(() => mockProfileService.getUserBalanceById("user123")) + .thenAnswer((_) async => {"balance": 50.0}); }); Widget createStartPageTestApp(DoorUnlocker unlocker) { @@ -33,40 +49,35 @@ void main() { path: '/', builder: (_, __) => StartPage(doorUnlocker: unlocker), ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - Widget createRouterTestApp() { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (_, __) => StartPage(), - ), GoRoute( path: '/scanner', builder: (_, __) => const Scaffold(body: Text('Scanner Page')), ), ], ); - return MaterialApp.router(routerConfig: router); } Future scrollToUnlockButton(WidgetTester tester) async { - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); - await tester.pumpAndSettle(); + final scrollViewFinder = find.descendant( + of: find.byType(StartPage), + matching: find.byType(SingleChildScrollView), + ); + expect(scrollViewFinder, findsOneWidget); + + await tester.drag(scrollViewFinder, const Offset(0, -500)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); } testWidgets('Tapping QR button navigates to /scanner', (tester) async { when(() => mockViewModel.userBalance).thenReturn(50.0); - await tester.pumpWidget(createRouterTestApp()); + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + final qrButton = find.widgetWithText(QRButton, "Scan QR code"); expect(qrButton, findsOneWidget); @@ -78,16 +89,13 @@ void main() { testWidgets('Unlock button shows searching dialog', (tester) async { when(() => mockViewModel.userBalance).thenReturn(50.0); - - when(() => mockUnlocker.unlockNearestDoor()) - .thenAnswer((_) async { + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 50)); return true; }); await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pumpAndSettle(); - await scrollToUnlockButton(tester); await tester.tap(find.text("Unlock Door")); @@ -95,8 +103,7 @@ void main() { expect(find.byType(Dialog), findsOneWidget); expect(find.textContaining("Finding Nearby Doors"), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); }); testWidgets('Successful unlock closes searching dialog and shows success dialog', @@ -133,17 +140,23 @@ void main() { }); testWidgets('shows low balance dialog when balance is below 20', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(19.99); + const String testUid = "user123"; + when(() => mockAuthService.getCurrentUserId).thenReturn(testUid); + when(() => mockProfileService.getUserBalanceById(testUid)) + .thenAnswer((_) async => {"balance": 15.0}); await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pump(); await tester.pumpAndSettle(); - await scrollToUnlockButton(tester); - await tester.tap(find.text('Unlock Door')); + final unlockButton = find.text('Unlock Door'); + expect(unlockButton, findsOneWidget); + await tester.tap(unlockButton); await tester.pumpAndSettle(); - expect(find.text('You need at least \$20 to unlock a door'), findsOneWidget); + expect(find.text('Low Balance'), findsOneWidget); + expect(find.textContaining('at least 20.00'), findsOneWidget); verifyNever(() => mockUnlocker.unlockNearestDoor()); }); From 09902095e13cd11c1218e994de1a880939c7645c Mon Sep 17 00:00:00 2001 From: karelinejones Date: Mon, 16 Mar 2026 20:03:42 -0400 Subject: [PATCH 138/141] fixed the indexes and lowered nav bar height --- lib/widgets/navigation_bar.dart | 45 ++++++++++++++------------- test/widgets/navigation_bar_test.dart | 44 +++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/lib/widgets/navigation_bar.dart b/lib/widgets/navigation_bar.dart index 50d67c2e..477936d5 100644 --- a/lib/widgets/navigation_bar.dart +++ b/lib/widgets/navigation_bar.dart @@ -13,7 +13,7 @@ class NavBar extends StatelessWidget { route: '/startPage', label: 'Start', icon: Icons.local_laundry_service_sharp, - routePrefixes: ['/start', '/startPage'], + routePrefixes: ['/start', '/startPage', '/scanner', '/paymentPage'], ), _NavDestination( route: '/loyalty', @@ -48,26 +48,29 @@ class NavBar extends StatelessWidget { final currentIndex = _getIndex(location); final colorScheme = Theme.of(context).colorScheme; - return BottomNavigationBar( - currentIndex: currentIndex, - backgroundColor: colorScheme.surface, - selectedItemColor: colorScheme.primary, - unselectedItemColor: Colors.grey, - type: BottomNavigationBarType.fixed, - onTap: (index) { - final route = _destinations[index].route; - if (!location.startsWith(route)) { - context.go(route); - } - }, - items: _destinations - .map( - (destination) => BottomNavigationBarItem( - icon: Icon(destination.icon), - label: destination.label, - ), - ) - .toList(), + return SizedBox( + height: 82, + child: BottomNavigationBar( + currentIndex: currentIndex, + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + onTap: (index) { + final route = _destinations[index].route; + if (!location.startsWith(route)) { + context.go(route); + } + }, + items: _destinations + .map( + (destination) => BottomNavigationBarItem( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), + ), ); } } diff --git a/test/widgets/navigation_bar_test.dart b/test/widgets/navigation_bar_test.dart index c676d9f1..dc465dcf 100644 --- a/test/widgets/navigation_bar_test.dart +++ b/test/widgets/navigation_bar_test.dart @@ -22,6 +22,20 @@ void main() { bottomNavigationBar: const NavBar(), ), ), + GoRoute( + path: '/scanner', + builder: (_, __) => Scaffold( + body: const Text('Scanner Page'), + bottomNavigationBar: const NavBar(), + ), + ), + GoRoute( + path: '/paymentPage', + builder: (_, __) => Scaffold( + body: const Text('Payment Page'), + bottomNavigationBar: const NavBar(), + ), + ), GoRoute( path: '/loyalty', builder: (_, __) => Scaffold( @@ -39,9 +53,7 @@ void main() { ], ); - return MaterialApp.router( - routerConfig: router, - ); + return MaterialApp.router(routerConfig: router); } group('NavBar Widget Tests', () { @@ -104,8 +116,30 @@ void main() { await tester.pumpWidget(wrapWithRouter('/loyalty')); await tester.pumpAndSettle(); - final bottomNav = tester.widget(find.byType(BottomNavigationBar)); + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); expect(bottomNav.currentIndex, 2); }); + + testWidgets('Scanner route highlights Start tab', (tester) async { + await tester.pumpWidget(wrapWithRouter('/scanner')); + await tester.pumpAndSettle(); + + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(bottomNav.currentIndex, 1); + }); + + testWidgets('Payment route highlights Start tab', (tester) async { + await tester.pumpWidget(wrapWithRouter('/paymentPage?machineId=abc')); + await tester.pumpAndSettle(); + + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(bottomNav.currentIndex, 1); + }); }); -} \ No newline at end of file +} From 3f79a9ef0892bac8556cf010b7f50c1d90262dda Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Tue, 17 Mar 2026 08:15:02 -0400 Subject: [PATCH 139/141] Test fixes. --- lib/pages/payment_page.dart | 1 - test/pages/payment_page_test.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 4230949e..3ef32f2e 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -344,7 +344,6 @@ class _PaymentPageState extends State { final userId = authService.getCurrentUserId; final updatedBalance = _userBalance! - _price!; - profileService.updateBalanceById(userId!, updatedBalance); setState(() => _userBalance = updatedBalance); showDialog( diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index f1557168..8c48297e 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -301,7 +301,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Machine Error'), findsWidgets); - verify( + verifyNever( () => mockTransactionService.recordTransaction( amount: any(named: 'amount'), description: any(named: 'description'), From f08f513c05be2da63918dfc29f7c425ba871a935 Mon Sep 17 00:00:00 2001 From: karelinejones Date: Tue, 17 Mar 2026 11:58:59 -0400 Subject: [PATCH 140/141] fixed loyalty card tests to work with nav bar size --- lib/pages/loyalty_card_page.dart | 62 +++++++++------------ test/pages/loyalty_card_page_test.dart | 76 ++++++++++++++------------ 2 files changed, 68 insertions(+), 70 deletions(-) diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index c449566b..77a8227b 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -118,8 +118,9 @@ class LoyaltyCardPage extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( + cacheExtent: 1000, + physics: const AlwaysScrollableScrollPhysics(), children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -148,40 +149,29 @@ class LoyaltyCardPage extends State { ], ), const SizedBox(height: 9), - Expanded( - child: ListView.builder( - shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: viewModel.recentTransactions.length, - itemBuilder: (context, index) { - final transaction = viewModel.recentTransactions[index]; - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 4.0, - vertical: 6.0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 4, - color: Theme.of(context).colorScheme.cardPrimary, - child: ListTile( - leading: const Icon( - Icons.receipt_long, - color: Color(0xFF2073A9), - ), - title: Text( - transaction.toString(), - style: const TextStyle( - fontSize: 14, - color: Colors.black87, - ), - ), - ), - ); - }, - ), - ), + ...viewModel.recentTransactions.map((transaction) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 6.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 4, + color: Theme.of(context).colorScheme.cardPrimary, + child: ListTile( + leading: const Icon( + Icons.receipt_long, + color: Color(0xFF2073A9), + ), + title: Text( + transaction.toString(), + style: const TextStyle(fontSize: 14, color: Colors.black87), + ), + ), + ); + }), ], ), ); diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 00deef62..255f39af 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -12,6 +12,22 @@ import 'mocks.dart'; void main() { late MockLoyaltyViewModel mockViewModel; + const singleTransaction = 'Test transaction'; + const firstTransaction = 'Test transaction 1'; + const secondTransaction = 'Test transaction 2'; + const thirdTransaction = 'Test transaction 3'; + const transactionHistory = [ + firstTransaction, + secondTransaction, + thirdTransaction, + ]; + + Finder findTransactionScrollable() { + return find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ); + } setUpAll(() { // Register fallback values for mocktail @@ -188,7 +204,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); @@ -197,36 +213,23 @@ void main() { }); testWidgets('should display all transactions in list', (tester) async { - when(() => mockViewModel.recentTransactions).thenReturn([ - 'Loaded \$10.00 on 01/10/2025', - 'Used \$2.50 on 01/09/2025', - 'Loaded \$25.00 on 01/08/2025', - ]); + when( + () => mockViewModel.recentTransactions, + ).thenReturn(transactionHistory); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pumpAndSettle(); - expect(find.text('Loaded \$10.00 on 01/10/2025'), findsOneWidget); + expect(find.text(firstTransaction, skipOffstage: false), findsOneWidget); await tester.scrollUntilVisible( - find.text('Used \$2.50 on 01/09/2025'), + find.text(secondTransaction), 100, - scrollable: find.descendant( - of: find.byType(ListView), - matching: find.byType(Scrollable), - ), + scrollable: findTransactionScrollable(), ); - expect(find.text('Used \$2.50 on 01/09/2025'), findsOneWidget); + expect(find.text(secondTransaction), findsOneWidget); - await tester.scrollUntilVisible( - find.text('Loaded \$25.00 on 01/08/2025'), - 100, - scrollable: find.descendant( - of: find.byType(ListView), - matching: find.byType(Scrollable), - ), - ); - expect(find.text('Loaded \$25.00 on 01/08/2025'), findsOneWidget); + expect(find.text(thirdTransaction, skipOffstage: false), findsOneWidget); }); testWidgets( @@ -234,7 +237,7 @@ void main() { (tester) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(false); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -250,7 +253,7 @@ void main() { (tester) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(true); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -266,7 +269,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(false); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -283,7 +286,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(true); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -300,13 +303,16 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.byType(Card), findsWidgets); - expect(find.byIcon(Icons.receipt_long), findsOneWidget); + expect(find.byType(Card, skipOffstage: false), findsWidgets); + expect( + find.byIcon(Icons.receipt_long, skipOffstage: false), + findsOneWidget, + ); }); }); @@ -835,7 +841,9 @@ void main() { }); }); group('Reward Info Dialog', () { - testWidgets('should display info button next to reward text', (tester) async { + testWidgets('should display info button next to reward text', ( + tester, + ) async { when(() => mockViewModel.userReward).thenReturn(5.0); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -845,8 +853,8 @@ void main() { }); testWidgets('should open reward info dialog when info button is tapped', ( - tester, - ) async { + tester, + ) async { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); @@ -863,8 +871,8 @@ void main() { }); testWidgets('should close reward info dialog when Got it is tapped', ( - tester, - ) async { + tester, + ) async { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); From f04a4a2387385b3377d2b0199f593797432333aa Mon Sep 17 00:00:00 2001 From: karelinejones Date: Tue, 17 Mar 2026 12:28:14 -0400 Subject: [PATCH 141/141] changed the filter button so i doesn't cut off title --- lib/pages/monthly_transaction_history.dart | 37 +++++++++++----------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 872b6f0e..4a9b75db 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -223,33 +223,32 @@ class _MonthlyTransactionHistoryState extends State { return Scaffold( appBar: AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration(gradient: colorScheme.primaryGradient), + ), leading: IconButton( - icon: Icon(Icons.arrow_back, color: colorScheme.fontPrimary), + icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => context.pop(), ), - backgroundColor: colorScheme.primary, - title: Text( + title: const Text( 'Monthly Transaction History', - style: TextStyle(color: colorScheme.fontPrimary), + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), ), - elevation: 2, centerTitle: true, + elevation: 2, actions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: TextButton.icon( - key: const ValueKey('year-filter-button'), - onPressed: showYearPickerSheet, - icon: Icon(Icons.arrow_drop_down, color: colorScheme.fontPrimary), - label: Text( - 'Year', - style: TextStyle( - color: colorScheme.fontPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), + IconButton( + key: const ValueKey('year-filter-button'), + onPressed: showYearPickerSheet, + icon: const Icon(Icons.filter_list, color: Colors.white), + tooltip: 'Filter by year', ), + const SizedBox(width: 8), ], ), body: buildMonthList(filteredMonths),