본문 바로가기

운동하는 개발자/Flutter

13년차 응용프로그래머, Gemini에게 Flutter 배우기 - 5 SQLite

728x90

SQLite 로컬DB사용

1. 패키지 2개 설치

flutter pub add sqflite path

 

2. DB 헬퍼 클래스 만들기 (싱글톤 패턴)

// db_helper.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DBHelper {
  // 1. C++ 스타일의 싱글톤 인스턴스 선언
  static final DBHelper _instance = DBHelper._internal();
  factory DBHelper() => _instance;
  DBHelper._internal();

  static Database? _database;

  // 2. DB 객체 가져오기 (Lazy Initialization)
  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB();
    return _database!;
  }

  // 3. DB 파일 생성 및 오픈
  Future<Database> _initDB() async {
    // 기기 내부의 안전한 가상 문서 경로를 찾아 줍니다.
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'pos_system.db');

    // DB 오픈 (파일이 없으면 onCreate를 실행하여 테이블을 만듭니다)
    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        // 전통적인 SQL 쿼리로 테이블을 생성합니다.
        await db.execute('''
          CREATE TABLE orders (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT,
            amount INTEGER,
            date TEXT
          )
        ''');
      },
    );
  }
}

 

3. CRUD 비즈니스 로직 작성 (Provider 결합)

// local_order_provider.dart
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'db_helper.dart'; // 방금 만든 DB 헬퍼

class LocalOrderProvider extends ChangeNotifier {
  List<Map<String, dynamic>> _localOrders = [];
  List<Map<String, dynamic>> get localOrders => _localOrders;

  // [Read] 데이터 조회 (SELECT)
  Future<void> loadOrders() async {
    final db = await DBHelper().database;
    
    // SQL 쿼리를 실행하여 결과를 List<Map> 형태로 받습니다.
    // Delphi의 Query.Open 후 Record셋을 받는 것과 같습니다.
    _localOrders = await db.query('orders', orderBy: 'id DESC');
    
    notifyListeners(); // 화면 갱신 방송
  }

  // [Create] 데이터 추가 (INSERT)
  Future<void> addOrder(String title, int amount) async {
    final db = await DBHelper().database;

    // 파라미터 바인딩을 통해 SQL 인젝션을 방어하며 데이터를 집어넣습니다.
    await db.insert(
      'orders',
      {
        'title': title,
        'amount': amount,
        'date': DateTime.now().toString().substring(0, 19), // 현재 시간
      },
      conflictAlgorithm: ConflictAlgorithm.replace, // 충돌 시 덮어쓰기
    );

    // 데이터를 추가했으니 리스트를 새로고침합니다.
    await loadOrders();
  }
}

 

4단계: UI 화면 구성 및 앱 구동 시 초기화

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; 

// 💡 [중요] 이미 만들어두신 로컬 DB 프로바이더 파일을 임포트합니다.
// 파일명이 다르면 실제 파일명에 맞게 수정해주세요.
import 'local_order_provider.dart'; 

// ==========================================================================
// 1. REST API용 모델 및 프로바이더 (임시로 main에 유지)
// ==========================================================================
class Order {
  final int id;
  final String title;
  final int amount;
  Order({required this.id, required this.title, required this.amount});
}

class OrderProvider extends ChangeNotifier {
  List<Order> _orders = [];
  bool _isLoading = false;
  List<Order> get orders => _orders;
  bool get isLoading => _isLoading;

  Future<void> fetchOrders() async {
    _isLoading = true;
    notifyListeners();
    try {
      final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'));
      if (response.statusCode == 200) {
        final List<dynamic> jsonData = jsonDecode(response.body);
        _orders = jsonData.take(5).map((item) => Order(
          id: item['id'],
          title: item['title'].toString().substring(0, 10),
          amount: 15000 + (item['id'] as int) * 1000,
        )).toList();
      }
    } catch (e) {
      debugPrint('API 에러: $e');
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// ==========================================================================
// 2. 앱 진입점 및 의존성 주입 (Windows FFI 초기화 포함)
// ==========================================================================
void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Windows 데스크톱 환경을 위한 SQLite 팩토리 초기화
  if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
    sqfliteFfiInit();
    databaseFactory = databaseFactoryFfi;
  }

  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => OrderProvider()),
        // 💡 외부에서 임포트한 LocalOrderProvider를 이곳에 주입합니다.
        ChangeNotifierProvider(create: (context) => LocalOrderProvider()), 
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '통합 테스트 앱',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const MainNavigationPage(),
    );
  }
}

// ==========================================================================
// 3. UI 화면 구성 (탭 네비게이션)
// ==========================================================================
class MainNavigationPage extends StatefulWidget {
  const MainNavigationPage({super.key});

  @override
  State<MainNavigationPage> createState() => _MainNavigationPageState();
}

class _MainNavigationPageState extends State<MainNavigationPage> {
  int _selectedIndex = 0;

  final List<Widget> _pages = [
    const ApiOrderPage(),
    const LocalDbPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: (index) => setState(() => _selectedIndex = index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.cloud), label: 'REST API'),
          BottomNavigationBarItem(icon: Icon(Icons.storage), label: '로컬 SQLite'),
        ],
      ),
    );
  }
}

// [탭 1] REST API 화면
class ApiOrderPage extends StatelessWidget {
  const ApiOrderPage({super.key});

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<OrderProvider>();

    return Scaffold(
      appBar: AppBar(title: const Text('REST API 연동')),
      body: Center(
        child: _buildBody(provider),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<OrderProvider>().fetchOrders(),
        child: const Icon(Icons.download),
      ),
    );
  }

  Widget _buildBody(OrderProvider provider) {
    if (provider.isLoading) return const CircularProgressIndicator();
    if (provider.orders.isEmpty) return const Text('하단 다운로드 버튼을 눌러주세요.');

    return ListView.builder(
      itemCount: provider.orders.length,
      itemBuilder: (context, index) {
        final order = provider.orders[index];
        return ListTile(
          title: Text(order.title),
          trailing: Text('${order.amount}원'),
        );
      },
    );
  }
}

// [탭 2] 로컬 DB 화면 (임포트한 LocalOrderProvider 사용)
class LocalDbPage extends StatefulWidget {
  const LocalDbPage({super.key});

  @override
  State<LocalDbPage> createState() => _MyLocalDbPageState();
}

class _MyLocalDbPageState extends State<LocalDbPage> {
  @override
  void initState() {
    super.initState();
    // 화면 로드 시 분리된 프로바이더의 조회 함수를 호출합니다.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<LocalOrderProvider>().loadOrders();
    });
  }

  @override
  Widget build(BuildContext context) {
    // 임포트된 프로바이더의 상태를 관찰합니다.
    final provider = context.watch<LocalOrderProvider>();

    return Scaffold(
      appBar: AppBar(title: const Text('로컬 SQLite DB')),
      body: provider.localOrders.isEmpty
          ? const Center(child: Text('우측 하단 버튼을 눌러 데이터를 추가하세요.'))
          : ListView.builder(
              itemCount: provider.localOrders.length,
              itemBuilder: (context, index) {
                final order = provider.localOrders[index];
                return ListTile(
                  leading: Text('${order['id']}'),
                  title: Text(order['title']),
                  subtitle: Text(order['date']),
                  trailing: Text('${order['amount']}원'),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<LocalOrderProvider>().addOrder('로컬 주문 결제', 18000),
        child: const Icon(Icons.add),
      ),
    );
  }
}

DB로 insert 후 다시 로드하고 리스너에게 갱신..

메인에서는 MultiProvider로 LocalOrderProvider를 생성해놔서 받아서 갱신

 

728x90