บทที่ 15mobile|13 KB

บทที่ 15: Image Handling & Export

บทที่ 15: Image Handling & Export

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • แปลง Widget ให้กลายเป็นรูปภาพ (PNG) ด้วย RepaintBoundary
  • บันทึกรูปภาพลง Gallery ของเครื่อง (ใช้ gal)
  • แชร์รูปภาพไปยังแอปอื่น (Line, Facebook, Story) ด้วย share_plus
  • เลือกรูปจาก Gallery มาแสดงในแอป (ใช้ image_picker)

📚 ทฤษฎีและแนวคิด

1. Widget to Image (การแปลง Widget เป็นรูป)

ใน Flutter ทุกอย่างคือ Widget แต่เราจะ "ถ่ายรูป" Widget นั้นออกมาได้อย่างไร? คำตอบคือ RepaintBoundary:

  • มันเหมือน "กรอบรูป" ที่เราเอาไปครอบ Widget ที่ต้องการ
  • เราสามารถสั่งให้กรอบรูปนี้ "Render" ภาพในกรอบออกมาเป็นข้อมูล Binary (Uint8List) ได้

2. Sandbox System (ข้อจำกัดเรื่องไฟล์)

  • iOS/Android: แอปไม่สามารถเขียนไฟล์ลง Gallery ตรงๆ ได้ตามใจชอบ ต้องขอ Permission
  • Temporary Directory: เวลาเราจะแชร์ไฟล์ เรามักจะบันทึกไฟล์ชั่วคราวใน Cache ของแอปก่อน แล้วค่อยส่ง Path ให้แอปอื่น

💻 การเขียนโค้ด (Implementation)

1. ติดตั้ง Package

dependencies:
  # สำหรับจับภาพและตกแต่ง
  flutter:
    sdk: flutter
  
  # สำหรับบันทึกลงเครื่อง
  gal: ^2.3.0
  
  # สำหรับแชร์
  share_plus: ^9.0.0
  path_provider: ^2.1.2  # หาที่เก็บไฟล์ชั่วคราว
  
  # สำหรับเลือกรูป
  image_picker: ^1.0.7

2. สร้าง Utility Class (ตัวช่วย)

ไฟล์: mobile/lib/utils/file_saver.dart

เราจะสร้าง Class ที่รวมฟังก์ชันเกี่ยวกับไฟล์ไว้ที่เดียว

import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:gal/gal.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';

class FileSaver {

อธิบายทีละบรรทัด (Imports):

  • import 'dart:ui' as ui

    • นำเข้า low-level painting library ตั้งชื่อเล่นว่า ui (เพื่อไม่ให้ชนกับ classes อื่น)
  • import 'package:gal/gal.dart'

    • Library สำหรับบันทึกรูปลง Gallery (Gallery Abstraction Layer)
  • import 'package:share_plus/share_plus.dart'

    • Library สำหรับเรียก Share Sheet ของเครื่อง (ส่งรูปเข้า Line/Facebook)

อธิบาย: // --- 1. Capture Widget as Image --- // รับ GlobalKey ของ RepaintBoundary -> คืนค่าเป็น bytes (PNG) static Future<Uint8List?> capturePng(GlobalKey key) async { try { // ค้นหา RenderObject จาก Key final boundary = key.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null;

  // แปลงเป็น Image (pixelRatio 3.0 = ชัดกว่าจอปกติ 3 เท่า)
  final image = await boundary.toImage(pixelRatio: 3.0);
  
  // แปลง Image เป็น ByteData (PNG)
  final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  
  return byteData?.buffer.asUint8List();
} catch (e) {
  print("Errot capturing png: $e");
  return null;
}

}

// --- 2. Save to Gallery --- static Future<void> saveToGallery(Uint8List bytes) async { // ต้องขอ Permission ก่อน (gal ทำให้เองในตัว) // แต่เพื่อความชัวร์ ควรเช็คก่อน if (!await Gal.hasAccess()) { await Gal.requestAccess(); }

// บันทึกไฟล์ชั่วคราว (เพราะ Gal รับเป็น path)
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/quote_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(bytes);

// บันทึกลง Gallery
await Gal.putImage(file.path, album: 'QUOM');

}

// --- 3. Share File --- static Future<void> shareImage(Uint8List bytes) async { final tempDir = await getTemporaryDirectory(); final file = File('${tempDir.path}/share_quote.png'); await file.writeAsBytes(bytes);

// ใช้ XFile (Format กลางของ Flutter)
await Share.shareXFiles(
  [XFile(file.path)],
  text: 'ข้อคิดดีๆ จากแอพ QUOM',
);

} }


### 3. หน้าจอ Export Screen

ไฟล์: `mobile/lib/screens/export_screen.dart`

หน้านี้คือหัวใจสำคัญที่เราจะให้ user แต่งรูปก่อนเซฟ

```dart
class ExportScreen extends StatefulWidget {
  final Note note; // รับข้อมูล Note มาแสดง
  const ExportScreen({required this.note, super.key});

  @override
  State<ExportScreen> createState() => _ExportScreenState();
}

class _ExportScreenState extends State<ExportScreen> {
  // Key สำคัญสำหรับทำ RepaintBoundary
  final GlobalKey _globalKey = GlobalKey();
  
  // State สำหรับปรับแต่ง
  Color _bgColor = Colors.white;
  bool _showCredit = true;

  // ฟังก์ชันกดปุ่มเซฟ
  Future<void> _saveImage() async {
    // 1. หมุนติ้วๆ
    // 2. Capture
    final bytes = await FileSaver.capturePng(_globalKey);
    if (bytes != null) {
      // 3. Save
      await FileSaver.saveToGallery(bytes);
      // 4. แจ้งเตือนสำเร็จ
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("บันทึกรูปเรียบร้อยแล้ว!")),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("แชร์คำคม")),
      body: Column(
        children: [
          // ส่วนแสดงผลรูปภาพที่จะเซฟ
          Expanded(
            child: Center(
              child: Padding(
                padding: const EdgeInsets.all(24.0),
                // --- หัวใจสำคัญ: RepaintBoundary ---
                child: RepaintBoundary(
                  key: _globalKey, // ผูก Key ไว้
                  child: Container(
                    // ตกแต่ง Container ให้เหมือน Card สวยๆ
                    padding: EdgeInsets.all(32),
                    decoration: BoxDecoration(
                      color: _bgColor,
                      borderRadius: BorderRadius.circular(20),
                      boxShadow: [
                        BoxShadow(
                          color: Colors.black26, 
                          blurRadius: 20, 
                          offset: Offset(0, 10)
                        ),
                      ],
                    ),
                    child: Column(
                      mainAxisSize: MainAxisSize.min, // ขนาดพอดีเนื้อหา
                      children: [
                        // Quote Text
                        Text(
                          '"${widget.note.quote}"',
                          textAlign: TextAlign.center,
                          style: TextStyle(
                            fontSize: 24,
                            fontWeight: FontWeight.bold,
                            fontFamily: 'Sarabun', // ฟอนต์ไทยสวยๆ
                          ),
                        ),
                        SizedBox(height: 20),
                        Divider(),
                        // Author / Credit
                        if (_showCredit)
                          Text(
                            "จากแอป QUOM",
                            style: TextStyle(color: Colors.grey),
                          ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
          
          // ส่วนเครื่องมือปรับแต่ง (Toolbar)
          Container(
            padding: EdgeInsets.all(16),
            color: Colors.white,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                 IconButton(
                   icon: Icon(Icons.color_lens, color: Colors.blue),
                   onPressed: () => setState(() => _bgColor = Colors.blue[50]!),
                 ),
                 IconButton(
                   icon: Icon(Icons.share),
                   onPressed: () async {
                     final bytes = await FileSaver.capturePng(_globalKey);
                     if (bytes != null) await FileSaver.shareImage(bytes);
                   },
                 ),
                 ElevatedButton.icon(
                   icon: Icon(Icons.save_alt),
                   label: Text("บันทึก"),
                   onPressed: _saveImage,
                 ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

4. Custom Painter (วาดลวดลายเอง)

บางทีเราอยากได้พื้นหลังลายจุด ลายตาราง ที่ไม่มีรูปให้โหลด เราสามารถ "วาดเอง" ได้ด้วย code

class PatternPainter extends CustomPainter {
  final Color color;
  PatternPainter({this.color = Colors.grey});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color.withOpacity(0.1)
      ..strokeWidth = 1;

    // วาดลายเส้นทแยงมุม
    for (double i = 0; i < size.width + size.height; i += 20) {
      canvas.drawLine(
        Offset(0, i),
        Offset(i, 0),
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

การใช้งาน: ใส่ CustomPaint เป็น background ใน Stack

Stack(
  children: [
    CustomPaint(
      size: Size.infinite,
      painter: PatternPainter(),
    ),
    // ... เนื้อหาอื่นๆ
  ],
)

📝 แบบฝึกหัด

โจทย์ที่ 1: เปลี่ยนฟอนต์ (Font Selection)

สิ่งที่ต้องทำ:

  1. เพิ่มตัวแปร String _fontFamily
  2. เพิ่มปุ่มเลือกฟอนต์ (Sarabun, Kanit, Prompt)
  3. เปลี่ยน TextStyle ตามฟอนต์ที่เลือก

โจทย์ที่ 2: Profile Image Overlay

สิ่งที่ต้องทำ:

  1. ใช้ ImagePicker ให้ user เลือกรูปตัวเอง
  2. แสดงรูปตัวเองเป็นวงกลมเล็กๆ มุมขวาล่างของ Card (ใน RepaintBoundary)

โจทย์ที่ 3: Gradient Background

สิ่งที่ต้องทำ:

  1. เปลี่ยน _bgColor จาก Color เดียว เป็น Gradient
  2. สร้างปุ่มเลือก Gradient Presets สวยๆ 3-4 แบบ

📌 สรุป

การทำ Export Image ให้ User รู้สึกเหมือนเราเป็นแอปแต่งรูปย่อมๆ:

  • RepaintBoundary: คือหัวใจในการแปลง UI เป็นรูป
  • Customization: ยิ่งให้ user ปรับแต่งได้มาก ยิ่งดี (สี, ฟอนต์, พื้นหลัง)
  • Share: การแชร์ที่ง่าย (กดแล้วเด้งไป Line/IG) ช่วยให้แอปเราเป็นที่รู้จักมากขึ้น

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 16: Multi-language (i18n) แอปเราดีขนาดนี้ คนต่างชาติต้องได้ใช้! มาทำระบบเปลี่ยนภาษา (ไทย/อังกฤษ) กันเถอะ