2차 팀 프로젝트 : BookBox (Front & Back)

도서 대여/예약 앱 플랫폼 : Flutter
SHIN's avatar
Oct 22, 2024
2차 팀 프로젝트 : BookBox (Front & Back)
 
자동 로그인 기능

자동로그인

 
로그인시 전달받는 토큰만 가지고 있다면 자동 로그인이 가능하게 해준다.
로그인이 필요한 페이지나 서비스에 접근하면 우선 자동 로그인 주소로 토큰을 보내본다.
이때 토큰을 검사하고 유효하면 로그인을 시켜준다.
 
로그인 요청시 로그인한 유저 정보를 가지고 대칭키로 암호화 시켜서 전달했던 토큰을
우리가 받아서 복호화 하고 확인했을 때 해당 유저에게 전달했던 토큰과 일치하면 로그인 시켜주는 것이다.
 
유효하지 않거나 만료시간이 지났다면 로그인 화면으로 보내주면 된다.
 
 
UserController
//자동 로그인 @PostMapping("/auto/login") public ResponseEntity<?> autoLogin(HttpServletRequest request) { String accessToken = request.getHeader("Authorization"); UserResponse.AutoLoginDTO responseDTO = userService.자동로그인(accessToken); return ResponseEntity.ok(Resp.ok(responseDTO)); }
 
컨트롤러에서 받으면 헤더의 Authorization부분, 즉 토큰을 꺼내서 accessToken 변수에 넣고
유저서비스의 자동로그인으로 전달한다.
이후 서비스에서 검증이 끝나면 responseDTO로 리턴한다.
 
 
 
UserService
// 자동 로그인. 토큰을 돌려줄 필요가 없다. public UserResponse.AutoLoginDTO 자동로그인(String accessToken) { Optional.ofNullable(accessToken).orElseThrow(() -> new ExceptionApi401("토큰을 찾을 수 없습니다.")); try { User user = JwtUtil.verify(accessToken); //존재하는 회원인지 확인. id를 꺼내서 존재하는 회원인지 확인 User userPS = userRepository.findById(user.getId()).orElseThrow( ()-> new ExceptionApi401("유저네임을 찾을 수 없습니다") ); return new UserResponse.AutoLoginDTO(userPS); }catch (SignatureVerificationException | JWTDecodeException e1) { throw new ExceptionApi401("유효한 토큰이 아닙니다."); } catch (TokenExpiredException e2){ throw new ExceptionApi401(("토큰이 만료되었습니다.")); } }
 
 
유저서비스에서 토큰만 전달받고 토큰을 복호화 하고 id정보만 꺼내서 유저 객체를 만들어서 리턴한다.
해당 객체에 담겨진 id값으로 db에서 유저를 찾아본다.
존재하는 회원인지 확인하고 시그니처가 다르거나 복호화 한 내용이 우리가 암호화 했을 때와 다르다면 유효하지 않은 토큰이고 토큰을 만들 때 설정한 시간이 지났다면 만료가 뜬다.
 
 
 
토큰을 검증한 JwtUtil
// 검증 코드 public static User verify(String jwt){ //JWT 토큰을 검증할 때는 Bearer을 떼야 한다. jwt = jwt.replace("Bearer ", ""); DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET_KEY)).build().verify(jwt); // TODO: 신민재 추가 // 역할이 USER 인지 확인 String role = decodedJWT.getClaim("role").asString(); if (!"USER".equals(role)) { throw new ExceptionApi400("유효하지 않은 JWT 토큰입니다."); } Long id = decodedJWT.getClaim("id").asLong(); // ID 추출 return User.builder() .id(id) .build(); }
 
 
 
UserResponse의 AutoLoginDTO
// 자동 로그인 record AutoLoginDTO(Long id, String username) { AutoLoginDTO(User user) { this(user.getId(), user.getUsername()); } }
 
 
이렇게 잘 되던 자동 로그인이
 
 
notion image
 
 
 
설정한 토큰 만료시간이 지나면
 
notion image
 
 
만료가 뜬다.
 
 
 
Dialog창 만들기
반납, 예약 취소 등 필요에 따라 확인 다이얼로그 창을 띄워서 재차 확인이 필요한 경우가 있다.
그럴 때 간단하게 사용할 수 있게 components로 custom_dialog.dart를 만들어 놓고 사용하면
편리할 것이다.
 
 
notion image
 
 
 

커스텀 다이얼로그 만들기

 
notion image
 
파일을 만들어서 필요시 커스텀 해서 사용할 수 있게 간단하게 만들어 볼 것이다.
 
custom_dialog.dart
import 'package:flutter/material.dart'; class CustomDialog { final String title; final String content; final VoidCallback onConfirm; CustomDialog({ required this.title, required this.content, required this.onConfirm, }); void show(BuildContext context) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(title), content: Text(content), actions: [ TextButton( child: Text("확인"), onPressed: () { onConfirm(); // 확인 버튼 눌렀을 때 전달 받은 함수 실행 Navigator.of(context).pop(); // 다이얼로그 닫기 }, ), TextButton( child: Text("취소"), onPressed: () { Navigator.of(context).pop(); // 다이얼로그 닫기 }, ), ], ); }, ); } }

호출 방법

notion image
 
CustomDialog를 호출할 때 Named Parameters(선택적 매개변수)에 필수로
title, content, onConfirm 을 전달해서
각각 제목, 내용, 확인 선택히 실행할 함수(콜백)로 사용할 수 있게 한다.
 
 
AlertDialog는 창에 띄워야 하기 때문에 객체를 생성하고 .show(context)를 붙여줘야 한다.
context는 화면을 나타내는 것이므로 .show(context)를 붙여줘야 현재 화면에 뜬다.
AlertDialog(~).show(cocntext); 이런 형태가 돼야 한다.
 
 
 

변수에 대응하는 부분

notion image
 
 
 
 
필요에 따라 타이틀과 내용을 바꿔서 사용하고
확인 선택시 실행해줄 함수를 원하는 코드를 전달해서 간단하게 사용하면 될 것이다.
 
왜 stateless도 아니고 stateful도 아닌 것인가?
 
CustomDialog는 StatelessWidget이나 StatefulWidget이 아니어도 된다.
showDialog는 Flutter에서 제공하는 함수로, 내부적으로 화면에 다이얼로그를 렌더링하기 때문에 CustomDialog 자체가 위젯일 필요는 없다. CustomDialog는 단순히 데이터를 저장하고, showDialog 메서드를 호출하여 다이얼로그를 표시하는 역할을 한다.
 
 
 
 
 
 
 
 
 
 
 
Floating버튼 만들기

버튼 통합

위에 Dialog창 만들고 반납, 연장 버튼으로 다이얼로그창을 연결했는데
대여중 화면의 경우 책의 정보, 연장, 반납 이렇게 3가지 버튼이 필요하다.
 
조그만 폰 화면인데 책 1권당 버튼 3개는 보기에도 좋지 않다.
그래서 플로팅 버튼을 만들어서 거기에 다 넣는 쪽으로 하는 것이 괜찮을 것 같다.
 
 

Floating 버튼

 
FloatingActionButton을 사용해서 이 버튼을 눌렀을 때 showModalBottomSheet 을 통해 화면 하단부에서 창이 뜨게 할 것이다. 한 줄 한 줄 요소는 customFloatingButton을 호출해서 actions: [ ] 내부에 ListTile 을 넣어주면 된다.
Floating 버튼 커스텀을 위해서 SizedBox에 감싸서 너비, 높이, 배경색, 투명도, 테두리설정, 아이콘설정을
넣어줬고 ListTile을 담은 actions를 제외하고는 값을 보내지 않으면 기본값들이 적용된다.
 
참고로 하단에 뜨는 showModalBottomSheet를 닫기 위해서는 창 바깥쪽을 터치하거나
Navigator.pop(context); 를 통해 닫아준다.
 
아래는 코드 전문이다.
 
notion image
 
 
ui/_components/custom_floating_btn.dart
import 'package:flutter/material.dart'; class CustomFloatingButton extends StatelessWidget { final List<ListTile> actions; final double width; final double height; final Color backgroundColor; final double opacity; // 투명도 설정 -> 기본 0.3 final double borderRadius; // 모서리 둥글기 추가 final Widget icon; // 아이콘을 커스터마이징할 수 있도록 필드 추가 CustomFloatingButton({ required this.actions, this.width = 30.0, this.height = 30.0, this.backgroundColor = const Color(0xFF7AB6FA), this.opacity = 0.3, this.borderRadius = 8.0, // 기본값으로 약간 둥글게 설정 this.icon = const Icon(Icons.more_vert), // 기본 아이콘 설정 }); @override Widget build(BuildContext context) { return SizedBox( width: width, height: height, child: FloatingActionButton( onPressed: () { showModalBottomSheet( useSafeArea: true, // 안전 영역을 고려 context: context, builder: (context) { return Wrap( children: actions, ); }, ); }, backgroundColor: backgroundColor.withOpacity(opacity), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(borderRadius), // 모서리 반경 설정 ), child: Icon(Icons.more_vert), ), ); } }

Floating 버튼의 위치 설정

Floating 버튼은 부모에게 부착된다.
예를들어 어떤 Column이 있는데(사진 + 글 등) 이 컬럼의 특정 위치에 Floating 버튼을 부착하고 싶다면
Column 밖에 Floating 버튼을 배치해야된다. (안 그러면 Column의 크기에 따라 위치가 동적으로 변하기 때문)
 
이때 Floating은 부모에게 부착되므로 Column과 Floating을 Stack 위젯으로 감싼다.
Stack 안에 있는 모든 자식 위젯들은 서로 겹칠 수 있기 때문에 이를 이용해서 Floating버튼을
Column의 어딘가에 겹쳐서 붙일 것인데 그러려면 위치 조정이 필수적일 것이다.
 
Floating 버튼이 Stack위젯 내부에서 위치변경을 하려면 Positioned 위젯으로 감싸준다.
positioned으로 top 혹은 bottom // left 혹은 right 옵션으로 부모를 기준으로 배치할 위치를 설정해주면 된다.
그러면 Stack 내부에서 자유로이 위치 변경을 할 수 있다.
 
즉 Stack이 Column과 Floating 버튼을 감싸면 이 둘은 겹칠 수 있게 되는데
이 내부에서 Floating버튼의 위치를 조절하고 싶다면 Floating버튼을 Positioned로 감싸서 조정해주면 된다.
Floating버튼이 부모인 Positioned에 달라붙고 Positioned도 부모인 Stack내부에서 위치조정을 통해
Column 과 겹쳐져서 Column위에서 원하는 곳으로 이동할 수 있게 되는 것이다.
 
그림을 그려보자면
notion image
 
이런식으로 Stack 내부에 주요 컨텐츠는 Column이고 여기에 floating 버튼을 positioned로 감싸서
부모인 Stack 내부에서 Stack내부에 함께 존재하는 Column과 겹쳐져서 위치조정이 가능하다.
 
예시 코드
Stack( children: [ Column( children: [ // Column 내부의 위젯들 ], ), Positioned( bottom: 16.0, // 화면 하단에 배치 right: 16.0, // 화면 오른쪽에 배치 child: FloatingActionButton( onPressed: () { // 버튼 동작 }, child: Icon(Icons.add), ), ), ], )
 
 
커스텀 플로팅 버튼 예시코드
Stack( children: [ InkWell( onTap: () { print('책 클릭 $index'); }, child: Column(children: [ CustomCardItem( imageUrl: "https://picsum.photos/id/${index + 10}/200/280", // 이미지 URL title: "책 제목\n2줄일 때", // 책 제 ), Text( '기한: 24-10-20', style: TextStyle(fontSize: 12), ), ]), ), Positioned( right: -5, top: 0, child: CustomFloatingButton( actions: [ ListTile(title: Text('책 이름 $index')), _bookInfo(context), _lendExtend(context, index), _lendReturn(context, index), ], ), ), ], );
책 사진과 제목 등의 정보를 Column 내부에 넣었고 이를 InkWell로 버튼을 만든 상태이다.
이 Column과 Positioned로 감싼 커스텀 플로팅 버튼을 Stack으로 묶었다.
커스텀 플로팅 버튼은 내부에 ListTile을 리스트로 받으므로 여러 개를 넣을 수 있고
여기에 ListTile을 4개 넣어서 플로팅 버튼을 누르면 각각의 ListTile이 출력되고
또한 각각의 ListTile마다 개별적인 Text 설정 이름과(책이름, 정보, 반납, 대여 등) 각각에 대한 익명함수가 설정이 돼있기 때문에 여러가지 로직을 실행시킬 수 있다.
 
notion image
 
 
공용 클래스 만들기
Book 테이블에만 존재하는 정보를 써야하는 경우에는
공용 Book 클래스를 만들어서 사용할 수 있다.
 
먼저 각 페이지 혹은 탭 화면별로 필요한 클래스는 vm에 언더바 ( _ ) 를 붙여서
전용 클래스로 만들어서 사용하다가 여기저기서 공통적으로 사용된다 싶으면 공용 클래스를 만들어서 사용하면 좋다.
 
 

공용 클래스 만들기

 
library 페이지에서 사용하는 Book 클래스
Book 테이블에
 
library_book.dart에 선언해놨다.
class Book { String isbn13; String title; String cover; String? author; String? pubDate; String? publisher; String? description; String? categoryId; int? sequence; bool? lendStatus; bool? reservationStatus; int? lendCount; int? reservationCount; int? likeCount; Book( {required this.isbn13, required this.title, required this.cover, this.author, this.publisher, this.categoryId, this.pubDate, this.description, this.sequence, this.lendStatus, this.reservationStatus, this.lendCount, this.reservationCount, this.likeCount}); Book.fromMap(map) : this.isbn13 = map['isbn13'], this.title = map['title'], this.author = map['author'], this.publisher = map['publisher'], this.categoryId = map['categoryId'], this.pubDate = map['pubDate'], this.cover = map['cover'], this.description = map['description'], this.sequence = map['sequence'], this.lendStatus = map['lendStatus'], this.reservationStatus = map['reservationStatus'], this.lendCount = map['lendCount'], this.reservationCount = map['reservationCount'], this.likeCount = map['likeCount']; }
 
 
 
필드를 모두 ? 처리해서 null 허용을 하고
생성자에는 named parameters(선택적 매개변수) 를 사용해서 필요한 경우만 넣어줄 수 있고 필요 없다면 안 넣어주면 된다.
 
이때 공용으로 사용할 때 무조건 필요한 것은 requried를 붙여주면 된다.
 
그리고 fromMap이라는 이름의 생성자를 만들어서 여기에 map을 넣어주면
Book클래스의 필드 이름과 같은 key값을 가지고 있는 map 정보를 가지고 Book 객체로 만들어 줄 수 있다.
 
파라미터 이름이 map인데 타입이 생략된 것이다.
 
주의
null 이 올 수 있는 값을 사용하는 곳에서 이 값으로 무언가 메서드를 호출한다던가 한다면 null 처리를 해줘야 한다. (아래 Lend 예시에서)
 
이런 처리를 하고 싶지 않으면 vm별로 언더바 ( _ )를 붙여서 전용 클래스를 만들고 해당 화면에 필요한 것들을 모두 required 처리해서 사용해도 무방하다.
 
 
 
 
 
통신 데이터 파싱해서 사용하기
 

통신시 파싱하는 방법

 
플러터는 통신으로 json 정보를 받으면 개발자가  직접 jsonDecode() 같은 코드를 사용해서 json을 map으로 파싱해서 사용해야 되지만
 
통신시 dio 라는 라이브러리를 사용하면 json으로 받은 것을 dart의 Map 타입으로 편하게 파싱해줄 수 있다.
 
response로 응답을 받은 것을 dio 라이브러리를 통해 response.data로 바로 map으로 파싱하고 map에서 키값으로 밸류를 꺼내는 것처럼 response.data[’body’]를 사용해서 body를 꺼내서 사용하는 것이었다.
 
통신을 하는 부분은 따로 repository 폴더에 화면 구조와 똑같이 해서 만들어 놨다
lend_tab.vm.dart에서 통신요청을 할 때는 lend_repository.dart를 사용한다.
 
notion image
 
 
 
lend_tab_vm.dart에서
대여중 리스트를 뽑기 위해서 통신을 통해 list를 받아 오고 싶다.
lend_repository.dart 에서 findAll을 통해 json을 map으로 파싱한 정보를 그대로 리턴하고 vm에서 list로 받고 있다.
 
notion image
 
 
lend_repository.dart의 findAll
notion image
 
dio를 사용해서 주소를 적고 요청을 하는데
core/utils에 my_http.dart에 있다.
import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:intl/intl.dart'; final baseUrl = "http://192.168.0.58:8080"; //로그인 되면, dio에 jwt 추가하기 //dio.options.headers['Authorization'] = 'Bearer $_accessToken'; final dio = Dio( BaseOptions( baseUrl: baseUrl, // 내 IP 입력 contentType: "application/json; charset=utf-8", //응답코드가 200이 아니면 다 터지는데 그러면 에러 메시지를 못 받는다. //그렇기 때문에 아래의 설정을 해줘야 응답 코드가 200이 아니어도(실패했어도) 서버가 터지지 않고 에러메시지를 받는다. //그래야 에러 메시지를 받아서 alert를 띄워줄 수 있을 것이다. validateStatus: (status) => true, // 200 이 아니어도 예외 발생안하게 설정 ), );
 
api 주소 요청시 앞에 붙이는 ip주소는 서버 ip주소를 넣으면 되는데
내가 내 컴퓨터에서 할 때는 시작 → cmd → ipconfig를 쳐서 IPv4주소 사용
이러고 my_http.dart에서 baseUrl에 넣어줘야 가능하다.
(참고로 내가 내 컴에서 할 때는 어플은 바로 가능하지만 웹으로 할 때는  CORS 설정 필요)
서버 CORS 설정 (1)
notion image
 
이렇게 맵으로 파싱된 정보를 뽑아서 그대로 리턴시킨다.
repository에서는 들어오는 json 데이터를 map으로 파싱하면서 받고 그대로 vm에 리턴해주면 된다.
 
그런데 map으로 어떻게 파싱해야될 지는 들어오는 데이터의 모양에 따라 다르다.
데이터에서 body를 빼고 그 안에서 또 books를 뺀 것은 들어오는 데이터의 모양이 아래처럼 body에 books라는 배열에 정보가 들어 있기 때문이다.
 
notion image
 
일단 List<dynamic> 타입으로 리턴해주고
vm에서 List<dynamic>타입으로 받아서 우리가 필요한 Lend타입인
List<Lend> 타입으로 변환 해준다. 이 작업은 repository의 책임이 아니기 때문에 vm에서 해줘야 한다.
 
참고로 map을 꺼내는 것은 2줄의 단계를 [’a’][’b’] 이런 식으로 한 번에 꺼낼 수 있다.
notion image
 
 
 
다시 vm으로 돌아가보면
 
ui/main/library/lend_tab의 lend_tab.vm
notion image
 
 
받아온 list를 map으로 돌리면서 Lend.fromMap(map) 생성자에 넣으면서 List<Lend>로 변환해줬다.
 
이 newList를 LendTabVm의 Model인 LendListModel에
객체를 새로 생성하면서 전달하면 상태 변경을 감지하고 이를 보고 있던 view들이 한 번에 바뀌게 되는 것이다.
 
 
아래는 library에서 사용중인 Lend 클래스이다. 공통적으로 항상 사용되는 필드는 ?를 안 붙였다.
import 'package:bookbox/core/utils/date_format.dart'; class Lend { int? lendId; String? lendDate; String returnDate; bool? returnStatus; String isbn13; String title; String cover; Lend({ this.lendId, this.lendDate, required this.returnDate, this.returnStatus, required this.isbn13, required this.title, required this.cover, }); Lend.fromMap(map) : this.lendId = map['lendId'], this.lendDate = DateUtil.format(map['lendDate']), this.returnDate = DateUtil.format(map['returnDate']), this.returnStatus = map['returnStatus'], this.isbn13 = map['isbn13'], this.title = map['title'], this.cover = map['cover']; }
 
 
 
참고로 lendDate는 core/utils에 date_format.dart의 DateUtil을 사용해서 시간 파싱을 해주고 있는데 이때 null이 들어오면 null포인터 예외가 터지므로 null 처리를 해줘야 한다. 위에 공용 클래스 만들기에서 설명한 것처럼 이런 null 처리를 하고 싶지 않다면 화면별로 언더바 ( _ ) 를 붙여서 개별 클래스를 사용한다.
 
date_format.dart
import 'package:intl/intl.dart'; class DateUtil { static String format(String? date) { if (date == null) { return ""; } DateTime dt = DateTime.parse(date); String formatDt = DateFormat("yy-MM-dd").format(dt); return formatDt; } }
 
null이 들어올 수도 있으므로 String? date 를 해줬다.
 
다크모드 기능
RiverPod으로 상태관리를 통해 테마를 조절한다
상태를 watch 해야 하는 Widget은 ConsumerWidget으로 변경해야 되므로
main.dart 를 ConsumerWidget으로 변경해준다.
 
최상단 main.dart
import 'package:bookbox/core/constants/styles.dart'; import 'package:bookbox/ui/_components/splash_screen.dart'; import 'package:bookbox/ui/admin/admin_page.dart'; import 'package:bookbox/ui/main/main_page.dart'; import 'package:bookbox/ui/user/join/join_page.dart'; import 'package:bookbox/ui/user/login/login_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp( ProviderScope( child: MyApp(), ), ); } class MyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 테마 상태를 가져옴 final themeMode = ref.watch(themeNotifierProvider); return MaterialApp( navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, home: SplashPage(), //initialRoute: "/login", //initialRoute: "/home", routes: { "/login": (context) => LoginPage(), "/join": (context) => const JoinPage(), "/home": (context) => MainPage(), "/admin": (context) => const AdminPage(), }, theme: lightTheme(), darkTheme: ThemeData.dark(), themeMode: themeMode, // 테마 모드 설정 ); } }
 
 
themeNotifierProvider를 watch하게 만들어서
우리 앱인 MaterialApp()의 themdMode를 설정하게 해 두었다.
 
themeNotifierProvider는 constants의 styles.dart에 위치한다.
기존에 다크모드가 없을 때는
TextTheme(), IconTheme, AppBarThem 이런 식으로 만들어 놓고 쓰다가
 
다크모드를 사용할 때는 기본 테마를
lightTextTheme(), lightIconTheme(), lightAppBarTheme() 라는 이름으로 바꿨고
 
다크모드에 사용할 테마를
darkTextTheme(), darkIconTheme(), darkAppBarTheme() 라는 이름으로 만들었다
 
이렇게 만들어둔 테마를 종합적으로 모아서
라이트모드, 다크모드에 사용할 ThemeData를 만들었고 그 이름을 lightTheme(), darkTheme() 으로 해서 종합했다.
 
themeNotifierProvider는 constants의 styles.dart의 종합 테마 코드
// 라이트 모드 테마 ThemeData lightTheme() { return ThemeData( scaffoldBackgroundColor: Colors.white, textTheme: lightTextTheme(), appBarTheme: lightAppBarTheme(), bottomNavigationBarTheme: lightBottomNavigatorTheme(), primarySwatch: Colors.lightBlue, ); } // 다크 모드 테마 ThemeData darkTheme() { return ThemeData( scaffoldBackgroundColor: Colors.black, textTheme: darkTextTheme(), appBarTheme: darkAppBarTheme(), bottomNavigationBarTheme: darkBottomNavigatorTheme(), primarySwatch: Colors.lightBlue, ); }
 
 
이제 리버팟 상태관리를 추가해줄 것인데
viewModel은 ThemeNotifier 라는 클래스를 만들었고
Model은 ThemeMode,
Model 관리자는 themeNotifierProvider 라고 만들었다.
 
여기서 Model이 되는 ThemeMode는 Flutter에 내장된 app.dart에 존재하는 녀석이다. 우리가 따로 만들어줄 필요 없이 ThemeMode의 상태를 변경해주면 main.dart에서 watch하고 있다가 상태변경이 감지되면 변경된 ThemeMode를 통해 테마를 변경하게 된다.
 
버튼으로 모드를 조절하기 때문에 버튼을 통해 light와 dark모드를 조절했고 이렇게 조절하면 현재 설정을 SharedPreferences 라이브러리를 통해 내부 저장소에 저장해서 앱을 껐다 켜도 항상 마지막에 설정한 모드를 불러올 수 있게 했다.
 
우리가 수업중에 배울 때 만들었던 Model은 이미 app.dart에 내장된 것을 사용했다고 보면 되고 거기에 SharedPreferences 라이브러리를 사용해서 현재 설정을 내부 저장소에 저장, 불러오기 하는 것을 추가한 것이다.
 
themeNotifierProvider는 constants의 styles.dart의 상태 변경 코드
// 테마 모드를 관리하는 StateNotifier class ThemeNotifier extends StateNotifier<ThemeMode> { static const _themePrefKey = 'theme_mode'; ThemeNotifier() : super(ThemeMode.light) { _loadTheme(); // 테마 로드 } // 다크 모드를 토글하는 메서드 void toggleTheme(bool isDarkMode) async { if (isDarkMode) { state = ThemeMode.dark; } else { state = ThemeMode.light; } await _saveTheme(); // 테마 저장 } // 테마를 SharedPreferences에 저장하는 메서드 Future<void> _saveTheme() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_themePrefKey, state == ThemeMode.dark); } // 앱 시작 시 저장된 테마를 불러오는 메서드 Future<void> _loadTheme() async { final prefs = await SharedPreferences.getInstance(); final isDarkMode = prefs.getBool(_themePrefKey) ?? false; // 기본값은 라이트 모드 state = isDarkMode ? ThemeMode.dark : ThemeMode.light; } } // StateNotifierProvider를 사용하여 테마 상태 제공 final themeNotifierProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) { return ThemeNotifier(); });
 
 
 
 
 
 
 
 
Share article

SHIN