24 Commits

Author SHA1 Message Date
d5083c1939 Merge branch 'dev'
v1.2.4
2026-03-04 14:58:45 +08:00
fe44848529 号码 2026-03-04 14:50:21 +08:00
572c416827 枪号回填,v1.2.4 2026-03-04 13:38:26 +08:00
8df16f0787 pdf查看 2026-03-03 17:51:06 +08:00
ce6bd3edd2 bugfix 2026-03-03 13:09:10 +08:00
6997b4ac9e 调整权限和库 2026-03-02 11:28:44 +08:00
a26d2478f3 样式 2026-02-28 17:31:28 +08:00
0dded3b928 ocr识别,历史新增类型 2026-02-28 17:15:42 +08:00
b7caf58adf 加氢站-查看证件 2026-02-28 15:00:56 +08:00
0df1902df2 无预约 2026-02-28 11:59:07 +08:00
a8314d8a7a 关闭弹窗 2026-02-27 10:55:55 +08:00
39cae906e9 加氢站-预约 2026-02-27 10:54:55 +08:00
14fd6c75d0 fix 2026-02-25 15:35:33 +08:00
1724852a39 司机-可上传证件 2026-02-25 15:35:02 +08:00
a05d4ebb9b 调整 2026-02-12 18:01:56 +08:00
600cea4379 进度条 2026-02-12 17:34:06 +08:00
3dfc1dfc2c 样式调整 2026-02-12 16:54:50 +08:00
909dc95771 增加 提示 2026-02-11 17:41:30 +08:00
cf0896453b 样式 2026-02-11 11:28:49 +08:00
35bd3a78a5 Merge branch 'dev' 2026-01-30 17:33:15 +08:00
032e60d362 Merge branch 'dev'
ui调整
# Conflicts:
#	ln_jq_app/lib/pages/login/view.dart
2026-01-30 17:08:49 +08:00
45f5035d1b 更换logo 2026-01-19 10:13:37 +08:00
f792915429 bugfix 2026-01-16 17:47:53 +08:00
edbacc502b 线上域名修改 2026-01-16 17:25:52 +08:00
49 changed files with 1514 additions and 451 deletions

View File

@@ -37,8 +37,8 @@ android {
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = 6
versionName = "1.2.3"
versionCode = 7
versionName = "1.2.4"
}
signingConfigs {

View File

@@ -1,47 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!--定位权限-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="小羚羚"
android:requestLegacyExternalStorage="true"
android:name="${applicationName}"
android:icon="@mipmap/logo">
android:icon="@mipmap/logo"
android:label="小羚羚">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
@@ -51,31 +54,54 @@
android:value="2" />
<!-- 请填写你自己的- appKey -->
<meta-data android:name="com.alibaba.app.appkey" android:value="335642645"/>
<meta-data
android:name="com.alibaba.app.appkey"
android:value="335642645" />
<!-- 请填写你自己的appSecret -->
<meta-data android:name="com.alibaba.app.appsecret" android:value="39628204345a4240b5b645b68a5896c7"/>
<meta-data
android:name="com.alibaba.app.appsecret"
android:value="39628204345a4240b5b645b68a5896c7" />
<!-- 华为通道的参数appid -->
<meta-data android:name="com.huawei.hms.client.appid" android:value="" />
<meta-data
android:name="com.huawei.hms.client.appid"
android:value="" />
<!-- vivo通道的参数api_key为appkey -->
<meta-data android:name="com.vivo.push.api_key" android:value="" />
<meta-data android:name="com.vivo.push.app_id" android:value="" />
<meta-data
android:name="com.vivo.push.api_key"
android:value="" />
<meta-data
android:name="com.vivo.push.app_id"
android:value="" />
<!-- honor通道的参数-->
<meta-data android:name="com.hihonor.push.app_id" android:value="" />
<meta-data
android:name="com.hihonor.push.app_id"
android:value="" />
<!-- oppo -->
<meta-data android:name="com.oppo.push.key" android:value="" />
<meta-data android:name="com.oppo.push.secret" android:value="" />
<meta-data
android:name="com.oppo.push.key"
android:value="" />
<meta-data
android:name="com.oppo.push.secret"
android:value="" />
<!-- 小米-->
<meta-data android:name="com.xiaomi.push.id" android:value="id=2222222222222222222" />
<meta-data android:name="com.xiaomi.push.key" android:value="id=5555555555555" />
<meta-data
android:name="com.xiaomi.push.id"
android:value="id=2222222222222222222" />
<meta-data
android:name="com.xiaomi.push.key"
android:value="id=5555555555555" />
<!-- 魅族-->
<meta-data android:name="com.meizu.push.id" android:value="" />
<meta-data android:name="com.meizu.push.key" android:value="" />
<meta-data
android:name="com.meizu.push.id"
android:value="" />
<meta-data
android:name="com.meizu.push.key"
android:value="" />
<!-- 接收推送消息 -->
@@ -99,6 +125,7 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -117,8 +144,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -109,7 +109,7 @@
/* --- 导航结果面板 (底部弹出) --- */
#panel {
position: fixed;
bottom: 75px;
bottom: 95px;
left: 0;
width: 100%;
height: 35%;
@@ -129,7 +129,7 @@
#location-btn {
position: fixed;
right: 10px;
bottom: 75px;
bottom: 105px;
/* 默认位置 */
width: 44px;
height: 44px;
@@ -159,7 +159,7 @@
/* --- 调整比例尺位置 --- */
.amap-scalecontrol {
/* 初始状态:避开底部的定位按钮或留出安全间距 */
bottom: 80px !important;
bottom: 110px !important;
left: 10px !important;
transition: bottom 0.3s ease;
/* 增加平滑动画 */
@@ -195,10 +195,10 @@
<div id="search-box">
<div class="input-row">
<input id="startInput" placeholder="起点: 请输入当前地点" />
<input id="startInput" placeholder="起点: 请输入当前地点" onfocus="this.select()" />
</div>
<div class="input-row">
<input id="endInput" placeholder="终点: 请输入目的地" />
<input id="endInput" placeholder="终点: 请输入目的地" onfocus="this.select()" />
<button onclick="startRouteSearch()">路径规划</button>
</div>
</div>
@@ -244,6 +244,11 @@
}
});
// 点击地图空白处重置状态
map.on('click', function() {
resetSearchState();
});
// 添加基础控件
map.addControl(new AMap.Scale());
map.addControl(new AMap.ToolBar({
@@ -285,6 +290,19 @@
});
}
/**
* 重置搜索状态,隐藏面板和路线
*/
function resetSearchState() {
if (document.body.classList.contains('panel-active')) {
console.log("JS->: 重置地图状态");
document.body.classList.remove('panel-active');
var panel = document.getElementById('panel');
panel.style.display = 'none';
if (driving) driving.clear();
}
}
/**
* 核心功能 1: 接收 Flutter 传来的定位数据
* Flutter 端调用: webViewController.evaluateJavascript("updateMyLocation(...)")
@@ -481,19 +499,25 @@
offset: new AMap.Pixel(-16, -32),
title: station.name,
label: {
content: '<div class="custom-bubble">' + station.name + '</div>',
content: '<div class="custom-bubble">' + station.name +
'</div>',
direction: 'top'
}
});
// 3. 绑定点击事件:选中即为目的地,并开始规划
sMarker.on('click', function() {
document.getElementById('endInput').value = station.address || station.name;
// 更新当前的 destMarker (如果需要)
if (destMarker) destMarker.setMap(null);
sMarker.on('click', function () {
var stationName = station.name || "目的地";
document.getElementById('endInput').value = station.address ||
stationName;
// 更新当前的 destMarker
if (destMarker && destMarker !== sMarker) destMarker.setMap(null);
destMarker = sMarker;
startRouteSearch();
// 直接传入坐标对象,避免关键字搜索失败
var loc = new AMap.LngLat(station.longitude, station.latitude);
startRouteSearch(loc);
});
stationMarkers.push(sMarker);
@@ -554,11 +578,12 @@
}
}
/**
* 路径规划
* 路径规划
* @param {AMap.LngLat} [destLoc] 可选的终点坐标
*/
function startRouteSearch() {
// 获取输入框的文字
function startRouteSearch(destLoc) {
var startKw = document.getElementById('startInput').value;
var endKw = document.getElementById('endInput').value;
@@ -566,28 +591,21 @@
alert("请输入起点");
return;
}
if (!endKw) {
alert("请输入终点");
return;
}
// 清除旧路线
if (driving) driving.clear();
// 收起键盘
document.getElementById('startInput').blur();
document.getElementById('endInput').blur();
// --- 构造路径规划的点 ---
var points = [];
// 1. 处理起点逻辑
// 1. 起点逻辑
if (!startKw || startKw === '我的位置' || startKw.includes('当前位置')) {
if (!currentLng || !currentLat) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('requestLocation');
}
if (window.flutter_inappwebview) window.flutter_inappwebview.callHandler('requestLocation');
alert("正在获取定位,请稍后...");
return;
}
@@ -601,10 +619,17 @@
});
}
// 2. 处理终点逻辑
points.push({
keyword: endKw
});
// 2. 终点逻辑:如果有传入坐标,则直接使用坐标导航,成功率最高
if (destLoc) {
points.push({
keyword: endKw,
location: destLoc // 关键:使用精确坐标
});
} else {
points.push({
keyword: endKw
});
}
// 3. 发起搜索
driving.search(points, function (status, result) {
@@ -613,10 +638,12 @@
var panel = document.getElementById('panel');
panel.style.display = 'block';
document.body.classList.add('panel-active');
} else {
console.log('JS: 规划失败', result);
alert("规划失败,请检查起终点名称");
}
// else {
// console.error('JS: 规划失败', result);
// // 如果坐标规划都失败了,通常是由于起终点距离过近或政策限制(如货车禁行)
// alert("路径规划未成功,请尝试微调起终点");
// }
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -41,6 +41,8 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- saver_gallery (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -62,6 +64,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -103,6 +106,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
saver_gallery:
:path: ".symlinks/plugins/saver_gallery/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
@@ -127,6 +132,7 @@ SPEC CHECKSUMS:
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b

View File

@@ -13,7 +13,7 @@
"size" : "20x20"
},
{
"filename" : "Icon-App-29x29@1x.png",
"filename" : "Icon-App-29x29 1.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
@@ -31,25 +31,25 @@
"size" : "29x29"
},
{
"filename" : "Icon-App-80x80.jpg",
"filename" : "Icon-App-80x80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-App-120x120.jpg",
"filename" : "Icon-App-120x120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-App-120x120 1.jpg",
"filename" : "Icon-App-120x120 1.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-App-180.jpg",
"filename" : "Icon-App-180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
@@ -61,19 +61,19 @@
"size" : "20x20"
},
{
"filename" : "Icon-App-20x20@2x.png",
"filename" : "Icon-App-20x20@2x 1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-App-29x29@1x.png",
"filename" : "Icon-App-29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "Icon-App-29x29@2x.png",
"filename" : "Icon-App-29x29@2x 1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
@@ -91,7 +91,7 @@
"size" : "40x40"
},
{
"filename" : "Icon-App-76x76@1x.png",
"filename" : "Icon-App-76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -76,5 +76,11 @@
<string>en</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<!-- 允许在“文件”App中直接打开文档 -->
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>

View File

@@ -8,11 +8,11 @@ class AppTheme {
static const Color themeColor = Color(0xFF017137);
//是否开放域名切换
static const bool is_show_host = true;
static const bool is_show_host = false;
//http://192.168.110.222:8080/
//http://192.168.110.44:8080/
static String test_service_url = "https://beta-esg.api.lnh2e.com/";
static String test_service_url = "http://47.101.201.13:8443/api/";
static const String release_service_url = "";
//加氢站相关查询

View File

@@ -17,7 +17,7 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
WidgetsBinding widgetsBinding = await init(
isDebug: true,
isDebug: false,
logTag: '小羚羚',
supportedLocales: [const Locale('zh', 'CN')],
);
@@ -35,7 +35,7 @@ void main() async {
// 设计稿尺寸 单位dp
designSize: const Size(390, 844),
// Getx Log
enableLog: true,
enableLog: false,
// 默认的跳转动画
defaultTransition: Transition.rightToLeft,
// 主题模式
@@ -71,6 +71,9 @@ void initHttpSet() {
HttpService.to.dio.interceptors.add(TokenInterceptor(tokenKey: 'asoco-token'));
HttpService.to.setOnResponseHandler((response) async {
try {
if (response.data == null) {
return null;
}
final baseModel = BaseModel.fromJson(response.data);
if (baseModel.code == 0 || baseModel.code == 200) {
return null;

View File

@@ -1,7 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:intl/intl.dart';
import 'package:ln_jq_app/common/model/base_model.dart';
import 'package:ln_jq_app/pages/b_page/site/controller.dart'; // Reuse ReservationModel
@@ -25,12 +22,14 @@ class HistoryController extends GetxController with BaseControllerMixin {
final RxBool hasData = false.obs;
String get formattedStartDate => DateFormat('yyyy/MM/dd').format(startDate.value);
String get formattedEndDate => DateFormat('yyyy/MM/dd').format(endDate.value);
String stationName = "";
final Map<String, String> statusOptions = {
'': '全部',
'100': '未预约加氢',
'0': '待加氢',
'1': '已加氢',
'2': '未加氢',

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:ln_jq_app/common/login_util.dart';
import 'package:ln_jq_app/common/styles/theme.dart';
import 'package:ln_jq_app/pages/b_page/history/view.dart';
import 'package:ln_jq_app/pages/c_page/message/view.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
@@ -57,17 +56,33 @@ class SitePage extends GetView<SiteController> {
),
GestureDetector(
onTap: () {
Get.to(
() => HistoryPage(),
arguments: {'stationName': controller.name},
);
// 手动录入
controller.confirmReservation("", isAdd: true);
},
child: Text(
'历史记录',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: Color.fromRGBO(156, 163, 175, 1),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Row(
children: [
const Icon(
Icons.add_circle_outline,
size: 18,
color: Color(0xFF666666),
),
const SizedBox(width: 4),
Text(
"无预约车辆加氢",
style: TextStyle(
color: Color.fromRGBO(51, 51, 51, 1),
fontSize: 13.sp,
fontWeight: FontWeight.w400
),
),
],
),
),
),
@@ -185,27 +200,7 @@ class SitePage extends GetView<SiteController> {
],
),
),
IconButton(
onPressed: () async {
var scanResult = await Get.to(() => const MessagePage());
if (scanResult == null) {
controller.msgNotice();
}
},
style: IconButton.styleFrom(
backgroundColor: Colors.grey[100],
padding: const EdgeInsets.all(8),
),
icon: Badge(
smallSize: 8,
backgroundColor: controller.isNotice ? Colors.red : Colors.transparent,
child: const Icon(
Icons.notifications_outlined,
color: Colors.black87,
size: 30,
),
),
),
_buildDropdownMenu(),
],
),
const SizedBox(height: 25),
@@ -233,6 +228,40 @@ class SitePage extends GetView<SiteController> {
);
}
Widget _buildDropdownMenu() {
return PopupMenuButton<String>(
icon: Container(child: const Icon(Icons.grid_view_rounded, size: 24)),
onSelected: (value) async {
if (value == 'message') {
var scanResult = await Get.to(() => const MessagePage());
if (scanResult == null) {
controller.msgNotice();
}
} else if (value == 'history') {
Get.to(() => const HistoryPage(), arguments: {'stationName': controller.name});
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'message',
child: Row(
children: [
Icon(Icons.notifications_none, size: 20),
SizedBox(width: 8),
Text('消息中心'),
],
),
),
const PopupMenuItem(
value: 'history',
child: Row(
children: [Icon(Icons.history, size: 20), SizedBox(width: 8), Text('加氢历史')],
),
),
],
);
}
Widget _buildStatBox(String title, String enTitle, String value, String unit) {
return Expanded(
child: Container(
@@ -466,7 +495,7 @@ class SitePage extends GetView<SiteController> {
/// 右侧具体数据卡片
Widget _buildInfoCard(ReservationModel item) {
return Container(
padding: EdgeInsets.only(left: 16.w, top: 8.5, bottom: 8.5, right: 16.w),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
@@ -508,59 +537,82 @@ class SitePage extends GetView<SiteController> {
),
const SizedBox(height: 8),
// 联系信息
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"${item.contactPerson} | ${item.contactPhone}",
style: TextStyle(
color: Color(0xFF999999),
fontSize: 13.sp,
fontWeight: FontWeight.w400,
),
),
],
Text(
item.contactPerson.isEmpty || item.contactPhone.isEmpty
? ""
: "${item.contactPerson} | ${item.contactPhone}",
style: TextStyle(
color: Color(0xFF999999),
fontSize: 13.sp,
fontWeight: FontWeight.w400,
),
),
//操作按钮(仅在待处理状态显示)
if (item.status == ReservationStatus.pending) ...[
const SizedBox(height: 15),
const Divider(height: 1, color: Color(0xFFF5F5F5)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildSmallButton(
"拒绝",
isOutline: true,
onTap: () {
controller.rejectReservation(item.id);
},
SizedBox(height: 6.h),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (item.hasDrivingAttachment)
controller.buildInfoTag('行驶证',item.drivingAttachments),
if (item.hasHydrogenationAttachment) ...[
SizedBox(width: 8.w),
controller.buildInfoTag('加氢证',item.hydrogenationAttachments)
],
Spacer(),
if (item.isEdit == "1") ...[
const SizedBox(height: 15),
const Divider(height: 1, color: Color(0xFFF5F5F5)),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: _buildSmallButton(
"修改信息",
isOutline: true,
onTap: () {
controller.confirmReservation(item.id, isEdit: true);
},
),
),
const SizedBox(width: 12),
_buildSmallButton(
"确认",
isOutline: false,
onTap: () {
controller.confirmReservation(item.id);
},
] else if (item.status == ReservationStatus.pending) ...[
const SizedBox(height: 15),
const Divider(height: 1, color: Color(0xFFF5F5F5)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildSmallButton(
"拒绝",
isOutline: true,
onTap: () {
controller.rejectReservation(item.id);
},
),
const SizedBox(width: 12),
_buildSmallButton(
"确认",
isOutline: false,
onTap: () {
controller.confirmReservation(item.id);
},
),
],
),
],
),
],
],
),
],
),
);
}
/// 通用小按钮
Widget _buildSmallButton(
String text, {
required bool isOutline,
required VoidCallback onTap,
}) {
const kPrimaryGreen = Color(0xFF006D35);
const kDangerRed = Color(0xFFFF7D7D);
var kDangerRed = text.contains('修改') ? Colors.red : Color.fromRGBO(255, 142, 98, 1);
return GestureDetector(
onTap: onTap,
@@ -634,139 +686,4 @@ class SitePage extends GetView<SiteController> {
),
);
}
/// 右侧操作按钮(拒绝/确认)
Widget _buildActionButtons(ReservationModel item) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 拒绝按钮(空心)
GestureDetector(
onTap: () => controller.rejectReservation(item.id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFF7D7D)),
),
child: const Text(
"拒绝",
style: TextStyle(
color: Color(0xFFFF7D7D),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 8),
// 确认按钮(实心深绿)
GestureDetector(
onTap: () => controller.confirmReservation(item.id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF006D35),
borderRadius: BorderRadius.circular(10),
),
child: const Text(
"确认",
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
/// 构建状态标签
Widget _buildStatusChip(ReservationStatus status) {
String text;
Color color;
switch (status) {
case ReservationStatus.pending:
text = '待加氢';
color = Colors.orange;
break;
case ReservationStatus.completed:
text = '已加氢';
color = Colors.greenAccent;
break;
case ReservationStatus.rejected:
text = '拒绝加氢';
color = Colors.red;
break;
case ReservationStatus.unadded:
text = '未加氢';
color = Colors.red;
break;
case ReservationStatus.cancel:
text = '已取消';
color = Colors.red;
break;
default:
text = '未知状态';
color = Colors.grey;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, color: color, size: 8),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
);
}
/// 构建信息详情行
Widget _buildDetailRow(
IconData icon,
String label,
String value, {
Color valueColor = Colors.black87,
}) {
return Row(
children: [
Icon(icon, color: Colors.grey, size: 20),
const SizedBox(width: 8),
Text('$label: ', style: const TextStyle(fontSize: 14, color: Colors.grey)),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 14,
color: valueColor,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
/// 底部构建带图标的提示信息行
Widget _buildInfoItem(IconData icon, String text) {
return Row(
children: [
Icon(icon, color: Colors.blue, size: 20),
const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 14, color: Colors.black54)),
],
);
}
}

View File

@@ -41,7 +41,7 @@ class BaseWidgetsPage extends GetView<BaseWidgetsController> {
Widget _buildNavigationBar() {
return SafeArea(
child: Container(
height: 50.h,
height: Get.height * 0.05,
margin: const EdgeInsets.fromLTRB(24, 0, 24, 10), // 悬浮边距
decoration: BoxDecoration(
color: Color.fromRGBO(240, 244, 247, 1), // 浅灰色背景

View File

@@ -1,5 +1,8 @@
import 'package:get/get.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:getx_scaffold/getx_scaffold.dart' as dio;
import 'package:image_picker/image_picker.dart';
import 'package:image_picker_platform_interface/src/types/image_source.dart';
import 'package:ln_jq_app/common/model/base_model.dart';
import 'package:ln_jq_app/common/model/vehicle_info.dart';
import 'package:ln_jq_app/pages/c_page/car_info/attachment_viewer_page.dart';
@@ -89,6 +92,113 @@ class CarInfoController extends GetxController with BaseControllerMixin {
}
}
//上传证件照
void pickImage(String title, ImageSource source) async {
if (source == ImageSource.camera) {
takePhotoAndRecognize(title);
} else if (source == ImageSource.gallery) {
//相册选择逻辑
var status = await Permission.photos.request();
if (!status.isGranted) {
if (status.isPermanentlyDenied) openAppSettings();
showErrorToast("需要相册权限才能拍照上传");
return;
}
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (image != null) {
_uploadAndSaveCertificate(title, image.path);
}
}
}
void takePhotoAndRecognize(String title) async {
var status = await Permission.camera.request();
if (!status.isGranted) {
if (status.isPermanentlyDenied) openAppSettings();
showErrorToast("需要相机权限才能拍照上传");
return;
}
final XFile? photo = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 80, // 压缩图片质量以加快上传
);
if (photo == null) return;
_uploadAndSaveCertificate(title, photo.path);
}
/// 提取共用的上传与关联证件逻辑
void _uploadAndSaveCertificate(String title, String filePath) async {
// 上传文件
String? imageUrl = await uploadFile(filePath);
if (imageUrl == null) return;
// 根据标题映射业务类型
String type = "";
switch (title) {
case "行驶证":
type = "9";
break;
case "营运证":
type = "10";
break;
case "加氢证":
type = "12";
break;
case "登记证":
type = "13";
break;
default:
return;
}
// 调用后台接口关联证件
var response = await HttpService.to.post(
"appointment/truck/uploadCertificatePic",
data: {
"plateNumber": plateNumber,
"imageUrl": imageUrl,
"type": type,
},
);
if (response != null) {
final result = BaseModel.fromJson(response.data);
if (result.code == 0) {
showSuccessToast("上传成功");
getUserBindCarInfo(); // 重新拉取数据更新UI
} else {
showErrorToast(result.error);
}
}
}
/// 上传图片
Future<String?> uploadFile(String filePath) async {
showLoading("正在上传...");
try {
dio.FormData formData = dio.FormData.fromMap({
'file': await dio.MultipartFile.fromFile(filePath, filename: 'ocr_identity.jpg'),
});
var response = await HttpService.to.post("appointment/ocr/upload", data: formData);
if (response != null) {
final result = BaseModel.fromJson(response.data);
if (result.code == 0) return result.data.toString();
showErrorToast(result.error);
}
} catch (e) {
showErrorToast("图片上传失败");
} finally {
dismissLoading();
}
return null;
}
void getUserBindCarInfo() async {
if (StorageService.to.hasVehicleInfo) {
VehicleInfo? bean = StorageService.to.vehicleInfo;
@@ -102,7 +212,7 @@ class CarInfoController extends GetxController with BaseControllerMixin {
// 获取证件信息
final response = await HttpService.to.get(
'appointment/vehicle/getPicInfoByVin?vin=$vin',
'appointment/vehicle/getPicInfoByVin?vin=$vin&plateNumber=$plateNumber',
);
if (response != null && response.data != null) {
@@ -134,10 +244,10 @@ class CarInfoController extends GetxController with BaseControllerMixin {
...registerAttachments,
];
color = data['color'].toString();
hydrogenCapacity = data['hydrogenCapacity'].toString();
rentFromCompany = data['rentFromCompany'].toString();
address = data['address'].toString();
color = data['color'].toString();
hydrogenCapacity = data['hydrogenCapacity'].toString();
rentFromCompany = data['rentFromCompany'].toString();
address = data['address'].toString();
loadAllPdfs();
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:get/get.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:image_picker/image_picker.dart';
import 'package:ln_jq_app/common/login_util.dart';
import 'package:ln_jq_app/pages/c_page/message/view.dart';
import 'package:ln_jq_app/storage_service.dart';
@@ -368,7 +369,7 @@ class CarInfoPage extends GetView<CarInfoController> {
children: [
_buildCertificateContent('行驶证', controller.drivingAttachments),
_buildCertificateContent('营运证', controller.operationAttachments),
_buildCertificateContent('加氢资格', controller.hydrogenationAttachments),
_buildCertificateContent('加氢证', controller.hydrogenationAttachments),
_buildCertificateContent('登记证', controller.registerAttachments),
],
),
@@ -388,7 +389,7 @@ class CarInfoPage extends GetView<CarInfoController> {
child: Padding(
padding: EdgeInsets.all(16.0),
child: attachments.isEmpty
? const Center(child: Text('暂无相关证件信息'))
? _buildEmptyCertificateState(title)
: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
@@ -440,6 +441,158 @@ class CarInfoPage extends GetView<CarInfoController> {
});
}
Widget _buildEmptyCertificateState(String title) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/ic_attention@2x.png', // 请替换为您的实际图片路径
width: 120,
height: 120,
),
const SizedBox(height: 16),
Text(
'您未上传“$title',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Color.fromRGBO(51, 51, 51, 1),
),
),
const SizedBox(height: 8),
Text(
'上传后可提前通知加氢站报备\n大幅减少加氢站等待时间',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: Color.fromRGBO(156, 163, 175, 1),
height: 1.5,
),
),
const SizedBox(height: 24),
SizedBox(
width: 200,
height: 44,
child: ElevatedButton.icon(
onPressed: () {
_showUploadDialog(title);
},
icon: const Text(
'立即上传',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
label: Image.asset(
'assets/images/ic_upload@2x.png',
height: 20.h,
width: 20.w,
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF017137),
foregroundColor: Colors.white,
shape: StadiumBorder(),
elevation: 0,
),
),
),
],
);
}
void _showUploadDialog(String title) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'上传$title',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
const SizedBox(height: 12),
Text(
'请确保拍摄证件清晰可见,关键文字无反光遮挡,这将有助于快速通过审核',
style: TextStyle(fontSize: 13, color: Colors.grey[400], height: 1.5),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _buildUploadOption(
icon: Icons.camera_alt_outlined,
label: '拍照上传',
onTap: () {
controller.pickImage(title, ImageSource.camera);
Get.back();
},
),
),
const SizedBox(width: 16),
Expanded(
child: _buildUploadOption(
icon: Icons.image_outlined,
label: '相册上传',
onTap: () {
controller.pickImage(title, ImageSource.gallery);
Get.back();
},
),
),
],
),
],
),
),
),
);
}
// 构建弹窗内的选择按钮
Widget _buildUploadOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: const Color(0xFFF2F9F7), // 浅绿色背景
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Color(0xFF017137), // 深绿色圆圈
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 28),
),
const SizedBox(height: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF333333),
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildCertDetailItem(
String label,
String value, {

View File

@@ -73,7 +73,7 @@ class MallRulePage extends StatelessWidget {
icon: 'tips_5@2x',
title: '积分使用规则',
content:
'个人账户内累计的所有有效积分,可在平台积分商城中,用于兑换商城内上架的各类商品、权益或服务,兑换时将按照商品标注的积分值扣除对应积分,积分兑换后不支持撤销、退换,商品兑换规则以积分商城内公示为准。',
'积分有效期3个月。个人账户内累计的所有有效积分,可在平台积分商城中,用于兑换商城内上架的各类商品、权益或服务,兑换时将按照商品标注的积分值扣除对应积分,积分兑换后不支持撤销、退换,商品兑换规则以积分商城内公示为准。',
),
const SizedBox(height: 40),
const Center(

View File

@@ -221,8 +221,14 @@ class C_ReservationController extends GetxController with BaseControllerMixin {
stateName: '',
addStatus: '',
addStatusName: '',
rejectReason: '',
hasEdit: true,
rejectReason: '',
isTruckAttachment: 0,
hasHydrogenationAttachment: true,
hasDrivingAttachment: true,
isEdit: '',
drivingAttachments: [],
hydrogenationAttachments: [], gunNumber: '',
);
//打开预约列表
@@ -253,7 +259,7 @@ class C_ReservationController extends GetxController with BaseControllerMixin {
if (_debounce?.isActive ?? false) {
return;
}
_debounce = Timer(const Duration(seconds: 1), () {});
_debounce = Timer(const Duration(milliseconds: 200), () {});
showLoading("加载中");
@@ -393,7 +399,7 @@ class C_ReservationController extends GetxController with BaseControllerMixin {
// 创建一个每1分钟执行一次的周期性定时器
_refreshTimer = Timer.periodic(const Duration(minutes: 1), (timer) {
getSiteList();
getSiteList(showloading: false);
});
}
@@ -521,8 +527,8 @@ class C_ReservationController extends GetxController with BaseControllerMixin {
updateUi();
}
void getSiteList() async {
if (StorageService.to.phone == "13888888888") {
void getSiteList({showloading = true}) async {
if (StorageService.to.phone == "13344444444") {
//该账号给stationOptions手动添加一个数据
final testStation = StationModel(
hydrogenId: '1142167389150920704',
@@ -546,7 +552,9 @@ class C_ReservationController extends GetxController with BaseControllerMixin {
}
try {
showLoading("加氢站数据加载中");
if (showloading) {
showLoading("加氢站数据加载中");
}
var responseData = await HttpService.to.get(
"appointment/station/queryHydrogenSiteInfo",

View File

@@ -49,7 +49,7 @@ class ReservationPage extends GetView<C_ReservationController> {
Positioned(
left: 20.w,
right: 20.w,
bottom: 110.h,
bottom: Get.height * (Get.height < 826 ? 0.08 : 0.11),
child: _buildReservationItem(context),
),
],
@@ -457,27 +457,34 @@ class ReservationPage extends GetView<C_ReservationController> {
/// 时间 Slider 选择器
Widget _buildTimeSlider(BuildContext context) {
return Obx(() {
// 获取当前小时作为滑块值 (0-23)
int currentHour = controller.startTime.value.hour;
// 动态获取站点的营业范围限制
// 获取站点信息
final station = controller.stationOptions.firstWhereOrNull(
(s) => s.hydrogenId == controller.selectedStationId.value,
);
// 解析营业时间
// 处理格式如 "09:00" 或 "09:00:00"
final startParts = (station?.startBusiness ?? "00:00").split(':');
final endParts = (station?.endBusiness ?? "23:59").split(':');
// 如果没有站点数据,默认隐藏
if (station == null) {
return const SizedBox(height: 100);
}
// 解析营业范围
final startParts = station.startBusiness.split(':');
final endParts = station.endBusiness.split(':');
int bizStartHour = int.tryParse(startParts[0]) ?? 0;
int bizEndHour = int.tryParse(endParts[0]) ?? 23;
int bizEndMinute = (endParts.length > 1) ? (int.tryParse(endParts[1]) ?? 0) : 0;
if (bizEndMinute == 0 && bizEndHour > bizStartHour) bizEndHour--;
// 优化结束小时逻辑
// 如果分钟为 0 (例如 18:00),说明该小时整点已关门,最大可选小时应减 1
if (bizEndMinute == 0 && bizEndHour > 0) {
bizEndHour--;
}
//确定当前滑块值
int currentHour = controller.startTime.value.hour;
double sliderValue = currentHour.toDouble().clamp(
bizStartHour.toDouble(),
bizEndHour.toDouble(),
);
double minVal = bizStartHour.toDouble();
double maxVal = bizEndHour.toDouble();
if (minVal >= maxVal) maxVal = minVal + 1;
return Column(
children: [
@@ -526,12 +533,9 @@ class ReservationPage extends GetView<C_ReservationController> {
overlayColor: const Color(0xFF006633).withOpacity(0.1),
),
child: Slider(
value: currentHour.toDouble().clamp(
bizStartHour.toDouble(),
bizEndHour.toDouble(),
),
min: bizStartHour.toDouble(),
max: bizEndHour.toDouble(),
value: sliderValue,
min: minVal,
max: maxVal,
// divisions: bizEndHour - bizStartHour > 0 ? bizEndHour - bizStartHour : 1,
onChanged: (val) {
int hour = val.toInt();

View File

@@ -798,7 +798,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -957,6 +957,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.3"
saver_gallery:
dependency: "direct main"
description:
name: saver_gallery
sha256: "1d942bd7f4fedc162d9a751e156ebac592e4b81fc2e757af82de9077f3437003"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: transitive
description:

View File

@@ -16,12 +16,12 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.2.3+6
version: 1.2.4+7
environment:
sdk: ^3.9.0
# Dependencies specify other packages that your package needs in order to work.
# Dependencies specify other packages tha。。。t your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
@@ -53,7 +53,8 @@ dependencies:
aliyun_push_flutter: ^1.3.6
pull_to_refresh: ^2.0.0
flutter_app_update: ^3.2.2
saver_gallery: ^4.0.0
path_provider: ^2.1.5
dev_dependencies:
flutter_test: