Phase 2 refinements: Firebase config, dev environment fixes, phase 3 requirement draft
- Integrated Firebase SDK in both Flutter apps (google-services, firebase_options) - Fixed auth flow, API client, and pairing/status blocs for dev environment - Added full Flutter project scaffolds (android, ios, web, etc.) - Added phase 3 chat engine requirement document - Added bugreport zip pattern to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,4 +32,8 @@ class ApiClient {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Response> getStream(String path) async {
|
||||
return _dio.get(path, options: Options(responseType: ResponseType.stream));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
|
||||
@@ -51,6 +53,7 @@ class AuthError extends AuthState {
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final ApiClient apiClient;
|
||||
final _auth = FirebaseAuth.instance;
|
||||
ConfirmationResult? _webConfirmationResult;
|
||||
|
||||
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
|
||||
on<AppStarted>(_onAppStarted);
|
||||
@@ -67,23 +70,50 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
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: (_) {},
|
||||
);
|
||||
|
||||
if (kIsWeb) {
|
||||
try {
|
||||
final confirmationResult = await _auth.signInWithPhoneNumber(event.phone);
|
||||
_webConfirmationResult = confirmationResult;
|
||||
emit(AuthOtpSent('web'));
|
||||
} catch (e) {
|
||||
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
|
||||
}
|
||||
} else {
|
||||
final completer = Completer<void>();
|
||||
await _auth.verifyPhoneNumber(
|
||||
phoneNumber: event.phone,
|
||||
verificationCompleted: (_) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
verificationFailed: (e) {
|
||||
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
codeSent: (verificationId, _) {
|
||||
emit(AuthOtpSent(verificationId));
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
codeAutoRetrievalTimeout: (_) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
);
|
||||
await completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (kIsWeb && _webConfirmationResult != null) {
|
||||
await _webConfirmationResult!.confirm(event.smsCode);
|
||||
} else {
|
||||
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.'));
|
||||
|
||||
@@ -92,11 +92,7 @@ class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
}
|
||||
|
||||
void _listenToSSE() {
|
||||
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
|
||||
dio.get(
|
||||
'/api/mitra/chat-requests/incoming',
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
).then((response) {
|
||||
apiClient.getStream('/api/mitra/chat-requests/incoming').then((response) {
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
_sseSubscription = stream
|
||||
.transform(utf8.decoder)
|
||||
|
||||
@@ -95,17 +95,16 @@ class StatusBloc extends Bloc<StatusEvent, StatusState> {
|
||||
}
|
||||
|
||||
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
|
||||
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/offline');
|
||||
} catch (_) {}
|
||||
_stopHeartbeat();
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
}
|
||||
// Don't auto-offline on pause — heartbeat timeout (45s) handles truly offline mitras.
|
||||
// This allows mitra to stay online when briefly switching apps.
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
|
||||
// Do NOT auto-set online on resume; mitra must explicitly toggle
|
||||
// Resume heartbeat if mitra was online
|
||||
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
add(StatusLoadRequested());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
|
||||
@@ -11,20 +12,63 @@ class OtpScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
final _otpController = TextEditingController();
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(6, (_) => TextEditingController());
|
||||
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otpController.dispose();
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final f in _focusNodes) {
|
||||
f.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _otp => _controllers.map((c) => c.text).join();
|
||||
|
||||
void _onChanged(int index, String value) {
|
||||
if (value.length == 1 && index < 5) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
if (_otp.length == 6) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeyDown(int index, KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
_controllers[index].text.isEmpty &&
|
||||
index > 0) {
|
||||
_controllers[index - 1].clear();
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final otp = _otp;
|
||||
if (otp.length != 6) return;
|
||||
final state = context.read<AuthBloc>().state;
|
||||
final verificationId = state is AuthOtpSent ? state.verificationId : '';
|
||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
||||
}
|
||||
|
||||
@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)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
// Clear fields on error
|
||||
for (final c in _controllers) {
|
||||
c.clear();
|
||||
}
|
||||
_focusNodes[0].requestFocus();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -34,26 +78,47 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
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,
|
||||
Text(
|
||||
'Kode OTP telah dikirim ke ${widget.phone}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(6, (index) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
child: KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent: (event) => _onKeyDown(index, event),
|
||||
child: TextField(
|
||||
controller: _controllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
onChanged: (value) => _onChanged(index, value),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
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));
|
||||
},
|
||||
onPressed: state is AuthLoading ? null : _submit,
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
|
||||
@@ -5,9 +5,48 @@ import '../../core/status/status_bloc.dart';
|
||||
import '../../core/chat/chat_request_bloc.dart';
|
||||
import '../chat/widgets/incoming_request_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Check if there's a pending request that was missed while backgrounded
|
||||
final chatState = context.read<ChatRequestBloc>().state;
|
||||
if (chatState is ChatRequestIncoming) {
|
||||
_showIncomingRequest(chatState.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showIncomingRequest(String sessionId) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isDismissible: false,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<ChatRequestBloc>(),
|
||||
child: IncomingRequestSheet(sessionId: sessionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
@@ -24,14 +63,7 @@ class HomeScreen extends StatelessWidget {
|
||||
BlocListener<ChatRequestBloc, ChatRequestState>(
|
||||
listener: (context, state) {
|
||||
if (state is ChatRequestIncoming) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isDismissible: false,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<ChatRequestBloc>(),
|
||||
child: IncomingRequestSheet(sessionId: state.sessionId),
|
||||
),
|
||||
);
|
||||
_showIncomingRequest(state.sessionId);
|
||||
} else if (state is ChatRequestAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sesi baru diterima!')),
|
||||
|
||||
76
mitra_app/lib/firebase_options.dart
Normal file
76
mitra_app/lib/firebase_options.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
return web;
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
||||
appId: '1:1068156046511:android:f30784f6b0423131b8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
||||
appId: '1:1068156046511:ios:b781f67a57d6db7bb8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
iosBundleId: 'com.halobestie.mitra',
|
||||
);
|
||||
|
||||
static const FirebaseOptions web = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAvDQp6xLOZHSwhaj9Zk3DjcMvQyX0Y7Oc',
|
||||
appId: '1:1068156046511:web:15b173b38aa563ceb8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
authDomain: 'halobestie-clone-dev.firebaseapp.com',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
measurementId: 'G-FK3V0LB3TT',
|
||||
);
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'core/status/status_bloc.dart';
|
||||
@@ -23,6 +24,8 @@ class App extends StatefulWidget {
|
||||
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
late final ApiClient _apiClient;
|
||||
late final AuthBloc _authBloc;
|
||||
late final GoRouter _router;
|
||||
late final StatusBloc _statusBloc;
|
||||
late final ChatRequestBloc _chatRequestBloc;
|
||||
|
||||
@@ -31,6 +34,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_apiClient = ApiClient();
|
||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||
_router = buildRouter(_authBloc);
|
||||
_statusBloc = StatusBloc(apiClient: _apiClient);
|
||||
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
||||
}
|
||||
@@ -38,6 +43,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_authBloc.close();
|
||||
_router.dispose();
|
||||
_statusBloc.close();
|
||||
_chatRequestBloc.close();
|
||||
super.dispose();
|
||||
@@ -56,7 +63,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => AuthBloc(apiClient: _apiClient)..add(AppStarted())),
|
||||
BlocProvider.value(value: _authBloc),
|
||||
BlocProvider.value(value: _statusBloc),
|
||||
BlocProvider.value(value: _chatRequestBloc),
|
||||
RepositoryProvider.value(value: _apiClient),
|
||||
@@ -67,13 +74,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_statusBloc.add(StatusLoadRequested());
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: buildRouter(context.read<AuthBloc>()),
|
||||
);
|
||||
},
|
||||
child: MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: _router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'features/auth/screens/login_screen.dart';
|
||||
@@ -5,14 +7,30 @@ import 'features/auth/screens/otp_screen.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'features/chat/screens/active_sessions_screen.dart';
|
||||
|
||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||
late final StreamSubscription _subscription;
|
||||
|
||||
_BlocRefreshNotifier(AuthBloc bloc) {
|
||||
_subscription = bloc.stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||
state.matchedLocation.startsWith('/otp');
|
||||
|
||||
if (authState is AuthLoading) return null;
|
||||
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
|
||||
if (!isAuthRoute) return '/login';
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user