Phase 1 scaffold: auth for all apps
- Backend: Fastify with two listeners (public + internal), routes, services, DB migration + seed - client_app: Flutter with BLoC, all auth screens (welcome, display name, register, OTP, force-register) - mitra_app: Flutter with BLoC, OTP-only login - control_center: React + Vite, email/password login, mitra/user management, anonymity settings - Docs: phase1 plan, API contract, client app mockup - CLAUDE.md and shared memory for all subprojects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
mitra_app/lib/core/api/api_client.dart
Normal file
30
mitra_app/lib/core/api/api_client.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
class ApiClient {
|
||||
static const String _baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://api.halobestie.com',
|
||||
);
|
||||
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
final token = await user.getIdToken();
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
115
mitra_app/lib/core/auth/auth_bloc.dart
Normal file
115
mitra_app/lib/core/auth/auth_bloc.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
|
||||
// Events
|
||||
abstract class AuthEvent extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AppStarted extends AuthEvent {}
|
||||
class PhoneOtpRequested extends AuthEvent {
|
||||
final String phone;
|
||||
PhoneOtpRequested(this.phone);
|
||||
@override List<Object?> get props => [phone];
|
||||
}
|
||||
class OtpVerified extends AuthEvent {
|
||||
final String verificationId;
|
||||
final String smsCode;
|
||||
OtpVerified(this.verificationId, this.smsCode);
|
||||
@override List<Object?> get props => [verificationId, smsCode];
|
||||
}
|
||||
class LogoutRequested extends AuthEvent {}
|
||||
|
||||
// States
|
||||
abstract class AuthState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AuthInitial extends AuthState {}
|
||||
class AuthLoading extends AuthState {}
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final Map<String, dynamic> profile;
|
||||
AuthAuthenticated(this.profile);
|
||||
@override List<Object?> get props => [profile];
|
||||
}
|
||||
class AuthOtpSent extends AuthState {
|
||||
final String verificationId;
|
||||
AuthOtpSent(this.verificationId);
|
||||
@override List<Object?> get props => [verificationId];
|
||||
}
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
AuthError(this.message);
|
||||
@override List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final ApiClient apiClient;
|
||||
final _auth = FirebaseAuth.instance;
|
||||
|
||||
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
|
||||
on<AppStarted>(_onAppStarted);
|
||||
on<PhoneOtpRequested>(_onPhoneOtpRequested);
|
||||
on<OtpVerified>(_onOtpVerified);
|
||||
on<LogoutRequested>(_onLogout);
|
||||
}
|
||||
|
||||
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
|
||||
if (_auth.currentUser != null) {
|
||||
await _verifyAndEmit(emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
await _auth.verifyPhoneNumber(
|
||||
phoneNumber: event.phone,
|
||||
verificationCompleted: (_) {},
|
||||
verificationFailed: (e) => emit(AuthError('Gagal mengirim OTP. Coba lagi.')),
|
||||
codeSent: (verificationId, _) => emit(AuthOtpSent(verificationId)),
|
||||
codeAutoRetrievalTimeout: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final credential = PhoneAuthProvider.credential(
|
||||
verificationId: event.verificationId,
|
||||
smsCode: event.smsCode,
|
||||
);
|
||||
await _auth.signInWithCredential(credential);
|
||||
await _verifyAndEmit(emit);
|
||||
} catch (e) {
|
||||
emit(AuthError('OTP tidak valid. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
|
||||
await _auth.signOut();
|
||||
emit(AuthInitial());
|
||||
}
|
||||
|
||||
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
|
||||
try {
|
||||
final response = await apiClient.post('/api/mitra/auth/verify');
|
||||
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
|
||||
} on Exception catch (e) {
|
||||
await _auth.signOut();
|
||||
// Surface specific errors from backend
|
||||
final msg = e.toString();
|
||||
if (msg.contains('ACCOUNT_NOT_FOUND')) {
|
||||
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
|
||||
} else if (msg.contains('ACCOUNT_INACTIVE')) {
|
||||
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
|
||||
} else {
|
||||
emit(AuthError('Gagal masuk. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
mitra_app/lib/features/auth/screens/login_screen.dart
Normal file
76
mitra_app/lib/features/auth/screens/login_screen.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
context.push('/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Halo Bestie Mitra',
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
|
||||
},
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
mitra_app/lib/features/auth/screens/otp_screen.dart
Normal file
68
mitra_app/lib/features/auth/screens/otp_screen.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
final String phone;
|
||||
const OtpScreen({super.key, required this.phone});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
final _otpController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otpController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kode OTP',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.length != 6) return;
|
||||
final verificationId = state is AuthOtpSent ? state.verificationId : '';
|
||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
||||
},
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
mitra_app/lib/features/home/home_screen.dart
Normal file
34
mitra_app/lib/features/home/home_screen.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/auth/auth_bloc.dart';
|
||||
|
||||
/// Phase 1 placeholder — will be replaced in Phase 2 with session/chat features.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final displayName = state is AuthAuthenticated
|
||||
? state.profile['display_name'] as String
|
||||
: '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie Mitra'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
32
mitra_app/lib/main.dart
Normal file
32
mitra_app/lib/main.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
runApp(const App());
|
||||
}
|
||||
|
||||
class App extends StatelessWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: buildRouter(context.read<AuthBloc>()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
mitra_app/lib/router.dart
Normal file
25
mitra_app/lib/router.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'features/auth/screens/login_screen.dart';
|
||||
import 'features/auth/screens/otp_screen.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||
state.matchedLocation.startsWith('/otp');
|
||||
|
||||
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
|
||||
if (!isAuthRoute) return '/login';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user