feat: Replace SplashPage with LoginPage for user authentication flow, add support/terms links and handle example text

Changed files
+217 -94
lib
+2 -2
lib/main.dart
··· 7 7 import 'package:grain/app_theme.dart'; 8 8 import 'package:grain/auth.dart'; 9 9 import 'package:grain/screens/home_page.dart'; 10 - import 'package:grain/screens/splash_page.dart'; 10 + import 'package:grain/screens/login_page.dart'; 11 11 12 12 import 'providers/profile_provider.dart'; 13 13 ··· 105 105 } else { 106 106 home = isSignedIn 107 107 ? MyHomePage(title: 'Grain', onSignOut: () => handleSignOut(context)) 108 - : SplashPage(onSignIn: handleSignIn); 108 + : LoginPage(onSignIn: handleSignIn); 109 109 } 110 110 return MaterialApp( 111 111 title: 'Grain',
+215
lib/screens/login_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:grain/auth.dart'; 3 + import 'package:grain/widgets/app_button.dart'; 4 + import 'package:grain/widgets/app_image.dart'; 5 + import 'package:grain/widgets/plain_text_field.dart'; 6 + import 'package:url_launcher/url_launcher.dart'; 7 + 8 + class LoginPage extends StatefulWidget { 9 + final void Function()? onSignIn; 10 + const LoginPage({super.key, this.onSignIn}); 11 + 12 + @override 13 + State<LoginPage> createState() => _LoginPageState(); 14 + } 15 + 16 + class _LoginPageState extends State<LoginPage> { 17 + final TextEditingController _handleController = TextEditingController(text: ''); 18 + bool _signingIn = false; 19 + 20 + Future<void> _signInWithBluesky(BuildContext context) async { 21 + final handle = _handleController.text.trim(); 22 + 23 + if (handle.isEmpty) return; 24 + setState(() { 25 + _signingIn = true; 26 + }); 27 + 28 + try { 29 + await auth.login(handle); 30 + 31 + if (widget.onSignIn != null) { 32 + widget.onSignIn!(); 33 + } 34 + } finally { 35 + setState(() { 36 + _signingIn = false; 37 + }); 38 + } 39 + } 40 + 41 + @override 42 + Widget build(BuildContext context) { 43 + final theme = Theme.of(context); 44 + return Scaffold( 45 + backgroundColor: theme.scaffoldBackgroundColor, 46 + body: Stack( 47 + fit: StackFit.expand, 48 + children: [ 49 + AppImage( 50 + url: 51 + 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@jpeg', 52 + fit: BoxFit.cover, 53 + ), 54 + Container(color: Colors.black.withOpacity(0.4)), 55 + Center( 56 + child: Column( 57 + mainAxisAlignment: MainAxisAlignment.center, 58 + children: [ 59 + const SizedBox(height: 24), 60 + Padding( 61 + padding: const EdgeInsets.symmetric(horizontal: 16), 62 + child: PlainTextField( 63 + label: '', 64 + controller: _handleController, 65 + hintText: 'Enter your handle or pds host', 66 + enabled: !_signingIn, 67 + onChanged: (_) {}, 68 + ), 69 + ), 70 + const SizedBox(height: 12), 71 + Padding( 72 + padding: const EdgeInsets.symmetric(horizontal: 16), 73 + child: SizedBox( 74 + width: double.infinity, 75 + child: AppButton( 76 + label: 'Login', 77 + onPressed: _signingIn ? null : () => _signInWithBluesky(context), 78 + loading: _signingIn, 79 + variant: AppButtonVariant.primary, 80 + height: 44, 81 + fontSize: 15, 82 + borderRadius: 6, 83 + ), 84 + ), 85 + ), 86 + const SizedBox(height: 8), 87 + Padding( 88 + padding: const EdgeInsets.symmetric(horizontal: 16), 89 + child: Container( 90 + width: double.infinity, 91 + decoration: BoxDecoration( 92 + color: Colors.black.withOpacity(0.7), 93 + borderRadius: BorderRadius.circular(6), 94 + ), 95 + padding: const EdgeInsets.all(12), 96 + child: Text( 97 + 'e.g., user.bsky.social, user.grain.social, example.com, https://pds.example.com', 98 + style: const TextStyle( 99 + color: Colors.white, 100 + fontSize: 13, 101 + fontFamily: 'monospace', 102 + ), 103 + ), 104 + ), 105 + ), 106 + ], 107 + ), 108 + ), 109 + Positioned( 110 + left: 0, 111 + right: 0, 112 + bottom: 0, 113 + child: Padding( 114 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), 115 + child: Column( 116 + crossAxisAlignment: CrossAxisAlignment.stretch, 117 + children: [ 118 + Row( 119 + crossAxisAlignment: CrossAxisAlignment.end, 120 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 121 + children: [ 122 + Flexible( 123 + child: Column( 124 + mainAxisSize: MainAxisSize.min, 125 + mainAxisAlignment: MainAxisAlignment.end, 126 + crossAxisAlignment: CrossAxisAlignment.start, 127 + children: [ 128 + Wrap( 129 + crossAxisAlignment: WrapCrossAlignment.center, 130 + spacing: 8, 131 + runSpacing: 4, 132 + children: [ 133 + const Text( 134 + '© 2025 Grain Social. All rights reserved.', 135 + style: TextStyle(color: Colors.white, fontSize: 12), 136 + ), 137 + _LinkText('Terms', 'https://grain.social/support/terms'), 138 + const Text( 139 + '|', 140 + style: TextStyle(color: Colors.white, fontSize: 12), 141 + ), 142 + _LinkText('Privacy', 'https://grain.social/support/privacy'), 143 + const Text( 144 + '|', 145 + style: TextStyle(color: Colors.white, fontSize: 12), 146 + ), 147 + _LinkText('Copyright', 'https://grain.social/support/copyright'), 148 + ], 149 + ), 150 + ], 151 + ), 152 + ), 153 + Flexible( 154 + child: Column( 155 + mainAxisSize: MainAxisSize.min, 156 + mainAxisAlignment: MainAxisAlignment.end, 157 + crossAxisAlignment: CrossAxisAlignment.end, 158 + children: [ 159 + Wrap( 160 + crossAxisAlignment: WrapCrossAlignment.center, 161 + children: [ 162 + const Text( 163 + 'Photo by ', 164 + style: TextStyle(color: Colors.white, fontSize: 12), 165 + ), 166 + _LinkText( 167 + '@chadtmiller.com', 168 + 'https://grain.social/profile/chadtmiller.com', 169 + ), 170 + ], 171 + ), 172 + ], 173 + ), 174 + ), 175 + ], 176 + ), 177 + ], 178 + ), 179 + ), 180 + ), 181 + ], 182 + ), 183 + ); 184 + } 185 + } 186 + 187 + class _LinkText extends StatelessWidget { 188 + final String text; 189 + final String url; 190 + const _LinkText(this.text, this.url); 191 + 192 + @override 193 + Widget build(BuildContext context) { 194 + return GestureDetector( 195 + onTap: () async { 196 + final uri = Uri.parse(url); 197 + if (await canLaunchUrl(uri)) { 198 + await launchUrl(uri); 199 + } else { 200 + ScaffoldMessenger.of( 201 + context, 202 + ).showSnackBar(SnackBar(content: Text('Could not open link: $url'))); 203 + } 204 + }, 205 + child: Text( 206 + text, 207 + style: const TextStyle( 208 + color: Colors.white, 209 + fontSize: 12, 210 + decoration: TextDecoration.underline, 211 + ), 212 + ), 213 + ); 214 + } 215 + }
-92
lib/screens/splash_page.dart
··· 1 - import 'package:flutter/material.dart'; 2 - import 'package:grain/auth.dart'; 3 - import 'package:grain/widgets/app_button.dart'; 4 - import 'package:grain/widgets/app_image.dart'; 5 - import 'package:grain/widgets/plain_text_field.dart'; 6 - 7 - class SplashPage extends StatefulWidget { 8 - final void Function()? onSignIn; 9 - const SplashPage({super.key, this.onSignIn}); 10 - 11 - @override 12 - State<SplashPage> createState() => _SplashPageState(); 13 - } 14 - 15 - class _SplashPageState extends State<SplashPage> { 16 - final TextEditingController _handleController = TextEditingController(text: ''); 17 - bool _signingIn = false; 18 - 19 - Future<void> _signInWithBluesky(BuildContext context) async { 20 - final handle = _handleController.text.trim(); 21 - 22 - if (handle.isEmpty) return; 23 - setState(() { 24 - _signingIn = true; 25 - }); 26 - 27 - try { 28 - await auth.login(handle); 29 - 30 - if (widget.onSignIn != null) { 31 - widget.onSignIn!(); 32 - } 33 - } finally { 34 - setState(() { 35 - _signingIn = false; 36 - }); 37 - } 38 - } 39 - 40 - @override 41 - Widget build(BuildContext context) { 42 - final theme = Theme.of(context); 43 - return Scaffold( 44 - backgroundColor: theme.scaffoldBackgroundColor, 45 - body: Stack( 46 - fit: StackFit.expand, 47 - children: [ 48 - AppImage( 49 - url: 50 - 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@jpeg', 51 - fit: BoxFit.cover, 52 - ), 53 - Container(color: Colors.black.withOpacity(0.4)), 54 - Center( 55 - child: Column( 56 - mainAxisAlignment: MainAxisAlignment.center, 57 - children: [ 58 - const SizedBox(height: 24), 59 - Padding( 60 - padding: const EdgeInsets.symmetric(horizontal: 16), 61 - child: PlainTextField( 62 - label: '', 63 - controller: _handleController, 64 - hintText: 'Enter your handle', 65 - enabled: !_signingIn, 66 - onChanged: (_) {}, 67 - ), 68 - ), 69 - const SizedBox(height: 12), 70 - Padding( 71 - padding: const EdgeInsets.symmetric(horizontal: 16), 72 - child: SizedBox( 73 - width: double.infinity, 74 - child: AppButton( 75 - label: 'Login', 76 - onPressed: _signingIn ? null : () => _signInWithBluesky(context), 77 - loading: _signingIn, 78 - variant: AppButtonVariant.primary, 79 - height: 44, 80 - fontSize: 15, 81 - borderRadius: 6, 82 - ), 83 - ), 84 - ), 85 - ], 86 - ), 87 - ), 88 - ], 89 - ), 90 - ); 91 - } 92 - }