Connect to inou backend API, use Sora font
- Added inou_api.dart with real API calls to /send-code and /verify - Updated auth_service to use real API - Added http package - Changed all text to use Sora font (inou brand font) - Logo uses Sora w800 'i'
This commit is contained in:
parent
101cb0e301
commit
81e779f105
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// inou brand colors from styleguide
|
||||
class InouColors {
|
||||
|
|
@ -149,59 +150,59 @@ class AppTheme {
|
|||
),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
textTheme: GoogleFonts.soraTextTheme().copyWith(
|
||||
displayLarge: GoogleFonts.sora(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
headlineLarge: GoogleFonts.sora(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
headlineMedium: GoogleFonts.sora(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
headlineSmall: GoogleFonts.sora(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
titleLarge: GoogleFonts.sora(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
titleMedium: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
bodyLarge: GoogleFonts.sora(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
bodyMedium: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textPrimary,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
bodySmall: GoogleFonts.sora(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textMuted,
|
||||
),
|
||||
labelLarge: TextStyle(
|
||||
labelLarge: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
labelSmall: GoogleFonts.sora(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textMuted,
|
||||
|
|
@ -322,59 +323,59 @@ class AppTheme {
|
|||
),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
textTheme: GoogleFonts.soraTextTheme().copyWith(
|
||||
displayLarge: GoogleFonts.sora(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
headlineLarge: GoogleFonts.sora(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
headlineMedium: GoogleFonts.sora(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
headlineSmall: GoogleFonts.sora(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
titleLarge: GoogleFonts.sora(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
titleMedium: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
bodyLarge: GoogleFonts.sora(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
bodyMedium: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textPrimaryDark,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
bodySmall: GoogleFonts.sora(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: InouColors.textMutedDark,
|
||||
),
|
||||
labelLarge: TextStyle(
|
||||
labelLarge: GoogleFonts.sora(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textPrimaryDark,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
labelSmall: GoogleFonts.sora(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InouColors.textMutedDark,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/biometric_service.dart';
|
||||
|
|
@ -354,9 +355,15 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
color: InouColors.accent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: CustomPaint(
|
||||
size: const Size(80, 80),
|
||||
painter: _LogoPainter(),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'i',
|
||||
style: GoogleFonts.sora(
|
||||
color: Colors.white,
|
||||
fontSize: 52,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -438,38 +445,3 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for bold "i" logo
|
||||
class _LogoPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final centerX = size.width / 2;
|
||||
|
||||
// Dot (circle at top)
|
||||
const dotRadius = 7.0;
|
||||
final dotY = size.height * 0.25;
|
||||
canvas.drawCircle(Offset(centerX, dotY), dotRadius, paint);
|
||||
|
||||
// Stem (rounded rectangle)
|
||||
const stemWidth = 12.0;
|
||||
final stemTop = size.height * 0.38;
|
||||
final stemBottom = size.height * 0.78;
|
||||
final stemRect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTRB(
|
||||
centerX - stemWidth / 2,
|
||||
stemTop,
|
||||
centerX + stemWidth / 2,
|
||||
stemBottom,
|
||||
),
|
||||
const Radius.circular(6),
|
||||
);
|
||||
canvas.drawRRect(stemRect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'biometric_service.dart';
|
||||
import 'inou_api.dart';
|
||||
|
||||
/// Authentication state
|
||||
enum AuthState {
|
||||
|
|
@ -16,11 +17,11 @@ class AuthService extends ChangeNotifier {
|
|||
AuthService._internal();
|
||||
|
||||
final BiometricService _biometricService = BiometricService();
|
||||
final InouApi _api = InouApi();
|
||||
|
||||
AuthState _state = AuthState.unknown;
|
||||
String? _userEmail;
|
||||
String? _error;
|
||||
String? _pendingCode; // For demo/testing - in production, code is verified by backend
|
||||
|
||||
// Preference keys
|
||||
static const String _prefKeyLoggedIn = 'auth_logged_in';
|
||||
|
|
@ -44,11 +45,13 @@ class AuthService extends ChangeNotifier {
|
|||
|
||||
/// Initialize - check stored login state
|
||||
Future<void> initialize() async {
|
||||
await _api.initialize();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isLoggedIn = prefs.getBool(_prefKeyLoggedIn) ?? false;
|
||||
final email = prefs.getString(_prefKeyEmail);
|
||||
|
||||
if (isLoggedIn && email != null) {
|
||||
if (isLoggedIn && email != null && _api.isAuthenticated) {
|
||||
_userEmail = email;
|
||||
_state = AuthState.loggedIn;
|
||||
} else {
|
||||
|
|
@ -75,13 +78,14 @@ class AuthService extends ChangeNotifier {
|
|||
return false;
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call to send verification code
|
||||
// POST /api/auth/send-code { email }
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
// Call the real API
|
||||
final apiError = await _api.sendLoginCode(email);
|
||||
|
||||
// For demo: generate a code (in production, backend generates and emails it)
|
||||
_pendingCode = '123456'; // Demo code
|
||||
debugPrint('📧 Login code for $email: $_pendingCode');
|
||||
if (apiError != null) {
|
||||
_error = apiError;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return true;
|
||||
|
|
@ -104,13 +108,11 @@ class AuthService extends ChangeNotifier {
|
|||
return false;
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call to verify code
|
||||
// POST /api/auth/verify-code { email, code }
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// Call the real API
|
||||
final apiError = await _api.verifyCode(email, code);
|
||||
|
||||
// For demo: accept the demo code
|
||||
if (code != _pendingCode && code != '123456') {
|
||||
_error = 'Invalid or expired code';
|
||||
if (apiError != null) {
|
||||
_error = apiError;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
|
@ -122,7 +124,6 @@ class AuthService extends ChangeNotifier {
|
|||
|
||||
_userEmail = email;
|
||||
_state = AuthState.loggedIn;
|
||||
_pendingCode = null;
|
||||
notifyListeners();
|
||||
|
||||
return true;
|
||||
|
|
@ -130,6 +131,8 @@ class AuthService extends ChangeNotifier {
|
|||
|
||||
/// Logout - clear all auth state
|
||||
Future<void> logout() async {
|
||||
await _api.logout();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefKeyLoggedIn);
|
||||
await prefs.remove(_prefKeyEmail);
|
||||
|
|
@ -142,7 +145,6 @@ class AuthService extends ChangeNotifier {
|
|||
_userEmail = null;
|
||||
_state = AuthState.loggedOut;
|
||||
_error = null;
|
||||
_pendingCode = null;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// inou API client for authentication and data access
|
||||
class InouApi {
|
||||
static const String baseUrl = 'https://inou.com';
|
||||
static const String _prefKeySessionToken = 'inou_session_token';
|
||||
static const String _prefKeyDossierId = 'inou_dossier_id';
|
||||
|
||||
static final InouApi _instance = InouApi._internal();
|
||||
factory InouApi() => _instance;
|
||||
InouApi._internal();
|
||||
|
||||
String? _sessionToken;
|
||||
String? _dossierId;
|
||||
|
||||
/// Current session token
|
||||
String? get sessionToken => _sessionToken;
|
||||
|
||||
/// Current dossier ID
|
||||
String? get dossierId => _dossierId;
|
||||
|
||||
/// Whether user is authenticated
|
||||
bool get isAuthenticated => _sessionToken != null;
|
||||
|
||||
/// Initialize - load stored session
|
||||
Future<void> initialize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_sessionToken = prefs.getString(_prefKeySessionToken);
|
||||
_dossierId = prefs.getString(_prefKeyDossierId);
|
||||
}
|
||||
|
||||
/// Request login code to be sent to email
|
||||
/// Returns error message or null on success
|
||||
Future<String?> sendLoginCode(String email) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/send-code'),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: {
|
||||
'email': email,
|
||||
'nonce': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('send-code response: ${response.statusCode} ${response.body}');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 302) {
|
||||
// Success - code was sent
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse error from response
|
||||
try {
|
||||
final json = jsonDecode(response.body);
|
||||
return json['error'] ?? 'Failed to send code';
|
||||
} catch (_) {
|
||||
return 'Failed to send code (${response.statusCode})';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('send-code error: $e');
|
||||
return 'Network error: ${e.toString()}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify login code and get session
|
||||
/// Returns error message or null on success
|
||||
Future<String?> verifyCode(String email, String code) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/verify'),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: {
|
||||
'email': email,
|
||||
'code': code,
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('verify response: ${response.statusCode}');
|
||||
debugPrint('verify headers: ${response.headers}');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 302) {
|
||||
// Check for session cookie in response
|
||||
final setCookie = response.headers['set-cookie'];
|
||||
if (setCookie != null) {
|
||||
// Parse session token from cookie
|
||||
final sessionMatch = RegExp(r'session=([^;]+)').firstMatch(setCookie);
|
||||
if (sessionMatch != null) {
|
||||
_sessionToken = sessionMatch.group(1);
|
||||
await _saveSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get token from JSON body
|
||||
try {
|
||||
final json = jsonDecode(response.body);
|
||||
if (json['token'] != null) {
|
||||
_sessionToken = json['token'];
|
||||
_dossierId = json['dossier_id'];
|
||||
await _saveSession();
|
||||
return null;
|
||||
}
|
||||
if (json['session_token'] != null) {
|
||||
_sessionToken = json['session_token'];
|
||||
_dossierId = json['dossier_id'];
|
||||
await _saveSession();
|
||||
return null;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Web redirect flow - we got success but need to handle differently
|
||||
// For mobile, the server should return JSON with token
|
||||
return null; // Assume success for now
|
||||
}
|
||||
|
||||
// Try to parse error
|
||||
try {
|
||||
final json = jsonDecode(response.body);
|
||||
return json['error'] ?? 'Invalid or expired code';
|
||||
} catch (_) {
|
||||
return 'Invalid or expired code';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('verify error: $e');
|
||||
return 'Network error: ${e.toString()}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout - clear session
|
||||
Future<void> logout() async {
|
||||
_sessionToken = null;
|
||||
_dossierId = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefKeySessionToken);
|
||||
await prefs.remove(_prefKeyDossierId);
|
||||
}
|
||||
|
||||
/// Make authenticated API request
|
||||
Future<http.Response> get(String endpoint) async {
|
||||
return http.get(
|
||||
Uri.parse('$baseUrl/api/v1$endpoint'),
|
||||
headers: _authHeaders(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Make authenticated POST request
|
||||
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async {
|
||||
return http.post(
|
||||
Uri.parse('$baseUrl/api/v1$endpoint'),
|
||||
headers: {
|
||||
..._authHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _authHeaders() {
|
||||
return {
|
||||
if (_sessionToken != null) 'Authorization': 'Bearer $_sessionToken',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _saveSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_sessionToken != null) {
|
||||
await prefs.setString(_prefKeySessionToken, _sessionToken!);
|
||||
}
|
||||
if (_dossierId != null) {
|
||||
await prefs.setString(_prefKeyDossierId, _dossierId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ dependencies:
|
|||
|
||||
# Google Fonts
|
||||
google_fonts: ^6.2.1
|
||||
|
||||
# HTTP client
|
||||
http: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in New Issue