2026年10月7日 星期三

 


清除項目編譯快取
執行 flutter clean

重新取得套件依賴
執行 flutter pub get

完整重新編譯並執行
flutter run

1. 新增專案套件 (pubspec.yaml)

在 Flutter 專案根目錄下的 pubspec.yaml 中,新增 mqtt_client 套件 dependencies:

name: mqtt_dht_led
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: ^3.13.5

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  mqtt_client: ^10.0.0 # 請使用最新版本
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^6.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/to/resolution-aware-images

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/to/asset-from-package

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/to/font-from-package

2. Flutter 完整程式碼 (lib/main.dart)

import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ESP32 控制面板',
      theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
      home: const ESP32ControllerScreen(),
    );
  }
}

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

  @override
  State<ESP32ControllerScreen> createState() => _ESP32ControllerScreenState();
}

class _ESP32ControllerScreenState extends State<ESP32ControllerScreen> {
  // MQTT 設定
  static const String broker = 'broker.hivemq.com';
  static const int port = 1883;

  static const String topicLedStatus = 'alex9ufo/2026/ledstatus';
  static const String topicTemp = 'alex9ufo/2026/temp';
  static const String topicHumi = 'alex9ufo/2026/humi';

  static const String topicLedCmd = 'alex9ufo/2026/led';
  static const String topicTempOk = 'alex9ufo/2026/tempOK';
  static const String topicHumiOk = 'alex9ufo/2026/humiOK';

  late MqttServerClient client;

  // 狀態變數
  String mqttStatus = 'MQTT 狀態: 連線中...';
  Color mqttStatusColor = Colors.orange;

  String ledStatus = 'LED 狀態: 待機中...';
  String tempStatus = '環境溫度: -- °C';
  String humiStatus = '環境濕度: -- %';

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

  @override
  void dispose() {
    client.disconnect();
    super.dispose();
  }

  // 初始化並連線 MQTT Broker
  Future<void> _connectMqtt() async {
    // 建立獨一無二的 Client Identifier
    final String clientId =
        'flutter_client_${DateTime.now().millisecondsSinceEpoch}';
    client = MqttServerClient(broker, clientId);
    client.port = port;
    client.keepAlivePeriod = 60;
    client.logging(on: false);

    // 設定連線訊息
    final connMess = MqttConnectMessage()
        .withClientIdentifier(clientId)
        .startClean()
        .withWillQos(MqttQos.atMostOnce);
    client.connectionMessage = connMess;

    // 設定 Callback 回呼函式
    client.onConnected = _onConnected;
    client.onDisconnected = _onDisconnected;

    try {
      await client.connect();
    } catch (e) {
      setState(() {
        mqttStatus = 'MQTT 狀態: 連線失敗';
        mqttStatusColor = Colors.red;
      });
      client.disconnect();
    }
  }

  // 連線成功回呼
  void _onConnected() {
    setState(() {
      mqttStatus = 'MQTT 狀態: 已連線 (Broker 線上)';
      mqttStatusColor = Colors.green;
    });

    // 訂閱主題
    client.subscribe(topicLedStatus, MqttQos.atMostOnce);
    client.subscribe(topicTemp, MqttQos.atMostOnce);
    client.subscribe(topicHumi, MqttQos.atMostOnce);

    // 監聽接收到的訊息 stream
    client.updates!.listen((List<MqttReceivedMessage<MqttMessage?>>? c) {
      final MqttPublishMessage recMess = c![0].payload as MqttPublishMessage;
      final String payload = MqttPublishPayload.bytesToStringAsString(
        recMess.payload.message,
      );
      final String topic = c[0].topic;

      _handleMessage(topic, payload);
    });
  }

  // 斷線回呼
  void _onDisconnected() {
    setState(() {
      mqttStatus = 'MQTT 狀態: 已離線';
      mqttStatusColor = Colors.red;
    });
  }

  // 處理收到的 MQTT 訊息 (對應原 Node-RED 的 Function 節點邏輯)
  void _handleMessage(String topic, String payload) {
    setState(() {
      if (topic == topicLedStatus) {
        // 格式化 LED 狀態
        ledStatus = 'LED 狀態: $payload';
      } else if (topic == topicTemp) {
        // 格式化溫度 + 自動回傳 TempOK
        tempStatus = '環境溫度: $payload °C';
        _sendCommand(topicTempOk, 'OK');
      } else if (topic == topicHumi) {
        // 格式化濕度 + 自動回傳 HumiOK
        humiStatus = '環境濕度: $payload %';
        _sendCommand(topicHumiOk, 'OK');
      }
    });
  }

  // 發送 MQTT 訊息
  void _sendCommand(String topic, String payload) {
    if (client.connectionStatus?.state == MqttConnectionState.connected) {
      final builder = MqttClientPayloadBuilder();
      builder.addString(payload);
      client.publishMessage(topic, MqttQos.atMostOnce, builder.payload!);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ESP32 控制面板'), centerTitle: true),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Card(
          elevation: 4,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // 標題 (對應 Group 名稱)
                const Text(
                  'ESP32 LED & DHT22 控制器',
                  textAlign: TextAlign.center,
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 12),

                // MQTT 連線狀態
                Text(
                  mqttStatus,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.bold,
                    color: mqttStatusColor,
                  ),
                ),
                const Divider(height: 24),

                // 1. LED 狀態顯示
                Text(
                  ledStatus,
                  textAlign: TextAlign.center,
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                    color: Colors.blue,
                  ),
                ),
                const SizedBox(height: 12),

                // 2. 溫度顯示
                Text(
                  tempStatus,
                  textAlign: TextAlign.center,
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                    color: Colors.brown,
                  ),
                ),
                const SizedBox(height: 12),

                // 3. 濕度顯示
                Text(
                  humiStatus,
                  textAlign: TextAlign.center,
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                    color: Colors.green,
                  ),
                ),
                const Divider(height: 32),

                // 控制按鈕區域 (對應 UI Button 節點)
                ElevatedButton(
                  onPressed: () => _sendCommand(topicLedCmd, 'on'),
                  child: const Text(
                    'ON (開啟)',
                    style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                  ),
                ),
                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: () => _sendCommand(topicLedCmd, 'off'),
                  child: const Text(
                    'OFF (關閉)',
                    style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                  ),
                ),
                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: () => _sendCommand(topicLedCmd, 'flash'),
                  child: const Text(
                    'FLASH (閃爍)',
                    style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                  ),
                ),
                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: () => _sendCommand(topicLedCmd, 'timer(5Sec)'),
                  child: const Text(
                    'Timer (定時5秒)',
                    style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}






#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>

#define DHTPIN 4
#define DHTTYPE DHT22

const char* ssid = "Wokwi-GUEST";
const char* password = "";

const char* mqtt_server = "broker.hivemq.com";
const int mqtt_port = 1883;

const char* sub_topic = "alex9ufo/2026/led";
const char* pub_topic = "alex9ufo/2026/ledstatus";
const char* temp_topic = "alex9ufo/2026/temp";
const char* humi_topic = "alex9ufo/2026/humi";
const char* temp_ok_topic = "alex9ufo/2026/tempOK";
const char* humi_ok_topic = "alex9ufo/2026/humiOK";

const int ledPin = 2;

WiFiClient espClient;
PubSubClient client(espClient);
DHT dht(DHTPIN, DHTTYPE);

enum Mode { OFF, ON, FLASH, TIMER };
Mode currentMode = OFF;

unsigned long lastFlashTime = 0;
unsigned long timerStartTime = 0;
unsigned long lastDHTTime = 0;
bool ledState = LOW;

void setup_wifi() {
  delay(10);
  Serial.print("[Wi-Fi] 正在連線至 ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\n[Wi-Fi] 已成功連線!");
}

void publishStatus(const char* payload) {
  client.publish(pub_topic, payload);
  Serial.print("[MQTT 發行] LED 狀態: ");
  Serial.println(payload);
}

void callback(char* topic, byte* payload, unsigned int length) {
  String message;
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }

  Serial.print("[MQTT 接收] 主題: ");
  Serial.print(topic);
  Serial.print(" | Payload: ");
  Serial.println(message);

  // 接收 tempOK 主題
  if (String(topic) == temp_ok_topic) {
    Serial.println("temp_Rx_OK");
    return;
  }
 
  // 接收 humiOK 主題
  if (String(topic) == humi_ok_topic) {
    Serial.println("humi_Rx_OK");
    return;
  }

  if (message == "on") {
    currentMode = ON;
    digitalWrite(ledPin, HIGH);
    publishStatus("on_led");
  } else if (message == "off") {
    currentMode = OFF;
    digitalWrite(ledPin, LOW);
    publishStatus("off_led");
  } else if (message == "flash") {
    currentMode = FLASH;
    publishStatus("flash_led");
  } else if (message == "timer(5Sec)") {
    currentMode = TIMER;
    timerStartTime = millis();
    digitalWrite(ledPin, HIGH);
    publishStatus("timer(5Sec)_led");
  }
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("[MQTT] 正在嘗試連線至 Broker: ");
    Serial.println(mqtt_server);
    String clientId = "ESP32Client-" + String(random(0xffff), HEX);
    if (client.connect(clientId.c_str())) {
      Serial.println("[MQTT] 連線成功!");
      client.subscribe(sub_topic);
      client.subscribe(temp_ok_topic); // 訂閱 tempOK
      client.subscribe(humi_ok_topic); // 訂閱 humiOK
    } else {
      Serial.print("[MQTT] 連線失敗, rc=");
      Serial.print(client.state());
      Serial.println(" 5 秒後重試...");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);
  digitalWrite(ledPin, LOW);
  dht.begin();
  setup_wifi();
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
}

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  client.loop();

  unsigned long currentMillis = millis();

  // 每 2 秒讀取並發行溫濕度
  if (currentMillis - lastDHTTime >= 5000) {
    lastDHTTime = currentMillis;
    float h = dht.readHumidity();
    float t = dht.readTemperature();

    if (!isnan(h) && !isnan(t)) {
      client.publish(temp_topic, String(t, 1).c_str());
      client.publish(humi_topic, String(h, 1).c_str());
      Serial.printf("[DHT22] 溫度: %.1f °C | 濕度: %.1f %%\n", t, h);
    }
  }

  if (currentMode == FLASH) {
    if (currentMillis - lastFlashTime >= 500) {
      lastFlashTime = currentMillis;
      ledState = !ledState;
      digitalWrite(ledPin, ledState);
    }
  } else if (currentMode == TIMER) {
    if (currentMillis - timerStartTime >= 5000) {
      digitalWrite(ledPin, LOW);
      currentMode = OFF;
      publishStatus("off_led");
    }
  }
}

2026年10月5日 星期一

AM/FM 廣播搜尋器 (AM/FM Radio Finder)

 AM/FM 廣播搜尋器 (AM/FM Radio Finder)




無線調幅AM廣播電臺頻率及發射機位址紀錄

資料資源描述
無線調幅AM廣播電臺頻率及發射機位址資料表

無線調頻FM廣播電臺頻率及發射機位址紀錄

資料資源描述
無線調頻FM廣播電臺頻率及發射機位址紀錄

Python程式


import json

import urllib.request

import urllib.parse

import tkinter as tk

from tkinter import ttk, messagebox

import threading


# NCC Open Data URL

AM_URL = "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556563683676434432.json&filedisplay=1556563683676434432_調幅AM_115_7_3.json&flag=doc"

FM_URL = "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556562735747244032.json&filedisplay=1556562735747244032_調頻FM_115_7_3.json&flag=doc"


# 台灣縣市清單

COUNTIES = [

    "全部縣市", "基隆市", "臺北市", "新北市", "桃園市", "新竹市", "新竹縣", "苗栗縣",

    "臺中市", "彰化縣", "南投縣", "雲林縣", "嘉義市", "嘉義縣", "臺南市", "高雄市",

    "屏東縣", "宜蘭縣", "花蓮縣", "臺東縣", "澎湖縣", "金門縣", "連江縣"

]


class RadioQueryApp:

    def __init__(self, root):

        self.root = root

        self.root.title("台灣各縣市 AM/FM 廣播電台查詢系統")

        self.root.geometry("900x550")


        self.am_data = []

        self.fm_data = []


        self.create_widgets()

        

        # 背景下載資料

        threading.Thread(target=self.load_data, daemon=True).start()


    def create_widgets(self):

        # 控制區

        ctrl_frame = ttk.Frame(self.root, padding=10)

        ctrl_frame.pack(fill=tk.X)


        # 縣市選擇

        ttk.Label(ctrl_frame, text="選擇縣市:").pack(side=tk.LEFT, padx=5)

        self.city_cb = ttk.Combobox(ctrl_frame, values=COUNTIES, state="readonly", width=12)

        self.city_cb.current(0)

        self.city_cb.pack(side=tk.LEFT, padx=5)

        self.city_cb.bind("<<ComboboxSelected>>", lambda e: self.filter_data())


        # 廣播類別

        ttk.Label(ctrl_frame, text="廣播類別:").pack(side=tk.LEFT, padx=(15, 5))

        self.type_cb = ttk.Combobox(ctrl_frame, values=["全部", "AM (調幅)", "FM (調頻)"], state="readonly", width=10)

        self.type_cb.current(0)

        self.type_cb.pack(side=tk.LEFT, padx=5)

        self.type_cb.bind("<<ComboboxSelected>>", lambda e: self.filter_data())


        # 關鍵字

        ttk.Label(ctrl_frame, text="關鍵字:").pack(side=tk.LEFT, padx=(15, 5))

        self.search_entry = ttk.Entry(ctrl_frame, width=15)

        self.search_entry.pack(side=tk.LEFT, padx=5)

        self.search_entry.bind("<KeyRelease>", lambda e: self.filter_data())


        # 狀態顯示

        self.status_label = ttk.Label(self.root, text="資料載入中,請稍候...", font=("Microsoft JhengHei", 10, "italic"))

        self.status_label.pack(fill=tk.X, padx=10)


        # 表格 (Treeview)

        table_frame = ttk.Frame(self.root, padding=10)

        table_frame.pack(fill=tk.BOTH, expand=True)


        columns = ("type", "name", "freq", "power", "area")

        self.tree = ttk.Treeview(table_frame, columns=columns, show="headings")


        self.tree.heading("type", text="類別")

        self.tree.heading("name", text="電台/發射台名稱")

        self.tree.heading("freq", text="頻率")

        self.tree.heading("power", text="功率")

        self.tree.heading("area", text="設置地點/播音區域")


        self.tree.column("type", width=80, anchor=tk.CENTER)

        self.tree.column("name", width=220, anchor=tk.W)

        self.tree.column("freq", width=110, anchor=tk.CENTER)

        self.tree.column("power", width=100, anchor=tk.CENTER)

        self.tree.column("area", width=340, anchor=tk.W)


        # 捲軸

        scrollbar = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.tree.yview)

        self.tree.configure(yscroll=scrollbar.set)

        

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)


    def safe_quote_url(self, url):

        """處理網址內的中文轉碼"""

        parsed = urllib.parse.urlparse(url)

        encoded_path = urllib.parse.quote(parsed.path)

        encoded_query = urllib.parse.quote(parsed.query, safe='=&')

        return urllib.parse.urlunparse((

            parsed.scheme, parsed.netloc, encoded_path,

            parsed.params, encoded_query, parsed.fragment

        ))


    def fetch_json(self, url):

        """獲取 JSON 資料"""

        encoded_url = self.safe_quote_url(url)

        req = urllib.request.Request(encoded_url, headers={'User-Agent': 'Mozilla/5.0'})

        with urllib.request.urlopen(req) as response:

            data = response.read().decode('utf-8-sig')

            return json.loads(data)


    def find_val(self, item, keywords, default="-"):

        """根據可能的 Key 關鍵字尋找 JSON 裡的欄位值"""

        for k, v in item.items():

            for kw in keywords:

                if kw in str(k):

                    return str(v).strip()

        return default


    def parse_records(self, raw_data, radio_type):

        """相容多種 JSON 結構與動態欄位解析"""

        records = []

        # 判斷 JSON 格式外層

        if isinstance(raw_data, list):

            items = raw_data

        elif isinstance(raw_data, dict):

            items = raw_data.get("result", {}).get("records", raw_data.get("data", []))

        else:

            items = []


        for item in items:

            if not isinstance(item, dict):

                continue

            

            # 多重比對常見欄位名稱

            name = self.find_val(item, ["事業名稱", "電台名稱", "台名", "廣播事業", "名稱"])

            freq = self.find_val(item, ["頻率", "MHz", "kHz"])

            power = self.find_val(item, ["功率", "發射功率"])

            area = self.find_val(item, ["設置地點", "發射台位置", "播音區域", "地址", "縣市", "設置區域"])


            # 將整個物件所有 value 轉成字串,用於廣泛比對

            full_text = " ".join([str(v) for v in item.values()])


            # 補全頻率單位

            unit = 'kHz' if radio_type == 'AM' else 'MHz'

            formatted_freq = f"{freq} {unit}" if freq != "-" and unit not in freq else freq


            records.append({

                "type": radio_type,

                "name": name,

                "freq": formatted_freq,

                "power": power,

                "area": area,

                "full_text": full_text

            })

        return records


    def load_data(self):

        try:

            am_raw = self.fetch_json(AM_URL)

            fm_raw = self.fetch_json(FM_URL)


            self.am_data = self.parse_records(am_raw, "AM")

            self.fm_data = self.parse_records(fm_raw, "FM")


            msg = f"載入完成!共 AM {len(self.am_data)} 筆,FM {len(self.fm_data)} 筆。"

            self.root.after(0, self.on_data_loaded, True, msg)

        except Exception as e:

            self.root.after(0, self.on_data_loaded, False, f"資料載入失敗: {str(e)}")


    def on_data_loaded(self, success, message):

        self.status_label.config(text=message)

        if success:

            self.filter_data()

        else:

            messagebox.showerror("錯誤", message)


    def filter_data(self):

        """資料篩選與繪製"""

        selected_city = self.city_cb.get()

        selected_type = self.type_cb.get()

        keyword = self.search_entry.get().strip().lower()


        # 清空清單

        for row in self.tree.get_children():

            self.tree.delete(row)


        dataset = []

        if selected_type in ["全部", "AM (調幅)"]:

            dataset.extend(self.am_data)

        if selected_type in ["全部", "FM (調頻)"]:

            dataset.extend(self.fm_data)


        count = 0

        for item in dataset:

            # 縣市比對(同時比對「臺」與「台」)

            city_match = True

            if selected_city != "全部縣市":

                city_1 = selected_city

                city_2 = selected_city.replace("臺", "台") if "臺" in selected_city else selected_city.replace("台", "臺")

                city_match = (city_1 in item["full_text"]) or (city_2 in item["full_text"])


            # 關鍵字比對

            keyword_match = True

            if keyword:

                keyword_match = keyword in item["full_text"].lower()


            if city_match and keyword_match:

                self.tree.insert("", tk.END, values=(

                    item["type"],

                    item["name"],

                    item["freq"],

                    item["power"],

                    item["area"]

                ))

                count += 1


        self.status_label.config(text=f"查詢結果:共 {count} 筆資料")


if __name__ == "__main__":

    root = tk.Tk()

    app = RadioQueryApp(root)

    root.mainloop()


 Flutter 專案的 pubspec.yaml

name: am_fm_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: ^3.13.5

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0 # 加入 HTTP 套件
 
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^6.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/to/resolution-aware-images

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/to/asset-from-package

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/to/font-from-package

main.dart 完整程式碼

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const RadioApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '台灣各縣市 AM/FM 廣播電台查詢系統',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const RadioHomePage(),
    );
  }
}

class RadioStation {
  final String type;
  final String name;
  final String freq;
  final String power;
  final String area;
  final String fullText;

  RadioStation({
    required this.type,
    required this.name,
    required this.freq,
    required this.power,
    required this.area,
    required this.fullText,
  });
}

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

  @override
  State<RadioHomePage> createState() => _RadioHomePageState();
}

class _RadioHomePageState extends State<RadioHomePage> {
  // NCC Open Data URL
  final String amUrl =
      "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556563683676434432.json&filedisplay=1556563683676434432_調幅AM_115_7_3.json&flag=doc";
  final String fmUrl =
      "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556562735747244032.json&filedisplay=1556562735747244032_調頻FM_115_7_3.json&flag=doc";

  final List<String> counties = [
    "全部縣市",
    "基隆市",
    "臺北市",
    "新北市",
    "桃園市",
    "新竹市",
    "新竹縣",
    "苗栗縣",
    "臺中市",
    "彰化縣",
    "南投縣",
    "雲林縣",
    "嘉義市",
    "嘉義縣",
    "臺南市",
    "高雄市",
    "屏東縣",
    "宜蘭縣",
    "花蓮縣",
    "臺東縣",
    "澎湖縣",
    "金門縣",
    "連江縣",
  ];

  final List<String> radioTypes = ["全部", "AM (調幅)", "FM (調頻)"];

  String selectedCity = "全部縣市";
  String selectedType = "全部";
  String keyword = "";

  List<RadioStation> amData = [];
  List<RadioStation> fmData = [];
  List<RadioStation> filteredData = [];

  bool isLoading = true;
  String statusMessage = "資料載入中...";

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

  // 尋找 JSON 中的動態 Key
  String findVal(
    Map<String, dynamic> item,
    List<String> keywords, {
    String defaultValue = "-",
  }) {
    for (var entry in item.entries) {
      for (var kw in keywords) {
        if (entry.key.toString().contains(kw)) {
          return entry.value?.toString().trim() ?? defaultValue;
        }
      }
    }
    return defaultValue;
  }

  // 解析 JSON
  List<RadioStation> parseRecords(dynamic rawData, String radioType) {
    List<RadioStation> records = [];
    List items = [];

    if (rawData is List) {
      items = rawData;
    } else if (rawData is Map) {
      items = rawData["result"]?["records"] ?? rawData["data"] ?? [];
    }

    for (var item in items) {
      if (item is! Map<String, dynamic>) continue;

      String name = findVal(item, ["事業名稱", "電台名稱", "台名", "廣播事業", "名稱"]);
      String freq = findVal(item, ["頻率", "MHz", "kHz"]);
      String power = findVal(item, ["功率", "發射功率"]);
      String area = findVal(item, [
        "設置地點",
        "發射台位置",
        "播音區域",
        "地址",
        "縣市",
        "設置區域",
      ]);

      String fullText = item.values.map((v) => v?.toString() ?? "").join(" ");
      String unit = radioType == 'AM' ? 'kHz' : 'MHz';
      String formattedFreq = (freq != "-" && !freq.contains(unit))
          ? "$freq $unit"
          : freq;

      records.add(
        RadioStation(
          type: radioType,
          name: name,
          freq: formattedFreq,
          power: power,
          area: area,
          fullText: fullText,
        ),
      );
    }
    return records;
  }

  // 抓取 API 資料
  Future<void> loadData() async {
    try {
      final responses = await Future.wait([
        http.get(Uri.parse(amUrl), headers: {'User-Agent': 'Mozilla/5.0'}),
        http.get(Uri.parse(fmUrl), headers: {'User-Agent': 'Mozilla/5.0'}),
      ]);

      if (responses[0].statusCode == 200 && responses[1].statusCode == 200) {
        final amRaw = jsonDecode(utf8.decode(responses[0].bodyBytes));
        final fmRaw = jsonDecode(utf8.decode(responses[1].bodyBytes));

        setState(() {
          amData = parseRecords(amRaw, "AM");
          fmData = parseRecords(fmRaw, "FM");
          isLoading = false;
          filterData();
        });
      } else {
        throw Exception("HTTP 請求失敗");
      }
    } catch (e) {
      setState(() {
        isLoading = false;
        statusMessage = "資料載入失敗,請檢查網路連線。";
      });
    }
  }

  // 資料過濾
  void filterData() {
    List<RadioStation> dataset = [];
    if (selectedType == "全部" || selectedType == "AM (調幅)") {
      dataset.addAll(amData);
    }
    if (selectedType == "全部" || selectedType == "FM (調頻)") {
      dataset.addAll(fmData);
    }

    setState(() {
      filteredData = dataset.where((item) {
        bool cityMatch = true;
        if (selectedCity != "全部縣市") {
          String city1 = selectedCity;
          String city2 = selectedCity.contains("臺")
              ? selectedCity.replaceAll("臺", "台")
              : selectedCity.replaceAll("台", "臺");
          cityMatch =
              item.fullText.contains(city1) || item.fullText.contains(city2);
        }

        bool keywordMatch = true;
        if (keyword.isNotEmpty) {
          keywordMatch = item.fullText.toLowerCase().contains(
            keyword.toLowerCase(),
          );
        }

        return cityMatch && keywordMatch;
      }).toList();

      statusMessage = "找到 ${filteredData.length} 筆電台資料";
    });
  }

  // 顯示詳細資料彈窗
  void showDetailsDialog(RadioStation station) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Row(
          children: [
            Chip(
              label: Text(
                station.type,
                style: const TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
              backgroundColor: station.type == "AM"
                  ? Colors.orange
                  : Colors.indigo,
              visualDensity: VisualDensity.compact,
            ),
            const SizedBox(width: 8),
            Expanded(
              child: Text(
                station.name,
                style: const TextStyle(fontSize: 18),
                overflow: TextOverflow.ellipsis,
              ),
            ),
          ],
        ),
        content: SingleChildScrollView(
          child: ListBody(
            children: [
              _detailRow("廣播頻率", station.freq),
              _detailRow("發射功率", station.power),
              _detailRow("設置地點/播音區域", station.area),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text("關閉"),
          ),
        ],
      ),
    );
  }

  Widget _detailRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            label,
            style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
          ),
          const SizedBox(height: 2),
          Text(
            value,
            style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("台灣廣播電台查詢"),
        centerTitle: true,
        backgroundColor: Theme.of(context).colorScheme.primaryContainer,
      ),
      body: SafeArea(
        child: Column(
          children: [
            // 上方選擇面板 (專為手機優化)
            Container(
              padding: const EdgeInsets.all(10.0),
              color: Colors.grey.shade50,
              child: Column(
                children: [
                  Row(
                    children: [
                      // 縣市選單
                      Expanded(
                        child: DropdownButtonFormField<String>(
                          isExpanded: true,
                          value: selectedCity,
                          decoration: const InputDecoration(
                            labelText: "縣市",
                            contentPadding: EdgeInsets.symmetric(
                              horizontal: 10,
                              vertical: 8,
                            ),
                            border: OutlineInputBorder(),
                          ),
                          items: counties.map((city) {
                            return DropdownMenuItem(
                              value: city,
                              child: Text(
                                city,
                                overflow: TextOverflow.ellipsis,
                              ),
                            );
                          }).toList(),
                          onChanged: (val) {
                            if (val != null) {
                              selectedCity = val;
                              filterData();
                            }
                          },
                        ),
                      ),
                      const SizedBox(width: 8),
                      // 類別選單
                      Expanded(
                        child: DropdownButtonFormField<String>(
                          isExpanded: true,
                          value: selectedType,
                          decoration: const InputDecoration(
                            labelText: "類別",
                            contentPadding: EdgeInsets.symmetric(
                              horizontal: 10,
                              vertical: 8,
                            ),
                            border: OutlineInputBorder(),
                          ),
                          items: radioTypes.map((type) {
                            return DropdownMenuItem(
                              value: type,
                              child: Text(
                                type,
                                overflow: TextOverflow.ellipsis,
                              ),
                            );
                          }).toList(),
                          onChanged: (val) {
                            if (val != null) {
                              selectedType = val;
                              filterData();
                            }
                          },
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  // 搜尋輸入框
                  SizedBox(
                    height: 45,
                    child: TextField(
                      decoration: const InputDecoration(
                        hintText: "搜尋電台名稱或地址",
                        prefixIcon: Icon(Icons.search, size: 20),
                        contentPadding: EdgeInsets.symmetric(
                          vertical: 0,
                          horizontal: 10,
                        ),
                        border: OutlineInputBorder(),
                      ),
                      onChanged: (val) {
                        keyword = val.trim();
                        filterData();
                      },
                    ),
                  ),
                ],
              ),
            ),

            // 統計條
            Container(
              width: double.infinity,
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              color: Theme.of(context).colorScheme.primaryContainer
                  .withOpacity(0.4),
              child: Text(
                statusMessage,
                style: const TextStyle(
                  fontSize: 13,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),

            // 電台列表 (手機卡片視圖)
            Expanded(
              child: isLoading
                  ? const Center(child: CircularProgressIndicator())
                  : filteredData.isEmpty
                  ? const Center(child: Text("沒有找到符合條件的廣播電台"))
                  : ListView.builder(
                      padding: const EdgeInsets.all(8),
                      itemCount: filteredData.length,
                      itemBuilder: (context, index) {
                        final station = filteredData[index];
                        return Card(
                          margin: const EdgeInsets.symmetric(vertical: 4),
                          elevation: 1.5,
                          child: ListTile(
                            contentPadding: const EdgeInsets.symmetric(
                              horizontal: 12,
                              vertical: 4,
                            ),
                            leading: CircleAvatar(
                              radius: 20,
                              backgroundColor: station.type == "AM"
                                  ? Colors.orange
                                  : Colors.indigo,
                              child: Text(
                                station.type,
                                style: const TextStyle(
                                  color: Colors.white,
                                  fontSize: 12,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ),
                            title: Text(
                              station.name,
                              style: const TextStyle(
                                fontWeight: FontWeight.bold,
                                fontSize: 15,
                              ),
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                            ),
                            subtitle: Padding(
                              padding: const EdgeInsets.only(top: 4.0),
                              child: Text(
                                station.area,
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: Colors.grey.shade700,
                                ),
                              ),
                            ),
                            trailing: Column(
                              mainAxisAlignment: MainAxisAlignment.center,
                              crossAxisAlignment: CrossAxisAlignment.end,
                              children: [
                                Text(
                                  station.freq,
                                  style: TextStyle(
                                    fontSize: 13,
                                    fontWeight: FontWeight.bold,
                                    color: Theme.of(context)
                                        .colorScheme
                                        .primary,
                                  ),
                                ),
                                const SizedBox(height: 2),
                                const Icon(
                                  Icons.chevron_right,
                                  size: 16,
                                  color: Colors.grey,
                                ),
                              ],
                            ),
                            onTap: () => showDetailsDialog(station),
                          ),
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }
}






  清除項目編譯快取 執行 flutter clean 重新取得套件依賴 執行 flutter pub get 完整重新編譯並執行 flutter run 1. 新增專案套件 (pubspec.yaml) 在 Flutter 專案根目錄下的 pubspec.yaml 中,新增 ...