본문 바로가기

Flutter 육아앱 만들기

[Flutter 육아앱 만들기] 4편 - 데이터 저장하기 (앱 껐다 켜도 기록이 남는다)

반응형
SMALL

[Flutter 육아앱 만들기] 4편 - 데이터 저장하기 (앱 껐다 켜도 기록이 남는다)

3편에서 수유 기록을 입력하고 목록으로 볼 수 있게 됐다. 근데 문제가 있다. 앱을 끄면 기록이 다 날아간다. 이번 편에서 기록을 영구 저장하는 방법을 알아본다.


저장 방법, 뭘 써야 하나?

Flutter에서 데이터를 로컬에 저장하는 방법은 여러 가지다:

방법 특징 언제 쓰나
shared_preferences 간단한 키-값 저장 설정값, 소량의 데이터
sqflite SQLite DB 대량의 구조화된 데이터
hive / isar 빠른 NoSQL DB 성능이 중요한 경우

우리 육아앱은 지금 당장 sqflite까지 필요하진 않다. 일단 shared_preferences로 시작하고, 나중에 데이터가 많아지면 sqflite로 이전하는 편을 따로 다룰 거다.

💡 shared_preferences는 iOS의 NSUserDefaults, Android의 SharedPreferences를 Flutter에서 쓸 수 있게 감싼 패키지다. 앱을 껐다 켜도 데이터가 유지된다.


패키지 설치

pubspec.yaml에 추가:

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  shared_preferences: ^2.3.2  # 추가

터미널에서:

flutter pub get

저장 원리

shared_preferences문자열, 숫자, 불리언 같은 단순한 값만 저장할 수 있다. 우리가 저장하려는 FeedingRecord 리스트는 직접 저장이 안 된다.

해결책은 JSON 변환이다:

FeedingRecord 리스트 → JSON 문자열 → 저장
저장된 문자열 → JSON 파싱 → FeedingRecord 리스트

코드 수정하기

lib/screens/feeding_page.dart 전체를 아래 코드로 교체한다:

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class FeedingRecord {
  final TimeOfDay time;
  final int amount;

  FeedingRecord({required this.time, required this.amount});

  // FeedingRecord → Map (JSON으로 변환하기 위해)
  Map<String, dynamic> toJson() => {
        'hour': time.hour,
        'minute': time.minute,
        'amount': amount,
      };

  // Map → FeedingRecord (JSON에서 불러오기 위해)
  factory FeedingRecord.fromJson(Map<String, dynamic> json) => FeedingRecord(
        time: TimeOfDay(hour: json['hour'], minute: json['minute']),
        amount: json['amount'],
      );
}

class FeedingPage extends StatefulWidget {
  const FeedingPage({super.key});

  @override
  State<FeedingPage> createState() => _FeedingPageState();
}

class _FeedingPageState extends State<FeedingPage> {
  final List<FeedingRecord> _records = [];
  final TextEditingController _amountController = TextEditingController();
  TimeOfDay _selectedTime = TimeOfDay.now();

  static const _storageKey = 'feeding_records';

  @override
  void initState() {
    super.initState();
    _loadRecords();  // 앱 시작 시 저장된 기록 불러오기
  }

  Future<void> _loadRecords() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonString = prefs.getString(_storageKey);
    if (jsonString == null) return;  // 저장된 게 없으면 그냥 끝

    final List<dynamic> jsonList = jsonDecode(jsonString);
    setState(() {
      _records.clear();
      _records.addAll(jsonList.map((e) => FeedingRecord.fromJson(e)));
    });
  }

  Future<void> _saveRecords() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonString = jsonEncode(_records.map((r) => r.toJson()).toList());
    await prefs.setString(_storageKey, jsonString);
  }

  Future<void> _pickTime() async {
    final picked = await showTimePicker(
      context: context,
      initialTime: _selectedTime,
    );
    if (picked != null) {
      setState(() => _selectedTime = picked);
    }
  }

  void _addRecord() {
    final text = _amountController.text.trim();
    if (text.isEmpty) return;
    final amount = int.tryParse(text);
    if (amount == null) return;

    setState(() {
      _records.insert(0, FeedingRecord(time: _selectedTime, amount: amount));
    });
    _saveRecords();  // 기록 추가할 때마다 저장
    _amountController.clear();
  }

  @override
  void dispose() {
    _amountController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('수유 기록'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Column(
        children: [
          _buildInputForm(),
          const Divider(),
          Expanded(child: _buildList()),
        ],
      ),
    );
  }

  Widget _buildInputForm() {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          TextButton.icon(
            onPressed: _pickTime,
            icon: const Icon(Icons.access_time),
            label: Text(_selectedTime.format(context)),
          ),
          const SizedBox(width: 8),
          Expanded(
            child: TextField(
              controller: _amountController,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                hintText: '수유량 (ml)',
                border: OutlineInputBorder(),
                contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              ),
            ),
          ),
          const SizedBox(width: 8),
          ElevatedButton(
            onPressed: _addRecord,
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.orange,
              foregroundColor: Colors.white,
            ),
            child: const Text('기록'),
          ),
        ],
      ),
    );
  }

  Widget _buildList() {
    if (_records.isEmpty) {
      return const Center(
        child: Text(
          '아직 기록이 없어요 🍼\n첫 수유를 기록해보세요!',
          textAlign: TextAlign.center,
          style: TextStyle(fontSize: 16, color: Colors.grey),
        ),
      );
    }
    return ListView.builder(
      itemCount: _records.length,
      itemBuilder: (context, index) {
        final record = _records[index];
        return ListTile(
          leading: const CircleAvatar(
            backgroundColor: Colors.orange,
            child: Icon(Icons.restaurant, color: Colors.white, size: 20),
          ),
          title: Text('${record.amount}ml'),
          subtitle: Text(record.time.format(context)),
        );
      },
    );
  }
}

코드 뜯어보기

toJson / fromJson = 데이터 변환

Map<String, dynamic> toJson() => {
  'hour': time.hour,
  'minute': time.minute,
  'amount': amount,
};

factory FeedingRecord.fromJson(Map<String, dynamic> json) => FeedingRecord(
  time: TimeOfDay(hour: json['hour'], minute: json['minute']),
  amount: json['amount'],
);

TimeOfDay는 JSON으로 바로 변환이 안 돼서 hourminute로 쪼개서 저장한다. 불러올 때 다시 합친다.

initState = 화면 시작할 때 실행

@override
void initState() {
  super.initState();
  _loadRecords();
}

initState()는 Widget이 처음 만들어질 때 한 번 실행된다. 여기서 저장된 기록을 불러온다. super.initState()는 반드시 먼저 호출해야 한다.

_loadRecords / _saveRecords

Future<void> _loadRecords() async {
  final prefs = await SharedPreferences.getInstance();
  final jsonString = prefs.getString(_storageKey);
  if (jsonString == null) return;

  final List<dynamic> jsonList = jsonDecode(jsonString);
  setState(() {
    _records.clear();
    _records.addAll(jsonList.map((e) => FeedingRecord.fromJson(e)));
  });
}

Future<void> _saveRecords() async {
  final prefs = await SharedPreferences.getInstance();
  final jsonString = jsonEncode(_records.map((r) => r.toJson()).toList());
  await prefs.setString(_storageKey, jsonString);
}
  • SharedPreferences.getInstance(): 저장소 인스턴스를 가져온다. await으로 기다려야 한다.
  • jsonEncode: Dart 객체 → JSON 문자열
  • jsonDecode: JSON 문자열 → Dart 객체
  • _storageKey: 저장할 때 쓰는 키 이름. 불러올 때도 같은 키를 써야 한다.

_addRecord()에서 기록을 추가할 때마다 _saveRecords()를 호출해서 저장한다.


확인해보기

flutter run

수유 기록을 몇 개 추가하고 앱을 완전히 종료했다가 다시 열어보자. 기록이 그대로 남아있으면 성공이다.


이번 편에서 배운 것

개념 설명
shared_preferences 앱 로컬 저장소. 키-값 형태로 데이터 저장
JSON 변환 복잡한 객체를 문자열로 변환해서 저장
toJson / fromJson 객체 ↔ Map 변환 패턴
initState() Widget 최초 생성 시 실행되는 생명주기 메서드
async / await 비동기 작업 처리 (파일 읽기/쓰기 등)

현재 앱 구조

lib/
├── main.dart
└── screens/
    └── feeding_page.dart  ← shared_preferences로 저장/불러오기 추가

💡 데이터가 많아지거나 검색/필터 기능이 필요해지면 sqflite로 이전해야 한다. 그 편은 나중에 따로 다룰 예정이다.


다음 편 예고: [Flutter 육아앱 만들기] 5편 - 성장 기록 차트 만들기

키/몸무게를 입력하면 그래프로 보여주는 화면을 만든다. Flutter에서 차트 그리는 법을 알아본다.

반응형
LIST