Fix auth: auto-create customer, display name flow, OTP auto-verify

- Backend: getOrCreateCustomer with phone fallback for re-login
- Backend: PATCH /api/client/auth/profile for display name update
- Client app: AuthNeedsDisplayNameData state + SetDisplayNameScreen
- Client app: ApiClient.patch method
- Both apps: handle verificationCompleted for auto-verify (test numbers)
- Both apps: skip credential sign-in if already auto-verified
- Remove debug prints from mitra auth + OTP screens
- Fix ChatRequestNotifier.startListening skips when accepting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:22:28 +08:00
parent 2e80434e9b
commit 212e1e8ac6
9 changed files with 178 additions and 11 deletions

View File

@@ -32,4 +32,9 @@ class ApiClient {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.patch(path, data: data);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -40,6 +40,11 @@ class AuthForceRegisterData extends AuthData {
const AuthForceRegisterData({required this.customerId, required this.displayName});
}
class AuthNeedsDisplayNameData extends AuthData {
final Map<String, dynamic> profile;
const AuthNeedsDisplayNameData(this.profile);
}
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
FirebaseAuth get _auth => FirebaseAuth.instance;
@@ -132,7 +137,11 @@ class Auth extends _$Auth {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (_) {
verificationCompleted: (credential) async {
try {
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (_) {}
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
@@ -153,11 +162,14 @@ class Auth extends _$Auth {
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
// If already signed in via auto-verification, skip credential sign-in
if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
}
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
@@ -191,8 +203,24 @@ class Auth extends _$Auth {
state = const AsyncData(AuthInitialData());
}
Future<void> setDisplayName(String displayName) async {
state = const AsyncLoading();
try {
final response = await _apiClient.patch('/api/client/auth/profile', data: {
'display_name': displayName,
});
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
} catch (e) {
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
}
}
Future<AuthData> _verifyAndReturn() async {
final response = await _apiClient.post('/api/client/auth/verify');
return AuthAuthenticatedData(response['data'] as Map<String, dynamic>);
final profile = response['data'] as Map<String, dynamic>;
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
return AuthNeedsDisplayNameData(profile);
}
return AuthAuthenticatedData(profile);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_notifier.dart';
class SetDisplayNameScreen extends ConsumerStatefulWidget {
const SetDisplayNameScreen({super.key});
@override
ConsumerState<SetDisplayNameScreen> createState() => _SetDisplayNameScreenState();
}
class _SetDisplayNameScreenState extends ConsumerState<SetDisplayNameScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
ref.read(authProvider.notifier).setDisplayName(name);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh Bestie kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
],
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/auth/screens/set_display_name_screen.dart';
import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
import 'features/chat/screens/searching_screen.dart';
@@ -55,6 +56,7 @@ GoRouter buildRouter(Ref ref) {
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
return (isSplash || isAuthRoute) ? '/home' : null;
}
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
if (data is AuthForceRegisterData) return '/auth/force-register';
if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome';
@@ -66,6 +68,7 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),