forked from baron/baron-sso
Merge pull request 'feature/uf-enhance' (#429) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#429
This commit is contained in:
@@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
|
||||
# OIDC 클라이언트 callback (콤마 구분)
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
|
||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
||||
CSRF_COOKIE_SECRET=localcsrf123
|
||||
|
||||
# AdminFront OIDC 설정
|
||||
ADMINFRONT_URL=http://localhost:5173
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||
|
||||
# DevFront OIDC 설정
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
DEVFRONT_URL=http://localhost:5174
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
|
||||
@@ -120,6 +120,8 @@ jobs:
|
||||
|
||||
# Frontend OIDC configs for Staging
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
ADMINFRONT_URL=http://172.16.10.176:5173
|
||||
DEVFRONT_URL=http://172.16.10.176:5174
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
|
||||
14
Makefile
14
Makefile
@@ -117,6 +117,10 @@ endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
CODE_CHECK_TEST_JOBS ?= 1
|
||||
PLAYWRIGHT_WORKERS ?= 1
|
||||
FLUTTER_TEST_CONCURRENCY ?= 1
|
||||
|
||||
code-check: code-check-lint code-check-test-jobs
|
||||
@echo "code-check complete."
|
||||
|
||||
@@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c
|
||||
|
||||
code-check-test-jobs:
|
||||
@echo "==> run CI-equivalent test jobs (parallel)"
|
||||
@$(MAKE) --no-print-directory -j5 --output-sync=target \
|
||||
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
|
||||
code-check-backend-tests \
|
||||
code-check-userfront-tests \
|
||||
code-check-userfront-e2e-tests \
|
||||
@@ -203,11 +207,11 @@ code-check-userfront-tests:
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
fi; \
|
||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||
cd "$$tmp_dir/userfront" && flutter test
|
||||
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
|
||||
|
||||
code-check-adminfront-tests:
|
||||
@echo "==> adminfront tests"
|
||||
./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
code-check-devfront-tests:
|
||||
@echo "==> devfront tests"
|
||||
@@ -219,7 +223,7 @@ code-check-devfront-tests:
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && npm test) || status=$$?; \
|
||||
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
@@ -267,7 +271,7 @@ code-check-userfront-e2e-tests:
|
||||
if [ $$status -eq 0 ]; then \
|
||||
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
||||
echo "==> userfront-e2e using PORT=$$port"; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
@@ -24,7 +28,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
name = clientID
|
||||
}
|
||||
|
||||
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
clientURL := resolveLinkedRPURL(
|
||||
client.ClientID,
|
||||
client.ClientURI,
|
||||
client.RedirectURIs,
|
||||
)
|
||||
|
||||
lastAuth := time.Time{}
|
||||
if session.AuthenticatedAt != nil {
|
||||
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
name = client.ClientID
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
clientURL := resolveLinkedRPURL(
|
||||
client.ClientID,
|
||||
client.ClientURI,
|
||||
client.RedirectURIs,
|
||||
)
|
||||
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
@@ -5423,6 +5420,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
|
||||
switch strings.TrimSpace(clientID) {
|
||||
case "adminfront":
|
||||
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
case "devfront":
|
||||
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(clientURI)
|
||||
if clientURL != "" {
|
||||
return clientURL
|
||||
}
|
||||
|
||||
if len(redirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
|
||||
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeScopes(current []string, next []string) []string {
|
||||
if len(next) == 0 {
|
||||
return current
|
||||
|
||||
@@ -211,6 +211,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id adminfront \
|
||||
--name "AdminFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
@@ -220,6 +221,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id devfront \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
@@ -20,7 +24,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -282,6 +282,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id adminfront \
|
||||
--name "AdminFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
@@ -291,6 +292,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id devfront \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -676,6 +676,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
||||
tos_full = "서비스 이용약관 전문..."
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
|
||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -1709,6 +1714,7 @@ title = "회원가입"
|
||||
[ui.userfront.signup.agreement]
|
||||
all = "모두 동의합니다"
|
||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||
required = "필수"
|
||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -1742,4 +1748,3 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -676,6 +676,11 @@ privacy_full = ""
|
||||
tos_full = ""
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = ""
|
||||
description = ""
|
||||
privacy_summary = ""
|
||||
progress = ""
|
||||
tos_summary = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -1709,6 +1714,7 @@ title = ""
|
||||
[ui.userfront.signup.agreement]
|
||||
all = ""
|
||||
privacy_title = ""
|
||||
required = ""
|
||||
tos_title = ""
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -1742,4 +1748,3 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
|
||||
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
|
||||
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
|
||||
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}"
|
||||
ADMINFRONT_URL="${ADMINFRONT_URL:-http://172.16.10.176:5173}"
|
||||
DEVFRONT_URL="${DEVFRONT_URL:-http://172.16.10.176:5174}"
|
||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
|
||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
|
||||
|
||||
declare -a WARNINGS=()
|
||||
@@ -382,12 +384,21 @@ run_validation() {
|
||||
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
|
||||
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
|
||||
validate_dotenv_line_safety "KRATOS_UI_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_URL"
|
||||
validate_dotenv_line_safety "DEVFRONT_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
|
||||
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
|
||||
|
||||
if [[ -n "$ADMINFRONT_URL" ]]; then
|
||||
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
|
||||
fi
|
||||
if [[ -n "$DEVFRONT_URL" ]]; then
|
||||
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
|
||||
fi
|
||||
|
||||
collect_values
|
||||
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
|
||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}"
|
||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
|
||||
validate_gateway_mapping
|
||||
build_allowed_return_urls
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI;
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
|
||||
@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await page.unroute('**/api/v1/**');
|
||||
});
|
||||
|
||||
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
expect(state.putBodies[0]?.department).toBe('QA-Updated');
|
||||
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
expect(state.department).toBe('QA');
|
||||
});
|
||||
|
||||
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
await page.reload();
|
||||
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||
|
||||
expect(state.putBodies[0]?.department).toBe('QA-1');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
||||
tos_full = "서비스 이용약관 전문..."
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -583,6 +588,7 @@ title = "회원가입"
|
||||
[ui.userfront.signup.agreement]
|
||||
all = "모두 동의합니다"
|
||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||
required = "필수"
|
||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -616,4 +622,3 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -277,6 +277,11 @@ privacy_full = ""
|
||||
tos_full = ""
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = ""
|
||||
description = ""
|
||||
privacy_summary = ""
|
||||
progress = ""
|
||||
tos_summary = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -583,6 +588,7 @@ title = ""
|
||||
[ui.userfront.signup.agreement]
|
||||
all = ""
|
||||
privacy_title = ""
|
||||
required = ""
|
||||
tos_title = ""
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -616,4 +622,3 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
234
userfront/lib/core/ui/toast_service.dart
Normal file
234
userfront/lib/core/ui/toast_service.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ToastType { success, error, info }
|
||||
|
||||
class _ToastItem {
|
||||
const _ToastItem({
|
||||
required this.id,
|
||||
required this.message,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String message;
|
||||
final ToastType type;
|
||||
}
|
||||
|
||||
class ToastService {
|
||||
static const Duration _displayDuration = Duration(milliseconds: 3000);
|
||||
static final ValueNotifier<List<_ToastItem>> _toasts =
|
||||
ValueNotifier<List<_ToastItem>>(<_ToastItem>[]);
|
||||
|
||||
static void success(String message) {
|
||||
show(message, type: ToastType.success);
|
||||
}
|
||||
|
||||
static void error(String message) {
|
||||
show(message, type: ToastType.error);
|
||||
}
|
||||
|
||||
static void info(String message) {
|
||||
show(message, type: ToastType.info);
|
||||
}
|
||||
|
||||
static void show(String message, {ToastType type = ToastType.success}) {
|
||||
final trimmed = message.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final item = _ToastItem(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}',
|
||||
message: trimmed,
|
||||
type: type,
|
||||
);
|
||||
|
||||
_toasts.value = [..._toasts.value, item];
|
||||
|
||||
unawaited(
|
||||
Future<void>.delayed(_displayDuration, () {
|
||||
_remove(item.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static void _remove(String id) {
|
||||
final next = _toasts.value.where((toast) => toast.id != id).toList();
|
||||
if (next.length == _toasts.value.length) {
|
||||
return;
|
||||
}
|
||||
_toasts.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
class ToastViewport extends StatelessWidget {
|
||||
const ToastViewport({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
ignoring: true,
|
||||
child: SafeArea(
|
||||
child: ValueListenableBuilder<List<_ToastItem>>(
|
||||
valueListenable: ToastService._toasts,
|
||||
builder: (context, toasts, _) {
|
||||
if (toasts.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final media = MediaQuery.of(context);
|
||||
final width = math.min(320.0, media.size.width - 32);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16, bottom: 16),
|
||||
child: SizedBox(
|
||||
width: width > 0 ? width : 320,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final toast in toasts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _ToastCard(item: toast),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastCard extends StatefulWidget {
|
||||
const _ToastCard({required this.item});
|
||||
|
||||
final _ToastItem item;
|
||||
|
||||
@override
|
||||
State<_ToastCard> createState() => _ToastCardState();
|
||||
}
|
||||
|
||||
class _ToastCardState extends State<_ToastCard> {
|
||||
bool _visible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_visible = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = _toastColorScheme(widget.item.type);
|
||||
final icon = _toastIcon(widget.item.type);
|
||||
|
||||
return AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
offset: _visible ? Offset.zero : const Offset(1, 0),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
opacity: _visible ? 1 : 0,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: scheme.border),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x26000000),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: scheme.foreground),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.item.message,
|
||||
style: TextStyle(
|
||||
color: scheme.foreground,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ToastColorScheme _toastColorScheme(ToastType type) {
|
||||
switch (type) {
|
||||
case ToastType.success:
|
||||
return const _ToastColorScheme(
|
||||
background: Color(0xFFECFDF5),
|
||||
border: Color(0xFFA7F3D0),
|
||||
foreground: Color(0xFF065F46),
|
||||
);
|
||||
case ToastType.error:
|
||||
return const _ToastColorScheme(
|
||||
background: Color(0xFFFFF1F2),
|
||||
border: Color(0xFFFDA4AF),
|
||||
foreground: Color(0xFF9F1239),
|
||||
);
|
||||
case ToastType.info:
|
||||
return const _ToastColorScheme(
|
||||
background: Color(0xFFEFF6FF),
|
||||
border: Color(0xFFBFDBFE),
|
||||
foreground: Color(0xFF1E40AF),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _toastIcon(ToastType type) {
|
||||
switch (type) {
|
||||
case ToastType.success:
|
||||
return Icons.check_circle_outline;
|
||||
case ToastType.error:
|
||||
return Icons.error_outline;
|
||||
case ToastType.info:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastColorScheme {
|
||||
const _ToastColorScheme({
|
||||
required this.background,
|
||||
required this.border,
|
||||
required this.foreground,
|
||||
});
|
||||
|
||||
final Color background;
|
||||
final Color border;
|
||||
final Color foreground;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class CreateUserScreen extends StatefulWidget {
|
||||
const CreateUserScreen({super.key});
|
||||
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password. Access Denied.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
ToastService.error('Invalid Password. Access Denied.');
|
||||
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||
}
|
||||
}
|
||||
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('User created successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success('User created successfully!');
|
||||
_formKey.currentState!.reset();
|
||||
_loginIdController.clear();
|
||||
_emailController.clear();
|
||||
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error('Error: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class UserManagementScreen extends StatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
@@ -108,12 +109,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
ToastService.error('Invalid Password');
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
}
|
||||
@@ -343,16 +339,12 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
// --- UI Helpers ---
|
||||
void _showError(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
||||
ToastService.error(msg);
|
||||
}
|
||||
|
||||
void _showSuccess(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
||||
ToastService.success(msg);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
@@ -187,16 +188,11 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback:
|
||||
'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -237,10 +233,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientName =
|
||||
_consentInfo?['client']?['client_name'] ??
|
||||
tr('msg.userfront.consent.client_unknown');
|
||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||
? clientRawName
|
||||
: (clientId != '-'
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
@@ -419,7 +418,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
)
|
||||
: Text(
|
||||
tr('ui.userfront.consent.accept'),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
@@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.forgot.sent')),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
bool _parseBoolParam(String? value) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../domain/cookie_session_policy.dart';
|
||||
import '../domain/login_link_route_policy.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../../../core/services/web_window.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
final String? verificationToken;
|
||||
@@ -1153,9 +1154,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
try {
|
||||
AuthProxyService.logError(message);
|
||||
} catch (e) {
|
||||
@@ -1165,9 +1164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _showInfo(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
||||
);
|
||||
ToastService.success(message);
|
||||
}
|
||||
|
||||
void _logTokenDetails(String jwt) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
@@ -23,15 +24,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_required',
|
||||
fallback: '카메라 권한이 필요합니다.',
|
||||
),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.reset.success')),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/widgets/language_selector.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../domain/dashboard_providers.dart';
|
||||
import '../domain/models.dart' hide LinkedRp;
|
||||
@@ -104,14 +105,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
try {
|
||||
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.success',
|
||||
params: {'app': appName},
|
||||
),
|
||||
),
|
||||
ToastService.success(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.success',
|
||||
params: {'app': appName},
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
@@ -121,15 +118,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.error',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -547,12 +537,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -626,12 +612,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1280,7 +1262,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final itemUrl = item.url;
|
||||
if (itemUrl != null && itemUrl.isNotEmpty) {
|
||||
final uri = Uri.parse(itemUrl);
|
||||
@@ -1290,18 +1271,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
await launchUrl(uri);
|
||||
return;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.dashboard.link_open_error')),
|
||||
),
|
||||
);
|
||||
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.dashboard.link_missing')),
|
||||
),
|
||||
);
|
||||
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
|
||||
}
|
||||
},
|
||||
child: opaqueCard,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../../../core/widgets/language_selector.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../domain/notifiers/profile_notifier.dart';
|
||||
@@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
final FocusNode _departmentFocus = FocusNode();
|
||||
final FocusNode _phoneFocus = FocusNode();
|
||||
final FocusNode _phoneCodeFocus = FocusNode();
|
||||
bool _nameTouched = false;
|
||||
bool _departmentTouched = false;
|
||||
bool _phoneTouched = false;
|
||||
bool _phoneCodeTouched = false;
|
||||
bool _isSavingField = false;
|
||||
String? _skipAutoSaveField;
|
||||
String? _fieldSaveError;
|
||||
|
||||
String _initialPhone = '';
|
||||
bool _isPhoneChanged = false;
|
||||
@@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameFocus.addListener(_onNameFocusChange);
|
||||
_departmentFocus.addListener(_onDepartmentFocusChange);
|
||||
_phoneFocus.addListener(_onPhoneFocusChange);
|
||||
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
|
||||
}
|
||||
|
||||
void _debugLog(
|
||||
@@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_log.fine(parts.join(' '));
|
||||
}
|
||||
|
||||
void _onNameFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
||||
});
|
||||
} else if (_nameFocus.hasFocus) {
|
||||
_nameTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDepartmentFocusChange() {
|
||||
if (!mounted) return;
|
||||
_debugLog(
|
||||
'department_focus_change',
|
||||
field: 'department',
|
||||
hasFocus: _departmentFocus.hasFocus,
|
||||
);
|
||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
||||
});
|
||||
} else if (_departmentFocus.hasFocus) {
|
||||
_departmentTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPhoneFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneFocus.hasFocus && _phoneTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneFocus.hasFocus) {
|
||||
_phoneTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPhoneCodeFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneCodeFocus.hasFocus) {
|
||||
_phoneCodeTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController?.dispose();
|
||||
@@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isCodeSent = false;
|
||||
_isVerifying = false;
|
||||
_codeController?.clear();
|
||||
_phoneTouched = false;
|
||||
_phoneCodeTouched = false;
|
||||
}
|
||||
|
||||
void _startEditing(String field, UserProfile profile) {
|
||||
_debugLog('start_editing', field: field);
|
||||
setState(() {
|
||||
_editingField = field;
|
||||
_fieldSaveError = null;
|
||||
if (field == 'name') {
|
||||
_nameController?.text = profile.name;
|
||||
} else if (field == 'department') {
|
||||
@@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,21 +203,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
|
||||
);
|
||||
ToastService.info(tr('msg.userfront.profile.phone.code_sent'));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.send_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.send_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -303,24 +231,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
|
||||
);
|
||||
}
|
||||
if (_editingField == 'phone') {
|
||||
await _saveField(profile);
|
||||
ToastService.success(tr('msg.userfront.profile.phone.verified'));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.verify_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.verify_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_newPasswordController?.clear();
|
||||
_confirmPasswordController?.clear();
|
||||
setState(() {
|
||||
_passwordSuccess = tr('msg.userfront.profile.password.changed');
|
||||
_passwordSuccess = null;
|
||||
});
|
||||
ToastService.success(tr('msg.userfront.profile.password.changed'));
|
||||
} catch (e) {
|
||||
final message = e.toString().replaceFirst('Exception: ', '');
|
||||
setState(() {
|
||||
@@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
params: {'error': message},
|
||||
);
|
||||
});
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.password.change_failed',
|
||||
params: {'error': message},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isPasswordSaving = false);
|
||||
@@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||
if (_editingField != field) return;
|
||||
if (_skipAutoSaveField == field) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'verifying');
|
||||
return;
|
||||
}
|
||||
if (_isSavingField) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
if (!_hasFieldChanged(profile, field)) {
|
||||
_debugLog(
|
||||
'autosave_skip',
|
||||
field: field,
|
||||
reason: 'unchanged',
|
||||
changed: false,
|
||||
);
|
||||
setState(() {
|
||||
if (field == 'phone') {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
if (field == 'name') {
|
||||
_nameTouched = false;
|
||||
} else if (field == 'department') {
|
||||
_departmentTouched = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_debugLog('autosave_trigger', field: field, changed: true);
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
void _handlePhoneFocusChange(UserProfile profile) {
|
||||
if (_editingField != 'phone') return;
|
||||
if (_skipAutoSaveField == 'phone') {
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) return;
|
||||
if (_isSavingField) return;
|
||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||
if (!_hasFieldChanged(profile, 'phone')) {
|
||||
setState(() {
|
||||
_resetPhoneState();
|
||||
_editingField = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
bool _hasFieldChanged(UserProfile profile, String field) {
|
||||
if (field == 'name') {
|
||||
return (_nameController?.text.trim() ?? '') != profile.name;
|
||||
@@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_debugLog('save_skip', reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
|
||||
final currentField = _editingField!;
|
||||
|
||||
final nextName = currentField == 'name'
|
||||
@@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
if (currentField == 'name' && nextName.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.name_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentField == 'department' && nextDepartment.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.department_required')),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.department_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentField == 'phone') {
|
||||
if (nextPhone.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.phone_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||
@@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
field: currentField,
|
||||
reason: 'phone_not_verified',
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.phone_verify_required')),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_isSavingField = true;
|
||||
setState(() {
|
||||
_isSavingField = true;
|
||||
});
|
||||
|
||||
_debugLog('save_dispatch', field: currentField, changed: true);
|
||||
|
||||
try {
|
||||
@@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
});
|
||||
_debugLog('save_success', field: currentField);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.profile.update_success'));
|
||||
}
|
||||
} catch (e) {
|
||||
_debugLog('save_failed', field: currentField, reason: e.toString());
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.update_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr(
|
||||
'msg.userfront.profile.update_failed',
|
||||
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||
);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_isSavingField = false;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSavingField = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasChanged = _hasFieldChanged(profile, field);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
errorText: _fieldSaveError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = field;
|
||||
},
|
||||
child: OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: Key('profile-$field-save-button'),
|
||||
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasChanged = _hasFieldChanged(profile, 'phone');
|
||||
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
focusNode: _phoneFocus,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '01012345678',
|
||||
errorText: _fieldSaveError,
|
||||
suffixIcon: _isPhoneVerified
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
@@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = 'phone';
|
||||
},
|
||||
child: OutlinedButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isUpdating || !canSave || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart';
|
||||
import 'core/i18n/locale_registry.dart';
|
||||
import 'core/i18n/locale_utils.dart';
|
||||
import 'core/i18n/toml_asset_loader.dart';
|
||||
import 'core/ui/toast_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'features/auth/presentation/consent_screen.dart';
|
||||
import 'i18n.dart';
|
||||
@@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget {
|
||||
localizationsDelegates: delegates,
|
||||
supportedLocales: supportedLocales,
|
||||
locale: locale,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [if (child != null) child, const ToastViewport()],
|
||||
);
|
||||
},
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||
|
||||
131
userfront/test/profile_page_edit_flow_test.dart
Normal file
131
userfront/test/profile_page_edit_flow_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
|
||||
|
||||
// Mocking the profile notifier
|
||||
class MockProfileNotifier extends ProfileNotifier {
|
||||
UserProfile? _profile;
|
||||
bool updateCalled = false;
|
||||
String? updatedName;
|
||||
|
||||
@override
|
||||
Future<UserProfile?> build() async {
|
||||
_profile = UserProfile(
|
||||
id: 'test-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Original Name',
|
||||
phone: '01012345678',
|
||||
department: 'Dev',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'C100',
|
||||
);
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile?> loadProfile() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(_profile);
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProfile({
|
||||
String? name,
|
||||
String? phone,
|
||||
String? department,
|
||||
}) async {
|
||||
updateCalled = true;
|
||||
updatedName = name;
|
||||
_profile = _profile!.copyWith(
|
||||
name: name ?? _profile!.name,
|
||||
phone: phone ?? _profile!.phone,
|
||||
department: department ?? _profile!.department,
|
||||
);
|
||||
state = AsyncValue.data(_profile);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)',
|
||||
(tester) async {
|
||||
final recordedErrors = <FlutterErrorDetails>[];
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final text = details.exceptionAsString();
|
||||
if (text.contains('A RenderFlex overflowed')) {
|
||||
return;
|
||||
}
|
||||
recordedErrors.add(details);
|
||||
};
|
||||
addTearDown(() {
|
||||
FlutterError.onError = previousOnError;
|
||||
});
|
||||
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final mockNotifier = MockProfileNotifier();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [profileProvider.overrideWith(() => mockNotifier)],
|
||||
child: const MaterialApp(home: Scaffold(body: ProfilePage())),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 1. Entering edit mode
|
||||
final editButton = find.byKey(const Key('profile-name-edit-button'));
|
||||
expect(editButton, findsOneWidget);
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final inputField = find.byKey(const Key('profile-name-input'));
|
||||
expect(inputField, findsOneWidget);
|
||||
|
||||
// 2. Testing cancel flow
|
||||
await tester.enterText(inputField, 'Changed Name');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
|
||||
await tester.tap(cancelButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// After cancellation, the field should be read-only again.
|
||||
expect(find.byKey(const Key('profile-name-input')), findsNothing);
|
||||
// Find text could be part of ListTile
|
||||
expect(find.text('Original Name'), findsWidgets);
|
||||
|
||||
// 3. Re-enter edit mode and explicitly save
|
||||
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('profile-name-input')),
|
||||
'Saved Name',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final saveButton = find.byKey(const Key('profile-name-save-button'));
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FlutterError.onError = previousOnError;
|
||||
|
||||
// Verify the mock received the update
|
||||
expect(mockNotifier.updateCalled, isTrue);
|
||||
expect(mockNotifier.updatedName, 'Saved Name');
|
||||
expect(recordedErrors, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user