update new version
This commit is contained in:
@@ -1,48 +1,60 @@
|
||||
import './clock_in_model.dart';
|
||||
import '../services/generic_api_service.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../auth_manager.dart'; // 確保引入 AuthManager
|
||||
|
||||
class ClockInApiService {
|
||||
final GenericApiService _apiService = GenericApiService();
|
||||
|
||||
// 獲取個人打卡歷史 (預設按時間降冪)
|
||||
Future<List<ClockInRecord>> fetchHistory(String userId) async {
|
||||
String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId";
|
||||
|
||||
return await _apiService.fetchList<ClockInRecord>(
|
||||
tableName: "hrs_ClockInRecord",
|
||||
pk: "ClockInId",
|
||||
queryFilter: queryFilter,
|
||||
fromJson: (json) => ClockInRecord.fromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
// 新增打卡紀錄 (POST)
|
||||
Future<bool> postClockIn(ClockInRecord record) async {
|
||||
// 假設後端有一個通用保存 API
|
||||
final data = {
|
||||
"ClockInUserId": record.userId,
|
||||
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||
"ClockInLatitude": record.latitude,
|
||||
"ClockInLongitude": record.longitude,
|
||||
"ClockInType": record.type,
|
||||
"ClockInStoreId": record.storeId,
|
||||
"CreatorId": record.userId,
|
||||
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||
};
|
||||
|
||||
// 呼叫 API 並回傳結果
|
||||
// return await _apiService.saveData("hrs_ClockInRecord", data);
|
||||
return true; // 模擬成功
|
||||
}
|
||||
|
||||
// 在 ClockInApiService 類別中新增:
|
||||
// 1. 獲取店點清單 (對應新 table)
|
||||
Future<List<ClockInStore>> fetchStores() async {
|
||||
return await _apiService.fetchList<ClockInStore>(
|
||||
tableName: "hrs_ClockInStore",
|
||||
pk: "StoreId",
|
||||
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點
|
||||
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 假設 Stat='Y' 為啟用
|
||||
fromJson: (json) => ClockInStore.fromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 獲取打卡歷史
|
||||
Future<List<ClockInRecord>> fetchHistory(String userId) async {
|
||||
// 1. 讀取 Token
|
||||
String? userId = await AuthManager.getUserId();
|
||||
|
||||
// String queryFilter = "1^100^ClockInDateTime^*^^^ClockInUserId^$userId";
|
||||
return await _apiService.fetchList<ClockInRecord>(
|
||||
tableName: "hrs_ClockInRecord",
|
||||
pk: "ClockInId",
|
||||
queryFilter: "1^100^ClockInDateTime^*^^^ClockInUserId^$userId",
|
||||
fromJson: (json) => ClockInRecord.fromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 提交打卡
|
||||
//Future<List> postClockIn(ClockInRecord record) async {
|
||||
Future<List<ClockInRecord>> postClockIn(ClockInRecord record) async {
|
||||
// 取得當前登入者 ID (確保 AuthManager 已改為單例模式)
|
||||
final String currentUid = AuthManager().currentUserId ?? record.userId.toString();
|
||||
|
||||
// 將所有欄位轉為 String,避免 Map<String, String?> 的錯誤
|
||||
final Map<String, String> data = {
|
||||
"ClockInUserId": currentUid,
|
||||
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||
"ClockInLatitude": (record.latitude ?? 0.0).toString(),
|
||||
"ClockInLongitude": (record.longitude ?? 0.0).toString(),
|
||||
"ClockInType": record.type ?? "未知",
|
||||
"ClockInStoreId": record.storeId ?? "",
|
||||
"CreatorId": currentUid,
|
||||
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||||
};
|
||||
|
||||
return await _apiService.fetchList<ClockInRecord>(
|
||||
tableName: "hrs_ClockInRecord",
|
||||
pk: "ClockInId",
|
||||
queryFilter: "", // 通常新增不需要 filter,依後端 API 協議而定
|
||||
action: "C", // 傳入您指定的動作碼 'C' (Create)
|
||||
data: data, // 將打卡欄位資料透過額外參數傳入
|
||||
fromJson: (json) => ClockInRecord.fromJson(json), // 這裡填入模型的解析工廠
|
||||
);
|
||||
}
|
||||
}
|
||||
+113
-122
@@ -17,7 +17,6 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
final ClockInApiService _apiService = ClockInApiService();
|
||||
late Future<List<ClockInRecord>> _historyFuture;
|
||||
|
||||
// --- 新增:店點相關變數 ---
|
||||
List<ClockInStore> _stores = [];
|
||||
ClockInStore? _selectedStore;
|
||||
bool _isLoadingStores = true;
|
||||
@@ -25,28 +24,33 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
Position? _currentPosition;
|
||||
double _distanceInMeters = -1;
|
||||
String _currentTime = "";
|
||||
late Timer _timer;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initData();
|
||||
_initAllData();
|
||||
_startClock();
|
||||
}
|
||||
|
||||
// 初始化資料:先抓店點,再抓歷史紀錄
|
||||
Future<void> _initData() async {
|
||||
Future<void> _initAllData() async {
|
||||
// 1. 先抓店點
|
||||
try {
|
||||
final stores = await _apiService.fetchStores();
|
||||
setState(() {
|
||||
_stores = stores;
|
||||
if (_stores.isNotEmpty) _selectedStore = _stores.first;
|
||||
_isLoadingStores = false;
|
||||
});
|
||||
_refreshHistory();
|
||||
_getCurrentLocation();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stores = stores;
|
||||
if (_stores.isNotEmpty) _selectedStore = _stores.first;
|
||||
_isLoadingStores = false;
|
||||
});
|
||||
// 2. 抓歷史紀錄
|
||||
_refreshHistory();
|
||||
// 3. 初次定位
|
||||
_handleLocationPermission();
|
||||
_updateLocation();
|
||||
}
|
||||
} catch (e) {
|
||||
_showMsg("初始化店點失敗: $e");
|
||||
_showMsg("讀取店點資訊失敗");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,61 +60,85 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
});
|
||||
}
|
||||
|
||||
// 修改:根據「目前選中店點」計算距離
|
||||
Future<void> _getCurrentLocation() async {
|
||||
Future<bool> _handleLocationPermission() async {
|
||||
bool serviceEnabled;
|
||||
LocationPermission permission;
|
||||
|
||||
// 檢查定位服務是否開啟
|
||||
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
_showMsg('手機定位服務已關閉,請開啟。');
|
||||
return false;
|
||||
}
|
||||
|
||||
permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
_showMsg('定位權限被拒絕。');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
_showMsg('定位權限被永久拒絕,請至系統設定開啟。');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _updateLocation() async {
|
||||
if (_selectedStore == null) return;
|
||||
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
|
||||
// 使用所選店點的經緯度進行計算
|
||||
final distance = Geolocator.distanceBetween(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
_selectedStore!.latitude,
|
||||
_selectedStore!.longitude
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
_distanceInMeters = distance;
|
||||
});
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||
double distance = Geolocator.distanceBetween(
|
||||
position.latitude, position.longitude,
|
||||
_selectedStore!.latitude, _selectedStore!.longitude
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
_distanceInMeters = distance;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("定位失敗: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshHistory() {
|
||||
final future = _apiService.fetchHistory(widget.userId);
|
||||
setState(() => _historyFuture = future);
|
||||
setState(() {
|
||||
_historyFuture = _apiService.fetchHistory(widget.userId);
|
||||
});
|
||||
}
|
||||
|
||||
// 修改:打卡動作加入 StoreId 與動態距離判斷
|
||||
Future<void> _handleClockIn(String type) async {
|
||||
if (_selectedStore == null) {
|
||||
_showMsg("錯誤:請先選擇打卡店點");
|
||||
if (_selectedStore == null) return;
|
||||
|
||||
// 再次確認位置
|
||||
await _updateLocation();
|
||||
|
||||
if (_distanceInMeters > _selectedStore!.distance) {
|
||||
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 過遠 (${_distanceInMeters.toInt()}m)");
|
||||
return;
|
||||
}
|
||||
|
||||
await _getCurrentLocation();
|
||||
|
||||
// 使用資料表中的 Distance 欄位作為判斷標準
|
||||
if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) {
|
||||
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)");
|
||||
return;
|
||||
}
|
||||
|
||||
final newRecord = ClockInRecord(
|
||||
userId: int.tryParse(widget.userId),
|
||||
final record = ClockInRecord(
|
||||
userId: widget.userId,
|
||||
type: type,
|
||||
latitude: _currentPosition?.latitude,
|
||||
longitude: _currentPosition?.longitude,
|
||||
storeId: _selectedStore!.storeId, // 使用動態 ID
|
||||
storeId: _selectedStore!.storeId,
|
||||
);
|
||||
|
||||
bool success = await _apiService.postClockIn(newRecord);
|
||||
await _apiService.postClockIn(record);
|
||||
/*
|
||||
final success = await _apiService.postClockIn(record);
|
||||
if (success) {
|
||||
_showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}");
|
||||
_showMsg("$type 打卡成功");
|
||||
_refreshHistory();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
void _showMsg(String msg) {
|
||||
@@ -119,49 +147,40 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 動態判斷是否在選中店點的範圍內
|
||||
bool isWithinRange = _selectedStore != null &&
|
||||
_distanceInMeters != -1 &&
|
||||
_distanceInMeters >= 0 &&
|
||||
_distanceInMeters <= _selectedStore!.distance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('員工行動打卡')),
|
||||
appBar: AppBar(title: const Text('行動打卡系統')),
|
||||
body: _isLoadingStores
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
// 新增:店點選擇下拉選單區
|
||||
_buildStoreSelector(),
|
||||
|
||||
// 店點切換
|
||||
_buildStorePicker(),
|
||||
// 打卡狀態區
|
||||
_buildStatusCard(isWithinRange),
|
||||
|
||||
const Divider(height: 1),
|
||||
// ... 歷史紀錄標題與清單部分維持不變 (同附件)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
// 歷史紀錄清單
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text("今日紀錄", style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<ClockInRecord>>(
|
||||
future: _historyFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄'));
|
||||
final list = snapshot.data ?? [];
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) => _buildHistoryTile(list[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -171,64 +190,46 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
);
|
||||
}
|
||||
|
||||
// 新增:店點選擇器 UI
|
||||
Widget _buildStoreSelector() {
|
||||
Widget _buildStorePicker() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Colors.white,
|
||||
child: DropdownButtonFormField<ClockInStore>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "選擇打卡店點",
|
||||
prefixIcon: Icon(Icons.store),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedStore,
|
||||
items: _stores.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s.storeName),
|
||||
)).toList(),
|
||||
decoration: const InputDecoration(labelText: "目前打卡店點", border: OutlineInputBorder()),
|
||||
items: _stores.map((s) => DropdownMenuItem(value: s, child: Text(s.storeName))).toList(),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedStore = val;
|
||||
_distanceInMeters = -1; // 切換時重置距離,等待下次定位
|
||||
});
|
||||
_getCurrentLocation(); // 切換後立即重新計算距離
|
||||
setState(() => _selectedStore = val);
|
||||
_updateLocation();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 修改:狀態卡片顯示
|
||||
Widget _buildStatusCard(bool isWithinRange) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: Colors.blue.shade50,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||
Text(_currentTime, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
if (_selectedStore != null) ...[
|
||||
Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
|
||||
Text(
|
||||
isWithinRange
|
||||
? "已進入 ${_selectedStore!.storeName} 範圍"
|
||||
: "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)",
|
||||
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
|
||||
Text(
|
||||
isWithinRange ? "進入打卡範圍" : "超出範圍 (${_distanceInMeters.toInt()}m / ${_selectedStore?.distance}m)",
|
||||
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))),
|
||||
Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("上班"), child: const Text("上班"))),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: ElevatedButton(onPressed: () => _handleClockIn("下班"), child: const Text("下班"))),
|
||||
],
|
||||
)
|
||||
],
|
||||
@@ -236,21 +237,11 @@ class _ClockInManagerState extends State<ClockInManager> {
|
||||
);
|
||||
}
|
||||
|
||||
// _actionBtn 與 _buildHistoryItem 維持與附件原程式相同...
|
||||
Widget _actionBtn(String label, Color color, VoidCallback onPressed) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: const TextStyle(fontSize: 18)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(ClockInRecord record) {
|
||||
Widget _buildHistoryTile(ClockInRecord record) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))),
|
||||
leading: Icon(Icons.access_time, color: record.typeColor),
|
||||
title: Text("${record.type} - ${record.formattedTime}"),
|
||||
subtitle: Text(record.formattedDate),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16),
|
||||
subtitle: Text(record.storeId ?? ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import 'package:intl/intl.dart';
|
||||
|
||||
class ClockInRecord {
|
||||
final int? clockInId;
|
||||
final int? userId;
|
||||
final String? userId; // 改為 String 以對應一般工號格式
|
||||
final DateTime? dateTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? type; // 上班/下班/加班...
|
||||
final String? type;
|
||||
final String? storeId;
|
||||
|
||||
ClockInRecord({
|
||||
@@ -23,7 +23,7 @@ class ClockInRecord {
|
||||
factory ClockInRecord.fromJson(Map<String, dynamic> json) {
|
||||
return ClockInRecord(
|
||||
clockInId: json['ClockInId'] as int?,
|
||||
userId: json['ClockInUserId'] as int?,
|
||||
userId: json['ClockInUserId']?.toString(),
|
||||
dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null,
|
||||
latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'),
|
||||
longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'),
|
||||
@@ -32,11 +32,9 @@ class ClockInRecord {
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: 格式化顯示時間
|
||||
String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--';
|
||||
String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A';
|
||||
|
||||
// Helper: 根據打卡類型回傳顏色
|
||||
Color get typeColor {
|
||||
if (type == '上班') return Colors.blue;
|
||||
if (type == '下班') return Colors.orange;
|
||||
@@ -50,7 +48,7 @@ class ClockInStore {
|
||||
final String? storeAddress;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final int distance; // 允許打卡公尺數
|
||||
final int distance;
|
||||
|
||||
ClockInStore({
|
||||
required this.storeId,
|
||||
@@ -63,13 +61,13 @@ class ClockInStore {
|
||||
|
||||
factory ClockInStore.fromJson(Map<String, dynamic> json) {
|
||||
return ClockInStore(
|
||||
storeId: json['StoreId'] as String,
|
||||
storeName: json['StoreName'] as String? ?? '',
|
||||
storeId: json['StoreId'] as String? ?? '',
|
||||
storeName: json['StoreName'] as String? ?? '未知店點',
|
||||
storeAddress: json['StoreAddress'] as String?,
|
||||
// 強制將 decimal/String 轉為 double
|
||||
latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0,
|
||||
longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0,
|
||||
distance: int.tryParse(json['Distance'].toString()) ?? 100,
|
||||
// 關鍵:處理 Decimal 轉 Double
|
||||
latitude: double.tryParse(json['StoreLatitude']?.toString() ?? '0') ?? 0.0,
|
||||
longitude: double.tryParse(json['StoreLongitude']?.toString() ?? '0') ?? 0.0,
|
||||
distance: int.tryParse(json['Distance']?.toString() ?? '100') ?? 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user