First Commit

This commit is contained in:
2026-04-17 00:37:21 +08:00
parent f445b408a4
commit fb33d42ab1
83 changed files with 3832 additions and 2 deletions
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import './house_note_model.dart';
import './house_note_api.dart';
// import 'package:uuid/uuid.dart';
class HouseEvaluationForm extends StatefulWidget {
final String houseId;
final String houseTitle;
const HouseEvaluationForm({required this.houseId, required this.houseTitle, super.key});
@override
State<HouseEvaluationForm> createState() => _HouseEvaluationFormState();
}
class _HouseEvaluationFormState extends State<HouseEvaluationForm> {
final _api = HouseNoteApiService();
bool _isLoading = true;
List<CheckCategory> _categories = [];
List<CheckItem> _allItems = [];
final Map<String, HouseEvaluation> _evalMap = {}; // Key: checkItemId
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
final results = await Future.wait([
_api.fetchCategories(),
_api.fetchAllCheckItems(),
_api.fetchEvaluations(widget.houseId),
]);
if (mounted) {
setState(() {
_categories = results[0] as List<CheckCategory>;
_allItems = results[1] as List<CheckItem>;
// 將已有的評估紀錄放入 Map
final existingEvals = results[2] as List<HouseEvaluation>;
for (var e in existingEvals) {
_evalMap[e.checkItemId] = e;
}
_isLoading = false;
});
}
} catch (e) {
debugPrint("載入失敗: $e");
// 【關鍵修正】避免發生錯誤時畫面永遠轉圈圈(無反應)
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('資料載入失敗,請檢查網路或 API: $e')),
);
}
}
}
Future<void> _saveAll() async {
setState(() => _isLoading = true);
// 只儲存有打分數 (score > 0) 的項目
final toSave = _evalMap.values.where((e) => e.score > 0).toList();
for (var eval in toSave) {
await _api.saveSingleEvaluation(eval);
}
if (mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${widget.houseTitle} - 賞屋評估'),
actions: [
IconButton(icon: const Icon(Icons.check), onPressed: _isLoading ? null : _saveAll),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: _categories.length,
itemBuilder: (context, catIdx) {
final cat = _categories[catIdx];
final items = _allItems.where((i) => i.categoryId == cat.id).toList();
return ExpansionTile(
initiallyExpanded: catIdx == 0,
title: Text(cat.name, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
children: items.map((item) => _buildItemRow(item)).toList(),
);
},
),
);
}
Widget _buildItemRow(CheckItem item) {
// 取得或初始化該項目的評估狀態
final eval = _evalMap.putIfAbsent(
item.id,
() => HouseEvaluation(
houseId: widget.houseId,
checkItemId: item.id,
itemName: item.name, // 【關鍵修正】把項目的中文名稱也傳進去
)
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontSize: 15)),
const SizedBox(height: 4),
Row(
children: [
// 1-5 分評分 (星星)
...List.generate(5, (index) {
int starValue = index + 1;
return IconButton(
icon: Icon(
starValue <= eval.score ? Icons.star : Icons.star_border,
color: Colors.orange,
),
onPressed: () => setState(() => eval.score = starValue),
);
}),
const Spacer(),
// 備註按鈕
IconButton(
icon: Icon(Icons.note_alt_outlined, color: (eval.itemMemo?.isNotEmpty ?? false) ? Colors.blue : Colors.grey),
onPressed: () => _editMemo(eval),
),
],
),
const Divider(),
],
),
);
}
void _editMemo(HouseEvaluation eval) {
final controller = TextEditingController(text: eval.itemMemo);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('項目備註'),
content: TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(hintText: '輸入屋況細節 (如:疑似壁癌...)'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
TextButton(
onPressed: () {
setState(() => eval.itemMemo = controller.text);
Navigator.pop(context);
},
child: const Text('確定')
),
],
),
);
}
}
+227
View File
@@ -0,0 +1,227 @@
import '../../services/generic_api_service.dart';
// import '../auth_manager.dart';
import 'house_note_model.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:convert'; // 補上這行,json.decode 才能運作
class HouseNoteApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取所有房屋列表
Future<List<House>> fetchHouses() async {
// 依據建立時間降冪排序
String queryFilter = "1^100^created_at^*^^^";
return await _apiService.fetchList<House>(
tableName: "hhp_houses",
pk: "id",
queryFilter: queryFilter,
fromJson: (json) => House.fromJson(json),
);
}
// 創建新物件 (UUID 在前端生成)
Future<List<House>> createHouse(House house) async {
final Map<String, dynamic> data = {
"id": house.id,
"title": house.title,
"address": house.address,
"latitude": house.latitude,
"longitude": house.longitude,
"total_price": house.totalPrice,
"total_ping": house.totalPing,
"property_type": house.propertyType,
"age": house.age,
"summary_notes": house.summaryNotes,
"created_at": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
return await _apiService.fetchList<House>(
tableName: "hhp_houses",
pk: "id",
queryFilter: "", // 【關鍵修正】補上必填參數,傳入空字串即可
action: "C",
data: data,
fromJson: (json) => House.fromJson(json),
);
}
// 獲取預設檢查清單
Future<List<CheckItem>> fetchDefaultCheckItems() async {
return await _apiService.fetchList<CheckItem>(
tableName: "hhp_check_items",
pk: "id",
queryFilter: "1^200^sort_order^*^^^is_default^1",
fromJson: (json) => CheckItem.fromJson(json),
);
}
// 上傳照片檔案 (參考 expense_api)
Future<String?> uploadImage(File imageFile) async {
try {
final request = http.MultipartRequest(
'POST',
// Uri.parse("${GenericApiService.BASE_IP}/xapi/v2/common/upload_file"),
Uri.parse("${GenericApiService.BASE_IP}/upload/eis_demo/images")
);
// 取得本地檔案名稱
String picFileName = "${GenericApiService.BASE_IP}/upload/eis_demo/images/${imageFile.path.split('/').last}";
request.files.add(await http.MultipartFile.fromPath('file', imageFile.path));
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
// final data = json.decode(response.body);
// return data['fileName'];
return picFileName; // 這是回傳後端產生的檔名
}
} catch (e) {
print("圖片上傳異常: $e");
}
return null;
}
// 儲存照片關聯資訊
Future<bool> saveHousePhoto(String houseId, String fileName, String note) async {
final Map<String, dynamic> data = {
"id": const Uuid().v4(),
"house_id": houseId,
"file_path": fileName,
"description": note,
"created_at": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
final result = await _apiService.fetchList<dynamic>(
tableName: "hhp_house_photos",
pk: "id",
queryFilter: "",
action: "C",
data: data,
fromJson: (json) => json,
);
return result.isNotEmpty;
}
// 獲取特定房屋的所有評估紀錄 (需 Join 項目名稱)
Future<List<HouseEvaluation>> fetchEvaluations(String houseId) async {
// 這裡假設後端會處理 Join hhp_check_items 取得名稱
String queryFilter = "1^100^id^*^^hhpm02^house_id^$houseId";
return await _apiService.fetchList<HouseEvaluation>(
tableName: "hhp_house_evaluations",
pk: "id",
queryFilter: queryFilter,
fromJson: (json) => HouseEvaluation.fromJson(json),
);
}
// 獲取特定房屋的所有照片
Future<List<HousePhoto>> fetchPhotos(String houseId) async {
String queryFilter = "1^100^created_at^*^^^house_id^$houseId";
return await _apiService.fetchList<HousePhoto>(
tableName: "hhp_house_photos",
pk: "id",
queryFilter: queryFilter,
fromJson: (json) => HousePhoto.fromJson(json),
);
}
// house_note_api.dart 新增方法
// 1. 獲取所有可用標籤庫 (hhp_tags)
Future<List<Tag>> fetchAllTags() async {
return await _apiService.fetchList<Tag>(
tableName: "hhp_tags",
pk: "id",
queryFilter: "1^100^id^*^^^",
fromJson: (json) => Tag.fromJson(json),
);
}
// 2. 獲取特定房屋已選取的標籤 ID 清單 (hhp_house_tags)
Future<List<String>> fetchHouseTagIds(String houseId) async {
final List<dynamic> result = await _apiService.fetchList<dynamic>(
tableName: "hhp_house_tags",
pk: "house_id", // 複合主鍵通常用其中一個,視 API 規範而定
queryFilter: "1^100^tag_id^*^^^house_id^$houseId",
fromJson: (json) => json,
);
return result.map((e) => e['tag_id'].toString()).toList();
}
// 3. 儲存/更新房屋標籤 (hhp_house_tags)
// 邏輯:通常是先刪除舊的再寫入新的,這裡簡化為寫入邏輯
Future<void> updateHouseTags(String houseId, List<String> selectedTagIds) async {
// A. 刪除該房屋舊有的所有標籤關聯 (假設 action 'D' 為刪除)
await _apiService.fetchList<dynamic>(
tableName: "hhp_house_tags",
pk: "house_id",
queryFilter: "house_id^$houseId",
action: "D",
fromJson: (json) => json,
);
// B. 逐一寫入新的標籤關聯
for (String tagId in selectedTagIds) {
await _apiService.fetchList<dynamic>(
tableName: "hhp_house_tags",
pk: "house_id",
queryFilter: "",
action: "C",
data: {
"house_id": houseId,
"tag_id": tagId,
},
fromJson: (json) => json,
);
}
}
// 1. 獲取所有檢查大類 (hhp_check_categories)
Future<List<CheckCategory>> fetchCategories() async {
return await _apiService.fetchList<CheckCategory>(
tableName: "hhp_check_categories",
pk: "id",
queryFilter: "1^100^sort_order^*^^^",
fromJson: (json) => CheckCategory.fromJson(json),
);
}
// 2. 儲存/更新評估紀錄
// 由於評估通常是多筆,我們這裡實作單筆提交,呼叫端可用迴圈或 Future.wait
Future<bool> saveSingleEvaluation(HouseEvaluation eval) async {
final Map<String, dynamic> data = {
"id": eval.id ?? const Uuid().v4(),
"house_id": eval.houseId,
"check_item_id": eval.checkItemId,
"score": eval.score,
"item_memo": eval.itemMemo ?? "",
"updated_at": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
final result = await _apiService.fetchList<dynamic>(
tableName: "hhp_house_evaluations",
pk: "id",
queryFilter: "",
action: "C", // 使用 Create/Update
data: data,
fromJson: (json) => json,
);
return result.isNotEmpty;
}
// 獲取所有檢查項目 (包含非預設項目,用於表單與名稱對應)
Future<List<CheckItem>> fetchAllCheckItems() async {
return await _apiService.fetchList<CheckItem>(
tableName: "hhp_check_items",
pk: "id",
// 撈取所有項目,以 sort_order 排序
queryFilter: "1^200^sort_order^*^^^",
fromJson: (json) => CheckItem.fromJson(json),
);
}
}
+394
View File
@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import './house_note_model.dart';
import './house_note_api.dart';
import './house_photo_form.dart';
import './house_tag_form.dart';
import './house_evaluation_form.dart';
class HouseNoteDetail extends StatefulWidget {
final House house;
const HouseNoteDetail({required this.house, super.key});
@override
State<HouseNoteDetail> createState() => _HouseNoteDetailState();
}
class _HouseNoteDetailState extends State<HouseNoteDetail> {
final HouseNoteApiService _api = HouseNoteApiService();
List<HouseEvaluation> _evaluations = [];
List<HousePhoto> _photos = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAllData();
}
// 同步抓取評估紀錄與照片
Future<void> _loadAllData() async {
setState(() => _isLoading = true);
try {
final results = await Future.wait([
_api.fetchEvaluations(widget.house.id),
_api.fetchPhotos(widget.house.id),
]);
if (mounted) {
setState(() {
_evaluations = results[0] as List<HouseEvaluation>;
_photos = results[1] as List<HousePhoto>;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('資料載入失敗: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('賞屋詳細內容'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadAllData,
)
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
child: Column(
children: [
// a. 基本資料區塊
_buildSectionHeader('房屋基本資料', Icons.info_outline),
_buildBasicInfoCard(),
// b. 屋況檢查區塊 (與照片主鍵關聯)
_buildSectionHeader('屋況檢查評估', Icons.checklist_rtl),
_buildEvaluationList(),
// c. 照片總覽區塊
_buildSectionHeader('照片總覽 (點擊可放大)', Icons.photo_library_outlined),
_buildPhotoGrid(),
const SizedBox(height: 100), // 留白空間
],
),
),
// 底部懸浮按鈕區
bottomSheet: _buildBottomActions(context),
);
}
// --- UI 元件函式 ---
// 分區標題
Widget _buildSectionHeader(String title, IconData icon) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Colors.grey.shade100,
child: Row(
children: [
Icon(icon, size: 20, color: Colors.blueAccent),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
],
),
);
}
// 基本資料卡片
Widget _buildBasicInfoCard() {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildDetailRow(Icons.home, '物件名稱', widget.house.title),
const Divider(),
_buildDetailRow(Icons.location_on, '完整地址', widget.house.address ?? '未填寫'),
const Divider(),
_buildDetailRow(Icons.monetization_on, '預估價格', widget.house.priceTag),
const Divider(),
_buildDetailRow(Icons.layers, '物件規格', widget.house.infoSummary),
],
),
),
),
);
}
// 輔助明細列
Widget _buildDetailRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: Colors.grey),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const Spacer(),
Expanded(
child: Text(
value,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
// 屋況檢查清單
Widget _buildEvaluationList() {
if (_evaluations.isEmpty) {
return const Padding(
padding: EdgeInsets.all(30),
child: Text('目前尚無評估數據', style: TextStyle(color: Colors.grey)),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _evaluations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final eval = _evaluations[index];
// 關鍵:找出有沒有照片是關聯到這個評估項目的 (透過 evaluation_id)
final HousePhoto? linkedPhoto = _photos.cast<HousePhoto?>().firstWhere(
(p) => p?.evaluationId == eval.id,
orElse: () => null,
);
return ListTile(
title: Text(eval.itemName, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: List.generate(5, (i) => Icon(
Icons.star,
size: 16,
color: i < eval.score ? Colors.orange : Colors.grey.shade300
)),
),
if (eval.itemMemo != null && eval.itemMemo!.isNotEmpty)
Text(eval.itemMemo!, style: const TextStyle(fontSize: 12, color: Colors.blueGrey)),
],
),
trailing: linkedPhoto != null
? GestureDetector(
onTap: () => _showFullScreenImage(context, linkedPhoto.fullUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
linkedPhoto.fullUrl,
width: 50, height: 50,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 30),
),
),
)
: null,
);
},
);
}
// 照片總覽 Grid
Widget _buildPhotoGrid() {
if (_photos.isEmpty) {
return const Padding(
padding: EdgeInsets.all(30),
child: Text('尚未拍攝照片', style: TextStyle(color: Colors.grey)),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _photos.length,
itemBuilder: (context, index) {
final photo = _photos[index];
return GestureDetector(
onTap: () => _showFullScreenImage(context, photo.fullUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
photo.fullUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Icon(Icons.broken_image, color: Colors.grey),
),
),
),
);
},
);
}
// --- 互動功能 ---
// 圖片放大功能 (支援雙指縮放)
void _showFullScreenImage(BuildContext context, String imageUrl) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
body: Center(
child: InteractiveViewer(
panEnabled: true,
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
imageUrl,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const CircularProgressIndicator(color: Colors.white);
},
),
),
),
),
),
);
}
// 底部按鈕
Widget _buildBottomActions(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10)]
),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HouseEvaluationForm(
houseId: widget.house.id,
houseTitle: widget.house.title,
),
),
);
// 返回後刷新詳情頁
if (result == true) {
_loadAllData();
}
},
icon: const Icon(Icons.rate_review),
label: const Text('賞屋評估'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 保持與拍照按鈕一致的圓角
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HousePhotoForm(
houseId: widget.house.id,
houseTitle: widget.house.title
)
)
);
// 拍完照回來自動刷新頁面
if (result == true) {
_loadAllData();
}
},
icon: const Icon(Icons.camera_alt),
label: const Text('拍照紀錄'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 保持與拍照按鈕一致的圓角
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
// 跳轉至標籤編輯頁面 (HouseTagForm)
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HouseTagForm(
houseId: widget.house.id,
houseTitle: widget.house.title,
),
),
);
// 當從標籤頁面返回,且 result 為 true 時,自動刷新詳情頁資料
if (result == true) {
_loadAllData();
}
},
icon: const Icon(Icons.local_offer_outlined),
label: const Text('編輯標籤'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal, // 背景顏色設定為藍綠色
foregroundColor: Colors.white, // 文字與圖示顏色設定為白色
padding: const EdgeInsets.symmetric(vertical: 12), // 設定垂直內距
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 保持與拍照按鈕一致的圓角
),
),
),
),
],
),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'house_note_model.dart';
import 'house_note_api.dart';
class HouseNoteForm extends StatefulWidget {
final House? house; // 若傳入則為編輯
const HouseNoteForm({this.house, super.key});
@override
State<HouseNoteForm> createState() => _HouseNoteFormState();
}
class _HouseNoteFormState extends State<HouseNoteForm> {
final _formKey = GlobalKey<FormState>();
final _api = HouseNoteApiService();
late String _title;
String? _address;
double? _totalPrice;
double? _totalPing;
String? _propertyType = '電梯大樓';
void _submit() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
final newHouse = House(
id: widget.house?.id ?? const Uuid().v4(), // 自動取得 UUID
title: _title,
address: _address,
totalPrice: _totalPrice,
totalPing: _totalPing,
propertyType: _propertyType,
);
await _api.createHouse(newHouse);
if (mounted) Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.house == null ? '新增看屋筆記' : '編輯物件')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
TextFormField(
initialValue: widget.house?.title,
decoration: const InputDecoration(labelText: '物件名稱 *', hintText: '例:桃園藝文特區三房'),
validator: (v) => v!.isEmpty ? '請輸入名稱' : null,
onSaved: (v) => _title = v!,
),
const SizedBox(height: 16),
TextFormField(
initialValue: widget.house?.address,
decoration: const InputDecoration(labelText: '地址', suffixIcon: Icon(Icons.my_location)),
onSaved: (v) => _address = v,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: widget.house?.totalPrice?.toString(),
decoration: const InputDecoration(labelText: '開價 (萬)', prefixIcon: Icon(Icons.monetization_on_outlined)),
keyboardType: TextInputType.number,
onSaved: (v) => _totalPrice = double.tryParse(v ?? ''),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
initialValue: widget.house?.totalPing?.toString(),
decoration: const InputDecoration(labelText: '坪數', prefixIcon: Icon(Icons.square_foot)),
keyboardType: TextInputType.number,
onSaved: (v) => _totalPing = double.tryParse(v ?? ''),
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _propertyType,
items: ['電梯大樓', '公寓', '華廈', '透天'].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
onChanged: (v) => setState(() => _propertyType = v),
decoration: const InputDecoration(labelText: '建物型態'),
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 54), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
child: const Text('儲存筆記', style: TextStyle(fontSize: 18)),
),
],
),
),
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // 支援 SystemNavigator.pop()
import 'house_note_model.dart';
import 'house_note_api.dart';
import 'house_note_form.dart';
import 'house_note_detail.dart';
class HouseNoteManager extends StatefulWidget {
const HouseNoteManager({super.key});
@override
State<HouseNoteManager> createState() => _HouseNoteManagerState();
}
class _HouseNoteManagerState extends State<HouseNoteManager> {
final HouseNoteApiService _api = HouseNoteApiService();
late Future<List<House>> _houseFuture;
@override
void initState() {
super.initState();
_refreshList();
}
void _refreshList() {
setState(() {
_houseFuture = _api.fetchHouses();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('🏠 我的賞屋筆記'),
actions: [
IconButton(
icon: const Icon(Icons.exit_to_app, color: Colors.redAccent),
tooltip: '離開系統',
onPressed: () => SystemNavigator.pop(), // 實作離開系統功能
)
],
),
body: FutureBuilder<List<House>>(
future: _houseFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: snapshot.data!.length,
itemBuilder: (ctx, i) => _buildHouseCard(snapshot.data![i]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HouseNoteForm()),
);
if (result == true) _refreshList();
},
label: const Text('新增看屋'),
icon: const Icon(Icons.add_location_alt_outlined),
),
);
}
// 修正:補回遺失的 _buildHouseCard 方法
Widget _buildHouseCard(House item) {
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
onTap: () {
// 導向詳情頁,這會用到 house_note_detail.dart
Navigator.push(
context,
MaterialPageRoute(builder: (context) => HouseNoteDetail(house: item)),
);
},
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(item.address ?? '暫無地址', maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
Row(
children: [
_buildTag(item.priceTag, Colors.blueGrey),
const SizedBox(width: 8),
_buildTag('${item.overallRating}', item.ratingColor),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
),
);
}
// 修正:補回遺失的 _buildTag 方法
Widget _buildTag(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color),
),
child: Text(
text,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
);
}
// 修正:補回遺失的 _buildEmptyState 方法
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.house_siding, size: 80, color: Colors.grey.shade300),
const SizedBox(height: 16),
const Text('還沒有記錄,點擊右下角開始第一筆看屋吧!', style: TextStyle(color: Colors.grey)),
],
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
class House {
final String id;
final String title;
final String? address;
final double? latitude;
final double? longitude;
final double? totalPrice;
final double? totalPing;
final double? age;
final String? propertyType;
final String? summaryNotes;
final double? overallRating;
final bool isFavorite;
final DateTime? createdAt;
House({
required this.id,
required this.title,
this.address,
this.latitude,
this.longitude,
this.totalPrice,
this.totalPing,
this.age,
this.propertyType,
this.summaryNotes,
this.overallRating,
this.isFavorite = false,
this.createdAt,
});
factory House.fromJson(Map<String, dynamic> json) {
return House(
id: json['id'] as String? ?? const Uuid().v4(),
title: json['title'] as String? ?? '未命名物件',
address: json['address'] as String?,
latitude: double.tryParse(json['latitude']?.toString() ?? ''),
longitude: double.tryParse(json['longitude']?.toString() ?? ''),
totalPrice: double.tryParse(json['total_price']?.toString() ?? ''),
totalPing: double.tryParse(json['total_ping']?.toString() ?? ''),
age: double.tryParse(json['age']?.toString() ?? ''),
propertyType: json['property_type'] as String?,
summaryNotes: json['summary_notes'] as String?,
overallRating: double.tryParse(json['overall_rating']?.toString() ?? '0'),
isFavorite: json['is_favorite'] == 1 || json['is_favorite'] == true,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at']) : null,
);
}
// UI Helper: 評分顏色
Color get ratingColor {
if ((overallRating ?? 0) >= 4) return Colors.green;
if ((overallRating ?? 0) >= 3) return Colors.orange;
return Colors.red;
}
// 格式化顯示價格與坪數
String get priceTag => totalPrice != null ? '${totalPrice}' : '價格未定';
String get infoSummary => '${propertyType ?? "未知"} | ${age ?? "?"}年 | ${totalPing ?? "?"}';
}
// 檢查項目定義 (Template)
class CheckItem {
final String id;
final String categoryId;
final String name;
final bool isDefault;
CheckItem({required this.id, required this.categoryId, required this.name, this.isDefault = true});
factory CheckItem.fromJson(Map<String, dynamic> json) {
return CheckItem(
id: json['id'] as String,
categoryId: json['category_id'] as String,
name: json['name'] as String,
isDefault: json['is_default'] == 1,
);
}
}
// 新增照片模型
class HousePhoto {
final String id;
final String houseId;
final String? evaluationId; // 關聯特定評估項目
final String filePath;
final String? description;
HousePhoto({required this.id, required this.houseId, this.evaluationId, required this.filePath, this.description});
factory HousePhoto.fromJson(Map<String, dynamic> json) {
return HousePhoto(
id: json['id'] as String,
houseId: json['house_id'] as String,
evaluationId: json['evaluation_id'] as String?,
filePath: json['file_path'] as String,
description: json['description'] as String?,
);
}
// 取得完整圖片網址
// String get fullUrl => "https://api.gex.com.tw/uploads/$filePath";
String get fullUrl {
if (filePath.startsWith('http')) {
return filePath;
}
return "https://api.gex.com.tw/uploads/$filePath";
}
}
// 新增評估紀錄模型
class HouseEvaluation {
String? id; // 【修改】允許為 null (新建立的評估還沒有 ID)
String houseId; // 移除 final
String checkItemId; // 移除 final
String itemName; // 移除 final,讓前端可以賦值
int score; // 【修改】移除 final,讓表單可以更新分數
String? itemMemo; // 【修改】移除 final,讓表單可以更新備註
HouseEvaluation({
this.id, // 【修改】移除 required
required this.houseId,
required this.checkItemId,
required this.itemName, // 給予預設值,解決報錯
this.score = 0,
this.itemMemo,
});
factory HouseEvaluation.fromJson(Map<String, dynamic> json) {
return HouseEvaluation(
id: json['id'] as String?,
houseId: json['house_id'] as String? ?? '',
checkItemId: json['check_item_id'] as String? ?? '',
itemName: json['itemName'] as String? ?? '',
score: int.tryParse(json['score']?.toString() ?? '0') ?? 0,
itemMemo: json['item_memo'] as String?,
);
}
}
// Tag
class Tag {
final String id;
final String name;
final String colorHex;
bool isSelected; // UI 用:判斷是否被選取
Tag({
required this.id,
required this.name,
required this.colorHex,
this.isSelected = false,
});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(
id: json['id'] as String,
name: json['name'] as String,
colorHex: json['color_hex'] as String? ?? '#CCCCCC',
);
}
// 輔助:轉換 Hex String 為 Color 物件
Color get color {
final hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16));
}
}
// 檢查項目大類模型
class CheckCategory {
final String id;
final String name;
final int sortOrder;
CheckCategory({required this.id, required this.name, required this.sortOrder});
factory CheckCategory.fromJson(Map<String, dynamic> json) {
return CheckCategory(
id: json['id'] as String,
name: json['name'] as String,
sortOrder: int.tryParse(json['sort_order']?.toString() ?? '0') ?? 0,
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import './house_note_api.dart';
class HousePhotoForm extends StatefulWidget {
final String houseId;
final String houseTitle;
const HousePhotoForm({required this.houseId, required this.houseTitle, super.key});
@override
State<HousePhotoForm> createState() => _HousePhotoFormState();
}
class _HousePhotoFormState extends State<HousePhotoForm> {
final _api = HouseNoteApiService();
final _noteController = TextEditingController();
File? _image;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_takePhoto(); // 進入頁面直接開啟相機
}
Future<void> _takePhoto() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.camera, imageQuality: 70);
if (pickedFile != null) {
setState(() => _image = File(pickedFile.path));
}
}
void _submit() async {
if (_image == null) return;
setState(() => _isSubmitting = true);
try {
// 1. 上傳檔案
String? fileName = await _api.uploadImage(_image!);
if (fileName != null) {
// 2. 寫入資料庫
await _api.saveHousePhoto(widget.houseId, fileName, _noteController.text);
if (mounted) Navigator.pop(context, true);
}
} catch (e) {
debugPrint("提交失敗: $e");
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('${widget.houseTitle} - 拍照紀錄')),
body: _isSubmitting
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
GestureDetector(
onTap: _takePhoto,
child: Container(
height: 300,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: _image == null
? const Center(child: Icon(Icons.add_a_photo, size: 50, color: Colors.grey))
: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(_image!, fit: BoxFit.cover),
),
),
),
const SizedBox(height: 20),
TextField(
controller: _noteController,
decoration: const InputDecoration(
labelText: '照片註記 (例如:客廳採光、疑似壁癌)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: _image == null ? null : _submit,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
child: const Text('上傳照片紀錄'),
),
],
),
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import './house_note_model.dart';
import './house_note_api.dart';
class HouseTagForm extends StatefulWidget {
final String houseId;
final String houseTitle;
const HouseTagForm({required this.houseId, required this.houseTitle, super.key});
@override
State<HouseTagForm> createState() => _HouseTagFormState();
}
class _HouseTagFormState extends State<HouseTagForm> {
final _api = HouseNoteApiService();
List<Tag> _allTags = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadTags();
}
Future<void> _loadTags() async {
try {
// 同時讀取標籤庫與該房屋已選的標籤
final results = await Future.wait([
_api.fetchAllTags(),
_api.fetchHouseTagIds(widget.houseId),
]);
final allTags = results[0] as List<Tag>;
final selectedIds = results[1] as List<String>;
// 標記已選狀態
for (var tag in allTags) {
if (selectedIds.contains(tag.id)) {
tag.isSelected = true;
}
}
setState(() {
_allTags = allTags;
_isLoading = false;
});
} catch (e) {
debugPrint("讀取標籤失敗: $e");
setState(() => _isLoading = false);
}
}
void _save() async {
setState(() => _isLoading = true);
final selectedIds = _allTags.where((t) => t.isSelected).map((t) => t.id).toList();
await _api.updateHouseTags(widget.houseId, selectedIds);
if (mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${widget.houseTitle} - 編輯標籤'),
actions: [
TextButton(
onPressed: _isLoading ? null : _save,
child: const Text('儲存', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _allTags.length,
itemBuilder: (context, index) {
final tag = _allTags[index];
return CheckboxListTile(
secondary: CircleAvatar(
backgroundColor: tag.color,
child: const Icon(Icons.local_offer, size: 16, color: Colors.white),
),
title: Text(tag.name),
value: tag.isSelected,
activeColor: tag.color,
onChanged: (val) {
setState(() => tag.isSelected = val ?? false);
},
);
},
),
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import './house/house_note_manager.dart';
void main() {
// 確保 Flutter 引擎初始化
WidgetsFlutterBinding.ensureInitialized();
runApp(const HouseHunterApp());
}
class HouseHunterApp extends StatelessWidget {
const HouseHunterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'House Hunter Pro',
debugShowCheckedModeBanner: false,
// 設定全域主題:採用現代化的 Material 3 設計
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2196F3), // 專業藍色調
primary: const Color(0xFF2196F3),
secondary: const Color(0xFF00BCD4),
surface: Colors.white,
),
// 設定表單預設樣式 (對應 house_note_form.dart)
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF2196F3), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
// 設定卡片預設樣式 (對應 house_note_manager.dart)
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
color: Colors.white,
),
// 設定 AppBar 樣式
appBarTheme: const AppBarTheme(
centerTitle: true,
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0.5,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
// 支援語系 (處理日期選擇器等元件的繁體中文顯示)
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('zh', 'TW'), // 繁體中文
],
// 根據需求描述:主頁面直接設定為房屋資訊列表
// 且不必登入即可使用
home: const HouseNoteManager(),
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:intl/intl.dart';
// import '../auth_manager.dart'; // 確保引入 AuthManager
// 共用 API 常數
//const String BASE_IP = "https://api.gex.com.tw:8033";
//const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/2/";
//const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT";
class GenericApiService {
// [修改] 移除 const COMMON_API_URL,改為方法獲取
static const String BASE_IP = "https://api.gex.com.tw"; // :8033
// 修改後的動態 URL 產生器,支援傳入不同的 store procedure 與回傳 dataset 數
String _getProcedureUrl(String endpoint, String version) {
// final String db = AuthManager().currentCompany ?? "demo"; //
final String dbAll = "eis_demo"; //
return "$BASE_IP/xapi/v2/$dbAll/$endpoint/$version/"; //
}
String _getToken() {
DateTime now = DateTime.now();
// 產生 ddmmyy 格式
// 使用 intl 套件的 DateFormat,或手動組合
String ddmmyy = DateFormat('ddMMyy').format(now);
// 計算 CheckSum: (yyyy + mm + dd) % 10
int checkSum = (now.year + now.month + now.day) % 10;
// 組合最終特殊 Token
String specialToken = "$ddmmyy$checkSum";
return specialToken;
}
/// 呼叫 Store Procedure 的通用方法
/// [T] : 目標資料模型
/// [procedureEndpoint] : 端點名稱 (例如: 'sp_get_channel_sales_stats')
/// [version] : 版本號,預設為 "2"
/// [params] : 傳遞給 SP 的參數 Map
/// [fromJson] : 將 Map 轉換為物件的工廠方法
Future<List<T>> fetchProcedure<T>({
required String procedureEndpoint,
String version = "2",
required Map<String, String> params,
required T Function(Map<String, dynamic>) fromJson,
}) async {
String? token = _getToken(); // await AuthManager.getToken();
if (token == null) throw Exception('未登入:找不到有效 Token');
final Map<String, String> body = {"token": token, ...params};
final String url = _getProcedureUrl(procedureEndpoint, version);
try {
final response = await http.post(
Uri.parse(url),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body,
);
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
final int code = responseData['code'] ?? responseData['Code'] ?? -1;
if (code == 0) {
final dynamic dataObject = responseData['data'];
if (dataObject is List) {
return dataObject.map((j) => fromJson(j as Map<String, dynamic>)).toList();
} else if (dataObject is Map<String, dynamic>) {
return [fromJson(dataObject)];
}
return []; // 數據格式不符回傳空清單
} else {
print("API 錯誤: ${responseData['msg'] ?? responseData['message']}");
return []; // Code 不為 0 回傳空清單
}
} else {
// [關鍵修正] statusCode 不為 200 時拋出異常或回傳空清單
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
print("fetchProcedure 發生異常: $e");
rethrow; // 重新拋出異常讓呼叫端處理
}
// [關鍵修正] 確保函式末端一定有回傳值,解決截圖中的錯誤
}
// 以下是 orm_api
String _getDynamicUrl() {
// 從 AuthManager 獲取目前登入的公司別,若無則預設 eis_demo
// final String db = AuthManager().currentCompany ?? "demo";
final String dbAll = "eis_demo";
return "$BASE_IP/xapi/v2/$dbAll/orm_api_v2/2/";
}
/// 通用的獲取列表方法
/// [T] : 目標資料模型 (例如 Announcement 或 Todo)
/// [tableName] : 資料庫表名 (API 參數)
/// [pk] : 主鍵欄位名稱 (API 參數)
/// [queryFilter] : 查詢過濾條件 (API 參數)
/// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson)
/// [action] : API 動作指令,預設為 'P'
Future<List<T>> fetchList<T>({
required String tableName,
required String pk,
required String queryFilter,
required T Function(Map<String, dynamic>) fromJson,
String action = 'P', // 對應 index.js 中的 _$_action
Map<String, dynamic> data = const {}, // 存放實際要存入資料庫的欄位值
}) async {
// 1. 讀取 Token (index.js 會優先檢查 body.token)
String? token = _getToken(); // await AuthManager.getToken();
if (token == null) {
throw Exception('未登入:找不到有效 Token');
}
// 2. 建立 API 參數
final Map<String, String> params = {
"token": token,
"_\$_tableName": tableName,
"_\$_pk": pk,
"_\$_action": action, // [修改] 使用傳入的參數
"_\$_query_filter": queryFilter,
};
// 3. 處理動態資料欄位 (將所有 value 轉為 String 以符合 http.post body 要求)
data.forEach((key, value) {
if (value != null) {
params[key] = value.toString();
}
});
try {
final response = await http.post(
Uri.parse(_getDynamicUrl()),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
// 【修正】同時相容大寫 Code 與小寫 code
final int code = responseData['code'] ?? responseData['Code'] ?? -1;
// 檢查後端回傳的 code
if (code == 0) {
final dynamic dataObject = responseData['data'];
// 處理 index.js 回傳的資料結構 (data -> items)
if (dataObject is Map && dataObject['items'] is List) {
List<dynamic> listJson = dataObject['items'];
return listJson.map((json) => fromJson(json as Map<String, dynamic>)).toList();
} else if (dataObject is Map) {
// 處理直接回傳單一物件的情況
return [fromJson(dataObject as Map<String, dynamic>)];
}
// 若直接回傳 List (部分 action 可能直接回傳 dataResult)
else if (dataObject is List) {
return dataObject.map((json) => fromJson(json as Map<String, dynamic>)).toList();
}
return [];
} else {
print("API 錯誤: ${responseData['msg'] ?? responseData['message']}");
return [];
}
} else {
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
print("GenericApiService 發生異常: $e");
rethrow;
}
}
}