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:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

View File

@@ -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.';
}
}
}