generated from ttipo/project-base
feat(flutter): [새 버전 받기]를 포털 받기 화면으로 바꾸고 release 는 우리 키 · 운영 주소로만 만든다
스토어에 올리지 않으니 업데이트 화면의 Play 스토어 열기를 포털 /app 을 바깥 브라우저로 여는 것으로 바꾸고, 설정 › 앱 정보에도 같은 줄을 더했다. release 빌드는 우리 서명 키(key.properties) · google-services.json · 운영 포털의 https API 주소가 갖춰지지 않으면 까닭을 알리고 멈춘다 — debug 키로 조용히 서명되거나 개발 주소가 든 판이 직원에게 나가지 않게(알리는 글에 주소 값은 싣지 않는다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +10,137 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 배포 판(release)의 서명 — 우리 서명 키로만 한다(앱은 스토어에 올리지 않고 사내에 직접 나눠 준다 — flutter/README "사내 배포").
|
||||
// android/key.properties(storeFile · storePassword · keyAlias · keyPassword)가 저장소 밖의 keystore 를 가리킨다.
|
||||
// key.properties · keystore 는 커밋하지 않는다(android/.gitignore). 값(비밀번호)은 어떤 오류 글에도 싣지 않는다.
|
||||
// 키가 갖춰지지 않았으면 release 작업을 멈춘다(맨 아래 확인) — flutter create 기본처럼 debug 키로 조용히 서명하면,
|
||||
// 그 판을 깐 휴대폰에는 우리 키로 서명한 다음 판을 덮어 설치할 수 없다(앱을 지우고 다시 설치해야 한다).
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
val releaseSigningProblem: String? = run {
|
||||
if (!keystorePropertiesFile.isFile) return@run "android/key.properties 가 없습니다."
|
||||
try {
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
} catch (e: Exception) {
|
||||
// .properties 는 \ 를 이스케이프로 읽는다 — Windows 경로의 \u… 는 여기서 실패한다
|
||||
return@run "android/key.properties 를 읽지 못했습니다. 경로의 \\ 는 / 로 적어 주세요."
|
||||
}
|
||||
val blank = listOf("storeFile", "storePassword", "keyAlias", "keyPassword")
|
||||
.filter { keystoreProperties.getProperty(it).isNullOrBlank() }
|
||||
if (blank.isNotEmpty()) {
|
||||
return@run "android/key.properties 에 비어 있는 칸이 있습니다: ${blank.joinToString(", ")}."
|
||||
}
|
||||
// 상대 경로는 android/ 기준(key.properties 가 있는 곳)이다 — 절대 경로로 적기를 권한다
|
||||
if (!rootProject.file(keystoreProperties.getProperty("storeFile").trim()).isFile) {
|
||||
return@run "android/key.properties 의 storeFile 이 가리키는 keystore 파일이 없습니다. 경로의 \\ 는 / 로 적어 주세요."
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
// ── 배포 판의 API 주소 판정 (순수 함수 — 아래 releaseApiUrlProblem 이 부른다) ──
|
||||
// 앱 안에 그대로 들어가는 flutter/.env(pubspec 의 asset)의 API_URL 을 앱과 같은 규칙으로 읽고, 운영 포털의 API 주소인지 본다.
|
||||
// Gradle 스크립트라 저장소의 시험(flutter test · CI 는 Gradle 을 돌리지 않는다)으로는 돌리지 못한다 — 고치면 flutter/README "사내 배포 › 4. release 빌드" 의
|
||||
// 확인(키 없이 · 개발 주소인 채로 `flutter build apk --release`)으로 다시 본다. 판정 글에는 주소 값을 싣지 않는다(무엇이 틀렸는지만).
|
||||
|
||||
/** 앱이 읽는 키 — 짝: Dart `lib/core/api/api_config.dart` 의 `kApiUrlEnvKey`(없으면 앱은 개발 주소 `kDefaultApiUrl` 로 돈다) */
|
||||
val apiUrlEnvKey = "API_URL"
|
||||
|
||||
private val dotenvComment = Regex("""#[^'"]*$""")
|
||||
private val dotenvCommentWithQuotes = Regex("""#.*$""")
|
||||
private val dotenvLeadingExport = Regex("""^ *export ?""")
|
||||
private val dotenvSurroundQuotes = Regex("""^(["'])(.*?[^\\])\1""")
|
||||
private val dotenvBashVar = Regex("""(\\)?(\$)(?:\{)?([a-zA-Z_][\w]*)+(?:\})?""")
|
||||
|
||||
/** Dart 의 `String.trim` — 공백과 함께 BOM(U+FEFF)도 뗀다 */
|
||||
fun dartTrim(text: String): String = text.trim { it.isWhitespace() || it == '\uFEFF' }
|
||||
|
||||
/**
|
||||
* flutter_dotenv 5.2.1 의 `Parser.parse` 를 옮겼다 — 앱이 `.env` 를 읽는 규칙 그대로(줄은 `\n` 으로만 나눈다).
|
||||
* 같은 키는 처음 것, 따옴표 밖의 `#` 주석 · 앞의 `export` · 둘러싼 따옴표를 떼고, 홑따옴표가 아니면 앞에서 읽은 `$이름` 을 채운다.
|
||||
* 짝: `pubspec.lock` 의 flutter_dotenv 판 — 판을 올리면 그 판의 `lib/src/parser.dart` 와 다시 견준다
|
||||
*/
|
||||
fun parseDotenv(lines: List<String>): Map<String, String> {
|
||||
val out = LinkedHashMap<String, String>()
|
||||
for (line in lines) {
|
||||
val stripped = dartTrim(line.replace(dotenvComment, ""))
|
||||
if (stripped.isEmpty() || !stripped.contains('=')) continue
|
||||
val idx = stripped.indexOf('=')
|
||||
val key = dartTrim(stripped.substring(0, idx).replace(dotenvLeadingExport, ""))
|
||||
if (key.isEmpty()) continue
|
||||
val rhs = dartTrim(stripped.substring(idx + 1))
|
||||
val quoted = dotenvSurroundQuotes.find(rhs)
|
||||
val quote = quoted?.groupValues?.get(1).orEmpty()
|
||||
var value = quoted?.groupValues?.get(2) ?: dartTrim(dartTrim(rhs.replace(dotenvCommentWithQuotes, "")))
|
||||
if (quote == "'") {
|
||||
value = value.replace("\\'", "'")
|
||||
} else {
|
||||
if (quote == "\"") value = value.replace("\\\"", "\"").replace("\\n", "\n")
|
||||
value = dotenvBashVar.replace(value) { m ->
|
||||
if (m.groupValues[1] == "\\") m.value else out[m.groupValues[3]].orEmpty()
|
||||
}.replace("\\\$", "\$")
|
||||
}
|
||||
out.putIfAbsent(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발 주소 — localhost · `.localhost` · `.test` 로 끝나는 이름 · 루프백(127.0.0.0/8 · ::1) · 0.0.0.0 · 안드로이드 에뮬레이터의 PC 주소 10.0.2.2.
|
||||
* [host] 는 소문자 · 대괄호 없이 받는다. IPv6 는 IP 글자일 때만 읽는다(이름을 묻지 않는다 — DNS 없음)
|
||||
*/
|
||||
fun isDevelopmentHost(host: String): Boolean {
|
||||
if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".test")) return true
|
||||
if (host == "10.0.2.2" || host == "0.0.0.0") return true
|
||||
if (Regex("""127(\.\d{1,3}){3}""").matches(host)) return true
|
||||
if (!host.contains(':')) return false
|
||||
return try {
|
||||
InetAddress.getByName(host).let { it.isLoopbackAddress || it.isAnyLocalAddress }
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** API 주소의 경로로 받는 것 — 같은 주소 배포라 늘 포털 출처의 `/api`(끝의 `/` 하나까지) */
|
||||
val releaseApiPaths = setOf("/api", "/api/")
|
||||
|
||||
/**
|
||||
* 배포 판에 넣을 API 주소의 문제 — 없으면 null. 운영 포털의 API 주소(`https://<포털>/api`)여야 한다:
|
||||
* 앞뒤 빈칸 없음(앱은 파서가 준 값을 그대로 쓴다 — 따옴표 안의 빈칸은 파서가 떼지 않는다) · https · 개발 주소 아님 ·
|
||||
* 사용자 정보 · 쿼리 · 조각 없음(APK 안의 .env 는 누구나 풀어 볼 수 있고, 사용자 정보는 dart:io 가 Basic 으로 보낸다) · 경로 `/api`.
|
||||
* 글은 고정 글이다 — 주소 값을 싣지 않는다(무엇이 틀렸는지만)
|
||||
*/
|
||||
fun apiUrlProblemForRelease(apiUrl: String?): String? {
|
||||
if (apiUrl.isNullOrEmpty()) return "flutter/.env 에 API_URL 이 없습니다."
|
||||
if (apiUrl != dartTrim(apiUrl)) return "flutter/.env 의 API_URL 값 앞뒤에 빈칸이 있습니다."
|
||||
val uri = try {
|
||||
URI(apiUrl)
|
||||
} catch (e: URISyntaxException) {
|
||||
return "flutter/.env 의 API_URL 을 주소로 읽지 못했습니다."
|
||||
}
|
||||
if (!uri.scheme.equals("https", ignoreCase = true)) return "flutter/.env 의 API_URL 이 https 주소가 아닙니다."
|
||||
val host = uri.host.orEmpty().removePrefix("[").removeSuffix("]").trimEnd('.').lowercase()
|
||||
if (host.isEmpty()) return "flutter/.env 의 API_URL 에서 호스트를 읽지 못했습니다."
|
||||
if (isDevelopmentHost(host)) return "flutter/.env 의 API_URL 이 개발 주소입니다."
|
||||
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) {
|
||||
return "flutter/.env 의 API_URL 에 사용자 정보 · 쿼리 · 조각이 있습니다."
|
||||
}
|
||||
if (uri.rawPath !in releaseApiPaths) return "flutter/.env 의 API_URL 경로가 /api 가 아닙니다."
|
||||
return null
|
||||
}
|
||||
// ── 배포 판의 API 주소 판정 끝 ──
|
||||
|
||||
// 앱에 들어갈 .env 를 지금 읽는다(Flutter 가 같은 파일을 asset 으로 싣는다). 없거나 읽지 못해도 release 를 막는다 — 값이 없으면 앱은 개발 주소로 돈다
|
||||
val appEnvFile = rootProject.file("../.env")
|
||||
val releaseApiUrlProblem: String? = if (!appEnvFile.isFile) {
|
||||
"flutter/.env 가 없습니다."
|
||||
} else {
|
||||
try {
|
||||
apiUrlProblemForRelease(parseDotenv(appEnvFile.readText(Charsets.UTF_8).split("\n"))[apiUrlEnvKey])
|
||||
} catch (e: Exception) {
|
||||
"flutter/.env 를 읽지 못했습니다."
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "kr.it.arasystem.app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -30,11 +166,22 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// 키가 갖춰졌을 때만 만든다 — 빈 값으로 만들면 debug 빌드까지 설정 단계에서 실패한다
|
||||
if (releaseSigningProblem == null) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile").trim())
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias").trim()
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// 우리 키 — 갖춰지지 않았으면 null(서명 없음)이고, 맨 아래 확인이 release 작업을 멈춘다
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,19 +192,35 @@ flutter {
|
||||
|
||||
// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림").
|
||||
// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다.
|
||||
// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게.
|
||||
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
|
||||
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다.
|
||||
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다.
|
||||
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 아래 확인의 조건도 함께 바꾼다.
|
||||
val firebaseConfig = file("google-services.json")
|
||||
if (firebaseConfig.exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
} else {
|
||||
}
|
||||
|
||||
// 배포 판(release 변형)에 갖출 것 — 하나라도 빠졌으면 release 작업이 하나라도 있을 때 멈추고, 빠진 것을 한 번에 알린다.
|
||||
// - google-services.json: 새 메일 알림 없이 나가지 않게
|
||||
// - 우리 서명 키(key.properties): debug 키로 서명한 판이 나가지 않게(위 releaseSigningProblem)
|
||||
// - 운영 API 주소(flutter/.env 의 API_URL): 개발 주소 · http 로 만든 판이 나가지 않게(위 releaseApiUrlProblem — 글에 주소 값을 싣지 않는다)
|
||||
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
|
||||
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. debug · profile 빌드는 그대로 된다.
|
||||
val releaseBlockers: List<String> = listOfNotNull(
|
||||
if (firebaseConfig.exists()) {
|
||||
null
|
||||
} else {
|
||||
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\")."
|
||||
},
|
||||
releaseSigningProblem?.let {
|
||||
"$it 배포 판은 우리 서명 키로만 서명합니다 (flutter/README \"사내 배포\")."
|
||||
},
|
||||
releaseApiUrlProblem?.let {
|
||||
"$it 배포 판은 운영 포털의 API 주소로만 만듭니다 (flutter/README \"사내 배포\")."
|
||||
},
|
||||
)
|
||||
if (releaseBlockers.isNotEmpty()) {
|
||||
gradle.taskGraph.whenReady {
|
||||
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
|
||||
throw GradleException(
|
||||
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").",
|
||||
)
|
||||
throw GradleException(releaseBlockers.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,23 +44,14 @@ String _clean(String? value) {
|
||||
: kept;
|
||||
}
|
||||
|
||||
/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번.
|
||||
/// 이 기기의 앱 판 · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번.
|
||||
/// 기기 정보를 읽지 못하면 아는 것만 싣는다.
|
||||
Future<
|
||||
({
|
||||
String? appVersion,
|
||||
String? packageName,
|
||||
String userAgent,
|
||||
int? androidSdkInt,
|
||||
})
|
||||
>
|
||||
Future<({String? appVersion, String userAgent, int? androidSdkInt})>
|
||||
loadAppDeviceInfo() async {
|
||||
String? appVersion;
|
||||
String? packageName;
|
||||
try {
|
||||
final PackageInfo info = await PackageInfo.fromPlatform();
|
||||
appVersion = info.version;
|
||||
packageName = info.packageName.isEmpty ? null : info.packageName;
|
||||
} on PlatformException {
|
||||
appVersion = null;
|
||||
}
|
||||
@@ -69,7 +60,6 @@ loadAppDeviceInfo() async {
|
||||
);
|
||||
return (
|
||||
appVersion: appVersion,
|
||||
packageName: packageName,
|
||||
userAgent: device.userAgent,
|
||||
androidSdkInt: device.androidSdkInt,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_link.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
|
||||
typedef AppDownloadLaunch = Future<bool> Function(Uri url, LaunchMode mode);
|
||||
|
||||
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
|
||||
launchUrl(url, mode: mode);
|
||||
|
||||
/// [새 버전 받기] — 포털의 휴대폰 앱 받기 화면(`app_download_link.dart`)을 바깥 브라우저로 연다.
|
||||
/// 업데이트 화면과 설정 › 앱 정보가 이 런처 하나를 함께 쓴다(같은 주소 · 같은 실패 알림).
|
||||
///
|
||||
/// - 바깥 앱(`LaunchMode.externalApplication` — 휴대폰의 기본 브라우저)으로 연다. 받기 화면은 로그인한 직원만 받으므로
|
||||
/// 브라우저의 웹메일 로그인을 쓴다(앱의 토큰은 넘기지 않는다).
|
||||
/// - `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — 인증 앱 열기와 같은 규칙).
|
||||
/// 못 열면 플러그인 예외를 버리고 거짓 — 화면이 [failureMessage] 를 알린다
|
||||
class AppDownloadLauncher {
|
||||
AppDownloadLauncher({
|
||||
required String apiBaseUrl,
|
||||
AppDownloadLaunch launch = _launchExternal,
|
||||
}) : pageUrl = appDownloadPageUrlOf(apiBaseUrl),
|
||||
_launch = launch;
|
||||
|
||||
/// 받기 화면 주소 — 앱의 API 주소로 만들지 못했으면 null(열지 않고 알린다)
|
||||
final Uri? pageUrl;
|
||||
|
||||
final AppDownloadLaunch _launch;
|
||||
|
||||
/// 열었으면 참
|
||||
Future<bool> open() async {
|
||||
final Uri? url = pageUrl;
|
||||
if (url == null) return false;
|
||||
try {
|
||||
return await _launch(url, LaunchMode.externalApplication);
|
||||
} on Object {
|
||||
// PlatformException(받을 앱 없음 · 화면 없음) 등 — 화면은 주소를 알려 직접 들어가게 한다
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 열지 못했을 때의 알림 글 — 직원이 브라우저에 직접 넣을 주소(스킴 없이)를 담는다
|
||||
String get failureMessage {
|
||||
final Uri? url = pageUrl;
|
||||
return url == null
|
||||
? AppDownloadTexts.openFailedNoAddress
|
||||
: AppDownloadTexts.openFailed(appDownloadPageAddressOf(url));
|
||||
}
|
||||
}
|
||||
|
||||
/// 받기 화면 런처 — 앱의 API 주소(`apiConfigProvider`)로 주소를 만든다. 시험은 가짜로 덮어쓴다
|
||||
final Provider<AppDownloadLauncher> appDownloadLauncherProvider =
|
||||
Provider<AppDownloadLauncher>(
|
||||
(Ref ref) =>
|
||||
AppDownloadLauncher(apiBaseUrl: ref.watch(apiConfigProvider).baseUrl),
|
||||
);
|
||||
|
||||
/// [새 버전 받기]를 누른 뒤 — 받기 화면을 열고, 열지 못하면 주소를 담아 알린다(두 화면이 같은 알림을 쓴다).
|
||||
/// [messenger] 는 누른 화면에서 기다리기 전에 잡아 둔다(브라우저가 뜨는 동안 화면이 바뀔 수 있다)
|
||||
Future<void> openAppDownloadPage(
|
||||
WidgetRef ref,
|
||||
ScaffoldMessengerState messenger,
|
||||
) async {
|
||||
final AppDownloadLauncher launcher = ref.read(appDownloadLauncherProvider);
|
||||
if (await launcher.open()) return;
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: KeepAllText(launcher.failureMessage)));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// 포털의 휴대폰 앱 받기 화면 주소 — 앱을 스토어에 올리지 않고 사내에서 직접 나눠 주는 곳(2026-11-08).
|
||||
/// 앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]가 이 주소를 바깥 브라우저로 연다(앱 보드 `App1Push` ⑩ · ④).
|
||||
///
|
||||
/// 같은 주소 배포라 앱의 API 주소(`.env` 의 `API_URL` — `ApiConfig.baseUrl`)는 늘 포털 출처의 `/api` 다.
|
||||
/// 그래서 받는 곳 주소를 서버에서 받지 않고(앱 설정 계약은 싣지 않는다) API 주소의 출처에 받기 화면 경로를 붙여 만든다.
|
||||
library;
|
||||
|
||||
/// 받기 화면 경로 — 짝: 웹 `frontend/src/shared/constants/mobile-app.ts` 의 `MOBILE_APP_PATH`(한쪽을 바꾸면 둘 다)
|
||||
const String kAppDownloadPagePath = '/app';
|
||||
|
||||
/// API 주소에서 받기 화면 주소를 만든다 — `https://portal.arasystem.it.kr/api` → `https://portal.arasystem.it.kr/app`.
|
||||
///
|
||||
/// 출처(스킴 · 호스트 · 포트)만 남긴다: API 경로(`/api`) · 쿼리 · 조각 · 사용자 정보는 버리고, 스킴의 기본 포트는 적지 않는다.
|
||||
/// http · https 주소가 아니거나 호스트가 없으면 null — 화면은 주소 없이 알린다
|
||||
Uri? appDownloadPageUrlOf(String apiBaseUrl) {
|
||||
final Uri? api = Uri.tryParse(apiBaseUrl.trim());
|
||||
if (api == null) return null;
|
||||
if (api.scheme != 'http' && api.scheme != 'https') return null;
|
||||
if (api.host.isEmpty) return null;
|
||||
return Uri(
|
||||
scheme: api.scheme,
|
||||
host: api.host,
|
||||
port: api.hasPort ? api.port : null,
|
||||
path: kAppDownloadPagePath,
|
||||
);
|
||||
}
|
||||
|
||||
/// 알림에 보이는 주소 — 스킴 없이(`portal.arasystem.it.kr/app` · `10.0.2.2:5173/app`).
|
||||
/// 직원이 휴대폰 브라우저의 주소 칸에 그대로 넣는다
|
||||
String appDownloadPageAddressOf(Uri url) => '${url.authority}${url.path}';
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 휴대폰 앱 받기 화면을 여는 [새 버전 받기]의 글 — 업데이트 화면(`features/app_update`)과 설정 › 앱 정보(`features/settings`)가 함께 쓴다.
|
||||
///
|
||||
/// 앱 보드 `App1Push` ⑩ · ④ 앱 정보 절(2026-11-08 확정 — 캔버스 v68)의 글 그대로다(디자인 README 「화면 문구 — 사용자에게 말하듯」).
|
||||
abstract final class AppDownloadTexts {
|
||||
/// 업데이트 화면의 단추 · 설정 › 앱 정보의 줄
|
||||
static const String getNewVersion = '새 버전 받기';
|
||||
|
||||
/// 브라우저를 열지 못했을 때(알림) — [address] 는 받기 화면 주소를 스킴 없이(`portal.arasystem.it.kr/app`).
|
||||
/// 직원이 휴대폰 브라우저에 직접 넣는다
|
||||
static String openFailed(String address) =>
|
||||
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 $address에 들어가 새 버전을 받아 주세요.';
|
||||
|
||||
/// 받기 화면 주소를 만들지 못했을 때(앱의 API 주소가 http · https 주소가 아니다 — 보드에 없는 경우.
|
||||
/// 그런 판은 서버에도 닿지 못해 업데이트 화면 · 설정 탭에 오지 않는다)
|
||||
static const String openFailedNoAddress = '브라우저를 열지 못했습니다. 관리자에게 문의해 주세요.';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// 앱 전체에서 쓰는 이름 · 식별 값.
|
||||
///
|
||||
/// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 —
|
||||
/// 스토어에 올린 뒤에는 바꿀 수 없다.
|
||||
/// 직원 휴대폰에 설치한 뒤에는 바꿀 수 없다(식별자가 다르면 다른 앱이라 새 판이 그 위에 설치되지 않는다).
|
||||
library;
|
||||
|
||||
/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름
|
||||
|
||||
@@ -5,9 +5,3 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
final Provider<String?> appVersionProvider = Provider<String?>(
|
||||
(Ref ref) => null,
|
||||
);
|
||||
|
||||
/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다.
|
||||
/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다)
|
||||
final Provider<String?> appPackageNameProvider = Provider<String?>(
|
||||
(Ref ref) => null,
|
||||
);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
|
||||
typedef StoreLaunch = Future<bool> Function(Uri url, LaunchMode mode);
|
||||
|
||||
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
|
||||
launchUrl(url, mode: mode);
|
||||
|
||||
/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소)
|
||||
const String kPlayStoreWebDetails =
|
||||
'https://play.google.com/store/apps/details';
|
||||
|
||||
/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면).
|
||||
///
|
||||
/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로.
|
||||
/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 —
|
||||
/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다
|
||||
class AppStoreLauncher {
|
||||
const AppStoreLauncher({StoreLaunch launch = _launchExternal})
|
||||
: _launch = launch;
|
||||
|
||||
final StoreLaunch _launch;
|
||||
|
||||
/// 열었으면 참
|
||||
Future<bool> open(String packageName) async {
|
||||
final Uri market = Uri(
|
||||
scheme: 'market',
|
||||
host: 'details',
|
||||
queryParameters: <String, String>{'id': packageName},
|
||||
);
|
||||
if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) {
|
||||
return true;
|
||||
}
|
||||
final Uri web = Uri.parse(
|
||||
kPlayStoreWebDetails,
|
||||
).replace(queryParameters: <String, String>{'id': packageName});
|
||||
return _tryLaunch(web, LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<bool> _tryLaunch(Uri url, LaunchMode mode) async {
|
||||
try {
|
||||
return await _launch(url, mode);
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다
|
||||
final Provider<AppStoreLauncher> appStoreLauncherProvider =
|
||||
Provider<AppStoreLauncher>((Ref ref) => const AppStoreLauncher());
|
||||
@@ -1,22 +1,18 @@
|
||||
/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`.
|
||||
/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포로 고쳐 확정 — 캔버스 v68)의 글 그대로다.
|
||||
/// 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`, [새 버전 받기]와 브라우저를 열지 못했을 때의 알림은
|
||||
/// 설정 › 앱 정보와 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다.
|
||||
///
|
||||
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
|
||||
abstract final class AppUpdateTexts {
|
||||
static const String message =
|
||||
'지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.';
|
||||
static const String openStore = 'Play 스토어 열기';
|
||||
'지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. 새 버전을 받아 설치한 뒤 다시 열어 주세요.';
|
||||
|
||||
/// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0"
|
||||
static String currentVersion(String version) => '지금 쓰는 버전 $version';
|
||||
|
||||
/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처
|
||||
static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?';
|
||||
static const String supportQuestion = '새 버전을 받거나 설치하기 어려우신가요?';
|
||||
|
||||
/// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴)
|
||||
static const String supportFallback =
|
||||
'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.';
|
||||
|
||||
/// Play 스토어를 열지 못했을 때(알림)
|
||||
static const String openStoreFailed =
|
||||
'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.';
|
||||
static const String supportFallback = '새 버전을 받거나 설치하기 어려우신가요? 관리자에게 문의해 주세요.';
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
|
||||
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
|
||||
@@ -17,9 +18,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다
|
||||
/// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`).
|
||||
///
|
||||
/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄.
|
||||
/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다.
|
||||
/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고,
|
||||
/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄.
|
||||
/// - [새 버전 받기]: 포털의 휴대폰 앱 받기 화면(`<포털 출처>/app`)을 바깥 브라우저로 연다 — 앱은 스토어에 올리지 않고
|
||||
/// 사내에서 직접 나눠 준다(2026-11-08). 열지 못하면 그 주소를 담아 알린다(설정 › 앱 정보와 같은 런처 · 알림 — `core/app_download/`).
|
||||
/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 새 판을 설치하면 안드로이드가 이 앱을 끝내고,
|
||||
/// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다.
|
||||
class AppUpdatePage extends ConsumerStatefulWidget {
|
||||
const AppUpdatePage({super.key});
|
||||
@@ -35,18 +37,8 @@ class _AppUpdatePageState extends ConsumerState<AppUpdatePage> {
|
||||
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
|
||||
}
|
||||
|
||||
Future<void> _openStore() async {
|
||||
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
|
||||
final String? packageName = ref.read(appPackageNameProvider);
|
||||
final bool opened =
|
||||
packageName != null &&
|
||||
await ref.read(appStoreLauncherProvider).open(packageName);
|
||||
if (opened) return;
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)),
|
||||
);
|
||||
void _openDownloadPage() {
|
||||
unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context)));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -79,12 +71,12 @@ class _AppUpdatePageState extends ConsumerState<AppUpdatePage> {
|
||||
],
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => unawaited(_openStore()),
|
||||
onPressed: _openDownloadPage,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: AppSpacing.s8,
|
||||
children: <Widget>[
|
||||
Text(AppUpdateTexts.openStore),
|
||||
Text(AppDownloadTexts.getNewVersion),
|
||||
Icon(Icons.open_in_new, size: AppSize.iconSmall),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
@@ -18,11 +20,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄).
|
||||
/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄 · 앱 정보 절).
|
||||
/// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다).
|
||||
///
|
||||
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · 앱 정보(앱 버전 · 오픈소스 라이선스) ·
|
||||
/// 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
|
||||
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 ·
|
||||
/// 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
|
||||
/// [새 버전 받기]는 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 연다(같은 런처 · 같은 실패 알림 — `core/app_download/`).
|
||||
/// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다).
|
||||
/// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
@@ -65,6 +68,10 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
await ref.read(sessionProvider.notifier).signOut();
|
||||
}
|
||||
|
||||
void _openDownloadPage() {
|
||||
unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
@@ -118,6 +125,11 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_VersionRow(version: version),
|
||||
_LinkRow(
|
||||
label: AppDownloadTexts.getNewVersion,
|
||||
opensOutside: true,
|
||||
onTap: _openDownloadPage,
|
||||
),
|
||||
_LinkRow(
|
||||
label: SettingsTexts.licenses,
|
||||
onTap: () =>
|
||||
@@ -321,13 +333,21 @@ class _VersionRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 누르면 다른 화면으로 가는 한 줄 — 이름 · 오른쪽 화살표
|
||||
/// 누르는 한 줄 — 이름 · 오른쪽 끝 아이콘. 앱 안의 다른 화면으로 가면 화살표, 바깥(브라우저)으로 열면 바깥으로 여는 아이콘
|
||||
/// (보드: 화살표 20 · 바깥 아이콘 18 을 20 칸 가운데에 — 두 아이콘의 가운데가 맞는다)
|
||||
class _LinkRow extends StatelessWidget {
|
||||
const _LinkRow({required this.label, required this.onTap});
|
||||
const _LinkRow({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.opensOutside = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 바깥 앱(브라우저)으로 연다 — 끝 아이콘이 바깥으로 여는 아이콘
|
||||
final bool opensOutside;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
@@ -352,10 +372,15 @@ class _LinkRow extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Expanded(child: Text(label, style: theme.textTheme.bodyLarge)),
|
||||
ExcludeSemantics(
|
||||
child: Icon(
|
||||
Icons.chevron_right,
|
||||
size: AppSize.iconMedium,
|
||||
color: extra.textMuted,
|
||||
child: SizedBox.square(
|
||||
dimension: AppSize.iconMedium,
|
||||
child: Icon(
|
||||
opensOutside ? Icons.open_in_new : Icons.chevron_right,
|
||||
size: opensOutside
|
||||
? AppSize.iconSmall
|
||||
: AppSize.iconMedium,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다.
|
||||
/// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`).
|
||||
/// 앱 정보의 [새 버전 받기] 줄과 그 실패 알림은 업데이트 화면과 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다(보드 `App1Push` ④).
|
||||
///
|
||||
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
|
||||
abstract final class SettingsTexts {
|
||||
|
||||
@@ -41,13 +41,8 @@ Future<void> main() async {
|
||||
firstRun: PrefsFirstRunMarker(),
|
||||
);
|
||||
await tokenStore.prepare();
|
||||
final ({
|
||||
String? appVersion,
|
||||
String? packageName,
|
||||
String userAgent,
|
||||
int? androidSdkInt,
|
||||
})
|
||||
device = await loadAppDeviceInfo();
|
||||
final ({String? appVersion, String userAgent, int? androidSdkInt}) device =
|
||||
await loadAppDeviceInfo();
|
||||
|
||||
// 새 메일 알림(FCM) — Firebase 를 띄우고 플러그인의 소식 줄(FID · 받은 알림 · 누른 알림)을 지금 듣는다.
|
||||
// 띄우지 못하면(google-services.json 없이 빌드 · 5초 넘음) 알림 없이 돈다 — 설정 줄은 "이 휴대폰에서는 받을 수 없음"
|
||||
@@ -70,7 +65,6 @@ Future<void> main() async {
|
||||
tokenStoreProvider.overrideWithValue(tokenStore),
|
||||
userAgentProvider.overrideWithValue(device.userAgent),
|
||||
appVersionProvider.overrideWithValue(device.appVersion),
|
||||
appPackageNameProvider.overrideWithValue(device.packageName),
|
||||
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
|
||||
pickedFileStoreProvider.overrideWithValue(pickedFiles),
|
||||
pushMessagingProvider.overrideWithValue(pushMessaging),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:arasystem_app/core/api/api_config.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// [새 버전 받기]의 런처 — 업데이트 화면 · 설정 › 앱 정보가 함께 쓴다(화면은 각 화면 시험이 본다)
|
||||
void main() {
|
||||
/// 다른 앱으로 넘긴 주소 · 방식
|
||||
final List<({Uri url, LaunchMode mode})> launched =
|
||||
<({Uri url, LaunchMode mode})>[];
|
||||
|
||||
setUp(launched.clear);
|
||||
|
||||
AppDownloadLauncher launcherOf(
|
||||
String apiBaseUrl, {
|
||||
required Future<bool> Function() answer,
|
||||
}) => AppDownloadLauncher(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
launch: (Uri url, LaunchMode mode) {
|
||||
launched.add((url: url, mode: mode));
|
||||
return answer();
|
||||
},
|
||||
);
|
||||
|
||||
test('받기 화면을 바깥 앱(브라우저)으로 한 번 연다 — 열었으면 참', () async {
|
||||
final AppDownloadLauncher launcher = launcherOf(
|
||||
'https://portal.arasystem.it.kr/api',
|
||||
answer: () async => true,
|
||||
);
|
||||
|
||||
expect(await launcher.open(), isTrue);
|
||||
expect(launched, hasLength(1));
|
||||
expect(
|
||||
launched.single.url,
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(launched.single.mode, LaunchMode.externalApplication);
|
||||
});
|
||||
|
||||
test('열지 못하면 거짓 — 알림 글은 받기 화면 주소를 스킴 없이 담는다', () async {
|
||||
final AppDownloadLauncher launcher = launcherOf(
|
||||
'https://portal.arasystem.it.kr/api',
|
||||
answer: () async => false,
|
||||
);
|
||||
|
||||
expect(await launcher.open(), isFalse);
|
||||
expect(
|
||||
launcher.failureMessage,
|
||||
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
test('여는 쪽이 던지면(받을 앱 없음) 삼키고 거짓 — 예외의 주소를 밖으로 내보내지 않는다', () async {
|
||||
final AppDownloadLauncher launcher = launcherOf(
|
||||
'https://portal.arasystem.it.kr/api',
|
||||
answer: () async => throw PlatformException(
|
||||
code: 'ACTIVITY_NOT_FOUND',
|
||||
message:
|
||||
'No Activity found to handle https://portal.arasystem.it.kr/app',
|
||||
),
|
||||
);
|
||||
|
||||
expect(await launcher.open(), isFalse);
|
||||
expect(launched, hasLength(1));
|
||||
});
|
||||
|
||||
test('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 거짓 — 주소 없는 알림 글', () async {
|
||||
final AppDownloadLauncher launcher = launcherOf(
|
||||
'portal.arasystem.it.kr/api',
|
||||
answer: () async => true,
|
||||
);
|
||||
|
||||
expect(launcher.pageUrl, isNull);
|
||||
expect(await launcher.open(), isFalse);
|
||||
expect(launched, isEmpty);
|
||||
expect(launcher.failureMessage, AppDownloadTexts.openFailedNoAddress);
|
||||
});
|
||||
|
||||
test('프로바이더는 앱의 API 주소(apiConfigProvider)로 받기 화면 주소를 만든다', () {
|
||||
final ProviderContainer container = ProviderContainer(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: <Override>[
|
||||
apiConfigProvider.overrideWithValue(
|
||||
const ApiConfig(baseUrl: 'http://10.0.2.2:5173/api'),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
expect(
|
||||
container.read(appDownloadLauncherProvider).pageUrl,
|
||||
Uri.parse('http://10.0.2.2:5173/app'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:arasystem_app/core/app_download/app_download_link.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 포털의 휴대폰 앱 받기 화면 주소 — 앱의 API 주소(같은 주소 배포 — 포털 출처의 `/api`)에서 `/api` 를 뗀 출처 + `/app`
|
||||
void main() {
|
||||
group('받기 화면 주소', () {
|
||||
test('운영 — https://portal.arasystem.it.kr/api 의 출처에 /app', () {
|
||||
expect(
|
||||
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api'),
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api/'),
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
reason: '끝의 / 가 붙어도 같다',
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf(' https://portal.arasystem.it.kr/api '),
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
reason: '앞뒤 공백은 뗀다',
|
||||
);
|
||||
});
|
||||
|
||||
test('개발 — 포트는 그대로 둔다(에뮬레이터 · vite 개발 서버)', () {
|
||||
expect(
|
||||
appDownloadPageUrlOf('http://10.0.2.2:3000/api'),
|
||||
Uri.parse('http://10.0.2.2:3000/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('http://10.0.2.2:5173/api'),
|
||||
Uri.parse('http://10.0.2.2:5173/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('http://localhost:5173/api'),
|
||||
Uri.parse('http://localhost:5173/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('http://[::1]:3000/api'),
|
||||
Uri.parse('http://[::1]:3000/app'),
|
||||
reason: 'IPv6 주소는 대괄호째',
|
||||
);
|
||||
});
|
||||
|
||||
test('스킴의 기본 포트는 적지 않고, 그 밖의 포트는 남긴다', () {
|
||||
expect(
|
||||
appDownloadPageUrlOf('https://portal.arasystem.it.kr:443/api'),
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('http://portal.arasystem.it.kr:80/api'),
|
||||
Uri.parse('http://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(
|
||||
appDownloadPageUrlOf('https://portal.arasystem.it.kr:8443/api'),
|
||||
Uri.parse('https://portal.arasystem.it.kr:8443/app'),
|
||||
);
|
||||
});
|
||||
|
||||
test('출처만 남긴다 — 쿼리 · 조각 · 사용자 정보는 버리고 대문자는 소문자로', () {
|
||||
final Uri? url = appDownloadPageUrlOf(
|
||||
'HTTPS://user:pw@Portal.Arasystem.IT.KR/api?x=1#y',
|
||||
);
|
||||
expect(url, Uri.parse('https://portal.arasystem.it.kr/app'));
|
||||
expect(url!.userInfo, isEmpty);
|
||||
expect(url.hasQuery, isFalse);
|
||||
expect(url.hasFragment, isFalse);
|
||||
expect(url.toString(), 'https://portal.arasystem.it.kr/app');
|
||||
});
|
||||
|
||||
test('http · https 주소가 아니거나 호스트가 없으면 null', () {
|
||||
for (final String input in <String>[
|
||||
'',
|
||||
' ',
|
||||
'portal.arasystem.it.kr/api',
|
||||
'/api',
|
||||
'not a url',
|
||||
'ftp://portal.arasystem.it.kr/api',
|
||||
'file:///api',
|
||||
'market://details?id=kr.it.arasystem.app',
|
||||
'https:///api',
|
||||
'http://[::1',
|
||||
]) {
|
||||
expect(appDownloadPageUrlOf(input), isNull, reason: input);
|
||||
}
|
||||
});
|
||||
|
||||
test('경로는 웹의 받기 화면 경로와 같다', () {
|
||||
expect(kAppDownloadPagePath, '/app');
|
||||
});
|
||||
});
|
||||
|
||||
group('알림에 보이는 주소 — 스킴 없이', () {
|
||||
test('호스트 · 포트 · 경로', () {
|
||||
expect(
|
||||
appDownloadPageAddressOf(
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
),
|
||||
'portal.arasystem.it.kr/app',
|
||||
);
|
||||
expect(
|
||||
appDownloadPageAddressOf(Uri.parse('http://10.0.2.2:5173/app')),
|
||||
'10.0.2.2:5173/app',
|
||||
);
|
||||
expect(
|
||||
appDownloadPageAddressOf(Uri.parse('http://[::1]:3000/app')),
|
||||
'[::1]:3000/app',
|
||||
);
|
||||
});
|
||||
|
||||
test('만든 주소를 그대로 보인다', () {
|
||||
expect(
|
||||
appDownloadPageAddressOf(
|
||||
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api')!,
|
||||
),
|
||||
'portal.arasystem.it.kr/app',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.dart';
|
||||
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
|
||||
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
|
||||
import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -12,19 +14,19 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
|
||||
/// 시험 앱의 패키지 이름
|
||||
const String _package = 'kr.it.arasystem.app';
|
||||
/// 운영의 앱 API 주소 — 받기 화면은 그 출처의 `/app`
|
||||
const String _apiBaseUrl = 'https://portal.arasystem.it.kr/api';
|
||||
|
||||
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
|
||||
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포). 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
|
||||
void main() {
|
||||
late LoginHarness h;
|
||||
|
||||
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다)
|
||||
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다
|
||||
final List<({Uri url, LaunchMode mode})> launched =
|
||||
<({Uri url, LaunchMode mode})>[];
|
||||
late bool Function(Uri url) opens;
|
||||
late Future<bool> Function(Uri url) opens;
|
||||
|
||||
Future<bool> launch(Uri url, LaunchMode mode) async {
|
||||
Future<bool> launch(Uri url, LaunchMode mode) {
|
||||
launched.add((url: url, mode: mode));
|
||||
return opens(url);
|
||||
}
|
||||
@@ -32,7 +34,7 @@ void main() {
|
||||
setUp(() {
|
||||
h = LoginHarness();
|
||||
launched.clear();
|
||||
opens = (Uri _) => true;
|
||||
opens = (Uri _) async => true;
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
@@ -43,25 +45,24 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> pump(WidgetTester tester, {String? packageName = _package}) =>
|
||||
Future<void> pump(WidgetTester tester, {String apiBaseUrl = _apiBaseUrl}) =>
|
||||
h.pump(
|
||||
tester,
|
||||
overrides: <Override>[
|
||||
appVersionProvider.overrideWithValue('1.0.0'),
|
||||
appPackageNameProvider.overrideWithValue(packageName),
|
||||
appStoreLauncherProvider.overrideWithValue(
|
||||
AppStoreLauncher(launch: launch),
|
||||
appDownloadLauncherProvider.overrideWithValue(
|
||||
AppDownloadLauncher(apiBaseUrl: apiBaseUrl, launch: launch),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Finder storeButton() =>
|
||||
find.widgetWithText(FilledButton, AppUpdateTexts.openStore);
|
||||
Finder downloadButton() =>
|
||||
find.widgetWithText(FilledButton, AppDownloadTexts.getNewVersion);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', (
|
||||
testWidgets('제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄(연락처)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.supportContact('정보보안팀 02-1234-5678');
|
||||
@@ -70,12 +71,21 @@ void main() {
|
||||
expect(find.byType(AppUpdatePage), findsOneWidget);
|
||||
expect(find.text(ScreenTitles.update), findsOneWidget);
|
||||
expect(findCopy(AppUpdateTexts.message), findsOneWidget);
|
||||
expect(storeButton(), findsOneWidget);
|
||||
expect(downloadButton(), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: downloadButton(),
|
||||
matching: find.byIcon(Icons.open_in_new),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: '바깥(브라우저)으로 연다',
|
||||
);
|
||||
expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget);
|
||||
expect(
|
||||
findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(launched, isEmpty, reason: '누르기 전에는 열지 않는다');
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
@@ -86,54 +96,72 @@ void main() {
|
||||
expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', (
|
||||
testWidgets('[새 버전 받기] — 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 한 번 연다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = (Uri url) => url.scheme != 'market';
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(launched.first.url.toString(), 'market://details?id=$_package');
|
||||
expect(launched.first.mode, LaunchMode.externalNonBrowserApplication);
|
||||
expect(
|
||||
launched.last.url.toString(),
|
||||
'https://play.google.com/store/apps/details?id=$_package',
|
||||
);
|
||||
expect(launched.last.mode, LaunchMode.externalApplication);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async {
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.tap(downloadButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(1));
|
||||
expect(launched.single.url.scheme, 'market');
|
||||
expect(
|
||||
launched.single.url,
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(launched.single.mode, LaunchMode.externalApplication);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(find.byType(AppUpdatePage), findsOneWidget, reason: '화면은 그대로');
|
||||
});
|
||||
|
||||
testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async {
|
||||
opens = (Uri _) => false;
|
||||
testWidgets('브라우저를 열지 못하면 받기 화면 주소를 스킴 없이 담아 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = (Uri _) async => false;
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.tap(downloadButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
expect(launched, hasLength(1));
|
||||
expect(
|
||||
snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
AppDownloadTexts.openFailed('portal.arasystem.it.kr/app'),
|
||||
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.',
|
||||
reason: '보드 ⑩ 의 알림 글 그대로',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async {
|
||||
await pump(tester, packageName: null);
|
||||
testWidgets('여는 쪽이 던져도(받을 앱 없음) 삼키고 같은 알림 — 개발 주소는 포트까지 보인다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = (Uri url) async =>
|
||||
throw PlatformException(code: 'ACTIVITY_NOT_FOUND', message: '$url');
|
||||
await pump(tester, apiBaseUrl: 'http://10.0.2.2:5173/api');
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.tap(downloadButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched.single.url, Uri.parse('http://10.0.2.2:5173/app'));
|
||||
expect(
|
||||
snack(AppDownloadTexts.openFailed('10.0.2.2:5173/app')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 관리자에게 문의해 달라고 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await pump(tester, apiBaseUrl: 'portal.arasystem.it.kr/api');
|
||||
|
||||
await tester.tap(downloadButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, isEmpty);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
expect(snack(AppDownloadTexts.openFailedNoAddress), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
|
||||
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.dart';
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/push_preferences.dart';
|
||||
@@ -9,6 +11,7 @@ import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
@@ -101,6 +104,115 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () {
|
||||
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다
|
||||
final List<({Uri url, LaunchMode mode})> launched =
|
||||
<({Uri url, LaunchMode mode})>[];
|
||||
late bool opens;
|
||||
|
||||
setUp(() {
|
||||
launched.clear();
|
||||
opens = true;
|
||||
h.app.overrides.add(
|
||||
appDownloadLauncherProvider.overrideWithValue(
|
||||
AppDownloadLauncher(
|
||||
apiBaseUrl: 'https://portal.arasystem.it.kr/api',
|
||||
launch: (Uri url, LaunchMode mode) async {
|
||||
launched.add((url: url, mode: mode));
|
||||
return opens;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Finder downloadRow() => find.ancestor(
|
||||
of: find.text(AppDownloadTexts.getNewVersion),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
|
||||
Finder rowIcon(String label, IconData icon) => find.descendant(
|
||||
of: find.ancestor(of: find.text(label), matching: find.byType(InkWell)),
|
||||
matching: find.byIcon(icon),
|
||||
);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
final double version = tester
|
||||
.getTopLeft(find.text(SettingsTexts.appVersion))
|
||||
.dy;
|
||||
final double download = tester
|
||||
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
|
||||
.dy;
|
||||
final double licenses = tester
|
||||
.getTopLeft(find.text(SettingsTexts.licenses))
|
||||
.dy;
|
||||
expect(version, lessThan(download));
|
||||
expect(download, lessThan(licenses));
|
||||
expect(
|
||||
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.getSize(downloadRow()).height,
|
||||
greaterThanOrEqualTo(52),
|
||||
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
|
||||
);
|
||||
expect(launched, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion));
|
||||
|
||||
expect(launched, hasLength(1));
|
||||
expect(
|
||||
launched.single.url,
|
||||
Uri.parse('https://portal.arasystem.it.kr/app'),
|
||||
);
|
||||
expect(launched.single.mode, LaunchMode.externalApplication);
|
||||
expect(h.app.location(), '/settings');
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('브라우저를 열지 못하면 업데이트 화면과 같은 알림 — 받기 화면 주소를 담는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = false;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion));
|
||||
|
||||
expect(launched, hasLength(1));
|
||||
expect(
|
||||
snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(h.app.location(), '/settings');
|
||||
});
|
||||
});
|
||||
|
||||
group('[로그아웃] — 확인 창부터', () {
|
||||
testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', (
|
||||
WidgetTester tester,
|
||||
|
||||
Reference in New Issue
Block a user