포트폴리오용 만보기 앱을 만들다가 구현하고픈 아이디어가 떠올랐다.
대부분의 만보기 앱은 유저가 가장 자주 보게 될 홈 화면에 유저가 걸어온 총 걸음의 수와 목표 걸음 수 등을 표시하곤 한다.
경우에 따라 프로필 사진 또는 인증샷을 첨부할수 있게 하거나, 진행도를 그래프로 보여주는 UI를 추가하는 경우도 더러 있다.
이번에 직접 구현해본 UI.
총 걸음 수 인디케이터, 목표 걸음 수 인디케이터, 표시용 텍스트로 구성되어있다.
구현하고픈 아이디어라는건 저 인디케이터 UI의 색상을 유저가 마음대로 커스텀할수 있게 만드는 것.
정확한 목표는 여러가지 디자인의 인디케이터 UI를 미리 구현해두고, 유저가 커스텀 페이지에서 걸음 수, 포인트 인디케이터와 텍스트의 색상을 설정하면 UI에 즉각적으로 적용되도록 하는 것이다.
더불어 유저가 커스텀한 색상들의 값을 로컬에 저장하고, 추후에는 이 데이터를 백엔드와도 연동해보려 한다.
구현 과정에서는 getX 패턴과 get storage를 활용한다.
이 글은 그것을 구현하는 과정으로서 지식 공유의 목적이라기보단, 필자의 개발 기록 보관용으로 작성되었다.
당연히 기능 구현을 위한 정석적인 방법이 아니며, 미숙한 코드가 많을수도 있다.
그럼에도 구현에 필요한 기능들을 함께 기술하여, 이 글이 flutter를 학습하는 누군가와, 얼마 못가 오늘 공부한 내용을 까먹고 벨로그를 뒤적거릴 몇주 뒤의 나에게 도움이 되길 바래본다.
참고로 이 결과물에선 pedometer 패키지를 사용하지 않았다. 에뮬레이터 관련 이슈를 당장 해결할수 없고(실물 기기와 개발자용 계정 없음...), 테스트의 용이성 때문. 그래서 당장의 만보기 기능은 유저가 1초마다 한번씩 걷는 것으로 상정하여, 걸음 수를 Timer의 초당 값이 변하는 걸로 대체한다.
우선 당연하게도 get, get storage 패키지가 설치되어있어야 한다.
필자는 추가로 UI 구현을 위해 step_progress_indicator 패키지도 함께 설치했다.
step_progress_indicator는 좀더 다양한 디자인의 progress indicator를 사용할수 있게 해주는 패키지다.
///main.dart
Future main() async {
await GetStorage.init();
await GetStorage().read('mainPageImageIndex');
await GetStorage().read('totalColor');
await GetStorage().read('walkColor');
await GetStorage().read('textColor');
print(GetStorage().read('totalColor').toString());
print(GetStorage().read('test'));
runApp(
GetMaterialApp(
debugShowCheckedModeBanner: false,
title: "Application",
initialRoute: AppPages.INITIAL,
getPages: AppPages.routes,
),
);
}
우선 앱이 시작되면 호출될 main. GetStorage를 이용하여 각 키값에 저장된 value를 불러온다.
이는 비동기 방식으로 처리되며, value를 불러와야만 main이 실행되는 구조.
우리는 getX 패턴을 사용하므로, getX의 다양한 기능을 사용하기 위해 MaterialApp이 아닌 GetMaterialApp을 사용한다.
이후 AppPages.에 작성된 INITIAL 변수에 해당하는 페이지로 라우팅된다.
//../routes/app_pages.dart
class AppPages {
AppPages._();
static const INITIAL = Routes.HOME;
static final routes = [
GetPage(
name: _Paths.HOME,
page: () => const HomeView(),
binding: HomeBinding(),
),
GetPage(
name: _Paths.WALK,
page: () => const WalkView(),
binding: WalkBinding(),
),
GetPage(
name: _Paths.FRIENDS,
page: () => const FriendsView(),
binding: FriendsBinding(),
),
GetPage(
name: _Paths.HEALTH,
page: () => const HealthView(),
binding: HealthBinding(),
),
GetPage(
name: _Paths.COMMUNITY,
page: () => const CommunityView(),
binding: CommunityBinding(),
),
GetPage(
name: _Paths.NEWS,
page: () => const NewsView(),
binding: NewsBinding(),
),
GetPage(
name: _Paths.MORE,
page: () => const MoreView(),
binding: MoreBinding(),
),
GetPage(
name: _Paths.MY_BOTTOM_NAV_BAR,
page: () => const MyBottomNavBarView(),
binding: MyBottomNavBarBinding(),
),
GetPage(
name: _Paths.CAMERA,
page: () => CameraView(),
binding: CameraBinding(),
),
];
}
GetPage를 활용한 라우트 관리.
필자는 get_cli를 활용하여 자동적으로 생성했다. get_cli에 관한 내용은 추후에 따로 작성할 예정.
해당 소스의 INITAL page는 home_view 로 연결된다.
// ./lib/app/modules/home/views/home_view/dart
class HomeView extends GetView<HomeController> {
const HomeView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
Get.put(MyBottomNavBarController());
final List<Widget> pages = [
WalkView(),
FriendsView(),
HealthView(),
CommunityView(),
NewsView(),
];
return Scaffold(
body: Obx(
() => SafeArea(
child: pages[MyBottomNavBarController.to.selectedIndex.value]),
),
bottomNavigationBar: MyBottomNavBarView(),
);
}
}
이니셜 라우트로 연결된 홈뷰.
사실상 이름만 homeView이고, 실질적인 기능은 바텀 네비게이션 바의 역할을 한다.
따라서 별도의 앱바와 UI가 없고, pages 리스트에 네비게이션 바로 표시할 페이지들을 넣어놓고, homeView의 UI로는 바텀 네비게이션 바만 표시한다.
바로 이 부분.
// ./lib/app/modules/my_bottom_nav_bar/views/my_bottom_nav_bar_view
class MyBottomNavBarView extends GetView<MyBottomNavBarController> {
const MyBottomNavBarView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Obx(
() => BottomNavigationBar(
currentIndex: controller.selectedIndex.value,
onTap: controller.changeIndex,
selectedItemColor: accentYellow,
unselectedItemColor: mainGrey,
unselectedLabelStyle: TextStyle(fontSize: 10),
selectedLabelStyle:
TextStyle(fontFamily: 'LS', fontWeight: FontWeight.w700),
backgroundColor: bgColor,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: controller.selectedIndex.value == 0
? Icon(
Icons.home,
color: accentYellow,
)
: Icon(
Icons.home,
color: mainGrey,
),
label: "Home"),
BottomNavigationBarItem(
icon: controller.selectedIndex.value == 1
? Icon(
Icons.people,
)
: Icon(
Icons.people,
),
label: "Friends"),
BottomNavigationBarItem(
icon: controller.selectedIndex.value == 2
? Icon(
Icons.health_and_safety,
color: accentYellow,
)
: Icon(
Icons.health_and_safety,
color: mainGrey,
),
label: "Health"),
BottomNavigationBarItem(
icon: controller.selectedIndex.value == 3
? Icon(
Icons.comment_rounded,
color: accentYellow,
)
: Icon(
Icons.comment_rounded,
color: mainGrey,
),
label: "Community"),
BottomNavigationBarItem(
icon: controller.selectedIndex.value == 4
? Icon(
Icons.newspaper,
color: accentYellow,
)
: Icon(
Icons.newspaper,
color: mainGrey,
),
label: "News"),
],
),
);
}
}
바텀 네비게이션 바의 UI 부분.
BottomNavigationBarItem으로 아이템들을 표시하며, getX contoller를 사용하여 선택했을 때와 선택하지 않았을 때의 아이콘을 조정할수 있도록 만들었다.
// ./lib/app/modules/my_bottom_nav_bar/controllers/my_bottom_nav_bar_controller
class MyBottomNavBarController extends GetxController {
static MyBottomNavBarController get to => Get.find();
final RxInt selectedIndex = 0.obs;
void changeIndex(int index) {
selectedIndex(index);
}
void onInit() {
super.onInit();
}
void onReady() {
super.onReady();
}
void onClose() {
super.onClose();
}
}
selectedIndex 변수를 .obs 형태로 만들어, 값이 변할때마다 감지할수 있도록 컨트롤러에 미리 작성해두었다.
이제 기본적인 UI를 담을 틀은 만들어졌으니, 상단에 올렸던 화면처럼 커스텀 인디케이터의 기능과 UI를 구현해보자.
디자인적 요소나 미구현된 기능은 생략하고, 코드의 기능들을 각 코드블럭의 주석에 서술한다.
이번 게시글에서 주로 다룰 파트이자, 바텀 네비게이션 바의 첫번째 아이템이다.
UI상에는 home으로 표기되지만, 소스는 이것이다. 사실상 walk_view가 홈 화면이다.
// ./lib/app/modules/walk/views/walk_view
class WalkView extends GetView<WalkController> {
const WalkView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
Get.put(WalkController());
Get.put(CameraController());
CameraController cameraController = CameraController();
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
title: Text(
'Walk',
style: TextStyle(
fontFamily: 'LS',
fontSize: 30,
fontWeight: FontWeight.w700,
color: accentYellow),
),
centerTitle: false,
elevation: 0,
backgroundColor: bgColor,
actions: [
IconButton(
onPressed: () {
//아직 미구현
},
icon: Icon(
Icons.add_alert,
color: accentYellow,
),
),
IconButton(
onPressed: () {
//아직 미구현
},
icon: Icon(
Icons.settings,
color: accentYellow,
),
),
],
),
body: SingleChildScrollView(
child: Column(
children: [
SizedBox(
height: 15,
),
Container(
child: Stack(
children: [
Obx(
() => Container(
margin: EdgeInsets.all(10),
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fill,
//카메라 컨트롤러의 selectedImagePath 스트링 값을 가져와 에셋이미지 패스 연결.
image: AssetImage(
'assets/images/${controller.imagePath}.png')),
color: bgColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: mainGrey,
offset: Offset(0.0, 1.0),
blurRadius: 6.0),
]),
height: 350,
width: 500,
child: PageView(
controller: controller.indicatorController,
onPageChanged: controller.onIndicatorPageChanged,
children: [
//Indicator
IndicatorCircularView(),
IndicatorStepView(),
IndicatorCircularBView(),
IndicatorStepBView(),
],
),
),
),
Row(
children: [
SizedBox(
width: 15,
),
FloatingActionButton.small(
heroTag: 'goToCamera',
backgroundColor: accentBrown,
onPressed: () {
controller.CameraBtnClicked();
},
child: Icon(Icons.camera),
),
SizedBox(
width: 265,
),
FloatingActionButton.small(
backgroundColor: accentBrown,
onPressed: () {
controller.shareBtnClicked();
},
child: Icon(Icons.share),
),
],
),
],
),
),
],
),
),
);
}
}
walkView의 변화하는 변수들과 기능들을 담당할 walkController.
// ./lib/app/modules/walk/controllers/walk_controller
//--사이클--
//Controller 빌드 시 onInit 실행=>
//onInit 으로 initPageValue 함수, startWalk 함수 호출=>
//if 문으로 storage 값이 존재할시 그대로 적용하고, null 이라면 기본 색상 및 기본 인덱스 적용=>
//동시에 startWalk 실행됨=>
//초마다 포인트 상승. 초마다 storage 의 value 를 listenKey 로 감지=>
//CameraController 에서 storage 의 value 를 수정하면 즉각 적용.
class WalkController extends GetxController {
static WalkController get to => Get.find();
//get storage 사용하기 쉽게 미리 선언.
final storage = GetStorage();
//camera controller 쪽에서 넘길 인덱스를 받아줄 그릇.
RxString imagePath = '0'.obs;
//camera controller 쪽에서 넘길 세가지 색상 값을 받아줄 그릇들.
//아래 타이머 위젯으로 1초마다 감지하여 실시간으로 변함.
//storage 에 저장될 값은 Color() 가 아닌 int 이기에 우선 0으로 지정.
var currentTotalColor = 0.obs;
var currentWalkColor = 0.obs;
var currentTextColor = 0.obs;
//순서대로 블루 핑크 옐로우.
//아래 initPage 에서 null 값을 체크하여 true 일 경우 반한될 기본 색상.
//current~ 컬러들이 int 값이기에, 값을 받아주려면 똑같이 int 여야함. 따라서 int color 값.
var initTotalColor = 0xFf4169e1.obs;
var initWalkColor = 0xFFff69b4.obs;
var initTextColor = 0xffffffff.obs;
//100걸음과 총 걸음수 최대값.
//목업이기에 사용하는 것이지, 실제로는 유저가 맥스값을 설정할수 있게 해야함.
final walk100maxSecond = 100;
final walkTotalMax = 1000;
//인디케이터 index
var indicatorIndex = 0.obs;
var currentIndicator = 0.obs;
final walk100maxSecondSplit5 = 20;
final walkTotalMaxSplit5 = 30;
final indicatorController = PageController(initialPage: 0, keepPage: true);
//아래 타이머로 변화될 값들. 이 값들이 circular indicator 로 전해져서 ui의 애니메이션처럼 표현.
RxInt walk100 = 0.obs;
RxInt walkTotal = 0.obs;
RxInt pointCount = 0.obs;
// /5
RxInt walk100s5 = 0.obs;
RxInt walkTotals5 = 0.obs;
void onInit() {
super.onInit();
//위젯 시작시 페이지 이미지 및 색상 데이터 가져옴.
initPageValue();
storage.read('test');
//동시에 두가지 만보기 타이머 시작.
startWalk();
startPoint();
// /5
startSplit5();
startSplitTotal5();
startSplit5Cut();
}
void onReady() {
super.onReady();
}
void onClose() {
super.onClose();
}
//위에서 호출한 메소드. 이미지 인덱스를 get storage 에서 읽어옴.
//camera controller 에서 get storage 에 저장한 인덱스 값.
//storage 의 값들을 체크하여 값이 null 일 경우 기본 값으로 저장한 값들로 변경.
initPageValue() {
imagePath.value = storage.read('mainPageImageIndex');
var getTotalColor = storage.read('totalColor');
if (getTotalColor == null) {
currentTotalColor.value = initTotalColor.value;
} else {
currentTotalColor.value = getTotalColor;
}
var getWalkColor = storage.read('walkColor');
if (getWalkColor == null) {
currentWalkColor.value = initWalkColor.value;
} else {
currentWalkColor.value = getWalkColor;
}
var getTextColor = storage.read('textColor');
if (getTextColor == null) {
currentTextColor.value = initTextColor.value;
} else {
currentTextColor.value = getTextColor;
}
print('init');
}
onIndicatorPageChanged(int page) {
print('page num:' + page.toString());
int currentIndicator = page.toInt();
//페이지가 바뀔때마다 호출. 즉, galleryPageIndex 가 현재 페이지를 담게 됨.
indicatorIndex.value = currentIndicator;
print(currentIndicator);
}
//공유 버튼 클릭시 호출.
shareBtnClicked() {
Get.bottomSheet(
Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
children: [
Center(
child: Text(
'Bottom Sheet',
style: TextStyle(fontSize: 18),
),
),
OutlinedButton(
onPressed: () {
Get.back();
},
child: const Text('Close'),
),
],
),
),
);
}
//카메라 버튼 클릭시 호출. 네비게이션.
CameraBtnClicked() {
Get.toNamed('/camera');
}
//타이머.
late Timer _timer;
//컨트롤러 init 시 작동되는 함수들.
//Duration 주기(1초)마다 총 걸음수와 100걸음 걸음수를 1씩 증가시키고, storage 값을 한번씩 읽음.
void startWalk() {
_timer = Timer.periodic(Duration(seconds: 1), (Timer timer) {
walkTotal.value++;
walk100.value++;
storage.write('indicatorIndex', indicatorIndex.value);
storage.listenKey('mainPageImageIndex', (value) {
imagePath.value = value;
});
storage.listenKey('totalColor', (value) {
currentTotalColor.value = value;
});
storage.listenKey('walkColor', (value) {
currentWalkColor.value = value;
});
storage.listenKey('textColor', (value) {
currentTextColor.value = value;
});
});
}
void startSplit5() {
_timer = Timer.periodic(Duration(seconds: 5), (Timer timer) {
walk100s5.value++;
});
}
//1000/1 / 30
void startSplitTotal5() {
_timer = Timer.periodic(Duration(seconds: 33), (Timer timer) {
walkTotals5.value++;
});
}
// /5
void startSplit5Cut() {
_timer = Timer.periodic(Duration(seconds: 100), (Timer timer) {
walk100s5.value -= 20;
});
}
//init 시 호출되는 또 다른 메소드.
// 사실 100만큼 올라갈때마다 그걸 감지해야 하는데, 목업이기에 임의로 100초 간격으로 업데이트.
void startPoint() {
_timer = Timer.periodic(Duration(seconds: 100), (Timer timer) {
walk100.value -= 100;
pointCount.value++;
});
}
}
직접 구현한 갤러리 기능과, color_picker 패키지로 구현한 색상 선택 기능을 담은 camera_view UI.
홈 화면의 왼쪽 상단 floatingActionButton을 클릭하면 나타난다.
// ./lib/app/modules/camera/views/camera_view.dart
class CameraView extends GetView<CameraController> {
CameraView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
elevation: 0,
backgroundColor: bgColor,
title: const Text(
'갤러리',
style: TextStyle(
color: Colors.black,
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w600),
),
centerTitle: true,
actions: [
Center(
child: Obx(
() => Text(
controller.galleryPageIndexPlus.value.toString() + '/5',
style: TextStyle(
color: Colors.black,
fontFamily: 'IBMKR',
fontWeight: FontWeight.w700,
fontSize: 18),
),
),
),
SizedBox(
width: 40,
),
],
),
body: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: EdgeInsets.all(10),
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: mainGrey,
offset: Offset(0.0, 1.0),
blurRadius: 6.0),
]),
height: 350,
width: 500,
child: PageView(
controller: controller.pageController,
//페이지 변경시 onGalleryPageChanged 호출
onPageChanged: controller.onGalleryPageChanged,
children: [
//galleryPageUnit 커스텀 위젯으로 코드 단축. 모듈화.
PageUnit(
assetImage: AssetImage('assets/images/0.png'),
),
PageUnit(
assetImage: AssetImage('assets/images/1.png'),
),
PageUnit(
assetImage: AssetImage('assets/images/2.png'),
),
PageUnit(
assetImage: AssetImage('assets/images/3.png'),
),
PageUnit(
assetImage: AssetImage('assets/images/4.png'),
),
],
),
),
SizedBox(
height: 40,
),
Row(
children: [
SizedBox(
width: 20,
),
GestureDetector(
onTap: () {
controller.totalColorChangeBtnClicked();
},
child: Container(
height: 85,
width: 165,
decoration: BoxDecoration(
color: bgColor,
boxShadow: [
BoxShadow(
color: Colors.grey,
blurRadius: 5.0,
spreadRadius: 0.0,
offset: Offset(0, 7),
),
],
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 30,
width: 30,
child: CircularProgressIndicator(
color: Color(controller.storage.read('totalColor')),
backgroundColor: bgColor,
strokeWidth: 3,
value: 70 / 100,
),
),
SizedBox(
width: 20,
),
Text(
'총 걸음 표시\n색상 선택하기',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
fontFamily: 'IBMKR'),
),
],
),
),
),
SizedBox(
width: 25,
),
GestureDetector(
onTap: () {
controller.walkColorChangeBtnClicked();
},
child: Container(
height: 85,
width: 165,
decoration: BoxDecoration(
color: bgColor,
boxShadow: [
BoxShadow(
color: Colors.grey,
blurRadius: 5.0,
spreadRadius: 0.0,
offset: Offset(0, 7),
),
],
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 25,
width: 25,
child: CircularProgressIndicator(
color: Color(controller.storage.read('walkColor')),
backgroundColor: bgColor,
strokeWidth: 3,
value: 90 / 100,
),
),
SizedBox(
width: 20,
),
Text(
'목표 걸음 표시\n색상 선택하기',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
fontFamily: 'IBMKR'),
),
],
),
),
),
SizedBox(
width: 10,
),
],
),
SizedBox(
height: 30,
),
GestureDetector(
onTap: () {
controller.textColorChangeBtnClicked();
},
child: Container(
height: 85,
width: 355,
decoration: BoxDecoration(
color: bgColor,
boxShadow: [
BoxShadow(
color: Colors.grey,
blurRadius: 5.0,
spreadRadius: 0.0,
offset: Offset(0, 7),
),
],
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 30,
),
Text(
'T',
style: TextStyle(
fontFamily: 'LS',
fontSize: 30,
fontWeight: FontWeight.w700,
color: Color(controller.storage.read('textColor'))),
),
SizedBox(
width: 20,
),
Text(
'텍스트\n색상 선택하기',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
fontFamily: 'IBMKR'),
),
],
),
),
),
SizedBox(
height: 30,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 360,
child: CustomButtonBrown(
btnText: '적용하기',
onPressed: () {
controller.applyBtnClicked();
Get.back();
},
),
),
],
),
SizedBox(
height: 40,
),
],
),
),
);
}
}
// ./lib/app/modules/camera/controllers/camera_controller.dart
//--사이클--
//각 UI 에 할당된 세 가지 color picker 로 색상 변경=>
//color picker 의 onColorChanged 로 각 값에 할당된 selectedColor 를 int color 값으로 반환함=>
//UI 에서 적용하기 버튼을 누르면 applyBtnClicked 함수가 호출되어 storage 에 세 가지 int color 값 저장=>
class CameraController extends GetxController {
WalkController walkController = WalkController();
//get storage 사용하기 쉽게 미리 선언.
GetStorage storage = GetStorage();
var textInt = 0.obs;
//블루 핑크 옐로우. 0으로 해도 상관은 없음. storage 에 저장될 색상.
//int 값이어야 storage 에 저장할수 있기에, Color 위젯이 아닌 int 로 표기함.
var totalColor = 0xFf4169e1.obs;
var walkColor = 0xFFff69b4.obs;
var textColor = 0xffffffff.obs;
//color picker 가 감지할 색상.
var selectedTotalColor = Color(0).obs;
var selectedWalkColor = Color(0).obs;
var selectedTextColor = Color(0).obs;
//현재 선택된 이미지 번호. 에셋 이미지로 0~4까지 다섯개.
var selectedImagePath = '0'.obs;
//갤러리의 pageView controller 가 감지할 현재 갤러리 번호.
var galleryPageIndex = 0.obs;
//갤러리 표시용. 위 값에서 +1.
var galleryPageIndexPlus = 0.obs;
//시작 페이지 0번. 노란 이미지.
final pageController = PageController(initialPage: 0);
//갤러리 페이지 변화를 감지. 변화된 페이지는 currentPage 라는 내부 변수로 담음.
//이것을 galleryPageIndex 로 다시 담아주기.
//print 는 디버그용.
onGalleryPageChanged(int page) {
print('page num:' + page.toString());
int currentPage = page.toInt();
//페이지가 바뀔때마다 호출. 즉, galleryPageIndex 가 현재 페이지를 담게 됨.
galleryPageIndex.value = currentPage;
galleryPageIndexPlus.value = currentPage += 1;
print('main page image path:' + selectedImagePath.value.toString());
print('current page:' + galleryPageIndex.value.toString());
}
//적용 버튼 클릭시 호출.
//walk controller 로 보낼 selectedImagePath 를 스트링으로 바꿔줌.
//그리고 저장해야할 값들(페이지 인덱스 스트링 값, 세가지 컬러 값)을 get storage 에 저장.
applyBtnClicked() {
selectedImagePath.value = galleryPageIndex.value.toString();
//storage 에 페이지 인덱스와 컬러 값들 저장. Color 값은 받을수 없으므로 int 로 변환하여 저장.
storage.write('mainPageImageIndex', selectedImagePath.value);
storage.write('totalColor', totalColor.value);
storage.write('walkColor', walkColor.value);
storage.write('textColor', textColor.value);
print('write:' + storage.read('mainPageImageIndex'));
print('write total color:' + storage.read('totalColor').toString());
print('write walk color:' + storage.read('walkColor').toString());
print('write text color:' + storage.read('textColor').toString());
}
count() {
textInt.value++;
print(textInt.value);
storage.listenKey('test', (value) => print('new key is $value'));
}
read() {
storage.read('test');
print(textInt.value);
}
debugBtn() {
storage.write('test', 'vvv');
print(textInt.value);
print(textColor);
print('write:' + storage.read('mainPageImageIndex'));
print('write total color:' + storage.read('totalColor').toString());
print('write walk color:' + storage.read('walkColor').toString());
print('write text color:' + storage.read('textColor').toString());
}
//표시 색상 선택시 호출.
//color picker 패키를 사용하여 색상 정해줌. 기본 컬러는 총 걸음 표시 색상. 바뀌면 함께 바뀜.
//onColorChanged 로 색상이 바뀔때마다 Color 형태로 값을 저장. 아래 두 메소드도 작동방식 동일.
//color 인자의 Color.value 로 int 색상 값을 추출.
//인자값으로 존재하는 color.value 를 활용하여 변수에 컬러 값을 int 로 받아옴.
totalColorChangeBtnClicked() {
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0))),
content: Column(
children: [
SizedBox(
height: 15,
),
ColorPicker(
pickerColor: Color(storage.read('totalColor')),
onColorChanged: (Color color) {
selectedTotalColor.value = color;
print(selectedTotalColor.value);
totalColor.value = color.value;
},
pickerAreaHeightPercent: 0.9,
enableAlpha: true,
paletteType: PaletteType.hsvWithHue,
),
SizedBox(
height: 70,
),
CustomButtonYellow(
btnText: '적용하기',
onPressed: () {
Get.back();
},
),
],
),
),
);
}
walkColorChangeBtnClicked() {
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0))),
content: Column(
children: [
SizedBox(
height: 15,
),
ColorPicker(
pickerColor: Color(storage.read('walkColor')),
onColorChanged: (Color color) {
selectedWalkColor.value = color;
print(color.value);
walkColor.value = color.value;
},
pickerAreaHeightPercent: 0.9,
enableAlpha: true,
paletteType: PaletteType.hsvWithHue,
),
SizedBox(
height: 70,
),
CustomButtonYellow(
btnText: '적용하기',
onPressed: () {
Get.back();
},
),
],
),
),
);
}
textColorChangeBtnClicked() {
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0))),
content: Column(
children: [
SizedBox(
height: 15,
),
ColorPicker(
pickerColor: Color(storage.read('textColor')),
onColorChanged: (Color color) {
selectedTextColor.value = color;
print(selectedTextColor.value);
textColor.value = color.value;
},
pickerAreaHeightPercent: 0.9,
enableAlpha: true,
paletteType: PaletteType.hsvWithHue,
),
SizedBox(
height: 70,
),
CustomButtonYellow(
btnText: '적용하기',
onPressed: () {
Get.back();
},
),
],
),
),
);
}
void onInit() {
super.onInit();
}
void onReady() {
super.onReady();
}
void onClose() {
super.onClose();
}
}
구현 화면. 현재는 네가지밖에 없으나, 추후에 더 고퀄리티의 UI 추가해볼 예정이다.
상단에 밝힌 포부대로 내 목표는 여러가지 인디케이터 UI를 유저가 고를수 있도록 하는 것이었다.
// ./lib/app/modules/walk/views/walk_view
//walk_view의 일부.
PageView(
controller: controller.indicatorController,
onPageChanged: controller.onIndicatorPageChanged,
children: [
//Indicator
IndicatorCircularView(),
IndicatorStepView(),
IndicatorCircularBView(),
IndicatorStepBView(),
],
),
단순한 디자인이라도 유저가 여러가지 디자인의 인디케이터를 사용할수 있도록, 프로필 사진을 담을 컨테이너를 Stack으로 감싸고, 인디케이터들을 담을 PageView를 그 위에 덧붙혔다.
stepIndicator들은 상단에 서술한 패키지를 사용했다.
//첫번째 인디케이터.
class IndicatorCircularView extends GetView<WalkController> {
const IndicatorCircularView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
Center(
child: SizedBox(
width: 250,
height: 250,
child: CircularProgressIndicator(
strokeWidth: 10,
backgroundColor: bgColor,
color: Color(controller.currentWalkColor.value),
value: controller.walk100.value.toDouble() /
controller.walk100maxSecond,
),
),
),
Center(
child: SizedBox(
width: 300,
height: 300,
child: CircularProgressIndicator(
strokeWidth: 12,
backgroundColor: bgColor,
color: Color(controller.currentTotalColor.value),
value: controller.walkTotal.value.toDouble() /
controller.walkTotalMax.toInt(),
),
),
),
Container(
child: Center(
child: SizedBox(
height: 300,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
' ${controller.walkTotal}\n 걸음',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
SizedBox(
height: 10,
),
Text(
' ${controller.walk100}/100\n 다음 포인트까지',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
SizedBox(
height: 20,
),
Text(
'${controller.pointCount} Cash',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
],
),
),
),
),
),
],
),
);
}
}
//두번째 인디케이터
class IndicatorStepView extends GetView<WalkController> {
const IndicatorStepView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
Center(
child: SizedBox(
width: 300,
height: 300,
child: CircularStepProgressIndicator(
stepSize: 35,
selectedStepSize: 40,
selectedColor: Color(controller.currentTotalColor.value),
unselectedColor: bgColor,
totalSteps: controller.walkTotalMaxSplit5,
currentStep: controller.walkTotals5.value,
),
),
),
Center(
child: SizedBox(
width: 250,
height: 250,
child: CircularStepProgressIndicator(
stepSize: 15,
selectedStepSize: 20,
selectedColor: Color(controller.currentWalkColor.value),
unselectedColor: bgColor,
totalSteps: controller.walk100maxSecondSplit5,
currentStep: controller.walk100s5.value,
),
),
),
Container(
child: Center(
child: SizedBox(
height: 300,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
' ${controller.walkTotal}\n 걸음',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
SizedBox(
height: 10,
),
Text(
' ${controller.walk100}/100\n 다음 포인트까지',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
SizedBox(
height: 20,
),
Text(
'${controller.pointCount} Cash',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
],
),
),
),
),
),
],
),
);
}
}
//세번째 인디케이터
class IndicatorCircularBView extends GetView<WalkController> {
const IndicatorCircularBView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 120,
height: 120,
child: Center(
child: Stack(
children: [
SizedBox(
width: 150,
height: 150,
child: CircularProgressIndicator(
strokeWidth: 12,
backgroundColor: bgColor,
color: Color(controller.currentTotalColor.value),
value: controller.walkTotal.value.toDouble() /
controller.walkTotalMax.toInt(),
),
),
Center(
child: Text(
' ${controller.walkTotal}\n 걸음',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
),
],
),
),
),
SizedBox(
width: 40,
),
SizedBox(
width: 120,
height: 120,
child: Center(
child: Stack(
children: [
SizedBox(
width: 150,
height: 150,
child: CircularProgressIndicator(
strokeWidth: 12,
backgroundColor: bgColor,
color: Color(controller.currentWalkColor.value),
value: controller.walk100.value.toDouble() /
controller.walk100maxSecond.toInt(),
),
),
Center(
child: Text(
' ${controller.walk100}/100\n 다음 포인트까지',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 14,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
),
],
),
),
),
],
),
SizedBox(
height: 30,
),
Text(
'${controller.pointCount} Cash',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
],
),
);
}
}
//네번째 인디케이터
class IndicatorStepBView extends GetView<WalkController> {
const IndicatorStepBView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 10,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 190,
height: 190,
child: Center(
child: Stack(
children: [
SizedBox(
width: 190,
height: 190,
child: CircularStepProgressIndicator(
stepSize: 35,
selectedStepSize: 40,
width: 200,
selectedColor:
Color(controller.currentTotalColor.value),
unselectedColor: bgColor,
totalSteps: controller.walkTotalMaxSplit5,
currentStep: controller.walkTotals5.value,
),
),
Center(
child: Text(
' ${controller.walkTotal}\n 걸음',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
),
],
),
),
),
SizedBox(
width: 40,
)
],
),
Row(
children: [
SizedBox(
width: 110,
),
Text(
'${controller.pointCount} Cash',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
SizedBox(
width: 20,
),
SizedBox(
width: 120,
height: 120,
child: Center(
child: Stack(
children: [
CircularStepProgressIndicator(
stepSize: 15,
selectedStepSize: 17,
selectedColor: Color(controller.currentWalkColor.value),
unselectedColor: bgColor,
totalSteps: controller.walk100maxSecondSplit5,
currentStep: controller.walk100s5.value,
),
Center(
child: Text(
' ${controller.walk100}/100\n 다음 포인트까지',
style: TextStyle(
fontFamily: 'IBMKR',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(controller.currentTextColor.value),
),
),
),
],
),
),
),
],
),
SizedBox(
height: 30,
),
],
),
);
}
}
색상 선택 후 UI에 적용되는 화면
앱을 재시작해도 storage에 저장된 데이터를 읽어 색상과 사진이 유지되는 화면