diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml new file mode 100644 index 0000000..dc2e251 --- /dev/null +++ b/.github/workflows/flutter_ci.yml @@ -0,0 +1,43 @@ +name: Flutter CI + + +on: + push: + branches: + - dev + - main + pull_request: + branches: [main] + +jobs: + test: + name: Analyze & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Restore firebase_options.dart from secret + run: echo "${{ secrets.FIREBASE_OPTIONS_B64 }}" | base64 --decode > lib/firebase_options.dart + + - name: Create placeholder .env for CI + run: echo "PLACEHOLDER=true" > .env + + - name: Install dependencies + run: flutter pub get + + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: flutter analyze + + - name: Run tests + run: flutter test \ No newline at end of file diff --git a/lib/models/event.dart b/lib/models/event.dart index 3a5f681..72ab033 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -16,4 +16,4 @@ class Event { required this.interestCount, this.isUgc = false, }); -} \ No newline at end of file +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 2e2e234..cfad6b6 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -10,7 +10,11 @@ class MapScreen extends StatefulWidget { final void Function(Event event) onViewEventDetails; final VoidCallback onOpenProfile; - const MapScreen({super.key, required this.onViewEventDetails, required this.onOpenProfile}); + const MapScreen({ + super.key, + required this.onViewEventDetails, + required this.onOpenProfile, + }); @override State createState() => _MapScreenState(); @@ -18,8 +22,16 @@ class MapScreen extends StatefulWidget { class _MapScreenState extends State { // Centered on Metro Vancouver, per the brief's target market. - static const _initialPosition = CameraPosition(target: LatLng(49.2400, -123.0000), zoom: 10.5); - static const _mapTypeCycle = [MapType.normal, MapType.satellite, MapType.terrain, MapType.hybrid]; + static const _initialPosition = CameraPosition( + target: LatLng(49.2400, -123.0000), + zoom: 10.5, + ); + static const _mapTypeCycle = [ + MapType.normal, + MapType.satellite, + MapType.terrain, + MapType.hybrid, + ]; GoogleMapController? _controller; bool _is3D = false; @@ -27,7 +39,9 @@ class _MapScreenState extends State { Set _buildMarkers(BuildContext context) { final theme = Theme.of(context); - final hue = theme.brightness == Brightness.dark ? BitmapDescriptor.hueCyan : BitmapDescriptor.hueAzure; + final hue = theme.brightness == Brightness.dark + ? BitmapDescriptor.hueCyan + : BitmapDescriptor.hueAzure; return mockEvents.map((event) { return Marker( @@ -36,29 +50,39 @@ class _MapScreenState extends State { icon: BitmapDescriptor.defaultMarkerWithHue( event.isUgc ? BitmapDescriptor.hueOrange : hue, ), - onTap: () => EventPreviewSheet.show(context, event, () => widget.onViewEventDetails(event)), + onTap: () => EventPreviewSheet.show( + context, + event, + () => widget.onViewEventDetails(event), + ), ); }).toSet(); } void _toggle3D() { setState(() => _is3D = !_is3D); - _controller?.animateCamera(CameraUpdate.newCameraPosition( - CameraPosition( - target: _initialPosition.target, - zoom: _initialPosition.zoom, - tilt: _is3D ? 60 : 0, + _controller?.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: _initialPosition.target, + zoom: _initialPosition.zoom, + tilt: _is3D ? 60 : 0, + ), ), - )); + ); } void _cycleMapType() { - final next = _mapTypeCycle[(_mapTypeCycle.indexOf(_mapType) + 1) % _mapTypeCycle.length]; + final next = + _mapTypeCycle[(_mapTypeCycle.indexOf(_mapType) + 1) % + _mapTypeCycle.length]; setState(() => _mapType = next); } void _recenter() { - _controller?.animateCamera(CameraUpdate.newCameraPosition(_initialPosition)); + _controller?.animateCamera( + CameraUpdate.newCameraPosition(_initialPosition), + ); } @override @@ -90,10 +114,14 @@ class _MapScreenState extends State { MapBottomSheet( suggestedEvents: mockEvents, onOpenProfile: widget.onOpenProfile, - onSelectEvent: (event) => EventPreviewSheet.show(context, event, () => widget.onViewEventDetails(event)), + onSelectEvent: (event) => EventPreviewSheet.show( + context, + event, + () => widget.onViewEventDetails(event), + ), ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/login.dart b/lib/screens/login.dart index b2e3527..6750742 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -32,7 +32,12 @@ class _LoginScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('WELCOME BACK', style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary)), + Text( + 'WELCOME BACK', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), const SizedBox(height: 8), Text('Log in', style: theme.textTheme.headlineMedium), const SizedBox(height: 4), @@ -46,12 +51,24 @@ class _LoginScreenState extends State { const SizedBox(height: 20), Row( children: [ - Expanded(child: Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15))), + Expanded( + child: Divider( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.15, + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('or', style: theme.textTheme.bodyMedium), ), - Expanded(child: Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15))), + Expanded( + child: Divider( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.15, + ), + ), + ), ], ), const SizedBox(height: 20), @@ -60,8 +77,13 @@ class _LoginScreenState extends State { TextFormField( controller: _emailCtrl, keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration(hintText: 'you@example.com', prefixIcon: Icon(Icons.mail_outline)), - validator: (v) => (v == null || !v.contains('@')) ? 'Enter a valid email' : null, + decoration: const InputDecoration( + hintText: 'you@example.com', + prefixIcon: Icon(Icons.mail_outline), + ), + validator: (v) => (v == null || !v.contains('@')) + ? 'Enter a valid email' + : null, ), const SizedBox(height: 16), Text('Password', style: theme.textTheme.bodyMedium), @@ -69,15 +91,22 @@ class _LoginScreenState extends State { TextFormField( controller: _passwordCtrl, obscureText: true, - decoration: const InputDecoration(hintText: 'Your password', prefixIcon: Icon(Icons.lock_outline)), - validator: (v) => (v == null || v.isEmpty) ? 'Enter your password' : null, + decoration: const InputDecoration( + hintText: 'Your password', + prefixIcon: Icon(Icons.lock_outline), + ), + validator: (v) => + (v == null || v.isEmpty) ? 'Enter your password' : null, ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () {}, // TODO: forgot password flow - child: Text('Forgot password?', style: TextStyle(color: theme.colorScheme.primary)), + child: Text( + 'Forgot password?', + style: TextStyle(color: theme.colorScheme.primary), + ), ), ), const SizedBox(height: 20), @@ -87,7 +116,11 @@ class _LoginScreenState extends State { }, child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Log in'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)], + children: [ + Text('Log in'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], ), ), const SizedBox(height: 24), @@ -97,4 +130,4 @@ class _LoginScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index cd9eea3..6fac562 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -27,8 +27,14 @@ class ProfileScreen extends StatelessWidget { children: [ CircleAvatar( radius: 44, - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15), - child: Icon(Icons.person, size: 44, color: theme.colorScheme.primary), + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.15, + ), + child: Icon( + Icons.person, + size: 44, + color: theme.colorScheme.primary, + ), ), Positioned( right: 0, @@ -38,15 +44,25 @@ class ProfileScreen extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.primary, shape: BoxShape.circle, - border: Border.all(color: theme.scaffoldBackgroundColor, width: 2), + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 2, + ), + ), + child: const Icon( + Icons.edit, + size: 14, + color: Colors.white, ), - child: const Icon(Icons.edit, size: 14, color: Colors.white), ), ), ], ), const SizedBox(height: 14), - Text(displayName, style: theme.textTheme.headlineMedium?.copyWith(fontSize: 22)), + Text( + displayName, + style: theme.textTheme.headlineMedium?.copyWith(fontSize: 22), + ), const SizedBox(height: 2), Text(username, style: theme.textTheme.bodyMedium), ], @@ -54,20 +70,48 @@ class ProfileScreen extends StatelessWidget { ), const SizedBox(height: 32), _SectionLabel('Account'), - _ProfileTile(icon: Icons.person_outline, label: 'Edit profile', onTap: () {}), - _ProfileTile(icon: Icons.lock_outline, label: 'Privacy & safety', onTap: () {}), - _ProfileTile(icon: Icons.notifications_none, label: 'Notifications', trailing: 'Post-V1', onTap: () {}), + _ProfileTile( + icon: Icons.person_outline, + label: 'Edit profile', + onTap: () {}, + ), + _ProfileTile( + icon: Icons.lock_outline, + label: 'Privacy & safety', + onTap: () {}, + ), + _ProfileTile( + icon: Icons.notifications_none, + label: 'Notifications', + trailing: 'Post-V1', + onTap: () {}, + ), const SizedBox(height: 24), _SectionLabel('App'), - _ProfileTile(icon: Icons.palette_outlined, label: 'Appearance', trailing: 'System', onTap: () {}), - _ProfileTile(icon: Icons.help_outline, label: 'Help & support', onTap: () {}), - _ProfileTile(icon: Icons.info_outline, label: 'About MeetEm', onTap: () {}), + _ProfileTile( + icon: Icons.palette_outlined, + label: 'Appearance', + trailing: 'System', + onTap: () {}, + ), + _ProfileTile( + icon: Icons.help_outline, + label: 'Help & support', + onTap: () {}, + ), + _ProfileTile( + icon: Icons.info_outline, + label: 'About MeetEm', + onTap: () {}, + ), const SizedBox(height: 32), OutlinedButton( onPressed: onLogOut, style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, - side: BorderSide(color: theme.colorScheme.error.withValues(alpha: 0.4)), + side: BorderSide( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), ), child: const Text('Log out'), ), @@ -96,7 +140,12 @@ class _ProfileTile extends StatelessWidget { final String? trailing; final VoidCallback onTap; - const _ProfileTile({required this.icon, required this.label, this.trailing, required this.onTap}); + const _ProfileTile({ + required this.icon, + required this.label, + this.trailing, + required this.onTap, + }); @override Widget build(BuildContext context) { @@ -108,17 +157,32 @@ class _ProfileTile extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ - Icon(icon, size: 22, color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), + Icon( + icon, + size: 22, + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), const SizedBox(width: 16), - Expanded(child: Text(label, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface))), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), if (trailing != null) ...[ Text(trailing!, style: theme.textTheme.bodyMedium), const SizedBox(width: 6), ], - Icon(Icons.chevron_right, size: 20, color: theme.colorScheme.onSurface.withValues(alpha: 0.3)), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/signup.dart b/lib/screens/signup.dart index 6ea1ddb..e376a67 100644 --- a/lib/screens/signup.dart +++ b/lib/screens/signup.dart @@ -36,11 +36,19 @@ class _SignUpScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('NEW ACCOUNT', style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary)), + Text( + 'NEW ACCOUNT', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), const SizedBox(height: 8), Text('Create account', style: theme.textTheme.headlineMedium), const SizedBox(height: 4), - Text('Join MeetEm and find people near you.', style: theme.textTheme.bodyMedium), + Text( + 'Join MeetEm and find people near you.', + style: theme.textTheme.bodyMedium, + ), const SizedBox(height: 24), OutlinedButton.icon( onPressed: () {}, // TODO: wire up Google Sign-In @@ -50,45 +58,72 @@ class _SignUpScreenState extends State { const SizedBox(height: 20), Row( children: [ - Expanded(child: Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15))), + Expanded( + child: Divider( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.15, + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('or', style: theme.textTheme.bodyMedium), ), - Expanded(child: Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15))), + Expanded( + child: Divider( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.15, + ), + ), + ), ], ), const SizedBox(height: 20), _FieldLabel('Full name'), TextFormField( controller: _nameCtrl, - decoration: const InputDecoration(hintText: 'Sarah Chen', prefixIcon: Icon(Icons.person_outline)), - validator: (v) => (v == null || v.trim().isEmpty) ? 'Enter your name' : null, + decoration: const InputDecoration( + hintText: 'Sarah Chen', + prefixIcon: Icon(Icons.person_outline), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Enter your name' : null, ), const SizedBox(height: 16), _FieldLabel('Email address'), TextFormField( controller: _emailCtrl, keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration(hintText: 'you@example.com', prefixIcon: Icon(Icons.mail_outline)), - validator: (v) => (v == null || !v.contains('@')) ? 'Enter a valid email' : null, + decoration: const InputDecoration( + hintText: 'you@example.com', + prefixIcon: Icon(Icons.mail_outline), + ), + validator: (v) => (v == null || !v.contains('@')) + ? 'Enter a valid email' + : null, ), const SizedBox(height: 16), _FieldLabel('Password'), TextFormField( controller: _passwordCtrl, obscureText: true, - decoration: - const InputDecoration(hintText: 'At least 8 characters', prefixIcon: Icon(Icons.lock_outline)), - validator: (v) => (v == null || v.length < 8) ? 'Minimum 8 characters' : null, + decoration: const InputDecoration( + hintText: 'At least 8 characters', + prefixIcon: Icon(Icons.lock_outline), + ), + validator: (v) => + (v == null || v.length < 8) ? 'Minimum 8 characters' : null, ), const SizedBox(height: 16), _FieldLabel('Confirm password'), TextFormField( controller: _confirmCtrl, obscureText: true, - decoration: const InputDecoration(prefixIcon: Icon(Icons.lock_outline)), - validator: (v) => (v != _passwordCtrl.text) ? 'Passwords do not match' : null, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.lock_outline), + ), + validator: (v) => + (v != _passwordCtrl.text) ? 'Passwords do not match' : null, ), const SizedBox(height: 28), ElevatedButton( @@ -97,7 +132,11 @@ class _SignUpScreenState extends State { }, child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Continue'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)], + children: [ + Text('Continue'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], ), ), const SizedBox(height: 24), @@ -120,4 +159,4 @@ class _FieldLabel extends StatelessWidget { child: Text(text, style: Theme.of(context).textTheme.bodyMedium), ); } -} \ No newline at end of file +} diff --git a/lib/screens/success_screen.dart b/lib/screens/success_screen.dart index 8ffa11b..67adab8 100644 --- a/lib/screens/success_screen.dart +++ b/lib/screens/success_screen.dart @@ -4,7 +4,11 @@ class SuccessScreen extends StatelessWidget { final String userName; final VoidCallback onStartExploring; - const SuccessScreen({super.key, this.userName = 'Sarah', required this.onStartExploring}); + const SuccessScreen({ + super.key, + this.userName = 'Sarah', + required this.onStartExploring, + }); @override Widget build(BuildContext context) { @@ -21,7 +25,11 @@ class SuccessScreen extends StatelessWidget { child: const Icon(Icons.check, color: Colors.white, size: 32), ), const SizedBox(height: 24), - Text('Welcome to\nMeetEm, $userName!', textAlign: TextAlign.center, style: theme.textTheme.headlineMedium), + Text( + 'Welcome to\nMeetEm, $userName!', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium, + ), const SizedBox(height: 12), Text( 'Your account is ready. Start meeting people near you.', @@ -44,7 +52,11 @@ class SuccessScreen extends StatelessWidget { onPressed: onStartExploring, child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Start exploring'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)], + children: [ + Text('Start exploring'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], ), ), ), @@ -71,4 +83,4 @@ class _Stat extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/username_pick.dart b/lib/screens/username_pick.dart index 83b8ea2..7fe08b5 100644 --- a/lib/screens/username_pick.dart +++ b/lib/screens/username_pick.dart @@ -23,7 +23,12 @@ class _UsernamePickState extends State { return Scaffold( appBar: AppBar( leading: const BackButton(), - title: Text('STEP 2 OF 3', style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary)), + title: Text( + 'STEP 2 OF 3', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), centerTitle: false, ), body: Padding( @@ -34,15 +39,24 @@ class _UsernamePickState extends State { const SizedBox(height: 12), Text('Pick a username', style: theme.textTheme.headlineMedium), const SizedBox(height: 4), - Text('You can always change this later.', style: theme.textTheme.bodyMedium), + Text( + 'You can always change this later.', + style: theme.textTheme.bodyMedium, + ), const SizedBox(height: 28), Center( child: Stack( children: [ CircleAvatar( radius: 44, - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15), - child: Icon(Icons.person_outline, size: 40, color: theme.colorScheme.primary), + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.15, + ), + child: Icon( + Icons.person_outline, + size: 40, + color: theme.colorScheme.primary, + ), ), Positioned( right: 0, @@ -52,9 +66,16 @@ class _UsernamePickState extends State { decoration: BoxDecoration( color: theme.colorScheme.primary, shape: BoxShape.circle, - border: Border.all(color: theme.scaffoldBackgroundColor, width: 2), + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 2, + ), + ), + child: const Icon( + Icons.add, + size: 16, + color: Colors.white, ), - child: const Icon(Icons.add, size: 16, color: Colors.white), ), ), ], @@ -65,7 +86,10 @@ class _UsernamePickState extends State { const SizedBox(height: 6), TextField( controller: _usernameCtrl, - decoration: const InputDecoration(hintText: '@sarah.chen', prefixIcon: Icon(Icons.alternate_email)), + decoration: const InputDecoration( + hintText: '@sarah.chen', + prefixIcon: Icon(Icons.alternate_email), + ), ), const SizedBox(height: 8), Text( @@ -77,7 +101,11 @@ class _UsernamePickState extends State { onPressed: widget.onContinue, child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Continue'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)], + children: [ + Text('Continue'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], ), ), const SizedBox(height: 24), @@ -86,4 +114,4 @@ class _UsernamePickState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index a1a7427..6182e2c 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -32,45 +32,47 @@ class WelcomeScreen extends StatelessWidget { ), Expanded( flex: 4, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Meet people\nnear you.', - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 12), - Text( - 'Join over 2.4M people discovering connections in their neighbourhood.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 28), - ElevatedButton( - onPressed: onGetStarted, - // style: theme.elevatedButtonTheme, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Get started'), - SizedBox(width: 8), - Icon(Icons.arrow_forward, size: 18), - ], + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Meet people\nnear you.', + style: theme.textTheme.headlineMedium, ), - ), - const SizedBox(height: 8), - Center( - child: TextButton( - onPressed: onLogin, - child: Text( - 'Already have an account? Log in', - style: theme.textTheme.bodyMedium, + const SizedBox(height: 12), + Text( + 'Join over 2.4M people discovering connections in their neighbourhood.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 28), + ElevatedButton( + onPressed: onGetStarted, + // style: theme.elevatedButtonTheme, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Get started'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: onLogin, + child: Text( + 'Already have an account? Log in', + style: theme.textTheme.bodyMedium, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/services/mock_events.dart b/lib/services/mock_events.dart index 20439a1..20e4282 100644 --- a/lib/services/mock_events.dart +++ b/lib/services/mock_events.dart @@ -51,4 +51,4 @@ final mockEvents = [ interestCount: 5, isUgc: true, ), -]; \ No newline at end of file +]; diff --git a/lib/services/onboarding_flow.dart b/lib/services/onboarding_flow.dart index d36b20e..bb4a819 100644 --- a/lib/services/onboarding_flow.dart +++ b/lib/services/onboarding_flow.dart @@ -19,46 +19,60 @@ class OnboardingFlow extends StatelessWidget { } void _pushSignUp(BuildContext context) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => SignUpScreen(onCreated: () => _pushProfileSetup(context)), - )); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + SignUpScreen(onCreated: () => _pushProfileSetup(context)), + ), + ); } void _pushLogin(BuildContext context) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => LoginScreen(onLoggedIn: () => _pushSuccess(context)), - )); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LoginScreen(onLoggedIn: () => _pushSuccess(context)), + ), + ); } void _pushProfileSetup(BuildContext context) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => UsernamePick(onContinue: () => _pushSuccess(context)), - )); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => UsernamePick(onContinue: () => _pushSuccess(context)), + ), + ); } void _pushSuccess(BuildContext context) { - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (_) => SuccessScreen(onStartExploring: () => _goToMap(context)), - )); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => + SuccessScreen(onStartExploring: () => _goToMap(context)), + ), + ); } void _goToMap(BuildContext context) { - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (_) => MapScreen( - onViewEventDetails: (event) { /* existing stub */ }, - onOpenProfile: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => ProfileScreen( - onLogOut: () { - // TODO: real sign-out once Firebase Auth is wired up - Navigator.of(context).pop(); - }, - ), - )); -}, -), - )); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => MapScreen( + onViewEventDetails: (event) { + /* existing stub */ + }, + onOpenProfile: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileScreen( + onLogOut: () { + // TODO: real sign-out once Firebase Auth is wired up + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ), + ); } - - -} \ No newline at end of file +} diff --git a/lib/widgets/event_preview_sheet.dart b/lib/widgets/event_preview_sheet.dart index b5b729e..b807b56 100644 --- a/lib/widgets/event_preview_sheet.dart +++ b/lib/widgets/event_preview_sheet.dart @@ -6,16 +6,25 @@ class EventPreviewSheet extends StatelessWidget { final Event event; final VoidCallback onViewDetails; - const EventPreviewSheet({super.key, required this.event, required this.onViewDetails}); + const EventPreviewSheet({ + super.key, + required this.event, + required this.onViewDetails, + }); - static Future show(BuildContext context, Event event, VoidCallback onViewDetails) { + static Future show( + BuildContext context, + Event event, + VoidCallback onViewDetails, + ) { return showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (_) => EventPreviewSheet(event: event, onViewDetails: onViewDetails), + builder: (_) => + EventPreviewSheet(event: event, onViewDetails: onViewDetails), ); } @@ -57,20 +66,36 @@ class EventPreviewSheet extends StatelessWidget { ), child: Text( 'COMMUNITY EVENT', - style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), ), ), - Text(event.name, style: theme.textTheme.headlineMedium?.copyWith(fontSize: 22)), + Text( + event.name, + style: theme.textTheme.headlineMedium?.copyWith(fontSize: 22), + ), const SizedBox(height: 10), Row( children: [ - Icon(Icons.access_time, size: 16, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)), + Icon( + Icons.access_time, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), const SizedBox(width: 6), Text(_timeLabel, style: theme.textTheme.bodyMedium), const SizedBox(width: 16), - Icon(Icons.people_outline, size: 16, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)), + Icon( + Icons.people_outline, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), const SizedBox(width: 6), - Text('${event.interestCount} interested', style: theme.textTheme.bodyMedium), + Text( + '${event.interestCount} interested', + style: theme.textTheme.bodyMedium, + ), ], ), const SizedBox(height: 24), @@ -81,11 +106,15 @@ class EventPreviewSheet extends StatelessWidget { }, child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [Text('View details'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)], + children: [ + Text('View details'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], ), ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/map_bottom_sheet.dart b/lib/widgets/map_bottom_sheet.dart index c387478..92ad529 100644 --- a/lib/widgets/map_bottom_sheet.dart +++ b/lib/widgets/map_bottom_sheet.dart @@ -25,7 +25,13 @@ class MapBottomSheet extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 16, offset: Offset(0, -2))], + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 16, + offset: Offset(0, -2), + ), + ], ), child: ListView( controller: scrollController, @@ -46,16 +52,28 @@ class MapBottomSheet extends StatelessWidget { children: [ Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), decoration: BoxDecoration( color: theme.inputDecorationTheme.fillColor, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Icons.search, size: 20, color: theme.colorScheme.onSurface.withValues(alpha: 0.5)), + Icon( + Icons.search, + size: 20, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + ), const SizedBox(width: 10), - Text('Search MeetEm', style: theme.textTheme.bodyMedium), + Text( + 'Search MeetEm', + style: theme.textTheme.bodyMedium, + ), ], ), ), @@ -65,8 +83,13 @@ class MapBottomSheet extends StatelessWidget { onTap: onOpenProfile, child: CircleAvatar( radius: 22, - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15), - child: Icon(Icons.person, color: theme.colorScheme.primary), + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.15, + ), + child: Icon( + Icons.person, + color: theme.colorScheme.primary, + ), ), ), ], @@ -74,7 +97,10 @@ class MapBottomSheet extends StatelessWidget { const SizedBox(height: 20), Text('Nearby now', style: theme.textTheme.titleMedium), const SizedBox(height: 8), - ...suggestedEvents.map((event) => _EventTile(event: event, onTap: () => onSelectEvent(event))), + ...suggestedEvents.map( + (event) => + _EventTile(event: event, onTap: () => onSelectEvent(event)), + ), ], ), ); @@ -100,7 +126,9 @@ class _EventTile extends StatelessWidget { children: [ CircleAvatar( radius: 18, - backgroundColor: (event.isUgc ? Colors.orange : theme.colorScheme.primary).withValues(alpha: 0.15), + backgroundColor: + (event.isUgc ? Colors.orange : theme.colorScheme.primary) + .withValues(alpha: 0.15), child: Icon( event.isUgc ? Icons.groups_outlined : Icons.event_outlined, size: 18, @@ -112,15 +140,26 @@ class _EventTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(event.name, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface)), - Text('${event.interestCount} interested', style: theme.textTheme.bodyMedium), + Text( + event.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + Text( + '${event.interestCount} interested', + style: theme.textTheme.bodyMedium, + ), ], ), ), - Icon(Icons.chevron_right, color: theme.colorScheme.onSurface.withValues(alpha: 0.3)), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withValues(alpha: 0.3), + ), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/map_side_controls.dart b/lib/widgets/map_side_controls.dart index d4fb5fa..8ed6fa8 100644 --- a/lib/widgets/map_side_controls.dart +++ b/lib/widgets/map_side_controls.dart @@ -21,21 +21,11 @@ class MapSideControls extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - _ControlButton( - label: '3D', - selected: is3D, - onTap: onToggle3D, - ), + _ControlButton(label: '3D', selected: is3D, onTap: onToggle3D), const SizedBox(height: 10), - _ControlButton( - icon: Icons.layers_outlined, - onTap: onCycleMapType, - ), + _ControlButton(icon: Icons.layers_outlined, onTap: onCycleMapType), const SizedBox(height: 10), - _ControlButton( - icon: Icons.navigation_outlined, - onTap: onRecenter, - ), + _ControlButton(icon: Icons.navigation_outlined, onTap: onRecenter), ], ); } @@ -47,7 +37,12 @@ class _ControlButton extends StatelessWidget { final bool selected; final VoidCallback onTap; - const _ControlButton({this.label, this.icon, this.selected = false, required this.onTap}); + const _ControlButton({ + this.label, + this.icon, + this.selected = false, + required this.onTap, + }); @override Widget build(BuildContext context) { @@ -68,7 +63,9 @@ class _ControlButton extends StatelessWidget { label!, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, - color: selected ? Colors.white : theme.colorScheme.onSurface, + color: selected + ? Colors.white + : theme.colorScheme.onSurface, ), ) : Icon(icon, size: 20, color: theme.colorScheme.onSurface), @@ -77,4 +74,4 @@ class _ControlButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/test/login_screen_test.dart b/test/login_screen_test.dart new file mode 100644 index 0000000..c152dad --- /dev/null +++ b/test/login_screen_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meetup_app/screens/login.dart'; + +void main() { + Widget wrap(VoidCallback onLoggedIn) => + MaterialApp(home: LoginScreen(onLoggedIn: onLoggedIn)); + + testWidgets('shows validation errors and does not log in with empty fields', ( + tester, + ) async { + var loggedIn = false; + await tester.pumpWidget(wrap(() => loggedIn = true)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Log in')); + await tester.pump(); + + expect(find.text('Enter a valid email'), findsOneWidget); + expect(find.text('Enter your password'), findsOneWidget); + expect(loggedIn, isFalse); + }); + + testWidgets('rejects an email missing the @ symbol', (tester) async { + var loggedIn = false; + await tester.pumpWidget(wrap(() => loggedIn = true)); + + await tester.enterText( + find.widgetWithText(TextFormField, 'you@example.com'), + 'not-an-email', + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log in')); + await tester.pump(); + + expect(find.text('Enter a valid email'), findsOneWidget); + expect(loggedIn, isFalse); + }); + + testWidgets('calls onLoggedIn when both fields are valid', (tester) async { + var loggedIn = false; + await tester.pumpWidget(wrap(() => loggedIn = true)); + + await tester.enterText( + find.widgetWithText(TextFormField, 'you@example.com'), + 'sarah@example.com', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Your password'), + 'hunter22', + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log in')); + await tester.pump(); + + expect(loggedIn, isTrue); + }); +} diff --git a/test/onboarding_flow_test.dart b/test/onboarding_flow_test.dart new file mode 100644 index 0000000..e5e04a5 --- /dev/null +++ b/test/onboarding_flow_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meetup_app/services/onboarding_flow.dart'; + +void main() { + testWidgets('navigates Welcome -> Login -> Success', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const MaterialApp(home: OnboardingFlow())); + + // Starts on Welcome + expect(find.text('Get started'), findsOneWidget); + + // Go to Login instead of Sign Up + await tester.tap(find.textContaining('Already have an account')); + await tester.pumpAndSettle(); + + expect(find.text('Good to see you again.'), findsOneWidget); + + // Fill valid credentials and submit + await tester.enterText( + find.widgetWithText(TextFormField, 'you@example.com'), + 'sarah@example.com', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Your password'), + 'hunter22', + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log in')); + await tester.pumpAndSettle(); + + // Navigates to Success + expect(find.textContaining('Welcome to'), findsOneWidget); + expect(find.text('Start exploring'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index b1083c6..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:meetup_app/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}