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.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
68
client_app/lib/firebase_options.dart
Normal file
68
client_app/lib/firebase_options.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user