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:
2026-04-07 19:16:34 +08:00
parent d668112edd
commit 844d7234e6
229 changed files with 10439 additions and 102 deletions

View File

@@ -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));
}
}

View File

@@ -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.'));

View File

@@ -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)

View File

@@ -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());
}

View File

@@ -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'),

View File

@@ -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!')),

View 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',
);
}

View File

@@ -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,
),
),
);

View File

@@ -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;