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