diff --git a/tailbone/templates/mobile/receiving/receive_row.mako b/tailbone/templates/mobile/receiving/receive_row.mako
new file mode 100644
index 00000000..53d8820f
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/receive_row.mako
@@ -0,0 +1,151 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/view_row.mako" />
+<%namespace file="/mobile/keypad.mako" import="keypad" />
+
+<%def name="title()">Receiving » ${batch.id_str} » ${master.render_product_key_value(row)}%def>
+
+<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${master.render_product_key_value(row)}%def>
+
+
+
+  
+    % if instance.product:
+        
${instance.brand_name or ""} 
+        ${instance.description} ${instance.size or ''} 
+        % if allow_cases:
+            1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom} 
+        % endif
+    % else:
+        ${instance.description} 
+    % endif
+  
+  % if product_image_url:
+    
+      ${h.image(product_image_url, "product image")}
+    
+  % endif
+
+  
+    % if batch.order_quantities_known:
+        
+          ordered 
+          
+            % if allow_cases:
+                ${h.pretty_quantity(row.cases_ordered or 0)} /
+            % endif
+            ${h.pretty_quantity(row.units_ordered or 0)}
+           
+         
+    % endif
+    
+      received 
+      
+        % if allow_cases:
+            ${h.pretty_quantity(row.cases_received or 0)} /
+        % endif
+        ${h.pretty_quantity(row.units_received or 0)}
+       
+     
+    
+      damaged 
+      
+        % if allow_cases:
+            ${h.pretty_quantity(row.cases_damaged or 0)} /
+        % endif
+        ${h.pretty_quantity(row.units_damaged or 0)}
+       
+     
+    % if allow_expired:
+        
+          expired 
+          
+            % if allow_cases:
+                ${h.pretty_quantity(row.cases_expired or 0)} /
+            % endif
+            ${h.pretty_quantity(row.units_expired or 0)}
+           
+         
+    % endif
+   
+
+
+% if request.session.peek_flash('receiving-warning'):
+    % for error in request.session.pop_flash('receiving-warning'):
+        ${error}
+    % endfor
+% endif
+
+% if not batch.executed and not batch.complete:
+
+    ${h.form(request.current_route_url(), class_='receiving-update')}
+    ${h.csrf_token(request)}
+    ${h.hidden('row', value=row.uuid)}
+    ${h.hidden('cases')}
+    ${h.hidden('units')}
+
+    ## only show quick-receive if we have an identifiable product
+    % if quick_receive and instance.product:
+        % if quick_receive_all:
+            ${quick_receive_text} 
+        % elif allow_cases:
+            Receive 1 CS 
+            
+              ## TODO: probably should make these optional / configurable
+              1 EA 
+              3 EA 
+              6 EA 
+            
+            Receive 1 ${unit_uom} 
+        % endif
+    % endif
+
+    ${keypad(unit_uom, uom, allow_cases=allow_cases)}
+
+    
+
+    ${h.hidden('quick_receive', value='false')}
+    ${h.end_form()}
+
+    % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)):
+        ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')}
+        ${h.csrf_token(request)}
+        ${h.submit('submit', "Delete this Row")}
+        ${h.end_form()}
+    % endif
+
+% endif
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 982b1983..3f86de61 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -1115,6 +1115,15 @@ class ReceivingBatchView(PurchasingBatchView):
         description = row.product.full_description if row.product else row.description
         return "({}) {}".format(key, description)
 
+    def make_mobile_row_grid_kwargs(self, **kwargs):
+        kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
+
+        # use custom `receive_row` instead of `view_row`
+        # TODO: should still use `view_row` in some cases? e.g. executed batch
+        kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
+
+        return kwargs
+
     def should_aggregate_products(self, batch):
         """
         Must return a boolean indicating whether rows should be aggregated by
@@ -1395,6 +1404,120 @@ class ReceivingBatchView(PurchasingBatchView):
                 self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
         return self.render_to_response('view_row', context, mobile=True)
 
+    def mobile_receive_row(self):
+        """
+        Mobile view for row-level receiving.
+        """
+        self.mobile = True
+        self.viewing = True
+        row = self.get_row_instance()
+        batch = row.batch
+        permission_prefix = self.get_permission_prefix()
+        form = self.make_mobile_row_form(row)
+        context = {
+            'row': row,
+            'batch': batch,
+            'parent_instance': batch,
+            'instance': row,
+            'instance_title': self.get_row_instance_title(row),
+            'parent_model_title': self.get_model_title(),
+            'product_image_url': self.get_row_image_url(row),
+            'form': form,
+            'allow_expired': self.handler.allow_expired_credits(),
+            'allow_cases': self.handler.allow_cases(),
+            'quick_receive': False,
+            'quick_receive_all': False,
+        }
+
+        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
+                                                               default=True)
+        if batch.order_quantities_known:
+            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
+                                                                       default=False)
+
+        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
+            schema = MobileReceivingForm().bind(session=self.Session())
+            update_form = forms.Form(schema=schema, request=self.request)
+            if update_form.validate(newstyle=True):
+                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
+                mode = update_form.validated['mode']
+                cases = update_form.validated['cases']
+                units = update_form.validated['units']
+
+                # handler takes care of the row receiving logic for us
+                kwargs = dict(update_form.validated)
+                del kwargs['row']
+                self.handler.receive_row(row, **kwargs)
+
+                # keep track of last-used uom, although we just track
+                # whether or not it was 'CS' since the unit_uom can vary
+                sticky_case = None
+                if not update_form.validated['quick_receive']:
+                    if cases and not units:
+                        sticky_case = True
+                    elif units and not cases:
+                        sticky_case = False
+                if sticky_case is not None:
+                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
+
+                return self.redirect(self.get_action_url('view', batch, mobile=True))
+
+        # unit_uom can vary by product
+        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
+
+        if context['quick_receive'] and context['quick_receive_all']:
+            if context['allow_cases']:
+                context['quick_receive_uom'] = 'CS'
+                raise NotImplementedError("TODO: add CS support for quick_receive_all")
+            else:
+                context['quick_receive_uom'] = context['unit_uom']
+                accounted_for = self.handler.get_units_accounted_for(row)
+                remainder = self.handler.get_units_ordered(row) - accounted_for
+
+                if accounted_for:
+                    # some product accounted for; button should receive "remainder" only
+                    if remainder:
+                        remainder = pretty_quantity(remainder)
+                        context['quick_receive_quantity'] = remainder
+                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
+                    else:
+                        # unless there is no remainder, in which case disable it
+                        context['quick_receive'] = False
+
+                else: # nothing yet accounted for, button should receive "all"
+                    if not remainder:
+                        raise ValueError("why is remainder empty?")
+                    remainder = pretty_quantity(remainder)
+                    context['quick_receive_quantity'] = remainder
+                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
+
+        # effective uom can vary in a few ways...the basic default is 'CS' if
+        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
+        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
+        if sticky_case is None:
+            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
+        elif sticky_case:
+            context['uom'] = 'CS'
+        else:
+            context['uom'] = context['unit_uom']
+        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
+            context['uom'] = context['unit_uom']
+
+        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
+            warn = True
+            if batch.is_truck_dump_parent() and row.product:
+                uuids = [child.uuid for child in batch.truck_dump_children]
+                if uuids:
+                    count = self.Session.query(model.PurchaseBatchRow)\
+                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
+                                        .filter(model.PurchaseBatchRow.product == row.product)\
+                                        .count()
+                    if count:
+                        warn = False
+            if warn:
+                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
+        return self.render_to_response('receive_row', context, mobile=True)
+
     def auto_receive(self):
         """
         View which can "auto-receive" all items in the batch.  Meant only as a
@@ -1455,6 +1578,11 @@ class ReceivingBatchView(PurchasingBatchView):
         model_key = cls.get_model_key()
         permission_prefix = cls.get_permission_prefix()
 
+        # row-level receiving
+        config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
+        config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
+                        permission='{}.edit_row'.format(permission_prefix))
+
         if cls.allow_truck_dump:
 
             # add TD child batch, from invoice file