Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home
- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette, Bricolage display, Poppins body, JetBrainsMono). - Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with +62 chip, leading-zero/62 normalization, allow '+' in input. - Build S3b OTP verification (6-digit, 60s resend timer, attempts hint, Focus(canRequestFocus:false) for maestro inputText compat) with full error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED, WRONG_FLOW, ACCOUNT_INACTIVE). - Add AccountInactive terminal screen for is_active=false mitras. - Typed MitraAuthError with Indonesian-first localized messages + retryAfterSeconds passthrough. - Rebuild home_screen.dart to match figma BestieHome (greeting + status card + Ganti Status CTA + Pengingat + 2-tile dark grid). - Backend: POST /internal/_test/seed-mitra (idempotent) and PATCH /internal/mitras/:id (display_name update). - Control center: inline Edit Nama on mitras row + expandable inline log table under clicked mitra (vs old below-table panel). - 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy path, account inactive, phone-format normalization, and the back-to-S3a regression. All green. Plan + memory documented in: - requirement/phase4-mitra-prehome-plan.md - requirement/flow_mitra.md / flow_mitra.mermaid.md §A Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,83 @@ class MitraAuthOtpSentData extends MitraAuthData {
|
||||
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
|
||||
}
|
||||
|
||||
/// Structured error emitted by [MitraAuth] so screens can branch on
|
||||
/// the backend error code (popup vs snackbar vs full-screen state)
|
||||
/// instead of regex-matching message strings.
|
||||
///
|
||||
/// `toString()` returns [message] so any `Text(error.toString())` call
|
||||
/// site keeps working during the migration.
|
||||
class MitraAuthError implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
final int? retryAfterSeconds;
|
||||
|
||||
const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
String _localizedRequestMessage(String? code, int? retryAfter) {
|
||||
switch (code) {
|
||||
case 'PHONE_INVALID':
|
||||
return 'Nomor HP tidak valid.';
|
||||
case 'OTP_COOLDOWN':
|
||||
return retryAfter != null
|
||||
? 'Tunggu ${retryAfter}s sebelum minta OTP lagi.'
|
||||
: 'Tunggu sebentar sebelum minta OTP lagi.';
|
||||
case 'OTP_RATE_LIMIT_PHONE':
|
||||
return 'Terlalu banyak permintaan OTP untuk nomor ini.';
|
||||
case 'OTP_RATE_LIMIT_IP':
|
||||
return 'Terlalu banyak permintaan OTP dari jaringan ini.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedVerifyMessage(String? code) {
|
||||
switch (code) {
|
||||
case 'CODE_INVALID':
|
||||
return 'Kode harus 6 digit angka.';
|
||||
case 'CODE_MISMATCH':
|
||||
return 'Kode OTP salah.';
|
||||
case 'OTP_EXPIRED':
|
||||
return 'Kode OTP kedaluwarsa. Minta kode baru.';
|
||||
case 'OTP_USED':
|
||||
return 'Kode OTP sudah digunakan.';
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
return 'Terlalu banyak percobaan. Minta kode baru.';
|
||||
case 'WRONG_FLOW':
|
||||
return 'OTP tidak valid untuk login mitra.';
|
||||
case 'ACCOUNT_INACTIVE':
|
||||
return 'Akun tidak aktif. Hubungi koordinator.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
MitraAuthError _buildError(
|
||||
DioException e,
|
||||
String Function(String? code, int? retryAfter) localize,
|
||||
String unknownMessage,
|
||||
) {
|
||||
final err = e.response?.data is Map ? e.response!.data['error'] : null;
|
||||
final code = err is Map ? err['code'] as String? : null;
|
||||
final serverMessage = err is Map ? err['message'] as String? : null;
|
||||
final details = err is Map ? err['details'] : null;
|
||||
final retryAfter = details is Map ? details['retry_after_seconds'] as int? : null;
|
||||
|
||||
// Prefer Indonesian localized copy for codes we know. Fall back to the
|
||||
// server message (which is English) only when the code is unrecognized,
|
||||
// and to a generic Indonesian unknown-message when neither is available.
|
||||
final localized = localize(code, retryAfter);
|
||||
final message = localized.isNotEmpty
|
||||
? localized
|
||||
: (serverMessage ?? unknownMessage);
|
||||
|
||||
return MitraAuthError(code ?? 'UNKNOWN', message, retryAfterSeconds: retryAfter);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class MitraAuth extends _$MitraAuth {
|
||||
final _storage = TokenStorage();
|
||||
@@ -113,9 +190,15 @@ class MitraAuth extends _$MitraAuth {
|
||||
channelUsed: data['channel_used'] as String?,
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
|
||||
state = AsyncError(
|
||||
_buildError(e, _localizedRequestMessage, 'Gagal mengirim OTP. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const MitraAuthError('UNKNOWN', 'Gagal mengirim OTP. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +219,15 @@ class MitraAuth extends _$MitraAuth {
|
||||
_bridge.setAccessToken(accessToken);
|
||||
state = AsyncData(MitraAuthAuthenticatedData(profile));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
|
||||
state = AsyncError(
|
||||
_buildError(e, (code, _) => _localizedVerifyMessage(code), 'Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const MitraAuthError('UNKNOWN', 'Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,40 +241,4 @@ class MitraAuth extends _$MitraAuth {
|
||||
state = const AsyncData(MitraAuthInitialData());
|
||||
}
|
||||
|
||||
String _otpRequestMessage(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'PHONE_INVALID':
|
||||
return 'Nomor HP tidak valid.';
|
||||
case 'OTP_COOLDOWN':
|
||||
return e.response?.data?['error']?['message'] as String? ??
|
||||
'Tunggu sebentar sebelum minta OTP lagi.';
|
||||
case 'OTP_RATE_LIMIT_PHONE':
|
||||
case 'OTP_RATE_LIMIT_IP':
|
||||
return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
|
||||
default:
|
||||
return 'Gagal mengirim OTP. Coba lagi.';
|
||||
}
|
||||
}
|
||||
|
||||
String _otpVerifyMessage(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'ACCOUNT_INACTIVE':
|
||||
return 'Akun tidak aktif. Hubungi administrator.';
|
||||
case 'WRONG_FLOW':
|
||||
return 'OTP tidak valid untuk login mitra.';
|
||||
case 'CODE_MISMATCH':
|
||||
case 'CODE_INVALID':
|
||||
return 'Kode OTP salah.';
|
||||
case 'OTP_EXPIRED':
|
||||
return 'Kode OTP kedaluwarsa. Minta kode baru.';
|
||||
case 'OTP_USED':
|
||||
return 'Kode OTP sudah digunakan.';
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
return 'Terlalu banyak percobaan. Minta kode baru.';
|
||||
default:
|
||||
return 'Gagal verifikasi. Coba lagi.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user