Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9b86d3e8 | ||
|
|
383d3143c8 | ||
|
|
2a5b0a8b32 | ||
|
|
7e7585fa8f | ||
|
|
12780eb3af | ||
|
|
f6be60ef46 | ||
|
|
726d2dc632 | ||
|
|
4e74667993 | ||
|
|
0fc80eb5c5 | ||
|
|
edf0444b71 | ||
|
|
6b53214913 | ||
|
|
13a86ec95e | ||
|
|
a29efe77ad | ||
|
|
a4a7b5be48 | ||
|
|
705c0c086a |
@@ -105,7 +105,8 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
||||||
NfcManager.instance.stopSession();
|
// 🌐 웹에서는 애초에 세션을 시작하지 않으므로 stopSession도 호출하지 않는다.
|
||||||
|
if (!kIsWeb) NfcManager.instance.stopSession();
|
||||||
_violationSub?.cancel();
|
_violationSub?.cancel();
|
||||||
_checkedOutSub?.cancel();
|
_checkedOutSub?.cancel();
|
||||||
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
|
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
|
||||||
@@ -119,6 +120,14 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 📡 NFC 감지 세션 시작
|
/// 📡 NFC 감지 세션 시작
|
||||||
void _startNfcSession() async {
|
void _startNfcSession() async {
|
||||||
|
// 🌐 웹 브라우저는 NFC 하드웨어에 접근할 방법이 없어서, 시도조차 하지 않고
|
||||||
|
// 바로 안내 문구만 보여준다 (괜히 켜는 척하다 먹통되는 것 방지).
|
||||||
|
if (kIsWeb) {
|
||||||
|
_statusMessage = "NFC 태그 기능은 스마트폰 앱에서만 사용할 수 있어요.";
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bool isAvailable = await NfcManager.instance.isAvailable();
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
|
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
NfcManager.instance.stopSession();
|
// 🌐 웹에서는 애초에 세션을 시작하지 않으므로 stopSession도 호출하지 않는다.
|
||||||
|
if (!kIsWeb) NfcManager.instance.stopSession();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,11 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
onMessage("주머니 번호를 입력해주세요.", true);
|
onMessage("주머니 번호를 입력해주세요.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 🌐 웹 브라우저는 NFC 하드웨어에 접근할 방법이 없어서 시도조차 하지 않는다.
|
||||||
|
if (kIsWeb) {
|
||||||
|
onMessage("NFC 태그 쓰기는 스마트폰 앱에서만 사용할 수 있어요.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final String pocketLabel = "POCKET_$number";
|
final String pocketLabel = "POCKET_$number";
|
||||||
|
|
||||||
bool isAvailable = await NfcManager.instance.isAvailable();
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 🛍️ 프로필 꾸미기 상점 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
// UI는 lib/ui/profile_customize_screen.dart가 담당한다.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart' show Color;
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
import '../theme/profile_themes.dart';
|
||||||
|
|
||||||
|
class ShopTemplate {
|
||||||
|
final int id;
|
||||||
|
final String code;
|
||||||
|
final String category;
|
||||||
|
final String name;
|
||||||
|
final int price;
|
||||||
|
final Color primaryColor;
|
||||||
|
final Color secondaryColor;
|
||||||
|
final bool owned;
|
||||||
|
final bool equipped;
|
||||||
|
|
||||||
|
ShopTemplate({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.category,
|
||||||
|
required this.name,
|
||||||
|
required this.price,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.secondaryColor,
|
||||||
|
required this.owned,
|
||||||
|
required this.equipped,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ShopTemplate.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ShopTemplate(
|
||||||
|
id: json['id'] as int,
|
||||||
|
code: json['code'] as String,
|
||||||
|
category: json['category'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
price: json['price'] as int,
|
||||||
|
primaryColor: _parseColor(json['primaryColor'] as String),
|
||||||
|
secondaryColor: _parseColor(json['secondaryColor'] as String),
|
||||||
|
owned: json['owned'] == true,
|
||||||
|
equipped: json['equipped'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseColor(String hex) {
|
||||||
|
return Color(int.parse(hex.substring(2), radix: 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileShopController extends ChangeNotifier {
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
int cashBalance = 0;
|
||||||
|
List<ShopTemplate> templates = [];
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (hasListeners) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadCatalog(String studentId) async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'$baseUrl/api/shop/catalog?studentId=${Uri.encodeComponent(studentId)}',
|
||||||
|
);
|
||||||
|
final response = await http.get(url);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
cashBalance = (data['cashBalance'] as num?)?.toInt() ?? 0;
|
||||||
|
templates = ((data['templates'] as List?) ?? [])
|
||||||
|
.map((t) => ShopTemplate.fromJson(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 네트워크 에러여도 화면은 빈 상태로라도 그려지게 조용히 넘어간다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> purchase(
|
||||||
|
String studentId,
|
||||||
|
int templateId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('$baseUrl/api/shop/purchase');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
|
||||||
|
);
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final success = data['status'] == 'success';
|
||||||
|
if (success) {
|
||||||
|
await loadCatalog(studentId);
|
||||||
|
}
|
||||||
|
return (success, '${data['message'] ?? (success ? '구매 완료!' : '구매 실패')}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> equip(
|
||||||
|
String studentId,
|
||||||
|
int? templateId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('$baseUrl/api/shop/equip');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
|
||||||
|
);
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final success = data['status'] == 'success';
|
||||||
|
if (success) {
|
||||||
|
await loadCatalog(studentId);
|
||||||
|
}
|
||||||
|
return (success, '${data['message'] ?? (success ? '장착 완료' : '장착 실패')}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도
|
||||||
|
/// 현재 장착 중인 테마 색상만 가볍게 조회한다.
|
||||||
|
static Future<ProfileTheme?> fetchEquippedTheme(String studentId) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}',
|
||||||
|
);
|
||||||
|
final response = await http.get(url);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
if (data['equipped'] == true) {
|
||||||
|
return profileThemes[data['code'] as String?];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 실패하면 그냥 기본 모습으로 보여준다.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import '../ui/app_notice.dart';
|
import '../ui/app_notice.dart';
|
||||||
|
import '../ui/hold_to_reveal_password_field.dart';
|
||||||
import '../ui/login_screen.dart';
|
import '../ui/login_screen.dart';
|
||||||
|
|
||||||
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
||||||
@@ -75,13 +76,12 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
TextField(
|
HoldToRevealPasswordField(
|
||||||
controller: _pwController,
|
controller: _pwController,
|
||||||
obscureText: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
labelText: '새로운 비밀번호 입력',
|
||||||
labelText: '새로운 비밀번호 입력',
|
textInputAction: TextInputAction.done,
|
||||||
border: OutlineInputBorder(),
|
onSubmitted: (_) => _updatePassword(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
|
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
|
||||||
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
|
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
|
||||||
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
|
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
|
||||||
|
//
|
||||||
|
// 🛍️ mist/paper/linen/sage(구조색 4종)는 프로필 꾸미기 템플릿을 장착하면 화면
|
||||||
|
// 전체에 그 테마가 번지도록 const를 빼고 바꿔치기 가능하게 했다. ink는 텍스트/
|
||||||
|
// 버튼 등 너무 많은 곳에서 쓰여서 바꾸면 가독성이 깨질 수 있어 그대로 고정 둔다.
|
||||||
|
// applyTemplateTheme()로만 바꾸고, 직접 대입하지 않는다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'profile_themes.dart';
|
||||||
|
|
||||||
class AppPalette {
|
class AppPalette {
|
||||||
AppPalette._();
|
AppPalette._();
|
||||||
|
|
||||||
static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼
|
static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼
|
||||||
static const Color sage = Color(0xFFDADDD8); // 보더/구분선/보조 표면
|
|
||||||
static const Color linen = Color(0xFFECEBE4); // 은은한 카드/배지 배경
|
static const Color _defaultSage = Color(0xFFDADDD8);
|
||||||
static const Color mist = Color(0xFFEEF0F2); // 페이지 배경
|
static const Color _defaultLinen = Color(0xFFECEBE4);
|
||||||
static const Color paper = Color(0xFFFAFAFF); // 카드 표면(거의 흰색)
|
static const Color _defaultMist = Color(0xFFEEF0F2);
|
||||||
|
static const Color _defaultPaper = Color(0xFFFAFAFF);
|
||||||
|
|
||||||
|
static Color sage = _defaultSage; // 보더/구분선/보조 표면
|
||||||
|
static Color linen = _defaultLinen; // 은은한 카드/배지 배경
|
||||||
|
static Color mist = _defaultMist; // 페이지 배경
|
||||||
|
static Color paper = _defaultPaper; // 카드 표면(거의 흰색)
|
||||||
|
|
||||||
|
/// 🛍️ 장착한 템플릿에 맞춰 구조색 4종을 한 번에 바꾼다. null을 주면 기본값으로 복원.
|
||||||
|
static void applyTemplateTheme(ProfileTheme? theme) {
|
||||||
|
mist = theme?.background ?? _defaultMist;
|
||||||
|
paper = theme?.surface ?? _defaultPaper;
|
||||||
|
linen = theme?.surfaceAlt ?? _defaultLinen;
|
||||||
|
sage = theme?.border ?? _defaultSage;
|
||||||
|
}
|
||||||
|
|
||||||
// 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼
|
// 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼
|
||||||
// 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다.
|
// 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// 🎨 프로필 꾸미기 "템플릿"을 장착하면 프로필 카드 색뿐 아니라 화면 전체의
|
||||||
|
// 배경/타일/아이콘 색까지 통째로 바뀌는 "테마" 데이터.
|
||||||
|
// 템플릿은 상점에서 사는 색/애니메이션 상품, 테마는 그 템플릿이 몰고 오는
|
||||||
|
// 화면 전체 디자인(배경색/타일색/아이콘색/모양)을 가리킨다 - 용어 구분은
|
||||||
|
// 사장님이 정해준 것을 그대로 따른다.
|
||||||
|
//
|
||||||
|
// 템플릿 하나하나에 대해 직접 디자인한 고정 팔레트라서, 서버에서 내려주는
|
||||||
|
// code(예: "lavender")로 이 맵을 찾아 쓴다. 템플릿이 늘어나면 여기도 추가해야 한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ProfileTheme {
|
||||||
|
final Color primary; // 프로필 카드 그라데이션 시작색 (템플릿 색)
|
||||||
|
final Color secondary; // 프로필 카드 그라데이션 끝색 (템플릿 색)
|
||||||
|
final Color background; // 페이지 배경
|
||||||
|
final Color surface; // 타일/카드 배경
|
||||||
|
final Color surfaceAlt; // 아이콘 동그라미 등 보조 표면
|
||||||
|
final Color border; // 타일 테두리/구분선
|
||||||
|
final Color accent; // 타일 아이콘 등 강조색
|
||||||
|
final Color onBackground; // 배경/타일 위에 올라가는 기본 텍스트 색
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
const ProfileTheme({
|
||||||
|
required this.primary,
|
||||||
|
required this.secondary,
|
||||||
|
required this.background,
|
||||||
|
required this.surface,
|
||||||
|
required this.surfaceAlt,
|
||||||
|
required this.border,
|
||||||
|
required this.accent,
|
||||||
|
required this.onBackground,
|
||||||
|
this.isDark = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const Map<String, ProfileTheme> profileThemes = {
|
||||||
|
'lavender': ProfileTheme(
|
||||||
|
primary: Color(0xFFB39DDB),
|
||||||
|
secondary: Color(0xFF7E57C2),
|
||||||
|
background: Color(0xFFF5F0FA),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surfaceAlt: Color(0xFFEDE4F7),
|
||||||
|
border: Color(0xFFDCCCEF),
|
||||||
|
accent: Color(0xFF7E57C2),
|
||||||
|
onBackground: Color(0xFF1C1C1C),
|
||||||
|
),
|
||||||
|
'mint': ProfileTheme(
|
||||||
|
primary: Color(0xFF80CBC4),
|
||||||
|
secondary: Color(0xFF00897B),
|
||||||
|
background: Color(0xFFEAF6F4),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surfaceAlt: Color(0xFFDBF0EC),
|
||||||
|
border: Color(0xFFBFE3DC),
|
||||||
|
accent: Color(0xFF00897B),
|
||||||
|
onBackground: Color(0xFF1C1C1C),
|
||||||
|
),
|
||||||
|
'sunset': ProfileTheme(
|
||||||
|
primary: Color(0xFFFFB74D),
|
||||||
|
secondary: Color(0xFFE64A19),
|
||||||
|
background: Color(0xFFFFF3E6),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surfaceAlt: Color(0xFFFFE5CC),
|
||||||
|
border: Color(0xFFFFD1A6),
|
||||||
|
accent: Color(0xFFE64A19),
|
||||||
|
onBackground: Color(0xFF1C1C1C),
|
||||||
|
),
|
||||||
|
'ocean': ProfileTheme(
|
||||||
|
primary: Color(0xFF4FC3F7),
|
||||||
|
secondary: Color(0xFF01579B),
|
||||||
|
background: Color(0xFFEAF6FC),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surfaceAlt: Color(0xFFD9EEF9),
|
||||||
|
border: Color(0xFFBBDDF0),
|
||||||
|
accent: Color(0xFF01579B),
|
||||||
|
onBackground: Color(0xFF1C1C1C),
|
||||||
|
),
|
||||||
|
'aurora': ProfileTheme(
|
||||||
|
primary: Color(0xFF81C784),
|
||||||
|
secondary: Color(0xFF1565C0),
|
||||||
|
background: Color(0xFFEDF6EF),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surfaceAlt: Color(0xFFDCEEE0),
|
||||||
|
border: Color(0xFFC3E2CA),
|
||||||
|
accent: Color(0xFF1565C0),
|
||||||
|
onBackground: Color(0xFF1C1C1C),
|
||||||
|
),
|
||||||
|
// 🌌 최고가 템플릿 - 다른 5개와 달리 통째로 다크 테마라서 들어가자마자 확 다르게 느껴진다.
|
||||||
|
'galaxy': ProfileTheme(
|
||||||
|
primary: Color(0xFF9575CD),
|
||||||
|
secondary: Color(0xFF1A237E),
|
||||||
|
background: Color(0xFF14122B),
|
||||||
|
surface: Color(0xFF211F42),
|
||||||
|
surfaceAlt: Color(0xFF2C2A52),
|
||||||
|
border: Color(0xFF3A3768),
|
||||||
|
accent: Color(0xFFB39DDB),
|
||||||
|
onBackground: Color(0xFFF5F3FF),
|
||||||
|
isDark: true,
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 🌐 지금 장착된 프로필 테마를 앱 전체에 퍼뜨리는 통로.
|
||||||
|
// AppPalette의 구조색(mist/paper/linen/sage)은 전역 static 값이라 바꾸면 되지만,
|
||||||
|
// TitlePill처럼 "실시간으로" 다시 그려져야 하는 공유 위젯은 구독할 대상이
|
||||||
|
// 따로 필요해서 ValueNotifier를 하나 더 둔다. 템플릿을 장착/해제할 때는
|
||||||
|
// AppPalette를 직접 건드리지 말고 꼭 이 함수를 통해서만 바꾼다.
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'app_palette.dart';
|
||||||
|
import 'profile_themes.dart';
|
||||||
|
|
||||||
|
final ValueNotifier<ProfileTheme?> globalProfileTheme = ValueNotifier(null);
|
||||||
|
|
||||||
|
void applyGlobalProfileTheme(ProfileTheme? theme) {
|
||||||
|
AppPalette.applyTemplateTheme(theme);
|
||||||
|
globalProfileTheme.value = theme;
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.danger,
|
backgroundColor: AppPalette.danger,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.message,
|
widget.message,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppPalette.paper,
|
color: AppPalette.paper,
|
||||||
fontSize: 13.5,
|
fontSize: 13.5,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../function/board_list_controller.dart';
|
import '../function/board_list_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'board_post_detail_screen.dart';
|
import 'board_post_detail_screen.dart';
|
||||||
import 'board_report_screen.dart';
|
import 'board_report_screen.dart';
|
||||||
import 'board_write_screen.dart';
|
import 'board_write_screen.dart';
|
||||||
@@ -149,7 +150,8 @@ class _BoardHomeScreenState extends State<BoardHomeScreen>
|
|||||||
floatingActionButton: _tabController == null
|
floatingActionButton: _tabController == null
|
||||||
? null
|
? null
|
||||||
: FloatingActionButton(
|
: FloatingActionButton(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
onPressed: _openWrite,
|
onPressed: _openWrite,
|
||||||
child: const Icon(Icons.edit_rounded),
|
child: const Icon(Icons.edit_rounded),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/board_post_controller.dart';
|
import '../function/board_post_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -101,7 +102,8 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
),
|
||||||
@@ -139,6 +141,7 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/device_checkout_ledger_controller.dart';
|
import '../function/device_checkout_ledger_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -77,8 +78,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -190,7 +193,9 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
|||||||
_approve(r['id']);
|
_approve(r['id']);
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
child: const Text('승인'),
|
child: const Text('승인'),
|
||||||
@@ -208,6 +213,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('닫기'),
|
child: const Text('닫기'),
|
||||||
),
|
),
|
||||||
@@ -350,7 +356,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
|||||||
? const BorderSide(
|
? const BorderSide(
|
||||||
color: AppPalette.warning,
|
color: AppPalette.warning,
|
||||||
)
|
)
|
||||||
: const BorderSide(
|
: BorderSide(
|
||||||
color: AppPalette.sage,
|
color: AppPalette.sage,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/device_checkout_controller.dart';
|
import '../function/device_checkout_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -121,6 +122,16 @@ class _DeviceCheckoutRequestScreenState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _pickStartTime,
|
onPressed: _pickStartTime,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
_startTime == null
|
_startTime == null
|
||||||
@@ -133,6 +144,16 @@ class _DeviceCheckoutRequestScreenState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _pickEndTime,
|
onPressed: _pickEndTime,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.stop_rounded),
|
icon: const Icon(Icons.stop_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
_endTime == null
|
_endTime == null
|
||||||
@@ -163,7 +184,9 @@ class _DeviceCheckoutRequestScreenState
|
|||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/friend_controller.dart';
|
import '../function/friend_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -47,7 +48,10 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
|||||||
title: const Text('친구 추가'),
|
title: const Text('친구 추가'),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: idController,
|
controller: idController,
|
||||||
|
autofocus: true,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '친구 학번',
|
labelText: '친구 학번',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -60,7 +64,8 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
@@ -99,6 +104,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||||
),
|
),
|
||||||
@@ -155,7 +161,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
onPressed: _showAddFriendDialog,
|
onPressed: _showAddFriendDialog,
|
||||||
icon: const Icon(Icons.person_add_rounded),
|
icon: const Icon(Icons.person_add_rounded),
|
||||||
@@ -213,7 +219,9 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../function/friend_controller.dart';
|
import '../function/friend_controller.dart';
|
||||||
import '../function/group_detail_controller.dart';
|
import '../function/group_detail_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'launchpad_transition.dart';
|
import 'launchpad_transition.dart';
|
||||||
import 'study_timer_screen.dart';
|
import 'study_timer_screen.dart';
|
||||||
@@ -106,7 +107,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
subtitle: Text('${f['studentId']}'),
|
subtitle: Text('${f['studentId']}'),
|
||||||
trailing: ElevatedButton(
|
trailing: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -158,8 +161,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
@@ -195,6 +200,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'나가기',
|
'나가기',
|
||||||
@@ -287,7 +293,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
onPressed: _showInviteSheet,
|
onPressed: _showInviteSheet,
|
||||||
icon: const Icon(Icons.person_add_rounded),
|
icon: const Icon(Icons.person_add_rounded),
|
||||||
@@ -323,7 +329,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
label: const Text('백품타 바로가기'),
|
label: const Text('백품타 바로가기'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppPalette.ink,
|
foregroundColor: AppPalette.ink,
|
||||||
side: const BorderSide(color: AppPalette.sage),
|
side: BorderSide(color: AppPalette.sage),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -398,7 +404,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () => _respondJoinRequest(
|
onPressed: () => _respondJoinRequest(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/group_controller.dart';
|
import '../function/group_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'group_detail_screen.dart';
|
import 'group_detail_screen.dart';
|
||||||
import 'launchpad_transition.dart';
|
import 'launchpad_transition.dart';
|
||||||
@@ -57,7 +58,10 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
|
autofocus: true,
|
||||||
maxLength: 20,
|
maxLength: 20,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '그룹 이름',
|
labelText: '그룹 이름',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -83,7 +87,8 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
@@ -180,7 +185,7 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
onPressed: _showCreateGroupDialog,
|
onPressed: _showCreateGroupDialog,
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
@@ -238,7 +243,9 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () => _respondInvite(
|
onPressed: () => _respondInvite(
|
||||||
@@ -444,7 +451,11 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
|||||||
else
|
else
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme
|
||||||
|
.value
|
||||||
|
?.accent ??
|
||||||
|
AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 👁️ 비밀번호 입력창 - 오른쪽 눈 아이콘을 누르고 있는 동안만 평문으로 보여준다.
|
||||||
|
// 토글형(눌러서 계속 보기) 대신 "홀드"로 만든 이유: 손을 떼는 순간 바로 다시 가려져서
|
||||||
|
// 누가 옆에서 보더라도 짧게 확인만 하고 끝낼 수 있다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class HoldToRevealPasswordField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String labelText;
|
||||||
|
final String? hintText;
|
||||||
|
final IconData prefixIcon;
|
||||||
|
final bool autofocus;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final TextInputAction? textInputAction;
|
||||||
|
final ValueChanged<String>? onSubmitted;
|
||||||
|
|
||||||
|
const HoldToRevealPasswordField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.labelText,
|
||||||
|
this.hintText,
|
||||||
|
this.prefixIcon = Icons.lock,
|
||||||
|
this.autofocus = false,
|
||||||
|
this.keyboardType,
|
||||||
|
this.textInputAction,
|
||||||
|
this.onSubmitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HoldToRevealPasswordField> createState() =>
|
||||||
|
_HoldToRevealPasswordFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HoldToRevealPasswordFieldState extends State<HoldToRevealPasswordField> {
|
||||||
|
bool _revealed = false;
|
||||||
|
|
||||||
|
void _setRevealed(bool value) {
|
||||||
|
if (_revealed != value) setState(() => _revealed = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
obscureText: !_revealed,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
keyboardType: widget.keyboardType,
|
||||||
|
textInputAction: widget.textInputAction,
|
||||||
|
onSubmitted: widget.onSubmitted,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.labelText,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(widget.prefixIcon),
|
||||||
|
suffixIcon: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (_) => _setRevealed(true),
|
||||||
|
onTapUp: (_) => _setRevealed(false),
|
||||||
|
onTapCancel: () => _setRevealed(false),
|
||||||
|
child: Icon(
|
||||||
|
_revealed ? Icons.visibility_rounded : Icons.visibility_off_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-50
@@ -6,6 +6,7 @@ import '../function/login_controller.dart';
|
|||||||
import '../function/session_store.dart';
|
import '../function/session_store.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
|
import 'hold_to_reveal_password_field.dart';
|
||||||
import 'main_dashboard.dart';
|
import 'main_dashboard.dart';
|
||||||
import 'teacher_register_screen.dart';
|
import 'teacher_register_screen.dart';
|
||||||
|
|
||||||
@@ -112,6 +113,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setDialogState) {
|
builder: (context, setDialogState) {
|
||||||
|
Future<void> submit() async {
|
||||||
|
String newPassword = newPwController.text.trim();
|
||||||
|
if (newPassword.isEmpty) {
|
||||||
|
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() => isUpdating = true);
|
||||||
|
|
||||||
|
final (success, message) = await _controller.changePassword(
|
||||||
|
studentId: studentId,
|
||||||
|
newPassword: newPassword,
|
||||||
|
);
|
||||||
|
setDialogState(() => isUpdating = false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
_navigateBasedOnRole(
|
||||||
|
role,
|
||||||
|
studentId,
|
||||||
|
name,
|
||||||
|
isDeviceMatched,
|
||||||
|
grade,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'초기 비밀번호 변경',
|
'초기 비밀번호 변경',
|
||||||
@@ -125,14 +156,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
style: TextStyle(color: AppPalette.danger),
|
style: TextStyle(color: AppPalette.danger),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
HoldToRevealPasswordField(
|
||||||
controller: newPwController,
|
controller: newPwController,
|
||||||
obscureText: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
labelText: '새 비밀번호',
|
||||||
labelText: '새 비밀번호',
|
prefixIcon: Icons.lock_reset,
|
||||||
border: OutlineInputBorder(),
|
textInputAction: TextInputAction.done,
|
||||||
prefixIcon: Icon(Icons.lock_reset),
|
onSubmitted: (_) => submit(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -147,36 +177,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
backgroundColor: AppPalette.ink,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: submit,
|
||||||
String newPassword = newPwController.text.trim();
|
|
||||||
if (newPassword.isEmpty) {
|
|
||||||
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDialogState(() => isUpdating = true);
|
|
||||||
|
|
||||||
final (success, message) = await _controller
|
|
||||||
.changePassword(
|
|
||||||
studentId: studentId,
|
|
||||||
newPassword: newPassword,
|
|
||||||
);
|
|
||||||
setDialogState(() => isUpdating = false);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
AppNotice.show(context, message);
|
|
||||||
_navigateBasedOnRole(
|
|
||||||
role,
|
|
||||||
studentId,
|
|
||||||
name,
|
|
||||||
isDeviceMatched,
|
|
||||||
grade,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
AppNotice.show(context, message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('변경하고 시작하기'),
|
child: const Text('변경하고 시작하기'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -234,6 +235,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
content: Text(message),
|
content: Text(message),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
child: const Text('확인'),
|
child: const Text('확인'),
|
||||||
),
|
),
|
||||||
@@ -254,9 +256,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('$title 권한 인증 (기존 방식)'),
|
title: Text('$title 권한 인증 (기존 방식)'),
|
||||||
content: TextField(
|
content: HoldToRevealPasswordField(
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
obscureText: true,
|
autofocus: true,
|
||||||
|
labelText: '비밀번호',
|
||||||
|
hintText: '비밀번호 4자리를 입력하세요',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
@@ -270,10 +274,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '비밀번호 4자리를 입력하세요',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -341,16 +341,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
HoldToRevealPasswordField(
|
||||||
controller: _pwController,
|
controller: _pwController,
|
||||||
obscureText: true,
|
labelText: '비밀번호',
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _login(),
|
onSubmitted: (_) => _login(),
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '비밀번호',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.lock),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
InkWell(
|
InkWell(
|
||||||
|
|||||||
+115
-49
@@ -7,10 +7,13 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
import '../function/dashboard_layout_controller.dart';
|
import '../function/dashboard_layout_controller.dart';
|
||||||
|
import '../function/profile_shop_controller.dart';
|
||||||
import '../function/session_store.dart';
|
import '../function/session_store.dart';
|
||||||
import '../function/student_dashboard_controller.dart';
|
import '../function/student_dashboard_controller.dart';
|
||||||
import '../models/dashboard_tile_layout.dart';
|
import '../models/dashboard_tile_layout.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/profile_themes.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'admin_dashboard.dart';
|
import 'admin_dashboard.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'board_home_screen.dart';
|
import 'board_home_screen.dart';
|
||||||
@@ -79,6 +82,24 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
if (kTileEditingEnabled) {
|
if (kTileEditingEnabled) {
|
||||||
_layout.load(_tileSpecs().map((t) => t.id).toList());
|
_layout.load(_tileSpecs().map((t) => t.id).toList());
|
||||||
}
|
}
|
||||||
|
if (_isStudent || _isTeacherOrAbove) {
|
||||||
|
_loadEquippedTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛍️ 프로필 꾸미기 상점에서 장착한 템플릿이 몰고 오는 테마 (없으면 기본 모습 그대로).
|
||||||
|
ProfileTheme? _equippedTheme;
|
||||||
|
|
||||||
|
// 🎨 타일 아이콘 등 강조색에 쓸 색 - 테마가 없으면 원래 쓰던 AppPalette.ink.
|
||||||
|
Color get _tileAccent => _equippedTheme?.accent ?? AppPalette.ink;
|
||||||
|
|
||||||
|
Future<void> _loadEquippedTheme() async {
|
||||||
|
final theme = await ProfileShopController.fetchEquippedTheme(_displayId);
|
||||||
|
applyGlobalProfileTheme(theme);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_equippedTheme = theme;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -139,6 +160,15 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: idController,
|
controller: idController,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final inputId = value.trim();
|
||||||
|
if (inputId.isNotEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_deleteUser(inputId);
|
||||||
|
}
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '학번 또는 교직원 번호',
|
labelText: '학번 또는 교직원 번호',
|
||||||
hintText: '예: 201101 또는 T1001',
|
hintText: '예: 201101 또는 T1001',
|
||||||
@@ -277,23 +307,28 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
final List<({String id, Widget child})> tiles = [];
|
final List<({String id, Widget child})> tiles = [];
|
||||||
|
|
||||||
if (_isStudent) {
|
if (_isStudent) {
|
||||||
|
// 🌐 웹은 NFC 하드웨어에 접근할 수 없으니, 등록된 기기가 아닐 때와 똑같이
|
||||||
|
// 잠긴 모양(자물쇠 아이콘 + 회색)으로 보여주고 누르면 안내만 뜨게 한다.
|
||||||
|
final bool nfcUnlocked = widget.isDeviceMatched && !kIsWeb;
|
||||||
tiles.add((
|
tiles.add((
|
||||||
id: 'nfc_tag',
|
id: 'nfc_tag',
|
||||||
child: _buildModernCard(
|
child: _buildModernCard(
|
||||||
icon: widget.isDeviceMatched
|
icon: nfcUnlocked ? Icons.contactless_rounded : Icons.lock_rounded,
|
||||||
? Icons.contactless_rounded
|
|
||||||
: Icons.lock_rounded,
|
|
||||||
title: 'NFC 태그',
|
title: 'NFC 태그',
|
||||||
subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용',
|
subtitle: kIsWeb
|
||||||
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage,
|
? '스마트폰 앱 전용 기능'
|
||||||
onTap: widget.isDeviceMatched
|
: (widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용'),
|
||||||
|
color: nfcUnlocked ? _tileAccent : AppPalette.sage,
|
||||||
|
onTap: nfcUnlocked
|
||||||
? () => _openPocketCheckIn(context)
|
? () => _openPocketCheckIn(context)
|
||||||
: () => AppNotice.show(
|
: () => AppNotice.show(
|
||||||
context,
|
context,
|
||||||
'대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
|
kIsWeb
|
||||||
|
? 'NFC 태그 기능은 스마트폰 앱에서만 사용할 수 있어요.'
|
||||||
|
: '대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
|
||||||
icon: Icons.lock_rounded,
|
icon: Icons.lock_rounded,
|
||||||
),
|
),
|
||||||
isActionButton: widget.isDeviceMatched,
|
isActionButton: nfcUnlocked,
|
||||||
isLoading: _controller.isLoading,
|
isLoading: _controller.isLoading,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -303,7 +338,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.fastfood_rounded,
|
icon: Icons.fastfood_rounded,
|
||||||
title: '실시간 학교 상황',
|
title: '실시간 학교 상황',
|
||||||
subtitle: '급식실 줄 & 매점 재고 확인',
|
subtitle: '급식실 줄 & 매점 재고 확인',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => AppNotice.show(
|
onTap: () => AppNotice.show(
|
||||||
context,
|
context,
|
||||||
'실시간 학교 상황 페이지로 이동합니다.',
|
'실시간 학교 상황 페이지로 이동합니다.',
|
||||||
@@ -317,7 +352,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.tablet_mac_rounded,
|
icon: Icons.tablet_mac_rounded,
|
||||||
title: '스마트기기 반출',
|
title: '스마트기기 반출',
|
||||||
subtitle: '패드 사용 신청 (시간/목적)',
|
subtitle: '패드 사용 신청 (시간/목적)',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => DeviceCheckoutRequestScreen(
|
(context) => DeviceCheckoutRequestScreen(
|
||||||
@@ -333,7 +368,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.timer_rounded,
|
icon: Icons.timer_rounded,
|
||||||
title: '백품타',
|
title: '백품타',
|
||||||
subtitle: '공부 타이머 | 학년별 공부 랭킹',
|
subtitle: '공부 타이머 | 학년별 공부 랭킹',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => StudyTimerScreen(
|
(context) => StudyTimerScreen(
|
||||||
@@ -350,7 +385,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.forum_rounded,
|
icon: Icons.forum_rounded,
|
||||||
title: '백판',
|
title: '백판',
|
||||||
subtitle: '익명 커뮤니티 게시판',
|
subtitle: '익명 커뮤니티 게시판',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => BoardHomeScreen(studentId: _displayId),
|
(context) => BoardHomeScreen(studentId: _displayId),
|
||||||
@@ -363,7 +398,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.campaign_rounded,
|
icon: Icons.campaign_rounded,
|
||||||
title: '선생님 호출',
|
title: '선생님 호출',
|
||||||
subtitle: '교무실 위치 확인 및 호출',
|
subtitle: '교무실 위치 확인 및 호출',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => TeacherCallScreen(
|
(context) => TeacherCallScreen(
|
||||||
@@ -379,7 +414,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.people_alt_rounded,
|
icon: Icons.people_alt_rounded,
|
||||||
title: '친구',
|
title: '친구',
|
||||||
subtitle: '학번으로 친구 추가',
|
subtitle: '학번으로 친구 추가',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) =>
|
(context) =>
|
||||||
@@ -393,7 +428,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.groups_rounded,
|
icon: Icons.groups_rounded,
|
||||||
title: '그룹 스터디',
|
title: '그룹 스터디',
|
||||||
subtitle: '친구랑 그룹 만들어 같이 공부',
|
subtitle: '친구랑 그룹 만들어 같이 공부',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => GroupListScreen(
|
(context) => GroupListScreen(
|
||||||
@@ -410,7 +445,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.calendar_month_rounded,
|
icon: Icons.calendar_month_rounded,
|
||||||
title: '출석 달력',
|
title: '출석 달력',
|
||||||
subtitle: '연속 출석 기록 확인',
|
subtitle: '연속 출석 기록 확인',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => StudyCalendarScreen(studentId: _displayId),
|
(context) => StudyCalendarScreen(studentId: _displayId),
|
||||||
@@ -426,7 +461,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.assignment_turned_in_rounded,
|
icon: Icons.assignment_turned_in_rounded,
|
||||||
title: '실시간 출석 확인',
|
title: '실시간 출석 확인',
|
||||||
subtitle: '학생 제출 로그 모니터링',
|
subtitle: '학생 제출 로그 모니터링',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => const TeacherAttendancePage(),
|
(context) => const TeacherAttendancePage(),
|
||||||
@@ -439,7 +474,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.manage_accounts_rounded,
|
icon: Icons.manage_accounts_rounded,
|
||||||
title: '학생 계정 관리',
|
title: '학생 계정 관리',
|
||||||
subtitle: '계정 추가 및 강제 리셋',
|
subtitle: '계정 추가 및 강제 리셋',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => const TeacherStudentManagementPage(),
|
(context) => const TeacherStudentManagementPage(),
|
||||||
@@ -452,7 +487,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.tablet_mac_rounded,
|
icon: Icons.tablet_mac_rounded,
|
||||||
title: '스마트기기 반출 대장',
|
title: '스마트기기 반출 대장',
|
||||||
subtitle: '패드 반출 신청 승인/거절',
|
subtitle: '패드 반출 신청 승인/거절',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => DeviceCheckoutLedgerPage(
|
(context) => DeviceCheckoutLedgerPage(
|
||||||
@@ -468,7 +503,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.shield_outlined,
|
icon: Icons.shield_outlined,
|
||||||
title: '백판 신고 검토',
|
title: '백판 신고 검토',
|
||||||
subtitle: '학생 게시판 신고 처리',
|
subtitle: '학생 게시판 신고 처리',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => BoardReportScreen(reviewerId: _displayId),
|
(context) => BoardReportScreen(reviewerId: _displayId),
|
||||||
@@ -481,7 +516,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.event_available_rounded,
|
icon: Icons.event_available_rounded,
|
||||||
title: '출석 달력 관리',
|
title: '출석 달력 관리',
|
||||||
subtitle: '날짜별 선물/이벤트 등록',
|
subtitle: '날짜별 선물/이벤트 등록',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => StudyCalendarScreen(
|
(context) => StudyCalendarScreen(
|
||||||
@@ -497,7 +532,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.my_location_rounded,
|
icon: Icons.my_location_rounded,
|
||||||
title: '내 위치 알리기',
|
title: '내 위치 알리기',
|
||||||
subtitle: '학생 호출 확인 및 위치 등록',
|
subtitle: '학생 호출 확인 및 위치 등록',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () => pushLaunchpad(
|
||||||
context,
|
context,
|
||||||
(context) => TeacherLocationScreen(teacherId: _displayId),
|
(context) => TeacherLocationScreen(teacherId: _displayId),
|
||||||
@@ -513,7 +548,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.terminal_rounded,
|
icon: Icons.terminal_rounded,
|
||||||
title: '서버 DB 제어',
|
title: '서버 DB 제어',
|
||||||
subtitle: '시스템 원격 초기화',
|
subtitle: '시스템 원격 초기화',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
pushLaunchpad(context, (context) => const AdminDashboard()),
|
pushLaunchpad(context, (context) => const AdminDashboard()),
|
||||||
),
|
),
|
||||||
@@ -524,19 +559,27 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
icon: Icons.delete_sweep_rounded,
|
icon: Icons.delete_sweep_rounded,
|
||||||
title: '계정 강제 삭제',
|
title: '계정 강제 삭제',
|
||||||
subtitle: '학생 및 교사 DB 삭제',
|
subtitle: '학생 및 교사 DB 삭제',
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
onTap: () => _showDeleteUserDialog(context),
|
onTap: () => _showDeleteUserDialog(context),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
tiles.add((
|
tiles.add((
|
||||||
id: 'nfc_tag_writer',
|
id: 'nfc_tag_writer',
|
||||||
child: _buildModernCard(
|
child: _buildModernCard(
|
||||||
icon: Icons.edit_note_rounded,
|
icon: kIsWeb ? Icons.lock_rounded : Icons.edit_note_rounded,
|
||||||
title: 'NFC 태그 쓰기',
|
title: 'NFC 태그 쓰기',
|
||||||
subtitle: '주머니 스티커 초기 설정',
|
subtitle: kIsWeb ? '스마트폰 앱 전용 기능' : '주머니 스티커 초기 설정',
|
||||||
color: AppPalette.ink,
|
color: kIsWeb ? AppPalette.sage : _tileAccent,
|
||||||
onTap: () =>
|
onTap: kIsWeb
|
||||||
pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
|
? () => AppNotice.show(
|
||||||
|
context,
|
||||||
|
'NFC 태그 쓰기는 스마트폰 앱에서만 사용할 수 있어요.',
|
||||||
|
icon: Icons.lock_rounded,
|
||||||
|
)
|
||||||
|
: () => pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => const NfcTagWriterScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -596,7 +639,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
final bool isEditing = _layout.isEditing;
|
final bool isEditing = _layout.isEditing;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppPalette.mist,
|
backgroundColor: _equippedTheme?.background ?? AppPalette.mist,
|
||||||
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
|
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
|
||||||
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
|
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -645,11 +688,22 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
vertical: 18,
|
vertical: 18,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.color,
|
color: _equippedTheme == null ? theme.color : null,
|
||||||
|
gradient: _equippedTheme == null
|
||||||
|
? null
|
||||||
|
: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_equippedTheme!.primary,
|
||||||
|
_equippedTheme!.secondary,
|
||||||
|
],
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: theme.color.withValues(alpha: 0.25),
|
color: (_equippedTheme?.primary ?? theme.color)
|
||||||
|
.withValues(alpha: 0.25),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 6),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
@@ -715,13 +769,19 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
elevation: 2,
|
elevation: 2,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: () => pushLaunchpad(
|
onTap: () async {
|
||||||
context,
|
await pushLaunchpad(
|
||||||
(context) => ProfileCustomizeScreen(
|
context,
|
||||||
studentId: _displayId,
|
(context) => ProfileCustomizeScreen(
|
||||||
studentName: _displayName,
|
studentId: _displayId,
|
||||||
),
|
studentName: _displayName,
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
// 상점에서 테마를 바꾸고 돌아왔을 수 있으니 다시 불러온다.
|
||||||
|
if (_isStudent || _isTeacherOrAbove) {
|
||||||
|
_loadEquippedTheme();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -763,8 +823,8 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isEditing)
|
if (isEditing)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8, left: 24, right: 24),
|
padding: const EdgeInsets.only(top: 8, left: 24, right: 24),
|
||||||
child: Text(
|
child: Text(
|
||||||
'타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.',
|
'타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -917,14 +977,14 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppPalette.ink,
|
color: _tileAccent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppPalette.paper,
|
color: AppPalette.paper,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.open_in_full_rounded,
|
Icons.open_in_full_rounded,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppPalette.paper,
|
color: AppPalette.paper,
|
||||||
@@ -984,9 +1044,12 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
padding: EdgeInsets.all(cardPadding),
|
padding: EdgeInsets.all(cardPadding),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppPalette.paper,
|
color: _equippedTheme?.surface ?? AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(cardRadius),
|
borderRadius: BorderRadius.circular(cardRadius),
|
||||||
border: Border.all(color: AppPalette.sage, width: 1),
|
border: Border.all(
|
||||||
|
color: _equippedTheme?.border ?? AppPalette.sage,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -995,7 +1058,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.all(iconBoxPadding),
|
padding: EdgeInsets.all(iconBoxPadding),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppPalette.linen,
|
color: _equippedTheme?.surfaceAlt ?? AppPalette.linen,
|
||||||
borderRadius: BorderRadius.circular(iconRadius),
|
borderRadius: BorderRadius.circular(iconRadius),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: iconSize),
|
child: Icon(icon, color: color, size: iconSize),
|
||||||
@@ -1014,7 +1077,9 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppPalette.ink,
|
color:
|
||||||
|
_equippedTheme?.onBackground ??
|
||||||
|
AppPalette.ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1039,7 +1104,8 @@ class _MainDashboardState extends State<MainDashboard> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: subtitleFontSize,
|
fontSize: subtitleFontSize,
|
||||||
color: AppPalette.ink.withValues(alpha: 0.55),
|
color: (_equippedTheme?.onBackground ?? AppPalette.ink)
|
||||||
|
.withValues(alpha: 0.55),
|
||||||
height: 1.15,
|
height: 1.15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1080,7 +1146,7 @@ class _DashboardMenuCard extends StatelessWidget {
|
|||||||
label: '로그아웃',
|
label: '로그아웃',
|
||||||
onTap: onLogout,
|
onTap: onLogout,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppPalette.sage),
|
Divider(height: 1, color: AppPalette.sage),
|
||||||
_MenuRow(
|
_MenuRow(
|
||||||
icon: Icons.settings_outlined,
|
icon: Icons.settings_outlined,
|
||||||
label: '설정',
|
label: '설정',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text("확인"),
|
child: const Text("확인"),
|
||||||
),
|
),
|
||||||
@@ -114,6 +115,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_controller.beginIosProximityWatch(pocketNumber);
|
_controller.beginIosProximityWatch(pocketNumber);
|
||||||
@@ -139,6 +141,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text("확인"),
|
child: const Text("확인"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// 🎨 프로필 커스텀 화면 (UI 전용). 아직은 자리만 잡아둔 빈 화면 - 프로필 꾸미기
|
// 🎨 프로필 커스텀 화면 (UI 전용). 공부시간으로 적립한 캐시로 프로필 카드 테마를
|
||||||
// 관련 기능들이 여기에 하나씩 추가될 예정이다.
|
// 구매/장착하는 상점. 서버 통신/상태는 lib/function/profile_shop_controller.dart가 담당한다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/profile_shop_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/profile_themes.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
class ProfileCustomizeScreen extends StatelessWidget {
|
class ProfileCustomizeScreen extends StatefulWidget {
|
||||||
final String studentId;
|
final String studentId;
|
||||||
final String studentName;
|
final String studentName;
|
||||||
|
|
||||||
@@ -14,38 +18,478 @@ class ProfileCustomizeScreen extends StatelessWidget {
|
|||||||
required this.studentName,
|
required this.studentName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfileCustomizeScreen> createState() => _ProfileCustomizeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
|
||||||
|
final ProfileShopController _controller = ProfileShopController();
|
||||||
|
|
||||||
|
// 🚧 첫 버전은 "기본 프로필 꾸미기"만 실제로 동작한다. 나머지 두 카테고리는
|
||||||
|
// 와이어프레임 자리만 잡아둔 준비중 상태.
|
||||||
|
String _selectedCategory = 'basic_profile';
|
||||||
|
|
||||||
|
// 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태.
|
||||||
|
// null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중.
|
||||||
|
int? _previewTemplateId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 상점 화면을 열 때마다 서버의 실제 장착 상태로 전역 테마를 다시 맞춰둔다
|
||||||
|
/// (딴 데서 장착 상태가 바뀌었을 수도 있는 엣지 케이스 보호용).
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
await _controller.loadCatalog(widget.studentId);
|
||||||
|
if (!mounted) return;
|
||||||
|
for (final t in _controller.templates) {
|
||||||
|
if (t.equipped) {
|
||||||
|
_syncGlobalTheme(t.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_syncGlobalTheme(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 지금 큰 타일에 보여줄 템플릿을 고른다. 직접 미리보기를 누른 게 있으면 그걸,
|
||||||
|
/// 없으면 실제 장착 중인 템플릿을, 그마저도 없으면 null(기본 모습)을 돌려준다.
|
||||||
|
ShopTemplate? _resolvePreviewTemplate() {
|
||||||
|
if (_previewTemplateId == -1) return null;
|
||||||
|
if (_previewTemplateId != null) {
|
||||||
|
for (final t in _controller.templates) {
|
||||||
|
if (t.id == _previewTemplateId) return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final t in _controller.templates) {
|
||||||
|
if (t.equipped) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🎨 지금 "체험하기/미리보기" 중인 테마로 화면 전체(배경/타일/버튼)를 물들인다.
|
||||||
|
/// 아무것도 직접 고르지 않았으면 실제 장착된 테마가 그대로 나오고, "기본 모습"을
|
||||||
|
/// 고르면 null이 되어 화면이 원래 모습으로 돌아간다 - 체험하기를 누르면 바로
|
||||||
|
/// 커스텀 화면 전체에 반영되는 효과를 _resolvePreviewTemplate() 하나로 처리한다.
|
||||||
|
ProfileTheme? get _displayTheme {
|
||||||
|
final preview = _resolvePreviewTemplate();
|
||||||
|
return preview == null ? null : profileThemes[preview.code];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePurchase(ShopTemplate template) async {
|
||||||
|
setState(() => _previewTemplateId = template.id);
|
||||||
|
final (success, message) = await _controller.purchase(
|
||||||
|
widget.studentId,
|
||||||
|
template.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
color: success ? AppPalette.success : AppPalette.danger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleEquip(int? templateId) async {
|
||||||
|
setState(() => _previewTemplateId = templateId ?? -1);
|
||||||
|
final (success, message) = await _controller.equip(
|
||||||
|
widget.studentId,
|
||||||
|
templateId,
|
||||||
|
);
|
||||||
|
if (success) _syncGlobalTheme(templateId);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
color: success ? AppPalette.approved : AppPalette.danger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 💡 커스텀창 안에서의 "미리보기"와 달리, 실제로 장착이 끝난 뒤에는 앱 전체
|
||||||
|
/// (TitlePill, 다른 화면의 배경/카드색)도 같이 바뀌도록 전역에 반영한다.
|
||||||
|
void _syncGlobalTheme(int? equippedTemplateId) {
|
||||||
|
if (equippedTemplateId == null) {
|
||||||
|
applyGlobalProfileTheme(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final t in _controller.templates) {
|
||||||
|
if (t.id == equippedTemplateId) {
|
||||||
|
applyGlobalProfileTheme(profileThemes[t.code]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppPalette.mist,
|
backgroundColor: _displayTheme?.background ?? AppPalette.mist,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: AppPalette.ink,
|
foregroundColor: AppPalette.ink,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: const TitlePill('프로필 꾸미기'),
|
title: TitlePill('프로필 꾸미기', previewTheme: _displayTheme),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: ListenableBuilder(
|
||||||
child: Padding(
|
listenable: _controller,
|
||||||
padding: const EdgeInsets.all(24),
|
builder: (context, _) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () => _controller.loadCatalog(widget.studentId),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
_buildCashBalance(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildCategoryTiles(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_controller.isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildTemplateGrid(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCashBalance() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _displayTheme?.secondary ?? AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.savings_rounded, color: Colors.white, size: 22),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Text(
|
||||||
|
'보유 캐시',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${_controller.cashBalance} 캐시',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryTiles() {
|
||||||
|
final previewTemplate = _resolvePreviewTemplate();
|
||||||
|
// 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에
|
||||||
|
// stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다.
|
||||||
|
return SizedBox(
|
||||||
|
height: 210,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _CategoryTile(
|
||||||
|
title: '기본 프로필 꾸미기',
|
||||||
|
icon: Icons.person_rounded,
|
||||||
|
selected: _selectedCategory == 'basic_profile',
|
||||||
|
enabled: true,
|
||||||
|
onTap: () => setState(() => _selectedCategory = 'basic_profile'),
|
||||||
|
previewPrimary: previewTemplate?.primaryColor,
|
||||||
|
previewSecondary: previewTemplate?.secondaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _CategoryTile(
|
||||||
|
title: '랭킹 · 친구창 배경',
|
||||||
|
subtitle: '준비중',
|
||||||
|
icon: Icons.leaderboard_rounded,
|
||||||
|
selected: false,
|
||||||
|
enabled: false,
|
||||||
|
onTap: null,
|
||||||
|
theme: _displayTheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _CategoryTile(
|
||||||
|
title: '출석 · 그룹창 표시',
|
||||||
|
subtitle: '준비중',
|
||||||
|
icon: Icons.groups_rounded,
|
||||||
|
selected: false,
|
||||||
|
enabled: false,
|
||||||
|
onTap: null,
|
||||||
|
theme: _displayTheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTemplateGrid() {
|
||||||
|
final templates = _controller.templates
|
||||||
|
.where((t) => t.category == _selectedCategory)
|
||||||
|
.toList();
|
||||||
|
final hasEquippedTemplate = templates.any((t) => t.equipped);
|
||||||
|
final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중.
|
||||||
|
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.15,
|
||||||
|
children: [
|
||||||
|
_DefaultTemplateTile(
|
||||||
|
equipped: !hasEquippedTemplate,
|
||||||
|
previewing: previewId == null,
|
||||||
|
theme: _displayTheme,
|
||||||
|
onEquip: () => _handleEquip(null),
|
||||||
|
onPreview: () => setState(() => _previewTemplateId = -1),
|
||||||
|
),
|
||||||
|
for (final template in templates)
|
||||||
|
_TemplateTile(
|
||||||
|
template: template,
|
||||||
|
canAfford: _controller.cashBalance >= template.price,
|
||||||
|
previewing: previewId == template.id,
|
||||||
|
theme: _displayTheme,
|
||||||
|
onPurchase: () => _handlePurchase(template),
|
||||||
|
onEquip: () => _handleEquip(template.id),
|
||||||
|
onPreview: () => setState(() => _previewTemplateId = template.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
// 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용).
|
||||||
|
final Color? previewPrimary;
|
||||||
|
final Color? previewSecondary;
|
||||||
|
// 🚧 "준비중" 비활성 타일의 은은한 틴트용 - 커스텀창의 체험하기 미리보기를 따라간다.
|
||||||
|
final ProfileTheme? theme;
|
||||||
|
|
||||||
|
const _CategoryTile({
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
this.previewPrimary,
|
||||||
|
this.previewSecondary,
|
||||||
|
this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool hasPreview = selected && previewPrimary != null;
|
||||||
|
return Material(
|
||||||
|
color: hasPreview
|
||||||
|
? Colors.transparent
|
||||||
|
: (selected
|
||||||
|
? AppPalette.ink
|
||||||
|
: (enabled
|
||||||
|
? AppPalette.paper
|
||||||
|
: (theme?.surfaceAlt ?? AppPalette.linen))),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
gradient: hasPreview
|
||||||
|
? LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [previewPrimary!, previewSecondary!],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: Border.all(
|
||||||
|
color: hasPreview
|
||||||
|
? previewSecondary!
|
||||||
|
: (selected
|
||||||
|
? AppPalette.ink
|
||||||
|
: (theme?.border ?? AppPalette.sage)),
|
||||||
|
width: hasPreview ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.auto_awesome_rounded,
|
icon,
|
||||||
size: 48,
|
size: 22,
|
||||||
color: AppPalette.ink,
|
color: selected
|
||||||
|
? Colors.white
|
||||||
|
: (enabled ? AppPalette.ink : Colors.grey),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
|
||||||
'곧 다양한 프로필 커스텀 기능이 추가될 예정이에요.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
Text(
|
||||||
'$studentName ($studentId)',
|
title,
|
||||||
style: const TextStyle(color: Colors.grey),
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: selected
|
||||||
|
? Colors.white
|
||||||
|
: (enabled ? AppPalette.ink : Colors.grey[600]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DefaultTemplateTile extends StatelessWidget {
|
||||||
|
final bool equipped;
|
||||||
|
final bool previewing;
|
||||||
|
final ProfileTheme? theme;
|
||||||
|
final VoidCallback onEquip;
|
||||||
|
final VoidCallback onPreview;
|
||||||
|
|
||||||
|
const _DefaultTemplateTile({
|
||||||
|
required this.equipped,
|
||||||
|
required this.previewing,
|
||||||
|
required this.theme,
|
||||||
|
required this.onEquip,
|
||||||
|
required this.onPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final onBackground = theme?.onBackground ?? AppPalette.ink;
|
||||||
|
final borderColor = equipped
|
||||||
|
? AppPalette.approved
|
||||||
|
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
|
||||||
|
final borderWidth = equipped || previewing ? 2.0 : 1.0;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: theme?.surface ?? AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: onPreview,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'기본 모습',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (previewing && !equipped)
|
||||||
|
Text(
|
||||||
|
'미리보기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: equipped
|
||||||
|
? OutlinedButton(
|
||||||
|
onPressed: null,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppPalette.approved,
|
||||||
|
side: const BorderSide(color: AppPalette.approved),
|
||||||
|
),
|
||||||
|
child: const Text('장착중'),
|
||||||
|
)
|
||||||
|
: OutlinedButton(
|
||||||
|
onPressed: onEquip,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: theme?.accent,
|
||||||
|
side: theme == null
|
||||||
|
? null
|
||||||
|
: BorderSide(color: theme!.accent),
|
||||||
|
),
|
||||||
|
child: const Text('장착하기'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -54,3 +498,156 @@ class ProfileCustomizeScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TemplateTile extends StatelessWidget {
|
||||||
|
final ShopTemplate template;
|
||||||
|
final bool canAfford;
|
||||||
|
final bool previewing;
|
||||||
|
final ProfileTheme? theme;
|
||||||
|
final VoidCallback onPurchase;
|
||||||
|
final VoidCallback onEquip;
|
||||||
|
final VoidCallback onPreview;
|
||||||
|
|
||||||
|
const _TemplateTile({
|
||||||
|
required this.template,
|
||||||
|
required this.canAfford,
|
||||||
|
required this.previewing,
|
||||||
|
required this.theme,
|
||||||
|
required this.onPurchase,
|
||||||
|
required this.onEquip,
|
||||||
|
required this.onPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final onBackground = theme?.onBackground ?? AppPalette.ink;
|
||||||
|
final borderColor = template.equipped
|
||||||
|
? AppPalette.approved
|
||||||
|
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
|
||||||
|
final borderWidth = template.equipped || previewing ? 2.0 : 1.0;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: theme?.surface ?? AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: onPreview,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [template.primaryColor, template.secondaryColor],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
template.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (previewing && !template.equipped)
|
||||||
|
Text(
|
||||||
|
'미리보기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${template.price} 캐시',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: onBackground.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (template.equipped)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: null,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppPalette.approved,
|
||||||
|
side: const BorderSide(color: AppPalette.approved),
|
||||||
|
),
|
||||||
|
child: const Text('장착중'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (template.owned)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onEquip,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: theme?.accent,
|
||||||
|
side: theme == null
|
||||||
|
? null
|
||||||
|
: BorderSide(color: theme!.accent),
|
||||||
|
),
|
||||||
|
child: const Text('장착하기'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onPreview,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: theme?.accent,
|
||||||
|
side: theme == null
|
||||||
|
? null
|
||||||
|
: BorderSide(color: theme!.accent),
|
||||||
|
),
|
||||||
|
child: const Text('체험하기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: canAfford ? onPurchase : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: theme?.secondary ?? AppPalette.ink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: AppPalette.sage,
|
||||||
|
),
|
||||||
|
child: const Text('구매하기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/study_calendar_controller.dart';
|
import '../function/study_calendar_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('닫기'),
|
child: const Text('닫기'),
|
||||||
),
|
),
|
||||||
@@ -152,7 +154,8 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor:
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -310,18 +313,16 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
|||||||
date.month == today.month &&
|
date.month == today.month &&
|
||||||
date.day == today.day;
|
date.day == today.day;
|
||||||
|
|
||||||
|
final accentColor =
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onDayTap(date),
|
onTap: () => _onDayTap(date),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: studied
|
color: studied ? accentColor : AppPalette.paper,
|
||||||
? AppPalette.ink
|
|
||||||
: AppPalette.paper,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isToday
|
color: isToday ? accentColor : AppPalette.sage,
|
||||||
? AppPalette.ink
|
|
||||||
: AppPalette.sage,
|
|
||||||
width: isToday ? 2 : 1,
|
width: isToday ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -359,7 +360,10 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_legendDot(AppPalette.ink, '공부한 날'),
|
_legendDot(
|
||||||
|
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||||
|
'공부한 날',
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_legendIcon(
|
_legendIcon(
|
||||||
Icons.card_giftcard_rounded,
|
Icons.card_giftcard_rounded,
|
||||||
|
|||||||
+122
-78
@@ -2,9 +2,11 @@
|
|||||||
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
|
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../function/browser_notification.dart';
|
import '../function/browser_notification.dart';
|
||||||
import '../function/study_timer_controller.dart';
|
import '../function/study_timer_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'launchpad_transition.dart';
|
import 'launchpad_transition.dart';
|
||||||
import 'study_preset_screen.dart';
|
import 'study_preset_screen.dart';
|
||||||
@@ -31,6 +33,9 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
late final StudyTimerController _controller;
|
late final StudyTimerController _controller;
|
||||||
Timer? _awayTimer;
|
Timer? _awayTimer;
|
||||||
|
// ⌨️ 스페이스바로 시작/일시정지를 토글하기 위한 포커스 노드. 모달(뽀모도로 설정 등)이
|
||||||
|
// 닫히면 포커스를 다시 여기로 돌려줘야 스페이스바가 계속 먹힌다.
|
||||||
|
final FocusNode _keyboardFocusNode = FocusNode();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -89,9 +94,34 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_awayTimer?.cancel();
|
_awayTimer?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_keyboardFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ⌨️ 스페이스바 입력 처리 - 멈춰있으면 시작, 돌아가고 있으면 일시정지,
|
||||||
|
/// 일시정지 중이면 재개. 뽀모도로가 자동으로 공부↔휴식을 전환하는 중에는
|
||||||
|
/// 화면에서도 수동 일시정지/재개 버튼을 숨기므로 스페이스바도 똑같이 막는다.
|
||||||
|
void _handleSpacePress() {
|
||||||
|
if (_controller.isBusy) return;
|
||||||
|
if (_controller.isIdle) {
|
||||||
|
requestNotificationPermission();
|
||||||
|
_runAction(_controller.start);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_controller.isRunning) {
|
||||||
|
_runAction(_controller.pause);
|
||||||
|
} else if (_controller.isPaused) {
|
||||||
|
_runAction(_controller.resume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛍️ 장착한 템플릿의 강조색 - 시작/재개처럼 "중립" 상태의 버튼/텍스트에만 쓴다.
|
||||||
|
// 일시정지(warning)/종료(danger) 같은 경고성 색은 의미가 있어서 그대로 둔다.
|
||||||
|
Color get _accentColor => globalProfileTheme.value?.accent ?? AppPalette.ink;
|
||||||
|
|
||||||
String _formatHMS(int totalSeconds) {
|
String _formatHMS(int totalSeconds) {
|
||||||
final h = totalSeconds ~/ 3600;
|
final h = totalSeconds ~/ 3600;
|
||||||
final m = (totalSeconds % 3600) ~/ 60;
|
final m = (totalSeconds % 3600) ~/ 60;
|
||||||
@@ -275,6 +305,8 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 모달이 닫히면 스페이스바 토글이 다시 먹히도록 포커스를 돌려준다.
|
||||||
|
_keyboardFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _minuteStepper({
|
Widget _minuteStepper({
|
||||||
@@ -336,91 +368,103 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final bool isRunning = _controller.isRunning;
|
final bool isRunning = _controller.isRunning;
|
||||||
final bool isPaused = _controller.isPaused;
|
final bool isPaused = _controller.isPaused;
|
||||||
return Scaffold(
|
return Focus(
|
||||||
backgroundColor: AppPalette.mist,
|
focusNode: _keyboardFocusNode,
|
||||||
appBar: AppBar(
|
autofocus: true,
|
||||||
backgroundColor: Colors.transparent,
|
onKeyEvent: (node, event) {
|
||||||
foregroundColor: AppPalette.ink,
|
if (event is KeyDownEvent &&
|
||||||
elevation: 0,
|
event.logicalKey == LogicalKeyboardKey.space) {
|
||||||
centerTitle: true,
|
_handleSpacePress();
|
||||||
title: const TitlePill('백품타'),
|
return KeyEventResult.handled;
|
||||||
actions: [
|
}
|
||||||
IconButton(
|
return KeyEventResult.ignored;
|
||||||
icon: const Icon(Icons.tune_rounded),
|
},
|
||||||
tooltip: '뽀모도로 설정',
|
child: Scaffold(
|
||||||
onPressed: _showPomodoroSettings,
|
backgroundColor: AppPalette.mist,
|
||||||
),
|
appBar: AppBar(
|
||||||
IconButton(
|
backgroundColor: Colors.transparent,
|
||||||
icon: const Icon(Icons.leaderboard_rounded),
|
foregroundColor: AppPalette.ink,
|
||||||
tooltip: '학년 랭킹',
|
elevation: 0,
|
||||||
onPressed: _openRanking,
|
centerTitle: true,
|
||||||
),
|
title: const TitlePill('백품타'),
|
||||||
],
|
actions: [
|
||||||
),
|
IconButton(
|
||||||
body: _controller.isLoading
|
icon: const Icon(Icons.tune_rounded),
|
||||||
? const Center(child: CircularProgressIndicator())
|
tooltip: '뽀모도로 설정',
|
||||||
: Center(
|
onPressed: _showPomodoroSettings,
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
padding: const EdgeInsets.all(24),
|
IconButton(
|
||||||
child: Column(
|
icon: const Icon(Icons.leaderboard_rounded),
|
||||||
mainAxisSize: MainAxisSize.min,
|
tooltip: '학년 랭킹',
|
||||||
children: [
|
onPressed: _openRanking,
|
||||||
Text(
|
),
|
||||||
_statusLabel(isRunning, isPaused),
|
],
|
||||||
style: const TextStyle(
|
),
|
||||||
fontSize: 15,
|
body: _controller.isLoading
|
||||||
color: Colors.grey,
|
? const Center(child: CircularProgressIndicator())
|
||||||
),
|
: Center(
|
||||||
),
|
child: SingleChildScrollView(
|
||||||
if (_controller.pomodoroEnabled &&
|
padding: const EdgeInsets.all(24),
|
||||||
_controller.phase != null) ...[
|
child: Column(
|
||||||
const SizedBox(height: 4),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
|
_statusLabel(isRunning, isPaused),
|
||||||
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
|
|
||||||
'완료 ${_controller.completedCycles}회',
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12.5,
|
fontSize: 15,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (_controller.pomodoroEnabled &&
|
||||||
const SizedBox(height: 12),
|
_controller.phase != null) ...[
|
||||||
Text(
|
const SizedBox(height: 4),
|
||||||
_formatHMS(_controller.liveElapsedSeconds),
|
Text(
|
||||||
style: TextStyle(
|
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
|
||||||
fontSize: 52,
|
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
|
||||||
fontWeight: FontWeight.bold,
|
'완료 ${_controller.completedCycles}회',
|
||||||
color: isPaused ? Colors.grey : AppPalette.ink,
|
style: const TextStyle(
|
||||||
),
|
fontSize: 12.5,
|
||||||
),
|
color: Colors.grey,
|
||||||
const SizedBox(height: 36),
|
fontWeight: FontWeight.w600,
|
||||||
_buildActionButtons(isRunning, isPaused),
|
),
|
||||||
const SizedBox(height: 40),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_statBox(
|
|
||||||
'오늘',
|
|
||||||
_formatHM(_controller.todaySecondsLive),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statBox(
|
|
||||||
'이번주',
|
|
||||||
_formatHM(_controller.weekSecondsLive),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statBox(
|
|
||||||
'전체',
|
|
||||||
_formatHM(_controller.totalSecondsLive),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
],
|
Text(
|
||||||
|
_formatHMS(_controller.liveElapsedSeconds),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isPaused ? Colors.grey : _accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
_buildActionButtons(isRunning, isPaused),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_statBox(
|
||||||
|
'오늘',
|
||||||
|
_formatHM(_controller.todaySecondsLive),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_statBox(
|
||||||
|
'이번주',
|
||||||
|
_formatHM(_controller.weekSecondsLive),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_statBox(
|
||||||
|
'전체',
|
||||||
|
_formatHM(_controller.totalSecondsLive),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -432,7 +476,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
size: 140,
|
size: 140,
|
||||||
icon: Icons.play_arrow_rounded,
|
icon: Icons.play_arrow_rounded,
|
||||||
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
|
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
|
||||||
color: AppPalette.ink,
|
color: _accentColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
requestNotificationPermission();
|
requestNotificationPermission();
|
||||||
_runAction(_controller.start);
|
_runAction(_controller.start);
|
||||||
@@ -456,7 +500,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|||||||
size: 110,
|
size: 110,
|
||||||
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||||
label: isPaused ? '재개' : '일시정지',
|
label: isPaused ? '재개' : '일시정지',
|
||||||
color: isPaused ? AppPalette.ink : AppPalette.warning,
|
color: isPaused ? _accentColor : AppPalette.warning,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_runAction(isPaused ? _controller.resume : _controller.pause),
|
_runAction(isPaused ? _controller.resume : _controller.pause),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_runAction(() => _controller.resetTestPermissions());
|
_runAction(() => _controller.resetTestPermissions());
|
||||||
@@ -98,7 +99,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
title: Text('$studentName 학생 반출 허용'),
|
title: Text('$studentName 학생 반출 허용'),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final minutes = int.tryParse(value.trim()) ?? 5;
|
||||||
|
Navigator.pop(context);
|
||||||
|
_runAction(() => _controller.allowRemoval(studentId, minutes));
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '허용 시간 (분)',
|
labelText: '허용 시간 (분)',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -139,7 +147,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final minutes = int.tryParse(value.trim()) ?? 10;
|
||||||
|
Navigator.pop(context);
|
||||||
|
_runAction(() => _controller.setPermissionWindow(minutes));
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '지금부터 허용 시간 (분)',
|
labelText: '지금부터 허용 시간 (분)',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -180,6 +195,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_runAction(() => _controller.dismissAll());
|
_runAction(() => _controller.dismissAll());
|
||||||
@@ -905,25 +921,25 @@ class _TimeSettingsMenuCard extends StatelessWidget {
|
|||||||
label: '새로고침',
|
label: '새로고침',
|
||||||
onTap: () => onPick('refresh'),
|
onTap: () => onPick('refresh'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppPalette.sage),
|
Divider(height: 1, color: AppPalette.sage),
|
||||||
_TimeMenuRow(
|
_TimeMenuRow(
|
||||||
icon: Icons.access_time_rounded,
|
icon: Icons.access_time_rounded,
|
||||||
label: attendanceTimeLabel,
|
label: attendanceTimeLabel,
|
||||||
onTap: () => onPick('attendance_time'),
|
onTap: () => onPick('attendance_time'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppPalette.sage),
|
Divider(height: 1, color: AppPalette.sage),
|
||||||
_TimeMenuRow(
|
_TimeMenuRow(
|
||||||
icon: Icons.timer_outlined,
|
icon: Icons.timer_outlined,
|
||||||
label: '반출 허용 시간 설정',
|
label: '반출 허용 시간 설정',
|
||||||
onTap: () => onPick('permission_window'),
|
onTap: () => onPick('permission_window'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppPalette.sage),
|
Divider(height: 1, color: AppPalette.sage),
|
||||||
_TimeMenuRow(
|
_TimeMenuRow(
|
||||||
icon: Icons.school_rounded,
|
icon: Icons.school_rounded,
|
||||||
label: '하교 처리',
|
label: '하교 처리',
|
||||||
onTap: () => onPick('dismissal'),
|
onTap: () => onPick('dismissal'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppPalette.sage),
|
Divider(height: 1, color: AppPalette.sage),
|
||||||
_TimeMenuRow(
|
_TimeMenuRow(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
label: '테스트용: 허용시간 초기화',
|
label: '테스트용: 허용시간 초기화',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../data/teacher_call_schedule.dart';
|
import '../data/teacher_call_schedule.dart';
|
||||||
import '../function/teacher_call_controller.dart';
|
import '../function/teacher_call_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
import 'app_notice.dart';
|
import 'app_notice.dart';
|
||||||
import 'title_pill.dart';
|
import 'title_pill.dart';
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
final _customPurposeController = TextEditingController();
|
final _customPurposeController = TextEditingController();
|
||||||
bool _useCustomPurpose = false;
|
bool _useCustomPurpose = false;
|
||||||
|
|
||||||
|
// 🛍️ 장착한 템플릿의 강조색 - 선택된 칩/버튼처럼 "중립" 요소에만 쓴다.
|
||||||
|
Color get _accentColor => globalProfileTheme.value?.accent ?? AppPalette.ink;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -150,7 +154,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _selectedTeacherId == null
|
backgroundColor: _selectedTeacherId == null
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: AppPalette.ink,
|
: _accentColor,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -226,7 +230,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
width: 4,
|
width: 4,
|
||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppPalette.ink,
|
color: _accentColor,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -318,11 +322,9 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? AppPalette.ink : AppPalette.paper,
|
color: selected ? _accentColor : AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
border: Border.all(
|
border: Border.all(color: selected ? _accentColor : AppPalette.sage),
|
||||||
color: selected ? AppPalette.ink : AppPalette.sage,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -376,7 +378,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
final location = t['location'] ?? '교무실';
|
final location = t['location'] ?? '교무실';
|
||||||
final isSelected = _selectedTeacherId == t['teacherId'];
|
final isSelected = _selectedTeacherId == t['teacherId'];
|
||||||
final inClass = _controller.isInClassNow(name);
|
final inClass = _controller.isInClassNow(name);
|
||||||
final statusColor = inClass ? AppPalette.warning : AppPalette.ink;
|
final statusColor = inClass ? AppPalette.warning : _accentColor;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() {
|
||||||
@@ -386,10 +388,10 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? AppPalette.ink : AppPalette.paper,
|
color: isSelected ? _accentColor : AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? AppPalette.ink : AppPalette.sage,
|
color: isSelected ? _accentColor : AppPalette.sage,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class _TeacherStudentManagementPageState
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_runBulkImport(rows);
|
_runBulkImport(rows);
|
||||||
@@ -236,6 +237,7 @@ class _TeacherStudentManagementPageState
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('확인'),
|
child: const Text('확인'),
|
||||||
),
|
),
|
||||||
@@ -296,6 +298,7 @@ class _TeacherStudentManagementPageState
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.ink,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: AppPalette.paper,
|
foregroundColor: AppPalette.paper,
|
||||||
@@ -341,6 +344,7 @@ class _TeacherStudentManagementPageState
|
|||||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
autofocus: true,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppPalette.danger,
|
backgroundColor: AppPalette.danger,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|||||||
+31
-17
@@ -1,32 +1,46 @@
|
|||||||
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
|
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
|
||||||
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
|
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
|
||||||
|
// 🛍️ 프로필 꾸미기 템플릿을 장착하면 이 알약 배경도 같이 바뀐다 - 앱 전체가
|
||||||
|
// 공유하는 위젯이라 여기 하나만 고치면 모든 화면의 제목 배경이 한번에 바뀐다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../theme/profile_themes.dart';
|
||||||
|
import '../theme/theme_sync.dart';
|
||||||
|
|
||||||
class TitlePill extends StatelessWidget {
|
class TitlePill extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
const TitlePill(this.text, {super.key});
|
/// 🎨 프로필 꾸미기 화면의 "체험하기" 미리보기처럼, 이 화면 안에서만 임시로
|
||||||
|
/// 보여줄 테마가 있을 때 넘긴다. 넘기면 전역 장착 테마보다 이게 우선한다.
|
||||||
|
final ProfileTheme? previewTheme;
|
||||||
|
|
||||||
|
const TitlePill(this.text, {super.key, this.previewTheme});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return ValueListenableBuilder(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
valueListenable: globalProfileTheme,
|
||||||
child: Container(
|
builder: (context, globalTheme, _) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
final theme = previewTheme ?? globalTheme;
|
||||||
decoration: BoxDecoration(
|
return Padding(
|
||||||
color: AppPalette.ink,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
borderRadius: BorderRadius.circular(999),
|
child: Container(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
text,
|
color: theme?.secondary ?? AppPalette.ink,
|
||||||
style: const TextStyle(
|
borderRadius: BorderRadius.circular(999),
|
||||||
color: Colors.white,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
child: Text(
|
||||||
fontSize: 15,
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user