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.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
Future<Response> getStream(String path) async {
return _dio.get(path, options: Options(responseType: ResponseType.stream));
}
}

View File

@@ -91,11 +91,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
} else if (customerId != null && displayName != null) {
// Check anonymity config
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
// Anonymous Firebase user — restore anonymous state
try {
final config = await apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
@@ -107,6 +106,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
} catch (_) {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} else if (currentUser != null && !currentUser.isAnonymous) {
// Fully registered Firebase user
await _verifyAndEmit(emit);
} else {
emit(AuthInitial());
}
@@ -115,6 +117,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
Future<void> _onAnonymousLogin(AnonymousLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
// Sign in anonymously with Firebase to get a real JWT
await _auth.signInAnonymously();
// Create/get customer record on backend linked to this Firebase UID
final response = await apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': event.displayName},

View File

@@ -77,6 +77,10 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
// Reset to initial so BlocListener can detect new errors
if (state is! PairingInitial) {
emit(PairingInitial());
}
try {
final response = await apiClient.post('/api/client/chat/request');
final data = response['data'] as Map<String, dynamic>;
@@ -104,12 +108,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
}
void _listenToSSE(String sessionId) {
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
// SSE endpoint — use responseType stream
dio.get(
'/api/client/chat/request/$sessionId/status',
options: Options(responseType: ResponseType.stream),
).then((response) {
apiClient.getStream('/api/client/chat/request/$sessionId/status').then((response) {
final stream = response.data.stream as Stream<List<int>>;
_sseSubscription = stream
.transform(utf8.decoder)

View File

@@ -0,0 +1,68 @@
// 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) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
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:ba6e699216de1c50b8185a',
messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
appId: '1:1068156046511:ios:c7786cedb9101d34b8185a',
messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
iosBundleId: 'com.halobestie.client.clientApp',
);
}

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/pairing/pairing_bloc.dart';
@@ -13,25 +14,43 @@ void main() async {
runApp(const App());
}
class App extends StatelessWidget {
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
final _apiClient = ApiClient();
late final AuthBloc _authBloc;
late final GoRouter _router;
@override
void initState() {
super.initState();
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
_router = buildRouter(_authBloc);
}
@override
void dispose() {
_authBloc.close();
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final apiClient = ApiClient();
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(apiClient: apiClient)..add(AppStarted())),
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
RepositoryProvider.value(value: apiClient),
BlocProvider.value(value: _authBloc),
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
RepositoryProvider.value(value: _apiClient),
],
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp.router(
title: 'Halo Bestie',
routerConfig: buildRouter(context.read<AuthBloc>()),
);
},
child: MaterialApp.router(
title: 'Halo Bestie',
routerConfig: _router,
),
);
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart';
@@ -12,14 +13,33 @@ import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart';
import 'features/chat/screens/session_active_screen.dart';
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
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: '/welcome',
refreshListenable: _BlocRefreshNotifier(authBloc),
redirect: (context, state) {
final authState = authBloc.state;
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome';
// Don't redirect while loading — stay on current screen
if (authState is AuthLoading) return null;
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
return isAuthRoute ? '/home' : null;
}