ํ”„๋กœ์ ํŠธ/์ž์œจ์ฃผํ–‰

[Flutter] ํ”Œ๋Ÿฌํ„ฐ ์•ฑ์—์„œ ์ „ํ™”๊ฑธ๊ธฐ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•˜๊ธฐ

THE NICOLE 2025. 9. 28. 16:18

Flutter ์•ฑ์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ง์ ‘ ์ „ํ™”๋ฅผ ๊ฑธ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹จ๊ณ„๋ณ„๋กœ ์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

1. ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜

๋จผ์ € url_launcher ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋Š” ์™ธ๋ถ€ ์•ฑ(์ „ํ™”, ๋ฌธ์ž, ๋ธŒ๋ผ์šฐ์ € ๋“ฑ)์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

pubspec.yaml์— ์˜์กด์„ฑ ์ถ”๊ฐ€

dependencies:
  flutter:
    sdk: flutter
  url_launcher: ^6.2.1  # ์ตœ์‹  ๋ฒ„์ „ ์‚ฌ์šฉ

ํŒจํ‚ค์ง€ ์„ค์น˜

flutter pub get

 

2. ํ”Œ๋žซํผ๋ณ„ ๊ถŒํ•œ ์„ค์ •

Android ์„ค์ •

android/app/src/main/AndroidManifest.xml ํŒŒ์ผ์— ์ „ํ™” ๊ถŒํ•œ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- ์ „ํ™” ๊ฑธ๊ธฐ ๊ถŒํ•œ ์ถ”๊ฐ€ -->
    <uses-permission android:name="android.permission.CALL_PHONE" />
    
    <application>
        <!-- ๊ธฐ์กด ์„ค์ •๋“ค... -->
    </application>
</manifest>

iOS ์„ค์ •

ios/Runner/Info.plist ํŒŒ์ผ์— URL ์Šคํ‚ด์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

<dict>
    <!-- ๊ธฐ์กด ์„ค์ •๋“ค... -->
    
    <!-- URL ์Šคํ‚ด ํ—ˆ์šฉ ๋ชฉ๋ก -->
    <key>LSApplicationQueriesSchemes</key>
    <array>
        <string>tel</string>
    </array>
</dict>

 

3. ์ฝ”๋“œ ๊ตฌํ˜„

๊ธฐ๋ณธ ๊ตฌํ˜„

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

class PhoneCallExample extends StatelessWidget {
  const PhoneCallExample({Key? key}) : super(key: key);

  // ์ „ํ™”๊ฑธ๊ธฐ ๋ฉ”์„œ๋“œ
  Future<void> _makePhoneCall(String phoneNumber) async {
    print('์ „ํ™”๊ฑธ๊ธฐ ์‹œ๋„: $phoneNumber');
    
    // 1. ์ „ํ™”๋ฒˆํ˜ธ ๊ฒ€์ฆ
    if (phoneNumber.isEmpty) {
      print('์ „ํ™”๋ฒˆํ˜ธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค');
      return;
    }

    try {
      // 2. ์ „ํ™”๋ฒˆํ˜ธ ์ •๋ฆฌ (ํ•˜์ดํ”ˆ, ๊ณต๋ฐฑ ๋“ฑ ์ œ๊ฑฐ)
      String cleanNumber = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
      
      // 3. ์ „ํ™” URI ์ƒ์„ฑ
      final Uri phoneUri = Uri(scheme: 'tel', path: cleanNumber);
      
      print('์ •๋ฆฌ๋œ ์ „ํ™”๋ฒˆํ˜ธ: $cleanNumber');
      print('์ „ํ™” URI: $phoneUri');

      // 4. ์ „ํ™” ์•ฑ ์‹คํ–‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
      if (await canLaunchUrl(phoneUri)) {
        // 5. ์ „ํ™” ์•ฑ ์‹คํ–‰
        await launchUrl(phoneUri);
        print('์ „ํ™” ์•ฑ ์‹คํ–‰ ์„ฑ๊ณต');
      } else {
        print('์ „ํ™” ์•ฑ์„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค');
      }
    } catch (e) {
      print('์ „ํ™”๊ฑธ๊ธฐ ์—๋Ÿฌ: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('์ „ํ™”๊ฑธ๊ธฐ ์˜ˆ์ œ')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _makePhoneCall('02-1234-5678'),
          child: Text('์ „ํ™”๊ฑธ๊ธฐ'),
        ),
      ),
    );
  }
}

๊ณ ๊ธ‰ ๊ตฌํ˜„ (์—๋Ÿฌ ์ฒ˜๋ฆฌ ํฌํ•จ)

class AdvancedPhoneCall extends StatelessWidget {
  Future<void> _makePhoneCallWithErrorHandling(
    BuildContext context, 
    String phoneNumber
  ) async {
    // 1. ์ „ํ™”๋ฒˆํ˜ธ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
    if (phoneNumber.isEmpty || phoneNumber == '๋ฒˆํ˜ธ์—†์Œ') {
      _showSnackBar(context, '์ „ํ™”๋ฒˆํ˜ธ๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค', Colors.red);
      return;
    }

    try {
      // 2. ์ „ํ™”๋ฒˆํ˜ธ ์ •๋ฆฌ
      String cleanNumber = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
      
      // 3. ํ•œ๊ตญ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹ ๊ฒ€์ฆ
      if (!_isValidKoreanPhoneNumber(cleanNumber)) {
        _showSnackBar(context, '์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹์ž…๋‹ˆ๋‹ค', Colors.red);
        return;
      }

      final Uri phoneUri = Uri(scheme: 'tel', path: cleanNumber);
      
      // 4. ์ „ํ™” ์•ฑ ์‹คํ–‰
      if (await canLaunchUrl(phoneUri)) {
        await launchUrl(phoneUri);
        _showSnackBar(context, '์ „ํ™”๋ฅผ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค', Colors.green);
      } else {
        _showSnackBar(context, '์ „ํ™” ์•ฑ์„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค', Colors.red);
      }
    } catch (e) {
      _showSnackBar(context, '์ „ํ™”๊ฑธ๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e', Colors.red);
    }
  }

  // ํ•œ๊ตญ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹ ๊ฒ€์ฆ
  bool _isValidKoreanPhoneNumber(String phoneNumber) {
    // ํœด๋Œ€ํฐ: 010์œผ๋กœ ์‹œ์ž‘ํ•˜๋Š” 11์ž๋ฆฌ
    // ์ผ๋ฐ˜์ „ํ™”: 02, 031-063์œผ๋กœ ์‹œ์ž‘
    final RegExp mobilePattern = RegExp(r'^010\d{8}$');
    final RegExp landlinePattern = RegExp(r'^0(2|[3-6][1-4])\d{7,8}$');
    
    return mobilePattern.hasMatch(phoneNumber) || 
           landlinePattern.hasMatch(phoneNumber);
  }

  // ์Šค๋‚ต๋ฐ” ํ‘œ์‹œ
  void _showSnackBar(BuildContext context, String message, Color color) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: color,
        duration: Duration(seconds: 2),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: () => _makePhoneCallWithErrorHandling(context, '010-1234-5678'),
          child: Text('ํœด๋Œ€ํฐ์œผ๋กœ ์ „ํ™”'),
        ),
        ElevatedButton(
          onPressed: () => _makePhoneCallWithErrorHandling(context, '02-1234-5678'),
          child: Text('์ผ๋ฐ˜์ „ํ™”๋กœ ์ „ํ™”'),
        ),
      ],
    );
  }
}

 

4. ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ์˜ˆ์™ธ์ƒํ™ฉ

์ฃผ์š” ์˜ˆ์™ธ์ƒํ™ฉ๋“ค

์ „ํ™”๋ฒˆํ˜ธ๊ฐ€ ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ

if (phoneNumber.isEmpty || phoneNumber == '๋ฒˆํ˜ธ์—†์Œ') {
  // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ
  return;
}

 

์ „ํ™” ์•ฑ์ด ์„ค์น˜๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ

if (await canLaunchUrl(phoneUri)) {
  await launchUrl(phoneUri);
} else {
  // ์ „ํ™” ์•ฑ์„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Œ
}

 

์ž˜๋ชป๋œ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹

String cleanNumber = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
if (cleanNumber.length < 10) {
  // ๋„ˆ๋ฌด ์งง์€ ์ „ํ™”๋ฒˆํ˜ธ
}

 

๋กœ๊น…๊ณผ ๋””๋ฒ„๊น…

Future<void> _makePhoneCallWithLogging(String phoneNumber) async {
  print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์‹œ์ž‘: $phoneNumber');
  
  try {
    String cleanNumber = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
    print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์ •๋ฆฌ๋œ ๋ฒˆํ˜ธ: $cleanNumber');
    
    final Uri phoneUri = Uri(scheme: 'tel', path: cleanNumber);
    print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] URI: $phoneUri');
    
    if (await canLaunchUrl(phoneUri)) {
      await launchUrl(phoneUri);
      print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์„ฑ๊ณต');
    } else {
      print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์‹คํŒจ: ์•ฑ ์‹คํ–‰ ๋ถˆ๊ฐ€');
    }
  } catch (e, stackTrace) {
    print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์˜ˆ์™ธ ๋ฐœ์ƒ: $e');
    print('๐Ÿ“ž [์ „ํ™”๊ฑธ๊ธฐ] ์ŠคํƒํŠธ๋ ˆ์ด์Šค: $stackTrace');
  }
}

 

5. ์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์ œ

์Œ์‹์  ์•ฑ์—์„œ์˜ ๊ตฌํ˜„

class RestaurantContactCard extends StatelessWidget {
  final String restaurantName;
  final String phoneNumber;

  const RestaurantContactCard({
    Key? key,
    required this.restaurantName,
    required this.phoneNumber,
  }) : super(key: key);

  Future<void> _callRestaurant(BuildContext context) async {
    print('[$restaurantName] ์ „ํ™”๊ฑธ๊ธฐ ์‹œ๋„: $phoneNumber');
    
    if (phoneNumber.isEmpty || phoneNumber == '๋ฒˆํ˜ธ์—†์Œ') {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('$restaurantName์˜ ์ „ํ™”๋ฒˆํ˜ธ๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค'),
          backgroundColor: Colors.red,
        ),
      );
      return;
    }

    try {
      String cleanNumber = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
      final Uri phoneUri = Uri(scheme: 'tel', path: cleanNumber);
      
      if (await canLaunchUrl(phoneUri)) {
        await launchUrl(phoneUri);
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('$restaurantName์— ์ „ํ™”๋ฅผ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค'),
            backgroundColor: Colors.green,
          ),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('์ „ํ™” ์•ฑ์„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'),
            backgroundColor: Colors.red,
          ),
        );
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('์ „ํ™”๊ฑธ๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค'),
          backgroundColor: Colors.red,
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            Text(restaurantName, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            SizedBox(height: 8),
            Text('์ „ํ™”: $phoneNumber'),
            SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: () => _callRestaurant(context),
              icon: Icon(Icons.phone),
              label: Text('์ „ํ™”๊ฑธ๊ธฐ'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.green,
                foregroundColor: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

6. ์ฃผ์˜์‚ฌํ•ญ ๋ฐ ํŒ

1. ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ

  • ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ๋Š” ์ „ํ™” ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
  • ๋ฐ˜๋“œ์‹œ ์‹ค์ œ ๊ธฐ๊ธฐ์—์„œ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค

2. ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ 

// ์ „ํ™”๊ฑธ๊ธฐ ์ „ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ
Future<void> _showCallConfirmDialog(BuildContext context, String phoneNumber) async {
  return showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        title: Text('์ „ํ™”๊ฑธ๊ธฐ'),
        content: Text('$phoneNumber๋กœ ์ „ํ™”๋ฅผ ๊ฑฐ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: Text('์ทจ์†Œ'),
          ),
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              _makePhoneCall(phoneNumber);
            },
            child: Text('์ „ํ™”๊ฑธ๊ธฐ'),
          ),
        ],
      );
    },
  );
}

3. ์„ฑ๋Šฅ ์ตœ์ ํ™”

// ์ „ํ™”๋ฒˆํ˜ธ ์ •๋ฆฌ ํ•จ์ˆ˜๋ฅผ ๋ณ„๋„๋กœ ๋ถ„๋ฆฌ
String _cleanPhoneNumber(String phoneNumber) {
  return phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
}

// ์บ์‹ฑ์„ ํ†ตํ•œ ์„ฑ๋Šฅ ํ–ฅ์ƒ
final Map<String, String> _cleanedNumbers = {};

String _getCleanedNumber(String original) {
  return _cleanedNumbers.putIfAbsent(original, () => _cleanPhoneNumber(original));
}

 

๊ฒฐ๋ก 

Flutter์—์„œ ์ „ํ™”๊ฑธ๊ธฐ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์€ url_launcher ํŒจํ‚ค์ง€์™€ ์ ์ ˆํ•œ ๊ถŒํ•œ ์„ค์ •๋งŒ ์žˆ์œผ๋ฉด ๋งค์šฐ ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์‹ค์ œ ์•ฑ์—์„œ๋Š” ๋‹ค์–‘ํ•œ ์˜ˆ์™ธ์ƒํ™ฉ๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ณ ๋ คํ•œ ๊ตฌํ˜„์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ํฌ์ธํŠธ

  1. url_launcher ํŒจํ‚ค์ง€ ์‚ฌ์šฉ
  2. ํ”Œ๋žซํผ๋ณ„ ๊ถŒํ•œ ์„ค์ • ํ•„์ˆ˜
  3. ์ „ํ™”๋ฒˆํ˜ธ ์œ ํšจ์„ฑ ๊ฒ€์ฆ
  4. ์ ์ ˆํ•œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
  5. ์‹ค์ œ ๊ธฐ๊ธฐ์—์„œ ํ…Œ์ŠคํŠธ

 

์ด ๊ฐ€์ด๋“œ๋ฅผ ๋”ฐ๋ผ ๊ตฌํ˜„ํ•˜๋ฉด ์•ˆ์ •์ ์ด๊ณ  ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ์ „ํ™”๊ฑธ๊ธฐ ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค!