First Commit
This commit is contained in:
@@ -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('確定')
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), // 保持與拍照按鈕一致的圓角
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('上傳照片紀錄'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user